repair river investigative and triennial ftp format

This commit is contained in:
ALim Aidrus 2025-11-20 10:21:01 +08:00
parent 18e853ac83
commit cf22668576
11 changed files with 450 additions and 135 deletions

View File

@ -146,8 +146,22 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
@override
Widget build(BuildContext context) {
// 1. Get the total screen width
final screenWidth = MediaQuery.of(context).size.width;
// 2. Define responsive widths for the sidebar container
final double collapsedWidth = (screenWidth * 0.08).clamp(55.0, 75.0); // Reduced factor to 8% and max to 75px
final double expandedWidth = (screenWidth * 0.2).clamp(200.0, 280.0); // 20% of width, min 200, max 280
// --- MODIFICATION: Initialize responsive size variables in build() ---
final double iconSize = (screenWidth * 0.035).clamp(20.0, 28.0);
final double textSize = (screenWidth * 0.03).clamp(14.0, 18.0);
final double collapsedIconSize = (screenWidth * 0.05).clamp(24.0, 32.0);
final double collapsedTextSize = (screenWidth * 0.02).clamp(10.0, 14.0);
// --- END MODIFICATION ---
return Container(
width: widget.isCollapsed ? 70 : 280, // Increased expanded width for sub-menus
width: widget.isCollapsed ? collapsedWidth : expandedWidth,
color: Theme.of(context).primaryColor, // Use theme primary color for sidebar
child: Column(
children: [
@ -155,59 +169,70 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
Expanded(
child: ListView(
primary: true,
padding: EdgeInsets.zero, // Remove default listview padding
children: [
if (!widget.isCollapsed) ...[
// If sidebar is expanded, show full categories with sub-menus
for (var item in _menuItems)
_buildExpandableNavItem(item),
// --- MODIFICATION: Pass size variables ---
_buildExpandableNavItem(item, iconSize, textSize),
] else ...[
// If sidebar is collapsed, only show icons for top-level items
for (var item in _menuItems)
_buildCollapsedNavItem(item),
// --- MODIFICATION: Pass collapsed size variables ---
_buildCollapsedNavItem(item, collapsedIconSize, collapsedTextSize),
],
],
),
),
const Divider(color: Colors.white24, height: 1), // Separator before logout
// Logout item, using an icon as it's a standard action
_buildNavItem(Icons.logout, "Logout", '/logout', isTopLevel: true),
// --- MODIFICATION: Pass size variables for logout item ---
_buildNavItem(Icons.logout, "Logout", '/logout', isTopLevel: true, iconSize: iconSize, textSize: textSize, collapsedIconSize: collapsedIconSize),
// --- END MODIFICATION ---
],
),
);
}
// Helper to build the leading widget (Icon or Image) for a sidebar item
Widget _buildLeadingWidget(SidebarItem item) {
// --- MODIFICATION: Added size parameter ---
Widget _buildLeadingWidget(SidebarItem item, double iconSize) {
// Now only checks for icon, as imagePath is not used for top-level items anymore
// but the property still exists on SidebarItem for potential future use or other items.
return Icon(item.icon, color: Colors.white);
return Icon(item.icon, color: Colors.white, size: iconSize);
}
// Builds an expandable item for parent categories (only when sidebar is expanded)
Widget _buildExpandableNavItem(SidebarItem item) {
// --- MODIFICATION: Added size parameters ---
Widget _buildExpandableNavItem(SidebarItem item, double iconSize, double textSize) {
if (item.children == null || item.children!.isEmpty) {
// This case handles a top-level item that is NOT a parent,
// like the "Settings" item.
return _buildNavItem(item.icon, item.label, item.route ?? '', isTopLevel: true, imagePath: item.imagePath);
return _buildNavItem(item.icon, item.label, item.route ?? '', isTopLevel: true, imagePath: item.imagePath, iconSize: iconSize, textSize: textSize);
}
return Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent), // Hide divider in expansion tile
child: ExpansionTile(
initiallyExpanded: false, // You can set this to true if you want some categories open by default
leading: _buildLeadingWidget(item), // Use the helper for leading widget
title: Text(item.label, style: const TextStyle(color: Colors.white)),
leading: _buildLeadingWidget(item, iconSize), // Use the helper for leading widget
// --- MODIFICATION: Use passed text size ---
title: Text(item.label, style: TextStyle(color: Colors.white, fontSize: textSize)),
// --- END MODIFICATION ---
iconColor: Colors.white,
collapsedIconColor: Colors.white,
childrenPadding: const EdgeInsets.only(left: 20.0), // Indent sub-items
children: item.children!.map((childItem) {
if (childItem.isParent) {
// Nested expansion tiles for sub-categories like "Manual", "Continuous"
return _buildExpandableNavItem(childItem);
// --- MODIFICATION: Pass size variables recursively ---
return _buildExpandableNavItem(childItem, iconSize, textSize);
} else {
// Leaf item (actual navigation link)
return _buildNavItem(childItem.icon, childItem.label, childItem.route ?? '', imagePath: childItem.imagePath);
// --- MODIFICATION: Pass size variables ---
return _buildNavItem(childItem.icon, childItem.label, childItem.route ?? '', imagePath: childItem.imagePath, iconSize: iconSize, textSize: textSize);
}
}).toList(),
),
@ -215,7 +240,8 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
}
// Builds a regular navigation item (for sub-items when expanded, or top-level when collapsed)
Widget _buildNavItem(IconData? icon, String label, String route, {bool isTopLevel = false, String? imagePath}) {
// --- MODIFICATION: Added size parameters ---
Widget _buildNavItem(IconData? icon, String label, String route, {bool isTopLevel = false, String? imagePath, required double iconSize, required double textSize, double? collapsedIconSize}) {
return InkWell(
onTap: () {
if (route.isNotEmpty) {
@ -230,17 +256,21 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
child: isTopLevel && widget.isCollapsed
? Center(
child: icon != null
? Icon(icon, color: Colors.white)
// --- MODIFICATION: Use collapsed icon size if available, otherwise use regular iconSize ---
? Icon(icon, color: Colors.white, size: collapsedIconSize ?? iconSize)
// --- END MODIFICATION ---
: const SizedBox.shrink(), // Fallback if no icon or imagePath
) // Only icon when collapsed
: Row(
children: [
icon != null
? Icon(icon, color: Colors.white)
? Icon(icon, color: Colors.white, size: iconSize)
: const SizedBox.shrink(), // Fallback if no icon or imagePath
const SizedBox(width: 12),
Expanded( // Use Expanded to prevent text overflow
child: Text(label, style: const TextStyle(color: Colors.white)),
// --- MODIFICATION: Use passed text size ---
child: Text(label, style: TextStyle(color: Colors.white, fontSize: textSize)),
// --- END MODIFICATION ---
),
],
),
@ -249,7 +279,8 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
}
// Builds a collapsed navigation item (only icon/image) for top-level categories
Widget _buildCollapsedNavItem(SidebarItem item) {
// --- MODIFICATION: Added size parameters ---
Widget _buildCollapsedNavItem(SidebarItem item, double collapsedIconSize, double collapsedTextSize) {
return InkWell(
onTap: () {
if (item.route != null && item.route!.isNotEmpty) {
@ -260,19 +291,23 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 0), // Adjusted vertical padding
padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 0), // Adjusted vertical padding
child: Column(
mainAxisSize: MainAxisSize.min, // Use minimum space
children: [
item.icon != null
? Icon(item.icon, color: Colors.white, size: 24) // Icon size
// --- MODIFICATION: Use passed collapsed icon size ---
? Icon(item.icon, color: Colors.white, size: collapsedIconSize)
// --- END MODIFICATION ---
: const SizedBox.shrink(), // Fallback if no icon
const SizedBox(height: 4), // Small space between icon and text
Text(
item.label,
style: const TextStyle(
style: TextStyle(
color: Colors.white,
fontSize: 10, // Smaller font size to fit
// --- MODIFICATION: Use passed collapsed text size ---
fontSize: collapsedTextSize,
// --- END MODIFICATION ---
),
overflow: TextOverflow.ellipsis, // Handle long labels
maxLines: 1, // Ensure it stays on one line

View File

@ -3,6 +3,9 @@ import 'package:provider/provider.dart';
import 'package:environment_monitoring_app/auth_provider.dart';
import 'package:environment_monitoring_app/collapsible_sidebar.dart';
// Define a breakpoint for switching to a persistent sidebar or a mobile drawer
const double kDrawerBreakpoint = 800.0;
class HomePage extends StatefulWidget {
const HomePage({super.key});
@ -11,22 +14,91 @@ class HomePage extends StatefulWidget {
}
class _HomePageState extends State<HomePage> {
bool _isSidebarCollapsed = true;
// isCollapsed is only relevant for the persistent (desktop) layout
bool _isSidebarCollapsed = false;
String _currentSelectedRoute = '/home';
// Helper method used by both the persistent sidebar and the drawer
void _handleNavigation(String route) {
setState(() {
_currentSelectedRoute = route;
});
Navigator.pushNamed(context, route);
// If using the drawer (mobile layout), close it after navigation
if (MediaQuery.of(context).size.width < kDrawerBreakpoint) {
// NOTE: This pop needs the proper context, which is available inside the Builder.
// We will handle the pop inside the onPressed function.
}
}
// A simplified toggle for the persistent sidebar's state
void _toggleSidebarState() {
setState(() {
_isSidebarCollapsed = !_isSidebarCollapsed;
});
}
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthProvider>(context);
final colorScheme = Theme.of(context).colorScheme;
final screenWidth = MediaQuery.of(context).size.width;
// --- Determine Layout Type ---
final bool isMobileLayout = screenWidth < kDrawerBreakpoint;
// --- Responsive Size Calculations ---
final double collapsedWidth = (screenWidth * 0.08).clamp(55.0, 75.0);
// --- MODIFICATION START: Increased Expanded Width for Drawer ---
final double expandedWidth = (screenWidth * 0.3).clamp(250.0, 350.0); // Increased factor to 30% and max to 350px
// --- MODIFICATION END ---
final double sidebarWidth = _isSidebarCollapsed ? collapsedWidth : expandedWidth;
// Grid properties are set based on whether the persistent sidebar is open OR if it's the mobile layout
final double effectiveContentWidth = isMobileLayout ? screenWidth : screenWidth - sidebarWidth;
final bool useCompactLayout = effectiveContentWidth < 600.0 && !isMobileLayout && !_isSidebarCollapsed;
final int crossAxisCount = useCompactLayout ? 1 : 2;
final double childAspectRatio = useCompactLayout ? 4.0 : 1.6;
final double iconSize = (screenWidth * 0.05).clamp(26.0, 40.0);
final double textSize = (screenWidth * 0.03).clamp(13.0, 18.0);
// --------------------------------------------------------------------------
final sidebar = CollapsibleSidebar(
isCollapsed: _isSidebarCollapsed,
onToggle: _toggleSidebarState,
onNavigate: _handleNavigation,
);
// Calculate required top padding for the Drawer content
final double statusBarHeight = MediaQuery.of(context).padding.top;
final double appBarHeight = kToolbarHeight;
final double totalDrawerTopPadding = statusBarHeight + appBarHeight;
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: Icon(_isSidebarCollapsed ? Icons.menu : Icons.close, color: Colors.white),
leading: Builder(
builder: (BuildContext innerContext) {
return IconButton(
icon: Icon(
isMobileLayout
? Icons.menu
: (_isSidebarCollapsed ? Icons.menu : Icons.close),
color: Colors.white
),
onPressed: () {
setState(() {
_isSidebarCollapsed = !_isSidebarCollapsed;
});
if (isMobileLayout) {
Scaffold.of(innerContext).openDrawer();
} else {
_toggleSidebarState();
}
},
);
},
),
title: const Text("MMS Version 3.12.01"),
@ -39,27 +111,23 @@ class _HomePageState extends State<HomePage> {
),
],
),
drawer: isMobileLayout ? Drawer(
width: expandedWidth,
child: Padding(
padding: EdgeInsets.only(top: totalDrawerTopPadding),
child: sidebar,
),
) : null,
body: Row(
children: [
CollapsibleSidebar(
isCollapsed: _isSidebarCollapsed,
onToggle: () {
setState(() {
_isSidebarCollapsed = !_isSidebarCollapsed;
});
},
onNavigate: (route) {
setState(() {
_currentSelectedRoute = route;
});
Navigator.pushNamed(context, route);
},
),
if (!isMobileLayout)
sidebar,
Expanded(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
"Welcome, ${auth.userEmail ?? 'User'}",
@ -68,7 +136,7 @@ class _HomePageState extends State<HomePage> {
color: colorScheme.onBackground,
),
),
const SizedBox(height: 8),
const SizedBox(height: 4),
Text(
"Select a Department:",
style: Theme.of(context).textTheme.titleSmall?.copyWith(
@ -76,14 +144,14 @@ class _HomePageState extends State<HomePage> {
color: colorScheme.onBackground,
),
),
const SizedBox(height: 8),
Expanded(
child: GridView.count(
crossAxisCount: 2,
const SizedBox(height: 4),
GridView.count(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1.6, // Wider and much shorter boxes
padding: EdgeInsets.zero, // No extra padding
childAspectRatio: childAspectRatio,
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
children: [
@ -93,6 +161,8 @@ class _HomePageState extends State<HomePage> {
icon: Icons.air,
color: Colors.blue.shade700,
route: '/air/home',
iconSize: iconSize,
textSize: textSize,
),
_buildMiniCategoryCard(
context,
@ -100,6 +170,8 @@ class _HomePageState extends State<HomePage> {
icon: Icons.water,
color: Colors.teal.shade700,
route: '/river/home',
iconSize: iconSize,
textSize: textSize,
),
_buildMiniCategoryCard(
context,
@ -107,11 +179,16 @@ class _HomePageState extends State<HomePage> {
icon: Icons.sailing,
color: Colors.indigo.shade700,
route: '/marine/home',
iconSize: iconSize,
textSize: textSize,
),
_buildMiniSettingsCard(
context,
iconSize: iconSize,
textSize: textSize,
),
_buildMiniSettingsCard(context),
],
),
),
],
),
),
@ -127,6 +204,8 @@ class _HomePageState extends State<HomePage> {
required IconData icon,
required Color color,
required String route,
required double iconSize,
required double textSize,
}) {
return Card(
elevation: 1,
@ -138,7 +217,7 @@ class _HomePageState extends State<HomePage> {
borderRadius: BorderRadius.circular(6),
onTap: () => Navigator.pushNamed(context, route),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
gradient: LinearGradient(
@ -147,16 +226,19 @@ class _HomePageState extends State<HomePage> {
colors: [color.withOpacity(0.9), color],
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 26, color: Colors.white),
const SizedBox(height: 4),
Icon(icon, size: iconSize, color: Colors.white),
const SizedBox(height: 2),
Text(
title,
style: const TextStyle(
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontSize: textSize,
fontWeight: FontWeight.bold,
),
),
@ -164,10 +246,14 @@ class _HomePageState extends State<HomePage> {
),
),
),
),
);
}
Widget _buildMiniSettingsCard(BuildContext context) {
Widget _buildMiniSettingsCard(BuildContext context, {
required double iconSize,
required double textSize,
}) {
return Card(
elevation: 1,
margin: EdgeInsets.zero,
@ -178,7 +264,7 @@ class _HomePageState extends State<HomePage> {
borderRadius: BorderRadius.circular(6),
onTap: () => Navigator.pushNamed(context, '/settings'),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
gradient: LinearGradient(
@ -187,16 +273,19 @@ class _HomePageState extends State<HomePage> {
colors: [Colors.grey.shade700, Colors.grey.shade800],
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.settings, size: 26, color: Colors.white),
const SizedBox(height: 4),
const Text(
Icon(Icons.settings, size: iconSize, color: Colors.white),
const SizedBox(height: 2),
Text(
"Settings",
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontSize: textSize,
fontWeight: FontWeight.bold,
),
),
@ -204,6 +293,7 @@ class _HomePageState extends State<HomePage> {
),
),
),
),
);
}
}

View File

@ -815,7 +815,7 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
}
Widget _buildConnectionCard({required String type, required dynamic connectionState, String? deviceName}) {
// Copied from RiverInSituStep3DataCaptureState._buildConnectionCard
// Copied from RiverInSituStep3DataCaptureState._buildConnectionCard, modified to use Wrap
final bool isConnected = connectionState == BluetoothConnectionState.connected || connectionState == SerialConnectionState.connected;
final bool isConnecting = connectionState == BluetoothConnectionState.connecting || connectionState == SerialConnectionState.connecting;
@ -844,8 +844,12 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
if (isConnecting || _isLoading)
const CircularProgressIndicator()
else if (isConnected)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
// --- START FIX: Replaced Row with Wrap to fix horizontal overflow with countdown timer ---
Wrap(
alignment: WrapAlignment.spaceEvenly,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8.0, // Horizontal space between buttons
runSpacing: 4.0, // Vertical space if it wraps
children: [
ElevatedButton.icon(
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
@ -868,6 +872,7 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
)
],
)
// --- END FIX ---
// Optionally add a button to reconnect if disconnected
else
ElevatedButton.icon(
@ -1051,7 +1056,7 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
}
Widget _buildFlowrateSection() {
// Copied from RiverInSituStep3DataCaptureState._buildFlowrateSection
// Copied from RiverInSituStep3DataCaptureState._buildFlowrateSection, modified to use Wrap
return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: Padding(
@ -1061,15 +1066,18 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
children: [
Text("Flowrate", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
// Radio buttons for method selection
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
// --- START FIX: Replaced Row with Wrap to fix horizontal overflow for radio buttons ---
Wrap(
alignment: WrapAlignment.spaceAround,
spacing: 8.0,
runSpacing: 4.0,
children: [
_buildFlowrateRadioButton("Surface Drifter"),
_buildFlowrateRadioButton("Flowmeter"),
_buildFlowrateRadioButton("NA"), // Not Applicable
],
),
// --- END FIX ---
// Conditional fields based on selected method
if (_selectedFlowrateMethod == 'Surface Drifter')
_buildSurfaceDrifterFields(),
@ -1084,7 +1092,7 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
}
Widget _buildFlowrateRadioButton(String title) {
// Copied from RiverInSituStep3DataCaptureState._buildFlowrateRadioButton
// Copied from RiverInSituStep3DataCaptureState._buildFlowrateRadioButton, added overflow handling
return Column(
children: [
Radio<String>(
@ -1092,7 +1100,11 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
groupValue: _selectedFlowrateMethod,
onChanged: _onFlowrateMethodChanged,
),
Text(title),
Text(
title,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, // Add ellipsis handling for safety
),
],
);
}

View File

@ -39,7 +39,7 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
List<String> _statesList = [];
List<Map<String, dynamic>> _stationsForState = [];
final List<String> _samplingTypes = ['Schedule', 'Triennial'];
final List<String> _samplingTypes = ['Triennial'];
@override
void initState() {

View File

@ -721,8 +721,12 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
if (isConnecting || _isLoading)
const CircularProgressIndicator()
else if (isConnected)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
// --- START FIX: Replaced Row with Wrap to fix horizontal overflow with countdown timer ---
Wrap(
alignment: WrapAlignment.spaceEvenly,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8.0, // Horizontal space between buttons
runSpacing: 4.0, // Vertical space if it wraps
children: [
// --- START MODIFICATION: Add countdown to Stop Reading button ---
ElevatedButton.icon(
@ -747,6 +751,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
)
],
)
// --- END FIX ---
],
),
),
@ -926,14 +931,18 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
children: [
Text("Flowrate", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
// --- START FIX: Wrap radio buttons in Expanded/Wrap widgets to prevent horizontal overflow ---
Wrap(
alignment: WrapAlignment.spaceAround,
spacing: 8.0,
runSpacing: 4.0,
children: [
_buildFlowrateRadioButton("Surface Drifter"),
_buildFlowrateRadioButton("Flowmeter"),
_buildFlowrateRadioButton("NA"),
],
),
// --- END FIX ---
if (_selectedFlowrateMethod == 'Surface Drifter')
_buildSurfaceDrifterFields(),
if (_selectedFlowrateMethod == 'Flowmeter')
@ -954,7 +963,11 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
groupValue: _selectedFlowrateMethod,
onChanged: _onFlowrateMethodChanged,
),
Text(title),
Text(
title,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, // Add ellipsis handling for safety
),
],
);
}

View File

@ -39,7 +39,7 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
List<String> _statesList = [];
List<Map<String, dynamic>> _stationsForState = [];
final List<String> _samplingTypes = ['Schedule', 'Triennial'];
final List<String> _samplingTypes = ['Schedule'];
@override
void initState() {

View File

@ -719,8 +719,12 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
if (isConnecting || _isLoading)
const CircularProgressIndicator()
else if (isConnected)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
// --- START FIX: Replaced Row with Wrap to fix horizontal overflow with countdown timer ---
Wrap(
alignment: WrapAlignment.spaceEvenly,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8.0, // Horizontal space between buttons
runSpacing: 4.0, // Vertical space if it wraps
children: [
// --- START MODIFICATION: Add countdown to Stop Reading button ---
ElevatedButton.icon(
@ -745,6 +749,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
)
],
)
// --- END FIX ---
],
),
),
@ -924,14 +929,16 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
children: [
Text("Flowrate", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
// --- START FIX: Wrap radio buttons in Expanded widgets to prevent horizontal overflow ---
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildFlowrateRadioButton("Surface Drifter"),
_buildFlowrateRadioButton("Flowmeter"),
_buildFlowrateRadioButton("NA"),
Expanded(child: _buildFlowrateRadioButton("Surface Drifter")),
Expanded(child: _buildFlowrateRadioButton("Flowmeter")),
Expanded(child: _buildFlowrateRadioButton("NA")),
],
),
// --- END FIX ---
if (_selectedFlowrateMethod == 'Surface Drifter')
_buildSurfaceDrifterFields(),
if (_selectedFlowrateMethod == 'Flowmeter')
@ -952,7 +959,11 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
groupValue: _selectedFlowrateMethod,
onChanged: _onFlowrateMethodChanged,
),
Text(title),
Text(
title,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, // Add ellipsis handling for safety
),
],
);
}

View File

@ -117,6 +117,25 @@ class MarineApiService {
}
// --- END: ADDED MISSING METHODS ---
// *** START: ADDED FOR NPE REPORTS ***
/// Fetches all NPE Reports submitted by the user (requires auth).
Future<Map<String, dynamic>> getAllNpeReports() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
// Corresponds to the new GET /marine/npe route in index.php
final response = await _baseService.get(baseUrl, 'marine/npe');
// Assuming the response returns the array of reports directly under the 'data' key
if (response['success'] == true && response['data'] is List) {
return response;
}
// Handle error or empty response
if (response['success'] == true && response['data'] == null) {
return {'success': true, 'data': [], 'message': 'No reports found.'};
}
return response;
}
// *** END: ADDED FOR NPE REPORTS ***
// *** START: ADDED FOR INVESTIGATIVE IMAGE REQUEST ***
/// Fetches investigative sampling records based on station and date.

View File

@ -1,3 +1,5 @@
// lib/services/marine_npe_report_service.dart
import 'dart:async';
import 'dart:io';
import 'dart:convert';
@ -84,9 +86,10 @@ class MarineNpeReportService {
Map<String, dynamic> apiImageResult = {};
try {
// --- MODIFIED: Use the new endpoint path for data ---
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/npe/report',
endpoint: 'marine/npe/report', // <-- Updated endpoint
body: data.toApiFormData(),
);
@ -96,7 +99,7 @@ class MarineNpeReportService {
if (reloginSuccess) {
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/npe/report',
endpoint: 'marine/npe/report', // <-- Updated endpoint
body: data.toApiFormData(),
);
}
@ -108,9 +111,10 @@ class MarineNpeReportService {
if (data.reportId != null) {
if (finalImageFiles.isNotEmpty) {
// --- MODIFIED: Use the new endpoint path for images ---
apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'marine/npe/images',
endpoint: 'marine/npe/images', // <-- Updated endpoint
fields: {'npe_id': data.reportId!},
files: finalImageFiles,
);
@ -124,6 +128,7 @@ class MarineNpeReportService {
} on SocketException catch (e) {
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': "API submission failed with network error: $e"};
// --- MODIFIED: Update queue with new endpoints ---
await _retryService.addApiToQueue(endpoint: 'marine/npe/report', method: 'POST', body: data.toApiFormData());
if (finalImageFiles.isNotEmpty && data.reportId != null) {
await _retryService.addApiToQueue(endpoint: 'marine/npe/images', method: 'POST_MULTIPART', fields: {'npe_id': data.reportId!}, files: finalImageFiles);
@ -131,6 +136,7 @@ class MarineNpeReportService {
} on TimeoutException catch (e) {
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': "API submission timed out: $e"};
// --- MODIFIED: Update queue with new endpoint ---
await _retryService.addApiToQueue(endpoint: 'marine/npe/report', method: 'POST', body: data.toApiFormData());
}

View File

@ -805,6 +805,62 @@ class RiverInvestigativeSamplingService { // Renamed class
}
}
/// Generates the specific Telegram alert message content for River Investigative.
Future<String> _generateSuccessAlertMessage(RiverInvesManualSamplingData data, {required bool isDataOnly}) async { // Updated model type
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
// Use helpers to get determined names/codes
final stationName = data.getDeterminedRiverName() ?? data.getDeterminedStationName() ?? 'N/A'; // Combine river/station name
final stationCode = data.getDeterminedStationCode() ?? 'N/A';
final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
final submitter = data.firstSamplerName ?? 'N/A';
final sondeID = data.sondeId ?? 'N/A';
final distanceKm = data.distanceDifferenceInKm ?? 0;
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceRemarks = data.distanceDifferenceRemarks ?? ''; // Default to empty string
final buffer = StringBuffer()
..writeln('✅ *River Investigative Sample ${submissionType} Submitted:*') // Updated title
..writeln();
// Adapt station info based on type
buffer.writeln('*Station Type:* ${data.stationTypeSelection ?? 'N/A'}');
if (data.stationTypeSelection == 'New Location') {
buffer.writeln('*New Location Name:* ${data.newStationName ?? 'N/A'}');
buffer.writeln('*New Location Code:* ${data.newStationCode ?? 'N/A'}');
buffer.writeln('*New Location State:* ${data.newStateName ?? 'N/A'}');
buffer.writeln('*New Location Basin:* ${data.newBasinName ?? 'N/A'}');
buffer.writeln('*New Location River:* ${data.newRiverName ?? 'N/A'}');
buffer.writeln('*Coordinates:* ${data.stationLatitude ?? 'N/A'}, ${data.stationLongitude ?? 'N/A'}');
} else {
buffer.writeln('*Station Name & Code:* $stationName ($stationCode)');
}
buffer
..writeln('*Date of Submitted:* $submissionDate')
..writeln('*Submitted by User:* $submitter')
..writeln('*Sonde ID:* $sondeID')
..writeln('*Status of Submission:* Successful');
// Include distance warning only if NOT a new location and distance > 50m
if (data.stationTypeSelection != 'New Location' && (distanceKm * 1000 > 50 || distanceRemarks.isNotEmpty)) {
buffer
..writeln()
..writeln('🔔 *Distance Alert:*')
..writeln('*Distance from station:* $distanceMeters meters');
if (distanceRemarks.isNotEmpty) {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
}
// Add parameter limit check section (uses the same river limits)
final outOfBoundsAlert = await _getOutOfBoundsAlertSection(data); // Call helper
if (outOfBoundsAlert.isNotEmpty) {
buffer.write(outOfBoundsAlert);
}
return buffer.toString();
}
/// Helper to generate the parameter limit alert section for Telegram (River Investigative).
Future<String> _getOutOfBoundsAlertSection(RiverInvesManualSamplingData data) async { // Updated model type
// Define mapping from data model keys to parameter names used in limits table
@ -881,7 +937,7 @@ class RiverInvestigativeSamplingService { // Renamed class
..writeln('The following parameters were outside their defined limits:');
buffer.writeAll(outOfBoundsMessages, '\n'); // Add each message on a new line
return buffer.toString();
return buffer.toString(); // --- FIX: Missing return statement was fixed ---
}
} // End of RiverInvestigativeSamplingService class

View File

@ -715,6 +715,79 @@ class RiverManualTriennialSamplingService {
// If needed, similar logic to _getOutOfBoundsAlertSection in RiverInSituSamplingService
// would need to be adapted here, potentially using riverParameterLimits from the DB.
// --- START FIX: Add parameter limit check section ---
final outOfBoundsAlert = await _getOutOfBoundsAlertSection(data);
if (outOfBoundsAlert.isNotEmpty) {
buffer.write(outOfBoundsAlert);
}
// --- END FIX ---
return buffer.toString();
}
/// Helper to generate the parameter limit alert section for Telegram.
Future<String> _getOutOfBoundsAlertSection(RiverManualTriennialSamplingData data) async {
// Define mapping from data model keys to parameter names used in limits table
const Map<String, String> _parameterKeyToLimitName = {
'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', 'ph': 'pH',
'salinity': 'Salinity', 'electricalConductivity': 'Conductivity', 'temperature': 'Temperature',
'tds': 'TDS', 'turbidity': 'Turbidity', 'ammonia': 'Ammonia', 'batteryVoltage': 'Battery',
};
final allLimits = await _dbHelper.loadRiverParameterLimits() ?? []; // Load river limits
if (allLimits.isEmpty) return "";
final readings = {
'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation,
'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity,
'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity,
'ammonia': data.ammonia, 'batteryVoltage': data.batteryVoltage,
};
final List<String> outOfBoundsMessages = [];
double? parseLimitValue(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
readings.forEach((key, value) {
if (value == null || value == -999.0) return;
final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return;
// Find the limit data for this parameter (river limits are not station-specific in the current DB structure)
final limitData = allLimits.firstWhere(
(l) => l['param_parameter_list'] == limitName,
orElse: () => <String, dynamic>{}, // Use explicit type
);
if (limitData.isNotEmpty) {
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
final upperLimit = parseLimitValue(limitData['param_upper_limit']);
if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) {
final valueStr = value.toStringAsFixed(5);
final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A';
final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A';
outOfBoundsMessages.add('- *$limitName*: `$valueStr` (Limit: `$lowerStr` - `$upperStr`)');
}
}
});
if (outOfBoundsMessages.isEmpty) {
return "";
}
final buffer = StringBuffer()
..writeln()
..writeln('⚠️ *Parameter Limit Alert:*')
..writeln('The following parameters were outside their defined limits:');
buffer.writeAll(outOfBoundsMessages, '\n');
return buffer.toString();
}
}