diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b11d52e..e80bc57 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,7 +27,7 @@ - + createState() => _InSituStep3DataCaptureState(); } -// START CHANGE: Add WidgetsBindingObserver to listen for app lifecycle events class _InSituStep3DataCaptureState extends State with WidgetsBindingObserver { -// END CHANGE final _formKey = GlobalKey(); bool _isLoading = false; bool _isAutoReading = false; StreamSubscription? _dataSubscription; + Map? _previousReadingsForComparison; + + /// Maps the app's internal parameter keys to the names used in the + /// 'param_parameter_list' column from the server. + final Map _parameterKeyToLimitName = const { + 'oxygenConcentration': 'Oxygen Conc', + 'oxygenSaturation': 'Oxygen Sat', + 'ph': 'pH', + 'salinity': 'Salinity', + 'electricalConductivity': 'Conductivity', + 'temperature': 'Temperature', + 'tds': 'TDS', + 'turbidity': 'Turbidity', + 'tss': 'TSS', + 'batteryVoltage': 'Battery', + }; + final List> _parameters = []; final _sondeIdController = TextEditingController(); @@ -55,36 +71,27 @@ class _InSituStep3DataCaptureState extends State with Wi void initState() { super.initState(); _initializeControllers(); - // START CHANGE: Register the lifecycle observer WidgetsBinding.instance.addObserver(this); - // END CHANGE } @override void dispose() { _dataSubscription?.cancel(); _disposeControllers(); - // START CHANGE: Remove the lifecycle observer WidgetsBinding.instance.removeObserver(this); - // END CHANGE super.dispose(); } - // START CHANGE: Add the observer method to handle app resume events @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - // When the app is resumed (e.g., after the user grants the native USB permission), - // call setState to force the widget to rebuild and show the latest connection status. if (mounted) { setState(() {}); } } } - // END CHANGE void _initializeControllers() { - // Use the date and time from Step 1 widget.data.dataCaptureDate = widget.data.samplingDate; widget.data.dataCaptureTime = widget.data.samplingTime; @@ -92,7 +99,6 @@ class _InSituStep3DataCaptureState extends State with Wi _dateController.text = widget.data.dataCaptureDate ?? ''; _timeController.text = widget.data.dataCaptureTime ?? ''; - // Set temporary default values to -999 for robust validation. widget.data.oxygenConcentration ??= -999.0; widget.data.oxygenSaturation ??= -999.0; widget.data.ph ??= -999.0; @@ -115,19 +121,18 @@ class _InSituStep3DataCaptureState extends State with Wi _tssController.text = widget.data.tss!.toString(); _batteryController.text = widget.data.batteryVoltage!.toString(); - // REPAIRED: Reordered parameters to match Step 4 and added icons. if (_parameters.isEmpty) { _parameters.addAll([ - {'icon': Icons.air, 'label': 'Oxygen Conc.', 'unit': 'mg/L', 'controller': _oxyConcController}, - {'icon': Icons.percent, 'label': 'Oxygen Sat.', 'unit': '%', 'controller': _oxySatController}, - {'icon': Icons.science_outlined, 'label': 'pH', 'unit': '', 'controller': _phController}, - {'icon': Icons.waves, 'label': 'Salinity', 'unit': 'ppt', 'controller': _salinityController}, - {'icon': Icons.flash_on, 'label': 'Conductivity', 'unit': 'µS/cm', 'controller': _ecController}, - {'icon': Icons.thermostat, 'label': 'Temperature', 'unit': '°C', 'controller': _tempController}, - {'icon': Icons.grain, 'label': 'TDS', 'unit': 'mg/L', 'controller': _tdsController}, - {'icon': Icons.opacity, 'label': 'Turbidity', 'unit': 'NTU', 'controller': _turbidityController}, - {'icon': Icons.filter_alt_outlined, 'label': 'TSS', 'unit': 'mg/L', 'controller': _tssController}, - {'icon': Icons.battery_charging_full, 'label': 'Battery', 'unit': 'V', 'controller': _batteryController}, + {'key': 'oxygenConcentration', 'icon': Icons.air, 'label': 'Oxygen Conc.', 'unit': 'mg/L', 'controller': _oxyConcController}, + {'key': 'oxygenSaturation', 'icon': Icons.percent, 'label': 'Oxygen Sat.', 'unit': '%', 'controller': _oxySatController}, + {'key': 'ph', 'icon': Icons.science_outlined, 'label': 'pH', 'unit': '', 'controller': _phController}, + {'key': 'salinity', 'icon': Icons.waves, 'label': 'Salinity', 'unit': 'ppt', 'controller': _salinityController}, + {'key': 'electricalConductivity', 'icon': Icons.flash_on, 'label': 'Conductivity', 'unit': 'µS/cm', 'controller': _ecController}, + {'key': 'temperature', 'icon': Icons.thermostat, 'label': 'Temperature', 'unit': '°C', 'controller': _tempController}, + {'key': 'tds', 'icon': Icons.grain, 'label': 'TDS', 'unit': 'mg/L', 'controller': _tdsController}, + {'key': 'turbidity', 'icon': Icons.opacity, 'label': 'Turbidity', 'unit': 'NTU', 'controller': _turbidityController}, + {'key': 'tss', 'icon': Icons.filter_alt_outlined, 'label': 'TSS', 'unit': 'mg/L', 'controller': _tssController}, + {'key': 'batteryVoltage', 'icon': Icons.battery_charging_full, 'label': 'Battery', 'unit': 'V', 'controller': _batteryController}, ]); } } @@ -148,29 +153,21 @@ class _InSituStep3DataCaptureState extends State with Wi _batteryController.dispose(); } - /// Handles the entire connection flow, including a permission check. Future _handleConnectionAttempt(String type) async { final service = context.read(); - - final bool hasPermissions = await service.requestDevicePermissions(); + final hasPermissions = await service.requestDevicePermissions(); if (!hasPermissions && mounted) { _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); return; } - _disconnectFromAll(); await Future.delayed(const Duration(milliseconds: 250)); - final bool connectionSuccess = await _connectToDevice(type); - if (connectionSuccess && mounted) { _dataSubscription?.cancel(); final stream = type == 'bluetooth' ? service.bluetoothDataStream : service.serialDataStream; - _dataSubscription = stream.listen((readings) { - if (mounted) { - _updateTextFields(readings); - } + if (mounted) _updateTextFields(readings); }); } } @@ -179,7 +176,6 @@ class _InSituStep3DataCaptureState extends State with Wi setState(() => _isLoading = true); final service = context.read(); bool success = false; - try { if (type == 'bluetooth') { final devices = await service.getPairedBluetoothDevices(); @@ -206,7 +202,6 @@ class _InSituStep3DataCaptureState extends State with Wi } } catch (e) { if (mounted) _showSnackBar('Connection failed: $e', isError: true); - success = false; } finally { if (mounted) setState(() => _isLoading = false); } @@ -218,37 +213,25 @@ class _InSituStep3DataCaptureState extends State with Wi setState(() { _isAutoReading = !_isAutoReading; if (_isAutoReading) { - if (activeType == 'bluetooth') service.startBluetoothAutoReading(); - else service.startSerialAutoReading(); + if (activeType == 'bluetooth') service.startBluetoothAutoReading(); else service.startSerialAutoReading(); } else { - if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); - else service.stopSerialAutoReading(); + if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading(); } }); } void _disconnect(String type) { final service = context.read(); - if (type == 'bluetooth') { - service.disconnectFromBluetooth(); - } else { - service.disconnectFromSerial(); - } + if (type == 'bluetooth') service.disconnectFromBluetooth(); else service.disconnectFromSerial(); _dataSubscription?.cancel(); _dataSubscription = null; - if (mounted) { - setState(() => _isAutoReading = false); - } + if (mounted) setState(() => _isAutoReading = false); } void _disconnectFromAll() { final service = context.read(); - if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { - _disconnect('bluetooth'); - } - if (service.serialConnectionState.value != SerialConnectionState.disconnected) { - _disconnect('serial'); - } + if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) _disconnect('bluetooth'); + if (service.serialConnectionState.value != SerialConnectionState.disconnected) _disconnect('serial'); } void _updateTextFields(Map readings) { @@ -267,50 +250,135 @@ class _InSituStep3DataCaptureState extends State with Wi }); } - void _goToNextStep() { + void _validateAndProceed() { + debugPrint("--- Parameter Validation Triggered ---"); + if (_isAutoReading) { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Data Collection Active'), - content: const Text('Please stop the live data collection before proceeding.'), - actions: [ - TextButton( - child: const Text('OK'), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ], - ); - }, - ); + _showStopReadingDialog(); return; } - _formKey.currentState!.save(); + final currentReadings = _captureReadingsToMap(); + debugPrint("Current Readings Captured: $currentReadings"); + + final authProvider = Provider.of(context, listen: false); + final allLimits = authProvider.parameterLimits ?? []; + debugPrint("Total parameter limits loaded from AuthProvider: ${allLimits.length} rules."); + if (allLimits.isNotEmpty) { + debugPrint("Sample limit rule from AuthProvider: ${allLimits.first}"); + } + + final marineLimits = allLimits.where((limit) => limit['department_id'] == 4).toList(); + debugPrint("Found ${marineLimits.length} rules after filtering for Marine department (ID 4)."); + + final outOfBoundsParams = _validateParameters(currentReadings, marineLimits); + debugPrint("Validation check complete. Found ${outOfBoundsParams.length} out-of-bounds parameters."); + + if (outOfBoundsParams.isNotEmpty) { + debugPrint("Action: Displaying parameter limit warning dialog for: $outOfBoundsParams"); + _showParameterLimitDialog(outOfBoundsParams, currentReadings); + } else { + debugPrint("Action: Validation passed or no applicable limits found. Proceeding to the next step."); + _saveDataAndMoveOn(currentReadings); + } + debugPrint("--- Parameter Validation Finished ---"); + } + + Map _captureReadingsToMap() { + final Map readings = {}; + for (var param in _parameters) { + final key = param['key'] as String; + final controller = param['controller'] as TextEditingController; + readings[key] = double.tryParse(controller.text) ?? -999.0; + } + return readings; + } + + List> _validateParameters(Map readings, List> limits) { + final List> invalidParams = []; + + 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 == -999.0) return; + + final limitName = _parameterKeyToLimitName[key]; + if (limitName == null) return; + + final limitData = limits.firstWhere( + (l) => l['param_parameter_list'] == limitName, + orElse: () => {}, + ); + + 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 paramInfo = _parameters.firstWhere((p) => p['key'] == key, orElse: () => {}); + invalidParams.add({ + 'label': paramInfo['label'] ?? key, + 'value': value, + 'lower_limit': lowerLimit, + 'upper_limit': upperLimit, + }); + } + } + }); + return invalidParams; + } + + void _saveDataAndMoveOn(Map readings) { try { const defaultValue = -999.0; - widget.data.temperature = double.tryParse(_tempController.text) ?? defaultValue; - widget.data.ph = double.tryParse(_phController.text) ?? defaultValue; - widget.data.salinity = double.tryParse(_salinityController.text) ?? defaultValue; - widget.data.electricalConductivity = double.tryParse(_ecController.text) ?? defaultValue; - widget.data.oxygenConcentration = double.tryParse(_oxyConcController.text) ?? defaultValue; - widget.data.oxygenSaturation = double.tryParse(_oxySatController.text) ?? defaultValue; - widget.data.tds = double.tryParse(_tdsController.text) ?? defaultValue; - widget.data.turbidity = double.tryParse(_turbidityController.text) ?? defaultValue; - widget.data.tss = double.tryParse(_tssController.text) ?? defaultValue; - widget.data.batteryVoltage = double.tryParse(_batteryController.text) ?? defaultValue; + widget.data.temperature = readings['temperature'] ?? defaultValue; + widget.data.ph = readings['ph'] ?? defaultValue; + widget.data.salinity = readings['salinity'] ?? defaultValue; + widget.data.electricalConductivity = readings['electricalConductivity'] ?? defaultValue; + widget.data.oxygenConcentration = readings['oxygenConcentration'] ?? defaultValue; + widget.data.oxygenSaturation = readings['oxygenSaturation'] ?? defaultValue; + widget.data.tds = readings['tds'] ?? defaultValue; + widget.data.turbidity = readings['turbidity'] ?? defaultValue; + widget.data.tss = readings['tss'] ?? defaultValue; + widget.data.batteryVoltage = readings['batteryVoltage'] ?? defaultValue; } catch (e) { _showSnackBar("Could not save parameters due to a data format error.", isError: true); return; } + if (_previousReadingsForComparison != null) { + setState(() { + _previousReadingsForComparison = null; + }); + } + widget.onNext(); } + void _showStopReadingDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Data Collection Active'), + content: const Text('Please stop the live data collection before proceeding.'), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ); + }, + ); + } + void _showSnackBar(String message, {bool isError = false}) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( @@ -323,18 +391,10 @@ class _InSituStep3DataCaptureState extends State with Wi Map? _getActiveConnectionDetails() { final service = context.watch(); if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { - return { - 'type': 'bluetooth', - 'state': service.bluetoothConnectionState.value, - 'name': service.connectedBluetoothDeviceName, - }; + return {'type': 'bluetooth', 'state': service.bluetoothConnectionState.value, 'name': service.connectedBluetoothDeviceName}; } if (service.serialConnectionState.value != SerialConnectionState.disconnected) { - return { - 'type': 'serial', - 'state': service.serialConnectionState.value, - 'name': service.connectedSerialDeviceName, - }; + return {'type': 'serial', 'state': service.serialConnectionState.value, 'name': service.connectedSerialDeviceName}; } return null; } @@ -356,68 +416,40 @@ class _InSituStep3DataCaptureState extends State with Wi children: [ Expanded( child: activeType == 'bluetooth' - ? FilledButton.icon( - icon: const Icon(Icons.bluetooth_connected), - label: const Text("Bluetooth"), - onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth'), - ) - : OutlinedButton.icon( - icon: const Icon(Icons.bluetooth), - label: const Text("Bluetooth"), - onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth'), - ), + ? FilledButton.icon(icon: const Icon(Icons.bluetooth_connected), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth')) + : OutlinedButton.icon(icon: const Icon(Icons.bluetooth), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth')), ), const SizedBox(width: 16), Expanded( child: activeType == 'serial' - ? FilledButton.icon( - icon: const Icon(Icons.usb), - label: const Text("USB Serial"), - onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial'), - ) - : OutlinedButton.icon( - icon: const Icon(Icons.usb), - label: const Text("USB Serial"), - onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial'), - ), + ? FilledButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial')) + : OutlinedButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial')), ), ], ), const SizedBox(height: 16), if (activeConnection != null) - _buildConnectionCard( - type: activeConnection['type'], - connectionState: activeConnection['state'], - deviceName: activeConnection['name'], - ), + _buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']), const SizedBox(height: 24), - ValueListenableBuilder( valueListenable: service.sondeId, builder: (context, sondeId, child) { final newSondeId = sondeId ?? ''; - // START CHANGE: Safely update the controller after the build frame is complete to prevent crash WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _sondeIdController.text != newSondeId) { _sondeIdController.text = newSondeId; widget.data.sondeId = newSondeId; } }); - // END CHANGE return TextFormField( controller: _sondeIdController, - decoration: const InputDecoration( - labelText: 'Sonde ID *', - hintText: 'Connect device or enter manually'), + decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'), validator: (v) => v!.isEmpty ? 'Sonde ID is required' : null, - onChanged: (value) { - widget.data.sondeId = value; - }, + onChanged: (value) => widget.data.sondeId = value, onSaved: (v) => widget.data.sondeId = v, ); }, ), - const SizedBox(height: 16), Row( children: [ @@ -426,8 +458,11 @@ class _InSituStep3DataCaptureState extends State with Wi Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))), ], ), - const Divider(height: 32), + if (_previousReadingsForComparison != null) + _buildComparisonView(), + + const Divider(height: 32), Column( children: _parameters.map((param) { return _buildParameterListItem( @@ -438,10 +473,9 @@ class _InSituStep3DataCaptureState extends State with Wi ); }).toList(), ), - const SizedBox(height: 32), ElevatedButton( - onPressed: _goToNextStep, + onPressed: _validateAndProceed, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: const Text('Next'), ), @@ -450,16 +484,167 @@ class _InSituStep3DataCaptureState extends State with Wi ); } - Widget _buildParameterListItem({ - required IconData icon, - required String label, - required String unit, - required TextEditingController controller, - }) { + Widget _buildComparisonView() { + final previousReadings = _previousReadingsForComparison!; + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + + return Card( + margin: const EdgeInsets.only(top: 24.0), + color: Theme.of(context).cardColor, // Adapts to theme's card color + child: Padding( + padding: const EdgeInsets.all(16.0), + child: DefaultTextStyle( // Ensure text adapts to theme + style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Resample Comparison", + style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Theme.of(context).primaryColor), + ), + const SizedBox(height: 8), + Table( + columnWidths: const { + 0: FlexColumnWidth(2), + 1: FlexColumnWidth(1.5), + 2: FlexColumnWidth(1.5), + }, + border: TableBorder( + horizontalInside: BorderSide(width: 1, color: Colors.grey.shade700, style: BorderStyle.solid), + verticalInside: BorderSide(width: 1, color: Colors.grey.shade700, style: BorderStyle.solid), + top: BorderSide(width: 1.5, color: Colors.grey.shade500), + bottom: BorderSide(width: 1.5, color: Colors.grey.shade500), + ), + children: [ + TableRow( + decoration: BoxDecoration(color: isDarkTheme ? Colors.grey.shade800 : Colors.grey.shade200), + children: [ + Padding(padding: const EdgeInsets.all(8.0), child: Text('Parameter', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleMedium?.color))), + Padding(padding: const EdgeInsets.all(8.0), child: Text('Previous', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleMedium?.color))), + Padding(padding: const EdgeInsets.all(8.0), child: Text('Current', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleMedium?.color))), + ], + ), + ..._parameters.map((param) { + final key = param['key'] as String; + final label = param['label'] as String; + final controller = param['controller'] as TextEditingController; + final previousValue = previousReadings[key]; + + return TableRow( + children: [ + Padding(padding: const EdgeInsets.all(8.0), child: Text(label)), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + previousValue == -999.0 ? '-.--' : previousValue!.toStringAsFixed(2), + style: TextStyle(color: isDarkTheme ? Colors.orange.shade200 : Colors.orange.shade700), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + controller.text.contains('-999') ? '-.--' : (double.tryParse(controller.text) ?? 0).toStringAsFixed(2), + style: TextStyle(color: isDarkTheme ? Colors.green.shade200 : Colors.green.shade700, fontWeight: FontWeight.bold), + ), + ), + ], + ); + }).toList(), + ], + ), + ], + ), + ), + ), + ); + } + + Future _showParameterLimitDialog(List> invalidParams, Map readings) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + return AlertDialog( + title: const Text('Parameter Limit Warning'), + content: SingleChildScrollView( + child: DefaultTextStyle( + style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color), // Make sure text is visible + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('The following parameters are outside the standard limits:'), + const SizedBox(height: 16), + Table( + columnWidths: const { + 0: FlexColumnWidth(2), + 1: FlexColumnWidth(2.5), + 2: FlexColumnWidth(1.5), + }, + border: TableBorder( + horizontalInside: BorderSide(width: 0.5, color: isDarkTheme ? Colors.grey.shade700 : Colors.grey.shade300), + verticalInside: BorderSide(width: 0.5, color: isDarkTheme ? Colors.grey.shade700 : Colors.grey.shade300), + top: BorderSide(width: 1, color: isDarkTheme ? Colors.grey.shade600 : Colors.grey.shade400), + bottom: BorderSide(width: 1, color: isDarkTheme ? Colors.grey.shade600 : Colors.grey.shade400), + ), + children: [ + TableRow( + decoration: BoxDecoration(color: isDarkTheme ? Colors.grey.shade800 : Colors.grey.shade200), + children: [ + Padding(padding: const EdgeInsets.all(6.0), child: Text('Parameter', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))), + Padding(padding: const EdgeInsets.all(6.0), child: Text('Limit Range', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))), + Padding(padding: const EdgeInsets.all(6.0), child: Text('Current', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))), + ], + ), + ...invalidParams.map((p) => TableRow( + children: [ + Padding(padding: const EdgeInsets.all(6.0), child: Text(p['label'])), + Padding(padding: const EdgeInsets.all(6.0), child: Text('${p['lower_limit']?.toStringAsFixed(1) ?? 'N/A'} - ${p['upper_limit']?.toStringAsFixed(1) ?? 'N/A'}')), + Padding( + padding: const EdgeInsets.all(6.0), + child: Text( + p['value'].toStringAsFixed(2), + style: const TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold), // Highlight the out-of-bounds value + ), + ), + ], + )).toList(), + ], + ), + const SizedBox(height: 16), + const Text('Please verify with standard solutions. Do you want to resample or proceed with the current values?'), + ], + ), + ), + ), + actions: [ + TextButton( + child: const Text('Resample'), + onPressed: () { + setState(() { + _previousReadingsForComparison = readings; + }); + Navigator.of(context).pop(); + }, + ), + FilledButton( + child: const Text('Proceed Anyway'), + onPressed: () { + Navigator.of(context).pop(); + _saveDataAndMoveOn(readings); + }, + ), + ], + ); + }, + ); + } + + Widget _buildParameterListItem({required IconData icon, required String label, required String unit, required TextEditingController controller}) { final bool isMissing = controller.text.isEmpty || controller.text.contains('-999'); final String displayValue = isMissing ? '-.--' : controller.text; final String displayLabel = unit.isEmpty ? label : '$label ($unit)'; - return Card( margin: const EdgeInsets.symmetric(vertical: 4.0), child: ListTile( @@ -478,14 +663,12 @@ class _InSituStep3DataCaptureState extends State with Wi Widget _buildConnectionCard({required String type, required dynamic connectionState, String? deviceName}) { final isConnected = connectionState == BluetoothConnectionState.connected || connectionState == SerialConnectionState.connected; final isConnecting = connectionState == BluetoothConnectionState.connecting || connectionState == SerialConnectionState.connecting; - Color statusColor = isConnected ? Colors.green : Colors.red; String statusText = isConnected ? 'Connected to ${deviceName ?? 'device'}' : 'Disconnected'; if (isConnecting) { statusColor = Colors.orange; statusText = 'Connecting...'; } - return Card( elevation: 2, child: Padding( @@ -504,10 +687,7 @@ class _InSituStep3DataCaptureState extends State with Wi icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), label: Text(_isAutoReading ? 'Stop Reading' : 'Start Reading'), onPressed: () => _toggleAutoReading(type), - style: ElevatedButton.styleFrom( - backgroundColor: _isAutoReading ? Colors.orange : Colors.green, - foregroundColor: Colors.white, - ), + style: ElevatedButton.styleFrom(backgroundColor: _isAutoReading ? Colors.orange : Colors.green, foregroundColor: Colors.white), ), TextButton.icon( icon: const Icon(Icons.link_off), diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart index 7d72ee8..5e05e82 100644 --- a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart +++ b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart @@ -7,6 +7,7 @@ import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:usb_serial/usb_serial.dart'; import 'package:intl/intl.dart'; +import '../../../../auth_provider.dart'; import '../../../../models/river_in_situ_sampling_data.dart'; import '../../../../services/river_in_situ_sampling_service.dart'; import '../../../../bluetooth/bluetooth_manager.dart'; @@ -28,13 +29,28 @@ class RiverInSituStep3DataCapture extends StatefulWidget { State createState() => _RiverInSituStep3DataCaptureState(); } -// MODIFIED: Added 'with WidgetsBindingObserver' to listen for app lifecycle events. class _RiverInSituStep3DataCaptureState extends State with WidgetsBindingObserver { final _formKey = GlobalKey(); bool _isLoading = false; bool _isAutoReading = false; StreamSubscription? _dataSubscription; + // --- START: Added for Parameter Validation Feature --- + Map? _previousReadingsForComparison; + + final Map _parameterKeyToLimitName = const { + 'oxygenConcentration': 'Oxygen Conc', + 'oxygenSaturation': 'Oxygen Sat', + 'ph': 'pH', + 'salinity': 'Salinity', + 'electricalConductivity': 'Conductivity', + 'temperature': 'Temperature', + 'tds': 'TDS', + 'turbidity': 'Turbidity', + 'ammonia': 'Ammonia', + }; + // --- END: Added for Parameter Validation Feature --- + final List> _parameters = []; // Sonde parameter controllers @@ -49,10 +65,10 @@ class _RiverInSituStep3DataCaptureState extends State _handleConnectionAttempt(String type) async { final service = context.read(); @@ -285,11 +290,9 @@ class _RiverInSituStep3DataCaptureState extends State[ - TextButton(child: const Text('OK'), onPressed: () => Navigator.of(context).pop()) - ]); - }); + _showStopReadingDialog(); return; } - if (_formKey.currentState!.validate()){ - _formKey.currentState!.save(); - try { - const defaultValue = -999.0; - widget.data.temperature = double.tryParse(_tempController.text) ?? defaultValue; - widget.data.ph = double.tryParse(_phController.text) ?? defaultValue; - widget.data.salinity = double.tryParse(_salinityController.text) ?? defaultValue; - widget.data.electricalConductivity = double.tryParse(_ecController.text) ?? defaultValue; - widget.data.oxygenConcentration = double.tryParse(_oxyConcController.text) ?? defaultValue; - widget.data.oxygenSaturation = double.tryParse(_oxySatController.text) ?? defaultValue; - widget.data.tds = double.tryParse(_tdsController.text) ?? defaultValue; - widget.data.turbidity = double.tryParse(_turbidityController.text) ?? defaultValue; - widget.data.ammonia = double.tryParse(_ammoniaController.text) ?? defaultValue; // MODIFIED: Replaced tss with ammonia - widget.data.batteryVoltage = double.tryParse(_batteryController.text) ?? defaultValue; + if (!_formKey.currentState!.validate()) { + return; + } + _formKey.currentState!.save(); - // Save flowrate data - widget.data.flowrateMethod = _selectedFlowrateMethod; - if (_selectedFlowrateMethod == 'Surface Drifter') { - widget.data.flowrateSurfaceDrifterHeight = double.tryParse(_sdHeightController.text); - widget.data.flowrateSurfaceDrifterDistance = double.tryParse(_sdDistanceController.text); - widget.data.flowrateSurfaceDrifterTimeFirst = _sdTimeFirstController.text; - widget.data.flowrateSurfaceDrifterTimeLast = _sdTimeLastController.text; - widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); - } else if (_selectedFlowrateMethod == 'Flowmeter') { - widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); - } else { // NA - widget.data.flowrateValue = null; - } + final currentReadings = _captureReadingsToMap(); + final authProvider = Provider.of(context, listen: false); + final allLimits = authProvider.parameterLimits ?? []; - } catch (e) { - _showSnackBar("Could not save parameters due to a data format error.", isError: true); - return; - } - widget.onNext(); + // Use department_id 3 for River + final riverLimits = allLimits.where((limit) => limit['department_id'] == 3).toList(); + final outOfBoundsParams = _validateParameters(currentReadings, riverLimits); + + if (outOfBoundsParams.isNotEmpty) { + _showParameterLimitDialog(outOfBoundsParams, currentReadings); + } else { + _saveDataAndMoveOn(currentReadings); } } + Map _captureReadingsToMap() { + final Map readings = {}; + for (var param in _parameters) { + final key = param['key'] as String; + final controller = param['controller'] as TextEditingController; + readings[key] = double.tryParse(controller.text) ?? -999.0; + } + return readings; + } + + List> _validateParameters(Map readings, List> limits) { + final List> invalidParams = []; + + 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 == -999.0) return; + + final limitName = _parameterKeyToLimitName[key]; + if (limitName == null) return; + + final limitData = limits.firstWhere( + (l) => l['param_parameter_list'] == limitName, + orElse: () => {}, + ); + + 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 paramInfo = _parameters.firstWhere((p) => p['key'] == key, orElse: () => {}); + invalidParams.add({ + 'label': paramInfo['label'] ?? key, + 'value': value, + 'lower_limit': lowerLimit, + 'upper_limit': upperLimit, + }); + } + } + }); + return invalidParams; + } + + void _saveDataAndMoveOn(Map readings) { + try { + const defaultValue = -999.0; + widget.data.temperature = readings['temperature'] ?? defaultValue; + widget.data.ph = readings['ph'] ?? defaultValue; + widget.data.salinity = readings['salinity'] ?? defaultValue; + widget.data.electricalConductivity = readings['electricalConductivity'] ?? defaultValue; + widget.data.oxygenConcentration = readings['oxygenConcentration'] ?? defaultValue; + widget.data.oxygenSaturation = readings['oxygenSaturation'] ?? defaultValue; + widget.data.tds = readings['tds'] ?? defaultValue; + widget.data.turbidity = readings['turbidity'] ?? defaultValue; + widget.data.ammonia = readings['ammonia'] ?? defaultValue; + widget.data.batteryVoltage = readings['batteryVoltage'] ?? defaultValue; + + widget.data.flowrateMethod = _selectedFlowrateMethod; + if (_selectedFlowrateMethod == 'Surface Drifter') { + widget.data.flowrateSurfaceDrifterHeight = double.tryParse(_sdHeightController.text); + widget.data.flowrateSurfaceDrifterDistance = double.tryParse(_sdDistanceController.text); + widget.data.flowrateSurfaceDrifterTimeFirst = _sdTimeFirstController.text; + widget.data.flowrateSurfaceDrifterTimeLast = _sdTimeLastController.text; + widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); + } else if (_selectedFlowrateMethod == 'Flowmeter') { + widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); + } else { // NA + widget.data.flowrateValue = null; + } + + } catch (e) { + _showSnackBar("Could not save parameters due to a data format error.", isError: true); + return; + } + + if (_previousReadingsForComparison != null) { + setState(() { + _previousReadingsForComparison = null; + }); + } + + widget.onNext(); + } + // --- END: New Validation Flow --- + void _showSnackBar(String message, {bool isError = false}) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( @@ -396,6 +464,21 @@ class _RiverInSituStep3DataCaptureState extends State[ + TextButton(child: const Text('OK'), onPressed: () => Navigator.of(context).pop()) + ] + ); + } + ); + } + Map? _getActiveConnectionDetails() { final service = context.watch(); if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { @@ -424,42 +507,21 @@ class _RiverInSituStep3DataCaptureState extends State _handleConnectionAttempt('bluetooth'), - ) - : OutlinedButton.icon( - icon: const Icon(Icons.bluetooth), - label: const Text("Bluetooth"), - onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth'), - ), + ? FilledButton.icon(icon: const Icon(Icons.bluetooth_connected), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth')) + : OutlinedButton.icon(icon: const Icon(Icons.bluetooth), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth')), ), const SizedBox(width: 16), Expanded( child: activeType == 'serial' - ? FilledButton.icon( - icon: const Icon(Icons.usb), - label: const Text("USB Serial"), - onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial'), - ) - : OutlinedButton.icon( - icon: const Icon(Icons.usb), - label: const Text("USB Serial"), - onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial'), - ), + ? FilledButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial')) + : OutlinedButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial')), ), ], ), const SizedBox(height: 16), if (activeConnection != null) - _buildConnectionCard( - type: activeConnection['type'], - connectionState: activeConnection['state'], - deviceName: activeConnection['name'], - ), + _buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']), const SizedBox(height: 24), - ValueListenableBuilder( valueListenable: service.sondeId, builder: (context, sondeId, child) { @@ -470,16 +532,11 @@ class _RiverInSituStep3DataCaptureState extends State v == null || v.isEmpty ? 'Sonde ID is required' : null, - onChanged: (value) { - widget.data.sondeId = value; - }, + onChanged: (value) { widget.data.sondeId = value; }, onSaved: (v) => widget.data.sondeId = v, ); }, @@ -494,6 +551,9 @@ class _RiverInSituStep3DataCaptureState extends State _showParameterLimitDialog(List> invalidParams, Map readings) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + return AlertDialog( + title: const Text('Parameter Limit Warning'), + content: SingleChildScrollView( + child: DefaultTextStyle( + style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('The following parameters are outside the standard limits:'), + const SizedBox(height: 16), + Table( + columnWidths: const { + 0: FlexColumnWidth(2), + 1: FlexColumnWidth(2.5), + 2: FlexColumnWidth(1.5), + }, + border: TableBorder( + horizontalInside: BorderSide(width: 0.5, color: isDarkTheme ? Colors.grey.shade700 : Colors.grey.shade300), + verticalInside: BorderSide(width: 0.5, color: isDarkTheme ? Colors.grey.shade700 : Colors.grey.shade300), + top: BorderSide(width: 1, color: isDarkTheme ? Colors.grey.shade600 : Colors.grey.shade400), + bottom: BorderSide(width: 1, color: isDarkTheme ? Colors.grey.shade600 : Colors.grey.shade400), + ), + children: [ + TableRow( + decoration: BoxDecoration(color: isDarkTheme ? Colors.grey.shade800 : Colors.grey.shade200), + children: [ + Padding(padding: const EdgeInsets.all(6.0), child: Text('Parameter', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))), + Padding(padding: const EdgeInsets.all(6.0), child: Text('Limit Range', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))), + Padding(padding: const EdgeInsets.all(6.0), child: Text('Current', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))), + ], + ), + ...invalidParams.map((p) => TableRow( + children: [ + Padding(padding: const EdgeInsets.all(6.0), child: Text(p['label'])), + Padding(padding: const EdgeInsets.all(6.0), child: Text('${p['lower_limit']?.toStringAsFixed(1) ?? 'N/A'} - ${p['upper_limit']?.toStringAsFixed(1) ?? 'N/A'}')), + Padding( + padding: const EdgeInsets.all(6.0), + child: Text( + p['value'].toStringAsFixed(2), + style: const TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold), + ), + ), + ], + )).toList(), + ], + ), + const SizedBox(height: 16), + const Text('Do you want to resample or proceed with the current values?'), + ], + ), + ), + ), + actions: [ + TextButton( + child: const Text('Resample'), + onPressed: () { + setState(() { + _previousReadingsForComparison = readings; + }); + Navigator.of(context).pop(); + }, + ), + FilledButton( + child: const Text('Proceed Anyway'), + onPressed: () { + Navigator.of(context).pop(); + _saveDataAndMoveOn(readings); + }, + ), + ], + ); + }, + ); + } + // --- END: New UI Widgets for Validation Feature --- + Widget _buildFlowrateSection() { return Card( margin: const EdgeInsets.symmetric(vertical: 4.0),