add in parameter limit check for manual sampling river and marine
This commit is contained in:
parent
adb3cb0754
commit
f742dd5853
@ -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}"
|
||||
|
||||
@ -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,8 +250,118 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
|
||||
});
|
||||
}
|
||||
|
||||
void _goToNextStep() {
|
||||
void _validateAndProceed() {
|
||||
debugPrint("--- Parameter Validation Triggered ---");
|
||||
|
||||
if (_isAutoReading) {
|
||||
_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 = 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) {
|
||||
@ -278,37 +371,12 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: const Text('OK'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_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.tss = double.tryParse(_tssController.text) ?? defaultValue;
|
||||
widget.data.batteryVoltage = double.tryParse(_batteryController.text) ?? defaultValue;
|
||||
} catch (e) {
|
||||
_showSnackBar("Could not save parameters due to a data format error.", isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
widget.onNext();
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, {bool isError = false}) {
|
||||
@ -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),
|
||||
|
||||
@ -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,42 +333,100 @@ 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()){
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
_formKey.currentState!.save();
|
||||
|
||||
final currentReadings = _captureReadingsToMap();
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final allLimits = authProvider.parameterLimits ?? [];
|
||||
|
||||
// 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 = 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;
|
||||
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;
|
||||
|
||||
// Save flowrate data
|
||||
widget.data.flowrateMethod = _selectedFlowrateMethod;
|
||||
if (_selectedFlowrateMethod == 'Surface Drifter') {
|
||||
widget.data.flowrateSurfaceDrifterHeight = double.tryParse(_sdHeightController.text);
|
||||
@ -383,9 +444,16 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
_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) {
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user