add in parameter limit check for manual sampling river and marine

This commit is contained in:
ALim Aidrus 2025-09-03 15:23:48 +08:00
parent adb3cb0754
commit f742dd5853
3 changed files with 654 additions and 266 deletions

View File

@ -27,7 +27,7 @@
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<!-- END: STORAGE PERMISSIONS -->
<!-- MMS V4 1.2.02 -->
<!-- MMS V4 1.2.03 -->
<application
android:label="MMS V4 debug"
android:name="${applicationName}"

View File

@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart';
import '../../../../auth_provider.dart';
import '../../../../models/in_situ_sampling_data.dart';
import '../../../../services/marine_in_situ_sampling_service.dart';
import '../../../../bluetooth/bluetooth_manager.dart'; // For connection state enum
@ -27,14 +28,29 @@ class InSituStep3DataCapture extends StatefulWidget {
State<InSituStep3DataCapture> createState() => _InSituStep3DataCaptureState();
}
// START CHANGE: Add WidgetsBindingObserver to listen for app lifecycle events
class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with WidgetsBindingObserver {
// END CHANGE
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _isAutoReading = false;
StreamSubscription? _dataSubscription;
Map<String, double>? _previousReadingsForComparison;
/// Maps the app's internal parameter keys to the names used in the
/// 'param_parameter_list' column from the server.
final Map<String, String> _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<Map<String, dynamic>> _parameters = [];
final _sondeIdController = TextEditingController();
@ -55,36 +71,27 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> 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<InSituStep3DataCapture> 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<InSituStep3DataCapture> 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<InSituStep3DataCapture> with Wi
_batteryController.dispose();
}
/// Handles the entire connection flow, including a permission check.
Future<void> _handleConnectionAttempt(String type) async {
final service = context.read<MarineInSituSamplingService>();
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<InSituStep3DataCapture> with Wi
setState(() => _isLoading = true);
final service = context.read<MarineInSituSamplingService>();
bool success = false;
try {
if (type == 'bluetooth') {
final devices = await service.getPairedBluetoothDevices();
@ -206,7 +202,6 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> 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<InSituStep3DataCapture> 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<MarineInSituSamplingService>();
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<MarineInSituSamplingService>();
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<String, double> readings) {
@ -267,50 +250,135 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> 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: <Widget>[
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<AuthProvider>(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<String, double> _captureReadingsToMap() {
final Map<String, double> 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<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) {
final List<Map<String, dynamic>> 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<String, double> 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: <Widget>[
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<InSituStep3DataCapture> with Wi
Map<String, dynamic>? _getActiveConnectionDetails() {
final service = context.watch<MarineInSituSamplingService>();
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<InSituStep3DataCapture> 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<String?>(
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<InSituStep3DataCapture> 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<InSituStep3DataCapture> 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<InSituStep3DataCapture> 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<void> _showParameterLimitDialog(List<Map<String, dynamic>> invalidParams, Map<String, double> 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: <Widget>[
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<InSituStep3DataCapture> 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<InSituStep3DataCapture> 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),

View File

@ -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<RiverInSituStep3DataCapture> createState() => _RiverInSituStep3DataCaptureState();
}
// MODIFIED: Added 'with WidgetsBindingObserver' to listen for app lifecycle events.
class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCapture> with WidgetsBindingObserver {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _isAutoReading = false;
StreamSubscription? _dataSubscription;
// --- START: Added for Parameter Validation Feature ---
Map<String, double>? _previousReadingsForComparison;
final Map<String, String> _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<Map<String, dynamic>> _parameters = [];
// Sonde parameter controllers
@ -49,10 +65,10 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final _tempController = TextEditingController();
final _tdsController = TextEditingController();
final _turbidityController = TextEditingController();
final _ammoniaController = TextEditingController(); // MODIFIED: Replaced tss with ammonia
final _ammoniaController = TextEditingController();
final _batteryController = TextEditingController();
// ADDED: Flowrate controllers and state
// Flowrate controllers and state
String? _selectedFlowrateMethod;
final _flowrateValueController = TextEditingController();
final _sdHeightController = TextEditingController();
@ -65,7 +81,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
super.initState();
_initializeControllers();
_initializeFlowrateControllers();
// ADDED: Register the observer to listen for app lifecycle changes.
WidgetsBinding.instance.addObserver(this);
}
@ -74,18 +89,13 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_dataSubscription?.cancel();
_disposeControllers();
_disposeFlowrateControllers();
// ADDED: Remove the observer to prevent memory leaks.
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
// ADDED: This method is called whenever the app lifecycle state changes.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// When the app resumes from the background (e.g., after the user grants a permission),
// call setState to force a UI rebuild. This ensures the connection buttons
// and status are updated correctly without needing to navigate away and back.
if (mounted) {
setState(() {});
}
@ -108,21 +118,23 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_tempController.text = widget.data.temperature?.toString() ?? '-999.0';
_tdsController.text = widget.data.tds?.toString() ?? '-999.0';
_turbidityController.text = widget.data.turbidity?.toString() ?? '-999.0';
_ammoniaController.text = widget.data.ammonia?.toString() ?? '-999.0'; // MODIFIED: Replaced tss with ammonia
_ammoniaController.text = widget.data.ammonia?.toString() ?? '-999.0';
_batteryController.text = widget.data.batteryVoltage?.toString() ?? '-999.0';
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.science, 'label': 'Ammonia', 'unit': 'mg/L', 'controller': _ammoniaController}, // MODIFIED: Replaced TSS with Ammonia
{'icon': Icons.battery_charging_full, 'label': 'Battery', 'unit': 'V', 'controller': _batteryController},
// --- START: Added 'key' for programmatic access ---
{'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': 'ammonia', 'icon': Icons.science, 'label': 'Ammonia', 'unit': 'mg/L', 'controller': _ammoniaController},
{'key': 'batteryVoltage', 'icon': Icons.battery_charging_full, 'label': 'Battery', 'unit': 'V', 'controller': _batteryController},
// --- END: Added 'key' for programmatic access ---
]);
}
}
@ -139,11 +151,10 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_tempController.dispose();
_tdsController.dispose();
_turbidityController.dispose();
_ammoniaController.dispose(); // MODIFIED: Replaced tss with ammonia
_ammoniaController.dispose();
_batteryController.dispose();
}
// --- START: Flowrate Logic ---
void _initializeFlowrateControllers() {
_selectedFlowrateMethod = widget.data.flowrateMethod;
_flowrateValueController.text = widget.data.flowrateValue?.toString() ?? '';
@ -169,7 +180,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
} else if (value == 'Flowmeter') {
_flowrateValueController.clear();
} else {
// Clear calculated value when switching back to Surface Drifter
_flowrateValueController.clear();
}
});
@ -189,14 +199,11 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final timeFormat = DateFormat("HH:mm:ss");
final timeFirst = timeFormat.parse(timeFirstStr);
final timeLast = timeFormat.parse(timeLastStr);
final differenceInSeconds = timeLast.difference(timeFirst).inSeconds;
if (differenceInSeconds <= 0) {
_showSnackBar("Time Last Deploy must be after Time First Deploy.", isError: true);
return;
}
final flowrate = distance / differenceInSeconds;
setState(() {
_flowrateValueController.text = flowrate.toStringAsFixed(4);
@ -219,8 +226,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
});
}
}
// --- END: Flowrate Logic ---
Future<void> _handleConnectionAttempt(String type) async {
final service = context.read<RiverInSituSamplingService>();
@ -285,11 +290,9 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
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();
}
});
}
@ -330,63 +333,128 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_tdsController.text = (readings['Conductivity:TDS mg/L'] ?? defaultValue).toStringAsFixed(5);
_turbidityController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5);
_batteryController.text = (readings['Sonde: Battery Voltage'] ?? defaultValue).toStringAsFixed(5);
// FIX: Add this line to read and display the Ammonia value from the sensor readings.
_ammoniaController.text = (readings['Ammonium (NH4+) mg/L'] ?? defaultValue).toStringAsFixed(5);
});
}
void _goToNextStep() {
// --- START: New Validation Flow ---
void _validateAndProceed() {
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: <Widget>[
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<AuthProvider>(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<String, double> _captureReadingsToMap() {
final Map<String, double> 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<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) {
final List<Map<String, dynamic>> 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<String, double> 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<RiverInSituStep3DataCaptur
}
}
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: <Widget>[
TextButton(child: const Text('OK'), onPressed: () => Navigator.of(context).pop())
]
);
}
);
}
Map<String, dynamic>? _getActiveConnectionDetails() {
final service = context.watch<RiverInSituSamplingService>();
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
@ -424,42 +507,21 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
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<String?>(
valueListenable: service.sondeId,
builder: (context, sondeId, child) {
@ -470,16 +532,11 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
widget.data.sondeId = newSondeId;
}
});
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 == 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<RiverInSituStep3DataCaptur
),
const Divider(height: 32),
if (_previousReadingsForComparison != null)
_buildComparisonView(),
Column(
children: _parameters.map((param) {
return _buildParameterListItem(
@ -504,15 +564,11 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
);
}).toList(),
),
const Divider(height: 32),
// --- START: Flowrate Section ---
_buildFlowrateSection(),
// --- END: Flowrate Section ---
const SizedBox(height: 32),
ElevatedButton(
onPressed: _goToNextStep,
onPressed: _validateAndProceed,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Next'),
),
@ -521,16 +577,10 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
);
}
Widget _buildParameterListItem({
required IconData icon,
required String label,
required String unit,
required TextEditingController controller,
}) {
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(
@ -592,7 +642,165 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
);
}
// ADDED: Widget for the entire Flowrate section
// --- START: New UI Widgets for Validation Feature ---
Widget _buildComparisonView() {
final previousReadings = _previousReadingsForComparison!;
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return Card(
margin: const EdgeInsets.symmetric(vertical: 16.0),
color: Theme.of(context).cardColor,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: DefaultTextStyle(
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<void> _showParameterLimitDialog(List<Map<String, dynamic>> invalidParams, Map<String, double> 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: <Widget>[
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),