modfiy marine and river ftp submission module and retry module so that it only process once and not resubmit the success data multiple time

This commit is contained in:
ALim Aidrus 2025-11-22 21:21:48 +08:00
parent 6c4bc335b8
commit 05d29bc107
11 changed files with 1451 additions and 1132 deletions

View File

@ -329,69 +329,71 @@ class RiverInvesManualSamplingData {
// Sampler & Time Info (Assuming same API keys as manual) // Sampler & Time Info (Assuming same API keys as manual)
add('first_sampler_user_id', firstSamplerUserId); add('first_sampler_user_id', firstSamplerUserId);
add('r_inv_second_sampler_id', secondSampler?['user_id']); // Prefixed inv? // *** FIX: Changed 'r_inv_' to 'r_inves_' to match API ***
add('r_inv_date', samplingDate); add('r_inves_second_sampler_id', secondSampler?['user_id']);
add('r_inv_time', samplingTime); add('r_inves_date', samplingDate);
add('r_inv_type', samplingType); // Should be 'Investigative' add('r_inves_time', samplingTime);
add('r_inv_sample_id_code', sampleIdCode); add('r_inves_type', samplingType);
add('r_inves_sample_id_code', sampleIdCode);
// Station Info (Conditional) // Station Info (Conditional)
add('r_inv_station_type', stationTypeSelection); add('r_inves_station_type', stationTypeSelection);
if (stationTypeSelection == 'Existing Manual Station') { if (stationTypeSelection == 'Existing Manual Station') {
add('station_id', selectedStation?['station_id']); // Assuming API wants the numeric ID add('station_id', selectedStation?['station_id']); // Assuming API wants the numeric ID
add('r_inv_station_code', selectedStation?['sampling_station_code']); // Add code for display/logging if needed add('r_inves_station_code', selectedStation?['sampling_station_code']); // Add code for display/logging if needed
} else if (stationTypeSelection == 'Existing Triennial Station') { } else if (stationTypeSelection == 'Existing Triennial Station') {
add('triennial_station_id', selectedTriennialStation?['station_id']); // Assuming a different key add('triennial_station_id', selectedTriennialStation?['station_id']); // Assuming a different key
add('r_inv_station_code', selectedTriennialStation?['triennial_station_code']); add('r_inves_station_code', selectedTriennialStation?['triennial_station_code']);
} else if (stationTypeSelection == 'New Location') { } else if (stationTypeSelection == 'New Location') {
add('r_inv_new_state_name', newStateName); add('r_inves_new_state_name', newStateName);
add('r_inv_new_basin_name', newBasinName); add('r_inves_new_basin_name', newBasinName);
add('r_inv_new_river_name', newRiverName); add('r_inves_new_river_name', newRiverName);
add('r_inv_new_station_name', newStationName); // Include newStationName add('r_inves_new_station_name', newStationName); // Include newStationName
add('r_inv_new_station_code', newStationCode); // Optional code add('r_inves_new_station_code', newStationCode); // Optional code
add('r_inv_station_latitude', stationLatitude); // Use the captured/entered lat/lon add('r_inves_station_latitude', stationLatitude); // Use the captured/entered lat/lon
add('r_inv_station_longitude', stationLongitude); add('r_inves_station_longitude', stationLongitude);
} }
// Location Verification (Assuming same keys) // Location Verification (Assuming same keys)
add('r_inv_current_latitude', currentLatitude); add('r_inves_current_latitude', currentLatitude);
add('r_inv_current_longitude', currentLongitude); add('r_inves_current_longitude', currentLongitude);
add('r_inv_distance_difference', distanceDifferenceInKm); add('r_inves_distance_difference', distanceDifferenceInKm);
add('r_inv_distance_difference_remarks', distanceDifferenceRemarks); add('r_inves_distance_difference_remarks', distanceDifferenceRemarks);
// Site Info (Assuming same keys) // Site Info (Assuming same keys)
add('r_inv_weather', weather); add('r_inves_weather', weather);
add('r_inv_event_remark', eventRemarks); add('r_inves_event_remark', eventRemarks);
add('r_inv_lab_remark', labRemarks); add('r_inves_lab_remark', labRemarks);
// Optional Remarks (Assuming same keys) // Optional Remarks (Assuming same keys)
add('r_inv_optional_photo_01_remarks', optionalRemark1); add('r_inves_optional_photo_01_remarks', optionalRemark1);
add('r_inv_optional_photo_02_remarks', optionalRemark2); add('r_inves_optional_photo_02_remarks', optionalRemark2);
add('r_inv_optional_photo_03_remarks', optionalRemark3); add('r_inves_optional_photo_03_remarks', optionalRemark3);
add('r_inv_optional_photo_04_remarks', optionalRemark4); add('r_inves_optional_photo_04_remarks', optionalRemark4);
// Parameters (Assuming same keys) // Parameters (Assuming same keys)
add('r_inv_sondeID', sondeId); add('r_inves_sondeID', sondeId);
add('data_capture_date', dataCaptureDate); // Reuse generic keys? // Note: data_capture_date/time might not be used by API if not in controller, but keeping generally safe
add('data_capture_time', dataCaptureTime); // Reuse generic keys? add('data_capture_date', dataCaptureDate);
add('r_inv_oxygen_conc', oxygenConcentration); add('data_capture_time', dataCaptureTime);
add('r_inv_oxygen_sat', oxygenSaturation); add('r_inves_oxygen_conc', oxygenConcentration);
add('r_inv_ph', ph); add('r_inves_oxygen_sat', oxygenSaturation);
add('r_inv_salinity', salinity); add('r_inves_ph', ph);
add('r_inv_conductivity', electricalConductivity); add('r_inves_salinity', salinity);
add('r_inv_temperature', temperature); add('r_inves_conductivity', electricalConductivity);
add('r_inv_tds', tds); add('r_inves_temperature', temperature);
add('r_inv_turbidity', turbidity); add('r_inves_tds', tds);
add('r_inv_ammonia', ammonia); add('r_inves_turbidity', turbidity);
add('r_inv_battery_volt', batteryVoltage); add('r_inves_ammonia', ammonia);
add('r_inves_battery_volt', batteryVoltage);
// Flowrate (Assuming same keys) // Flowrate (Assuming same keys)
add('r_inv_flowrate_method', flowrateMethod); add('r_inves_flowrate_method', flowrateMethod);
add('r_inv_flowrate_sd_height', flowrateSurfaceDrifterHeight); add('r_inves_flowrate_sd_height', flowrateSurfaceDrifterHeight);
add('r_inv_flowrate_sd_distance', flowrateSurfaceDrifterDistance); add('r_inves_flowrate_sd_distance', flowrateSurfaceDrifterDistance);
add('r_inv_flowrate_sd_time_first', flowrateSurfaceDrifterTimeFirst); add('r_inves_flowrate_sd_time_first', flowrateSurfaceDrifterTimeFirst);
add('r_inv_flowrate_sd_time_last', flowrateSurfaceDrifterTimeLast); add('r_inves_flowrate_sd_time_last', flowrateSurfaceDrifterTimeLast);
add('r_inv_flowrate_value', flowrateValue); add('r_inves_flowrate_value', flowrateValue);
// Additional data that might be useful for display or if API needs it redundantly // Additional data that might be useful for display or if API needs it redundantly
add('first_sampler_name', firstSamplerName); add('first_sampler_name', firstSamplerName);
@ -407,16 +409,16 @@ class RiverInvesManualSamplingData {
/// Converts the image properties into a Map<String, File?> for the multipart API request. /// Converts the image properties into a Map<String, File?> for the multipart API request.
/// Keys should match the expected API endpoint fields for Investigative images. /// Keys should match the expected API endpoint fields for Investigative images.
Map<String, File?> toApiImageFiles() { Map<String, File?> toApiImageFiles() {
// Assuming same keys as manual, but prefixed with r_inv_? // *** FIX: Updated keys to 'r_inves_' to match DB/Controller ***
return { return {
'r_inv_background_station': backgroundStationImage, 'r_inves_background_station': backgroundStationImage,
'r_inv_upstream_river': upstreamRiverImage, 'r_inves_upstream_river': upstreamRiverImage,
'r_inv_downstream_river': downstreamRiverImage, 'r_inves_downstream_river': downstreamRiverImage,
'r_inv_sample_turbidity': sampleTurbidityImage, 'r_inves_sample_turbidity': sampleTurbidityImage,
'r_inv_optional_photo_01': optionalImage1, 'r_inves_optional_photo_01': optionalImage1,
'r_inv_optional_photo_02': optionalImage2, 'r_inves_optional_photo_02': optionalImage2,
'r_inv_optional_photo_03': optionalImage3, 'r_inves_optional_photo_03': optionalImage3,
'r_inv_optional_photo_04': optionalImage4, 'r_inves_optional_photo_04': optionalImage4,
}; };
} }

View File

@ -224,8 +224,9 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
if (value == 'NA') { if (value == 'NA') {
_flowrateValueController.text = 'NA'; _flowrateValueController.text = 'NA';
} else if (value == 'Flowmeter') { } else if (value == 'Flowmeter') {
// Keep existing value if user switches back, or clear if desired // --- MODIFICATION: Clear flowrate value for Flowmeter ---
// _flowrateValueController.clear(); _flowrateValueController.clear();
// --- END MODIFICATION ---
_sdHeightController.clear(); _sdHeightController.clear();
_sdDistanceController.clear(); _sdDistanceController.clear();
_sdTimeFirstController.clear(); _sdTimeFirstController.clear();
@ -466,10 +467,13 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
return; return;
} }
// --- START MODIFICATION: Disable Next if Connected ---
// --- MODIFICATION: Changed to allow proceeding if reading is STOPPED, even if connected ---
if (_isAutoReading) { if (_isAutoReading) {
_showStopReadingDialog(); _showStopReadingDialog();
return; return;
} }
// --- END MODIFICATION ---
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) {
return; return;
@ -649,9 +653,10 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
} }
Map<String, dynamic>? _getActiveConnectionDetails() { Map<String, dynamic>? _getActiveConnectionDetails() {
// Logic copied from RiverInSituStep3DataCaptureState._getActiveConnectionDetails // --- START FIX: Use read() instead of watch() ---
// Uses the correct _samplingService instance via context.watch final service = context.read<RiverInvestigativeSamplingService>();
final service = context.watch<RiverInvestigativeSamplingService>(); // Watch Investigative service // --- END FIX ---
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { 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};
} }
@ -675,6 +680,15 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
final activeConnection = _getActiveConnectionDetails(); final activeConnection = _getActiveConnectionDetails();
final String? activeType = activeConnection?['type'] as String?; final String? activeType = activeConnection?['type'] as String?;
// Check if ANY device is currently connected
final bool isDeviceConnected = activeConnection != null;
// --- START MODIFICATION: Logic for disabling inputs ---
// Disable interaction if auto-reading is active OR if locked out.
// If reading is stopped (even if connected), we allow interaction.
final bool shouldDisableInput = _isAutoReading || _isLockedOut;
// --- END MODIFICATION ---
return WillPopScope( return WillPopScope(
onWillPop: () async { onWillPop: () async {
if (_isLockedOut) { if (_isLockedOut) {
@ -719,7 +733,9 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
ValueListenableBuilder<String?>( ValueListenableBuilder<String?>(
valueListenable: service.sondeId, // Listen to the correct service instance valueListenable: service.sondeId, // Listen to the correct service instance
builder: (context, sondeId, child) { builder: (context, sondeId, child) {
final newSondeId = sondeId ?? ''; // --- START FIX: Only update if non-null to prevent clearing on disconnect ---
if (sondeId != null && sondeId.isNotEmpty) {
final newSondeId = sondeId;
// Use addPostFrameCallback to avoid setting state during build // Use addPostFrameCallback to avoid setting state during build
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _sondeIdController.text != newSondeId) { if (mounted && _sondeIdController.text != newSondeId) {
@ -727,6 +743,8 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
widget.data.sondeId = newSondeId; // Update model widget.data.sondeId = newSondeId; // Update model
} }
}); });
}
// --- END FIX ---
return TextFormField( return TextFormField(
controller: _sondeIdController, 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'),
@ -768,14 +786,20 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
const Divider(height: 32), const Divider(height: 32),
// Flowrate Section // Flowrate Section
_buildFlowrateSection(), // --- MODIFIED: Pass connection state to Flowrate Section ---
_buildFlowrateSection(isInputDisabled: shouldDisableInput),
const SizedBox(height: 32), const SizedBox(height: 32),
// Next Button with Lockout Timer // Next Button with Lockout Timer
ElevatedButton( ElevatedButton(
onPressed: _isLockedOut ? null : _validateAndProceed, // Disable if Locked Out OR Auto Reading is active
onPressed: (_isLockedOut || _isAutoReading) ? null : _validateAndProceed,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text(_isLockedOut ? 'Next ($_lockoutSecondsRemaining\s)' : 'Next'), child: Text(
_isLockedOut
? 'Next ($_lockoutSecondsRemaining\s)'
: (_isAutoReading ? 'Stop Reading to Proceed' : 'Next') // Helper text
),
), ),
], ],
), ),
@ -1055,8 +1079,8 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
); );
} }
Widget _buildFlowrateSection() { // Updated to include disable logic
// Copied from RiverInSituStep3DataCaptureState._buildFlowrateSection, modified to use Wrap Widget _buildFlowrateSection({bool isInputDisabled = false}) {
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0), margin: const EdgeInsets.symmetric(vertical: 4.0),
child: Padding( child: Padding(
@ -1065,8 +1089,31 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text("Flowrate", style: Theme.of(context).textTheme.titleLarge), Text("Flowrate", style: Theme.of(context).textTheme.titleLarge),
if (isInputDisabled)
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.orange, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
"Please stop reading to enter flowrate.",
style: TextStyle(color: Colors.orange[800], fontSize: 12),
),
),
],
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
// --- START FIX: Replaced Row with Wrap to fix horizontal overflow for radio buttons --- // Wrap content in AbsorbPointer and Opacity if connected
AbsorbPointer(
absorbing: isInputDisabled,
child: Opacity(
opacity: isInputDisabled ? 0.5 : 1.0,
child: Column(
children: [
// Replaced Row with Wrap to fix horizontal overflow for radio buttons
Wrap( Wrap(
alignment: WrapAlignment.spaceAround, alignment: WrapAlignment.spaceAround,
spacing: 8.0, spacing: 8.0,
@ -1077,7 +1124,6 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
_buildFlowrateRadioButton("NA"), // Not Applicable _buildFlowrateRadioButton("NA"), // Not Applicable
], ],
), ),
// --- END FIX ---
// Conditional fields based on selected method // Conditional fields based on selected method
if (_selectedFlowrateMethod == 'Surface Drifter') if (_selectedFlowrateMethod == 'Surface Drifter')
_buildSurfaceDrifterFields(), _buildSurfaceDrifterFields(),
@ -1088,11 +1134,14 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
], ],
), ),
), ),
),
],
),
),
); );
} }
Widget _buildFlowrateRadioButton(String title) { Widget _buildFlowrateRadioButton(String title) {
// Copied from RiverInSituStep3DataCaptureState._buildFlowrateRadioButton, added overflow handling
return Column( return Column(
children: [ children: [
Radio<String>( Radio<String>(
@ -1110,7 +1159,6 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
} }
Widget _buildSurfaceDrifterFields() { Widget _buildSurfaceDrifterFields() {
// Copied from RiverInSituStep3DataCaptureState._buildSurfaceDrifterFields
return Padding( return Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: Column( child: Column(
@ -1175,13 +1223,17 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
} }
Widget _buildNAField() { Widget _buildNAField() {
// Copied from RiverInSituStep3DataCaptureState._buildNAField // Fix: Use controller to set value instead of initialValue to avoid conflict crash
if (_flowrateValueController.text != 'NA') {
_flowrateValueController.text = 'NA';
}
return Padding( return Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: TextFormField( child: TextFormField(
controller: _flowrateValueController, controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), decoration: const InputDecoration(labelText: 'Flowrate (m/s)'),
initialValue: 'NA', // Set initial value to NA // initialValue: 'NA', // Removed to fix AssertionError: initialValue == null || controller == null
readOnly: true, // Make it read-only readOnly: true, // Make it read-only
), ),
); );

View File

@ -211,12 +211,20 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
void _onFlowrateMethodChanged(String? value) { void _onFlowrateMethodChanged(String? value) {
setState(() { setState(() {
_selectedFlowrateMethod = value; _selectedFlowrateMethod = value;
widget.data.flowrateMethod = value; // Update model immediately
if (value == 'NA') { if (value == 'NA') {
_flowrateValueController.text = 'NA'; _flowrateValueController.text = 'NA';
} else if (value == 'Flowmeter') { } else if (value == 'Flowmeter') {
// --- MODIFICATION: Clear flowrate value for Flowmeter ---
_flowrateValueController.clear(); _flowrateValueController.clear();
} else { // --- END MODIFICATION ---
_flowrateValueController.clear(); _sdHeightController.clear();
_sdDistanceController.clear();
_sdTimeFirstController.clear();
_sdTimeLastController.clear();
} else { // Surface Drifter
_flowrateValueController.clear(); // Will be calculated
} }
}); });
} }
@ -233,9 +241,21 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
try { try {
final timeFormat = DateFormat("HH:mm:ss"); final timeFormat = DateFormat("HH:mm:ss");
// Use a common date (like today) to allow time difference calculation across midnight
final now = DateTime.now();
final timeFirst = timeFormat.parse(timeFirstStr); final timeFirst = timeFormat.parse(timeFirstStr);
final dateTimeFirst = DateTime(now.year, now.month, now.day, timeFirst.hour, timeFirst.minute, timeFirst.second);
final timeLast = timeFormat.parse(timeLastStr); final timeLast = timeFormat.parse(timeLastStr);
final differenceInSeconds = timeLast.difference(timeFirst).inSeconds; var dateTimeLast = DateTime(now.year, now.month, now.day, timeLast.hour, timeLast.minute, timeLast.second);
// Handle crossing midnight
if (dateTimeLast.isBefore(dateTimeFirst)) {
dateTimeLast = dateTimeLast.add(const Duration(days: 1));
}
final differenceInSeconds = dateTimeLast.difference(dateTimeFirst).inSeconds;
if (differenceInSeconds <= 0) { if (differenceInSeconds <= 0) {
_showSnackBar("Time Last Deploy must be after Time First Deploy.", isError: true); _showSnackBar("Time Last Deploy must be after Time First Deploy.", isError: true);
return; return;
@ -280,6 +300,13 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
if (mounted) { if (mounted) {
_updateTextFields(readings); _updateTextFields(readings);
} }
}, onError: (error) {
debugPrint("Error on data stream: $error");
if (mounted) _showSnackBar("Data stream error: $error", isError: true);
_disconnect(type); // Disconnect on stream error
}, onDone: () {
debugPrint("Data stream done.");
if (mounted) _disconnect(type); // Disconnect when stream closes
}); });
} }
} }
@ -291,7 +318,8 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
try { try {
if (type == 'bluetooth') { if (type == 'bluetooth') {
final devices = await service.getPairedBluetoothDevices(); final devices = await service.getPairedBluetoothDevices();
if (devices.isEmpty && mounted) { if (!mounted) return false; // Check mounted after async gap
if (devices.isEmpty) {
_showSnackBar('No paired Bluetooth devices found.', isError: true); _showSnackBar('No paired Bluetooth devices found.', isError: true);
return false; return false;
} }
@ -302,8 +330,9 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
} }
} else if (type == 'serial') { } else if (type == 'serial') {
final devices = await service.getAvailableSerialDevices(); final devices = await service.getAvailableSerialDevices();
if (devices.isEmpty && mounted) { if (!mounted) return false;
_showSnackBar('No USB Serial devices found.', isError: true); if (devices.isEmpty) {
_showSnackBar('No USB Serial devices found. Ensure device is plugged in.', isError: true);
return false; return false;
} }
final selectedDevice = await showSerialPortListDialog(context: context, devices: devices); final selectedDevice = await showSerialPortListDialog(context: context, devices: devices);
@ -357,6 +386,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
_startLockoutTimer(); // --- MODIFICATION: Start countdown _startLockoutTimer(); // --- MODIFICATION: Start countdown
} else { } else {
if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading(); if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading();
// NOTE: _lockoutTimer is intentionally NOT cancelled here so the lockout persists for the remaining duration
} }
}); });
} }
@ -419,6 +449,8 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
} }
// --- END MODIFICATION --- // --- END MODIFICATION ---
// --- START MODIFICATION: Disable Next if Connected and Auto Reading is active ---
// Similar to River In-Situ, allow manual stop to re-enable 'Next'
if (_isAutoReading) { if (_isAutoReading) {
_showStopReadingDialog(); _showStopReadingDialog();
return; return;
@ -519,8 +551,16 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
widget.data.flowrateSurfaceDrifterTimeLast = _sdTimeLastController.text; widget.data.flowrateSurfaceDrifterTimeLast = _sdTimeLastController.text;
widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); widget.data.flowrateValue = double.tryParse(_flowrateValueController.text);
} else if (_selectedFlowrateMethod == 'Flowmeter') { } else if (_selectedFlowrateMethod == 'Flowmeter') {
widget.data.flowrateSurfaceDrifterHeight = null;
widget.data.flowrateSurfaceDrifterDistance = null;
widget.data.flowrateSurfaceDrifterTimeFirst = null;
widget.data.flowrateSurfaceDrifterTimeLast = null;
widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); widget.data.flowrateValue = double.tryParse(_flowrateValueController.text);
} else { // NA } else { // NA
widget.data.flowrateSurfaceDrifterHeight = null;
widget.data.flowrateSurfaceDrifterDistance = null;
widget.data.flowrateSurfaceDrifterTimeFirst = null;
widget.data.flowrateSurfaceDrifterTimeLast = null;
widget.data.flowrateValue = null; widget.data.flowrateValue = null;
} }
@ -539,8 +579,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
widget.onNext(); widget.onNext();
} }
void _showSnackBar(String message, {bool isError = false}) { void _showSnackBar(String message, {bool isError = false}) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( ScaffoldMessenger.of(context).showSnackBar(SnackBar(
@ -566,7 +604,10 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
} }
Map<String, dynamic>? _getActiveConnectionDetails() { Map<String, dynamic>? _getActiveConnectionDetails() {
final service = context.watch<RiverInSituSamplingService>(); // --- START FIX: Use read() instead of watch() ---
final service = context.read<RiverInSituSamplingService>();
// --- END FIX ---
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { 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};
} }
@ -582,6 +623,15 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
final activeConnection = _getActiveConnectionDetails(); final activeConnection = _getActiveConnectionDetails();
final String? activeType = activeConnection?['type'] as String?; final String? activeType = activeConnection?['type'] as String?;
// Check if ANY device is currently connected
final bool isDeviceConnected = activeConnection != null;
// --- START MODIFICATION: Logic for disabling inputs ---
// Disable interaction if auto-reading is active OR if locked out.
// If reading is stopped (even if connected), we allow interaction.
final bool shouldDisableInput = _isAutoReading || _isLockedOut;
// --- END MODIFICATION ---
// --- START MODIFICATION: Add WillPopScope to block back navigation --- // --- START MODIFICATION: Add WillPopScope to block back navigation ---
return WillPopScope( return WillPopScope(
onWillPop: () async { onWillPop: () async {
@ -589,6 +639,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
_showSnackBar("Please wait for the initial reading period to complete.", isError: true); _showSnackBar("Please wait for the initial reading period to complete.", isError: true);
return false; // Prevent back navigation return false; // Prevent back navigation
} }
_disconnectFromAll();
return true; // Allow back navigation return true; // Allow back navigation
}, },
child: Form( child: Form(
@ -620,13 +671,17 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
ValueListenableBuilder<String?>( ValueListenableBuilder<String?>(
valueListenable: service.sondeId, valueListenable: service.sondeId,
builder: (context, sondeId, child) { builder: (context, sondeId, child) {
final newSondeId = sondeId ?? ''; // --- START FIX: Only update if non-null to prevent clearing on disconnect ---
if (sondeId != null && sondeId.isNotEmpty) {
final newSondeId = sondeId;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _sondeIdController.text != newSondeId) { if (mounted && _sondeIdController.text != newSondeId) {
_sondeIdController.text = newSondeId; _sondeIdController.text = newSondeId;
widget.data.sondeId = newSondeId; widget.data.sondeId = newSondeId;
} }
}); });
}
// --- END FIX ---
return TextFormField( return TextFormField(
controller: _sondeIdController, 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'),
@ -661,13 +716,21 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
}).toList(), }).toList(),
), ),
const Divider(height: 32), const Divider(height: 32),
_buildFlowrateSection(),
// --- MODIFIED: Use 'shouldDisableInput' instead of 'isDeviceConnected' ---
_buildFlowrateSection(isInputDisabled: shouldDisableInput),
const SizedBox(height: 32), const SizedBox(height: 32),
// --- START MODIFICATION: Add countdown to Next button ---
// --- MODIFIED: Enable Next button if reading stopped (even if connected) ---
ElevatedButton( ElevatedButton(
onPressed: _isLockedOut ? null : _validateAndProceed, // Disable if locked out OR reading is active
onPressed: (_isLockedOut || _isAutoReading) ? null : _validateAndProceed,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text(_isLockedOut ? 'Next ($_lockoutSecondsRemaining\s)' : 'Next'), child: Text(
_isLockedOut
? 'Next ($_lockoutSecondsRemaining\s)'
: (_isAutoReading ? 'Stop Reading to Proceed' : 'Next')
),
), ),
// --- END MODIFICATION --- // --- END MODIFICATION ---
], ],
@ -679,7 +742,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
Widget _buildParameterListItem({ required IconData icon, required String label, required String unit, required TextEditingController controller, bool isOutOfBounds = false}) { Widget _buildParameterListItem({ required IconData icon, required String label, required String unit, required TextEditingController controller, bool isOutOfBounds = false}) {
final bool isMissing = controller.text.isEmpty || controller.text.contains('-999'); final bool isMissing = controller.text.isEmpty || controller.text.contains('-999');
final String displayValue = isMissing ? '-.--' : controller.text; final String displayValue = isMissing ? '-.--' : (double.tryParse(controller.text) ?? -999.0).toStringAsFixed(5);
final String displayLabel = unit.isEmpty ? label : '$label ($unit)'; final String displayLabel = unit.isEmpty ? label : '$label ($unit)';
final Color valueColor = isOutOfBounds final Color valueColor = isOutOfBounds
@ -804,6 +867,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
final controller = param['controller'] as TextEditingController; final controller = param['controller'] as TextEditingController;
final previousValue = previousReadings[key]; final previousValue = previousReadings[key];
final bool isCurrentValueOutOfBounds = _outOfBoundsKeys.contains(key); final bool isCurrentValueOutOfBounds = _outOfBoundsKeys.contains(key);
final currentValue = double.tryParse(controller.text) ?? -999.0;
return TableRow( return TableRow(
children: [ children: [
@ -818,7 +882,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Text( child: Text(
controller.text.contains('-999') ? '-.--' : (double.tryParse(controller.text) ?? 0).toStringAsFixed(5), currentValue == -999.0 ? '-.--' : currentValue.toStringAsFixed(5),
style: TextStyle( style: TextStyle(
color: isCurrentValueOutOfBounds color: isCurrentValueOutOfBounds
? Colors.red ? Colors.red
@ -921,7 +985,8 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
); );
} }
Widget _buildFlowrateSection() { // Updated to include disable logic
Widget _buildFlowrateSection({bool isInputDisabled = false}) {
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0), margin: const EdgeInsets.symmetric(vertical: 4.0),
child: Padding( child: Padding(
@ -930,8 +995,31 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text("Flowrate", style: Theme.of(context).textTheme.titleLarge), Text("Flowrate", style: Theme.of(context).textTheme.titleLarge),
if (isInputDisabled)
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.orange, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
"Please stop reading to enter flowrate.",
style: TextStyle(color: Colors.orange[800], fontSize: 12),
),
),
],
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
// --- START FIX: Wrap radio buttons in Expanded/Wrap widgets to prevent horizontal overflow --- // Wrap content in AbsorbPointer and Opacity if connected
AbsorbPointer(
absorbing: isInputDisabled,
child: Opacity(
opacity: isInputDisabled ? 0.5 : 1.0,
child: Column(
children: [
// Replaced Row with Wrap to fix horizontal overflow for radio buttons
Wrap( Wrap(
alignment: WrapAlignment.spaceAround, alignment: WrapAlignment.spaceAround,
spacing: 8.0, spacing: 8.0,
@ -939,10 +1027,10 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
children: [ children: [
_buildFlowrateRadioButton("Surface Drifter"), _buildFlowrateRadioButton("Surface Drifter"),
_buildFlowrateRadioButton("Flowmeter"), _buildFlowrateRadioButton("Flowmeter"),
_buildFlowrateRadioButton("NA"), _buildFlowrateRadioButton("NA"), // Not Applicable
], ],
), ),
// --- END FIX --- // Conditional fields based on selected method
if (_selectedFlowrateMethod == 'Surface Drifter') if (_selectedFlowrateMethod == 'Surface Drifter')
_buildSurfaceDrifterFields(), _buildSurfaceDrifterFields(),
if (_selectedFlowrateMethod == 'Flowmeter') if (_selectedFlowrateMethod == 'Flowmeter')
@ -952,6 +1040,10 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
], ],
), ),
), ),
),
],
),
),
); );
} }
@ -980,38 +1072,43 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
TextFormField( TextFormField(
controller: _sdHeightController, controller: _sdHeightController,
decoration: const InputDecoration(labelText: 'Height (m)'), decoration: const InputDecoration(labelText: 'Height (m)'),
keyboardType: TextInputType.number, keyboardType: const TextInputType.numberWithOptions(decimal: true),
// Add validation if needed
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _sdDistanceController, controller: _sdDistanceController,
decoration: const InputDecoration(labelText: 'Distance (m)'), decoration: const InputDecoration(labelText: 'Distance (m) *'),
keyboardType: TextInputType.number, keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => v == null || v.isEmpty ? 'Distance is required' : null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _sdTimeFirstController, controller: _sdTimeFirstController,
decoration: const InputDecoration(labelText: 'Time First Deploy (HH:mm:ss)', suffixIcon: Icon(Icons.timer)), decoration: const InputDecoration(labelText: 'Time First Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)),
readOnly: true, readOnly: true,
onTap: () => _selectTime(context, _sdTimeFirstController), onTap: () => _selectTime(context, _sdTimeFirstController),
validator: (v) => v == null || v.isEmpty ? 'Start time is required' : null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _sdTimeLastController, controller: _sdTimeLastController,
decoration: const InputDecoration(labelText: 'Time Last Deploy (HH:mm:ss)', suffixIcon: Icon(Icons.timer)), decoration: const InputDecoration(labelText: 'Time Last Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)),
readOnly: true, readOnly: true,
onTap: () => _selectTime(context, _sdTimeLastController), onTap: () => _selectTime(context, _sdTimeLastController),
validator: (v) => v == null || v.isEmpty ? 'End time is required' : null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _calculateFlowrate, onPressed: _calculateFlowrate,
child: const Text('Get Flowrate'), child: const Text('Calculate Flowrate'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _flowrateValueController, controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), decoration: const InputDecoration(labelText: 'Calculated Flowrate (m/s)'),
readOnly: true, readOnly: true,
// Add validator if calculation must be done?
), ),
], ],
), ),
@ -1023,20 +1120,28 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: TextFormField( child: TextFormField(
controller: _flowrateValueController, controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), decoration: const InputDecoration(labelText: 'Flowrate (m/s) *'),
keyboardType: TextInputType.number, keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => v == null || v.isEmpty ? 'Flowrate value is required' : null,
), ),
); );
} }
Widget _buildNAField() { Widget _buildNAField() {
// Fix: Use controller to set value instead of initialValue to avoid conflict crash
if (_flowrateValueController.text != 'NA') {
_flowrateValueController.text = 'NA';
}
return Padding( return Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: TextFormField( child: TextFormField(
controller: _flowrateValueController, controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), decoration: const InputDecoration(labelText: 'Flowrate (m/s)'),
readOnly: true, // initialValue: 'NA', // Removed to fix AssertionError: initialValue == null || controller == null
readOnly: true, // Make it read-only
), ),
); );
} }
}
} // End of State class

View File

@ -7,15 +7,15 @@ import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart'; import 'package:usb_serial/usb_serial.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../../auth_provider.dart'; import '../../../../../auth_provider.dart';
import '../../../../models/river_in_situ_sampling_data.dart'; import '../../../../../models/river_in_situ_sampling_data.dart';
//import '../../../../services/api_service.dart'; // Import to access DatabaseHelper //import '../../../../../services/api_service.dart'; // Import to access DatabaseHelper
import 'package:environment_monitoring_app/services/database_helper.dart'; import 'package:environment_monitoring_app/services/database_helper.dart';
import '../../../../services/river_in_situ_sampling_service.dart'; import '../../../../../services/river_in_situ_sampling_service.dart';
import '../../../../bluetooth/bluetooth_manager.dart'; import '../../../../../bluetooth/bluetooth_manager.dart';
import '../../../../serial/serial_manager.dart'; import '../../../../../serial/serial_manager.dart';
import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart'; import '../../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart';
import '../../../../serial/widget/serial_port_list_dialog.dart'; import '../../../../../serial/widget/serial_port_list_dialog.dart';
class RiverInSituStep3DataCapture extends StatefulWidget { class RiverInSituStep3DataCapture extends StatefulWidget {
final RiverInSituSamplingData data; final RiverInSituSamplingData data;
@ -211,12 +211,20 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
void _onFlowrateMethodChanged(String? value) { void _onFlowrateMethodChanged(String? value) {
setState(() { setState(() {
_selectedFlowrateMethod = value; _selectedFlowrateMethod = value;
widget.data.flowrateMethod = value; // Update model immediately
if (value == 'NA') { if (value == 'NA') {
_flowrateValueController.text = 'NA'; _flowrateValueController.text = 'NA';
} else if (value == 'Flowmeter') { } else if (value == 'Flowmeter') {
// --- MODIFICATION: Clear flowrate value for Flowmeter ---
_flowrateValueController.clear(); _flowrateValueController.clear();
} else { // --- END MODIFICATION ---
_flowrateValueController.clear(); _sdHeightController.clear();
_sdDistanceController.clear();
_sdTimeFirstController.clear();
_sdTimeLastController.clear();
} else { // Surface Drifter
_flowrateValueController.clear(); // Will be calculated
} }
}); });
} }
@ -235,7 +243,18 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final timeFormat = DateFormat("HH:mm:ss"); final timeFormat = DateFormat("HH:mm:ss");
final timeFirst = timeFormat.parse(timeFirstStr); final timeFirst = timeFormat.parse(timeFirstStr);
final timeLast = timeFormat.parse(timeLastStr); final timeLast = timeFormat.parse(timeLastStr);
final differenceInSeconds = timeLast.difference(timeFirst).inSeconds; // Use a common date (like today) to allow time difference calculation across midnight
final now = DateTime.now();
final dateTimeFirst = DateTime(now.year, now.month, now.day, timeFirst.hour, timeFirst.minute, timeFirst.second);
var dateTimeLast = DateTime(now.year, now.month, now.day, timeLast.hour, timeLast.minute, timeLast.second);
// Handle crossing midnight
if (dateTimeLast.isBefore(dateTimeFirst)) {
dateTimeLast = dateTimeLast.add(const Duration(days: 1));
}
final differenceInSeconds = dateTimeLast.difference(dateTimeFirst).inSeconds;
if (differenceInSeconds <= 0) { if (differenceInSeconds <= 0) {
_showSnackBar("Time Last Deploy must be after Time First Deploy.", isError: true); _showSnackBar("Time Last Deploy must be after Time First Deploy.", isError: true);
return; return;
@ -264,51 +283,61 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
} }
Future<void> _handleConnectionAttempt(String type) async { Future<void> _handleConnectionAttempt(String type) async {
final service = context.read<RiverInSituSamplingService>(); // Uses the correct _samplingService instance
final bool hasPermissions = await service.requestDevicePermissions(); final bool hasPermissions = await _samplingService.requestDevicePermissions();
if (!hasPermissions && mounted) { if (!hasPermissions && mounted) {
_showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true);
return; return;
} }
_disconnectFromAll(); _disconnectFromAll();
await Future.delayed(const Duration(milliseconds: 250)); await Future.delayed(const Duration(milliseconds: 250)); // Short delay after disconnect
final bool connectionSuccess = await _connectToDevice(type); final bool connectionSuccess = await _connectToDevice(type);
if (connectionSuccess && mounted) { if (connectionSuccess && mounted) {
_dataSubscription?.cancel(); _dataSubscription?.cancel(); // Cancel previous subscription if any
final stream = type == 'bluetooth' ? service.bluetoothDataStream : service.serialDataStream; final stream = type == 'bluetooth' ? _samplingService.bluetoothDataStream : _samplingService.serialDataStream;
_dataSubscription = stream.listen((readings) { _dataSubscription = stream.listen((readings) {
if (mounted) { if (mounted) {
_updateTextFields(readings); _updateTextFields(readings);
} }
}, onError: (error) {
debugPrint("Error on data stream: $error");
if (mounted) _showSnackBar("Data stream error: $error", isError: true);
_disconnect(type); // Disconnect on stream error
}, onDone: () {
debugPrint("Data stream done.");
if (mounted) _disconnect(type); // Disconnect when stream closes
}); });
} }
} }
Future<bool> _connectToDevice(String type) async { Future<bool> _connectToDevice(String type) async {
// Uses the correct _samplingService instance
setState(() => _isLoading = true); setState(() => _isLoading = true);
final service = context.read<RiverInSituSamplingService>();
bool success = false; bool success = false;
try { try {
if (type == 'bluetooth') { if (type == 'bluetooth') {
final devices = await service.getPairedBluetoothDevices(); final devices = await _samplingService.getPairedBluetoothDevices();
if (devices.isEmpty && mounted) { if (!mounted) return false; // Check mounted after async gap
if (devices.isEmpty) {
_showSnackBar('No paired Bluetooth devices found.', isError: true); _showSnackBar('No paired Bluetooth devices found.', isError: true);
return false; return false;
} }
final selectedDevice = await showBluetoothDeviceListDialog(context: context, devices: devices); final selectedDevice = await showBluetoothDeviceListDialog(context: context, devices: devices);
if (selectedDevice != null) { if (selectedDevice != null) {
await service.connectToBluetoothDevice(selectedDevice); await _samplingService.connectToBluetoothDevice(selectedDevice);
success = true; success = true;
} }
} else if (type == 'serial') { } else if (type == 'serial') {
final devices = await service.getAvailableSerialDevices(); final devices = await _samplingService.getAvailableSerialDevices();
if (devices.isEmpty && mounted) { if (!mounted) return false;
_showSnackBar('No USB Serial devices found.', isError: true); if (devices.isEmpty) {
_showSnackBar('No USB Serial devices found. Ensure device is plugged in.', isError: true);
return false; return false;
} }
final selectedDevice = await showSerialPortListDialog(context: context, devices: devices); final selectedDevice = await showSerialPortListDialog(context: context, devices: devices);
if (selectedDevice != null) { if (selectedDevice != null) {
await service.connectToSerialDevice(selectedDevice); await _samplingService.connectToSerialDevice(selectedDevice);
success = true; success = true;
} }
} }
@ -357,6 +386,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_startLockoutTimer(); // --- MODIFICATION: Start countdown _startLockoutTimer(); // --- MODIFICATION: Start countdown
} else { } else {
if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading(); if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading();
// NOTE: _lockoutTimer is intentionally NOT cancelled here so the lockout persists for the remaining duration
} }
}); });
} }
@ -419,10 +449,16 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
} }
// --- END MODIFICATION --- // --- END MODIFICATION ---
// --- START MODIFICATION: Disable Next if Connected and Auto Reading is active ---
// Check if reading is active or if device is connected but not reading (user must disconnect or stop reading first)
// Wait, request was: "either user click stop reading or disconnect button then the next button and flowrate can be used again"
// So if _isAutoReading is true, block. If device connected but _isAutoReading is false, ALLOW.
if (_isAutoReading) { if (_isAutoReading) {
_showStopReadingDialog(); _showStopReadingDialog(); // Still show dialog if reading is actively running
return; return;
} }
// Remove the forced disconnect check here because user can proceed if they stopped reading manually
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) {
return; return;
@ -519,8 +555,16 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
widget.data.flowrateSurfaceDrifterTimeLast = _sdTimeLastController.text; widget.data.flowrateSurfaceDrifterTimeLast = _sdTimeLastController.text;
widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); widget.data.flowrateValue = double.tryParse(_flowrateValueController.text);
} else if (_selectedFlowrateMethod == 'Flowmeter') { } else if (_selectedFlowrateMethod == 'Flowmeter') {
widget.data.flowrateSurfaceDrifterHeight = null;
widget.data.flowrateSurfaceDrifterDistance = null;
widget.data.flowrateSurfaceDrifterTimeFirst = null;
widget.data.flowrateSurfaceDrifterTimeLast = null;
widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); widget.data.flowrateValue = double.tryParse(_flowrateValueController.text);
} else { // NA } else { // NA
widget.data.flowrateSurfaceDrifterHeight = null;
widget.data.flowrateSurfaceDrifterDistance = null;
widget.data.flowrateSurfaceDrifterTimeFirst = null;
widget.data.flowrateSurfaceDrifterTimeLast = null;
widget.data.flowrateValue = null; widget.data.flowrateValue = null;
} }
@ -564,7 +608,10 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
} }
Map<String, dynamic>? _getActiveConnectionDetails() { Map<String, dynamic>? _getActiveConnectionDetails() {
final service = context.watch<RiverInSituSamplingService>(); // --- START FIX: Use read() instead of watch() ---
final service = context.read<RiverInSituSamplingService>();
// --- END FIX ---
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { 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};
} }
@ -580,13 +627,22 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final activeConnection = _getActiveConnectionDetails(); final activeConnection = _getActiveConnectionDetails();
final String? activeType = activeConnection?['type'] as String?; final String? activeType = activeConnection?['type'] as String?;
// --- START MODIFICATION: Add WillPopScope to block back navigation --- // Check if ANY device is currently connected
final bool isDeviceConnected = activeConnection != null;
// --- START MODIFICATION: Logic for disabling inputs ---
// Disable interaction if auto-reading is active OR if locked out.
// If reading is stopped (even if connected), we allow interaction.
final bool shouldDisableInput = _isAutoReading || _isLockedOut;
// --- END MODIFICATION ---
return WillPopScope( return WillPopScope(
onWillPop: () async { onWillPop: () async {
if (_isLockedOut) { if (_isLockedOut) {
_showSnackBar("Please wait for the initial reading period to complete.", isError: true); _showSnackBar("Please wait for the initial reading period to complete.", isError: true);
return false; // Prevent back navigation return false; // Prevent back navigation
} }
_disconnectFromAll();
return true; // Allow back navigation return true; // Allow back navigation
}, },
child: Form( child: Form(
@ -618,13 +674,17 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
ValueListenableBuilder<String?>( ValueListenableBuilder<String?>(
valueListenable: service.sondeId, valueListenable: service.sondeId,
builder: (context, sondeId, child) { builder: (context, sondeId, child) {
final newSondeId = sondeId ?? ''; // --- START FIX: Only update if non-null to prevent clearing on disconnect ---
if (sondeId != null && sondeId.isNotEmpty) {
final newSondeId = sondeId;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _sondeIdController.text != newSondeId) { if (mounted && _sondeIdController.text != newSondeId) {
_sondeIdController.text = newSondeId; _sondeIdController.text = newSondeId;
widget.data.sondeId = newSondeId; widget.data.sondeId = newSondeId;
} }
}); });
}
// --- END FIX ---
return TextFormField( return TextFormField(
controller: _sondeIdController, 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'),
@ -659,25 +719,32 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
}).toList(), }).toList(),
), ),
const Divider(height: 32), const Divider(height: 32),
_buildFlowrateSection(),
// --- MODIFIED: Use 'shouldDisableInput' instead of 'isDeviceConnected' ---
_buildFlowrateSection(isInputDisabled: shouldDisableInput),
const SizedBox(height: 32), const SizedBox(height: 32),
// --- START MODIFICATION: Add countdown to Next button ---
// --- MODIFIED: Enable Next button if reading stopped (even if connected) ---
ElevatedButton( ElevatedButton(
onPressed: _isLockedOut ? null : _validateAndProceed, // Disable if locked out OR reading is active
onPressed: (_isLockedOut || _isAutoReading) ? null : _validateAndProceed,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text(_isLockedOut ? 'Next ($_lockoutSecondsRemaining\s)' : 'Next'), child: Text(
_isLockedOut
? 'Next ($_lockoutSecondsRemaining\s)'
: (_isAutoReading ? 'Stop Reading to Proceed' : 'Next')
),
), ),
// --- END MODIFICATION --- // --- END MODIFICATION ---
], ],
), ),
), ),
); );
// --- END MODIFICATION ---
} }
Widget _buildParameterListItem({ required IconData icon, required String label, required String unit, required TextEditingController controller, bool isOutOfBounds = false}) { Widget _buildParameterListItem({ required IconData icon, required String label, required String unit, required TextEditingController controller, bool isOutOfBounds = false}) {
final bool isMissing = controller.text.isEmpty || controller.text.contains('-999'); final bool isMissing = controller.text.isEmpty || controller.text.contains('-999');
final String displayValue = isMissing ? '-.--' : controller.text; final String displayValue = isMissing ? '-.--' : (double.tryParse(controller.text) ?? -999.0).toStringAsFixed(5);
final String displayLabel = unit.isEmpty ? label : '$label ($unit)'; final String displayLabel = unit.isEmpty ? label : '$label ($unit)';
final Color valueColor = isOutOfBounds final Color valueColor = isOutOfBounds
@ -719,14 +786,14 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
if (isConnecting || _isLoading) if (isConnecting || _isLoading)
const CircularProgressIndicator() const CircularProgressIndicator()
else if (isConnected) else if (isConnected)
// --- START FIX: Replaced Row with Wrap to fix horizontal overflow with countdown timer --- // Replaced Row with Wrap to fix horizontal overflow with countdown timer
Wrap( Wrap(
alignment: WrapAlignment.spaceEvenly, alignment: WrapAlignment.spaceEvenly,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8.0, // Horizontal space between buttons spacing: 8.0, // Horizontal space between buttons
runSpacing: 4.0, // Vertical space if it wraps runSpacing: 4.0, // Vertical space if it wraps
children: [ children: [
// --- START MODIFICATION: Add countdown to Stop Reading button --- // Add countdown to Stop Reading button
ElevatedButton.icon( ElevatedButton.icon(
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
label: Text(_isAutoReading label: Text(_isAutoReading
@ -740,7 +807,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
), ),
// --- END MODIFICATION ---
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.link_off), icon: const Icon(Icons.link_off),
label: const Text('Disconnect'), label: const Text('Disconnect'),
@ -749,7 +815,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
) )
], ],
) )
// --- END FIX ---
], ],
), ),
), ),
@ -802,6 +867,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final controller = param['controller'] as TextEditingController; final controller = param['controller'] as TextEditingController;
final previousValue = previousReadings[key]; final previousValue = previousReadings[key];
final bool isCurrentValueOutOfBounds = _outOfBoundsKeys.contains(key); final bool isCurrentValueOutOfBounds = _outOfBoundsKeys.contains(key);
final currentValue = double.tryParse(controller.text) ?? -999.0;
return TableRow( return TableRow(
children: [ children: [
@ -816,7 +882,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Text( child: Text(
controller.text.contains('-999') ? '-.--' : (double.tryParse(controller.text) ?? 0).toStringAsFixed(5), currentValue == -999.0 ? '-.--' : currentValue.toStringAsFixed(5),
style: TextStyle( style: TextStyle(
color: isCurrentValueOutOfBounds color: isCurrentValueOutOfBounds
? Colors.red ? Colors.red
@ -919,7 +985,8 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
); );
} }
Widget _buildFlowrateSection() { // Updated to include disable logic
Widget _buildFlowrateSection({bool isInputDisabled = false}) {
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0), margin: const EdgeInsets.symmetric(vertical: 4.0),
child: Padding( child: Padding(
@ -928,17 +995,42 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text("Flowrate", style: Theme.of(context).textTheme.titleLarge), Text("Flowrate", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8), if (isInputDisabled)
// --- START FIX: Wrap radio buttons in Expanded widgets to prevent horizontal overflow --- Padding(
Row( padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
mainAxisAlignment: MainAxisAlignment.spaceAround, child: Row(
children: [ children: [
Expanded(child: _buildFlowrateRadioButton("Surface Drifter")), Icon(Icons.info_outline, color: Colors.orange, size: 20),
Expanded(child: _buildFlowrateRadioButton("Flowmeter")), const SizedBox(width: 8),
Expanded(child: _buildFlowrateRadioButton("NA")), Expanded(
child: Text(
"Please stop reading to enter flowrate.",
style: TextStyle(color: Colors.orange[800], fontSize: 12),
),
),
], ],
), ),
// --- END FIX --- ),
const SizedBox(height: 8),
// Wrap content in AbsorbPointer and Opacity if connected
AbsorbPointer(
absorbing: isInputDisabled,
child: Opacity(
opacity: isInputDisabled ? 0.5 : 1.0,
child: Column(
children: [
// Replaced Row with Wrap to fix horizontal overflow for radio buttons
Wrap(
alignment: WrapAlignment.spaceAround,
spacing: 8.0,
runSpacing: 4.0,
children: [
_buildFlowrateRadioButton("Surface Drifter"),
_buildFlowrateRadioButton("Flowmeter"),
_buildFlowrateRadioButton("NA"), // Not Applicable
],
),
// Conditional fields based on selected method
if (_selectedFlowrateMethod == 'Surface Drifter') if (_selectedFlowrateMethod == 'Surface Drifter')
_buildSurfaceDrifterFields(), _buildSurfaceDrifterFields(),
if (_selectedFlowrateMethod == 'Flowmeter') if (_selectedFlowrateMethod == 'Flowmeter')
@ -948,6 +1040,10 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
], ],
), ),
), ),
),
],
),
),
); );
} }
@ -976,38 +1072,43 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
TextFormField( TextFormField(
controller: _sdHeightController, controller: _sdHeightController,
decoration: const InputDecoration(labelText: 'Height (m)'), decoration: const InputDecoration(labelText: 'Height (m)'),
keyboardType: TextInputType.number, keyboardType: const TextInputType.numberWithOptions(decimal: true),
// Add validation if needed
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _sdDistanceController, controller: _sdDistanceController,
decoration: const InputDecoration(labelText: 'Distance (m)'), decoration: const InputDecoration(labelText: 'Distance (m) *'),
keyboardType: TextInputType.number, keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => v == null || v.isEmpty ? 'Distance is required' : null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _sdTimeFirstController, controller: _sdTimeFirstController,
decoration: const InputDecoration(labelText: 'Time First Deploy (HH:mm:ss)', suffixIcon: Icon(Icons.timer)), decoration: const InputDecoration(labelText: 'Time First Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)),
readOnly: true, readOnly: true,
onTap: () => _selectTime(context, _sdTimeFirstController), onTap: () => _selectTime(context, _sdTimeFirstController),
validator: (v) => v == null || v.isEmpty ? 'Start time is required' : null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _sdTimeLastController, controller: _sdTimeLastController,
decoration: const InputDecoration(labelText: 'Time Last Deploy (HH:mm:ss)', suffixIcon: Icon(Icons.timer)), decoration: const InputDecoration(labelText: 'Time Last Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)),
readOnly: true, readOnly: true,
onTap: () => _selectTime(context, _sdTimeLastController), onTap: () => _selectTime(context, _sdTimeLastController),
validator: (v) => v == null || v.isEmpty ? 'End time is required' : null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _calculateFlowrate, onPressed: _calculateFlowrate,
child: const Text('Get Flowrate'), child: const Text('Calculate Flowrate'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _flowrateValueController, controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), decoration: const InputDecoration(labelText: 'Calculated Flowrate (m/s)'),
readOnly: true, readOnly: true,
// Add validator if calculation must be done?
), ),
], ],
), ),
@ -1019,19 +1120,26 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: TextFormField( child: TextFormField(
controller: _flowrateValueController, controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), decoration: const InputDecoration(labelText: 'Flowrate (m/s) *'),
keyboardType: TextInputType.number, keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => v == null || v.isEmpty ? 'Flowrate value is required' : null,
), ),
); );
} }
Widget _buildNAField() { Widget _buildNAField() {
// Fix: Use controller to set value instead of initialValue to avoid conflict crash
if (_flowrateValueController.text != 'NA') {
_flowrateValueController.text = 'NA';
}
return Padding( return Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: TextFormField( child: TextFormField(
controller: _flowrateValueController, controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), decoration: const InputDecoration(labelText: 'Flowrate (m/s)'),
readOnly: true, // initialValue: 'NA', // Removed to fix AssertionError: initialValue == null || controller == null
readOnly: true, // Make it read-only
), ),
); );
} }

View File

@ -32,7 +32,7 @@ import 'submission_ftp_service.dart';
import 'telegram_service.dart'; import 'telegram_service.dart';
import 'retry_service.dart'; import 'retry_service.dart';
import 'base_api_service.dart'; // Import for SessionExpiredException import 'base_api_service.dart'; // Import for SessionExpiredException
import 'user_preferences_service.dart'; // ADDED
/// A dedicated service to handle all business logic for the Marine In-Situ Sampling feature. /// A dedicated service to handle all business logic for the Marine In-Situ Sampling feature.
/// This includes location, image processing, device communication, and data submission. /// This includes location, image processing, device communication, and data submission.
@ -51,6 +51,7 @@ class MarineInSituSamplingService {
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
final RetryService _retryService = RetryService(); final RetryService _retryService = RetryService();
final TelegramService _telegramService; final TelegramService _telegramService;
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
MarineInSituSamplingService(this._telegramService); MarineInSituSamplingService(this._telegramService);
@ -262,6 +263,12 @@ class MarineInSituSamplingService {
// data.reportId already contains the timestamp ID // data.reportId already contains the timestamp ID
// --- END: MODIFIED TO USE TIMESTAMP ID --- // --- END: MODIFIED TO USE TIMESTAMP ID ---
// 1. Check module preferences for API
final pref = await _userPreferencesService.getModulePreference(moduleName);
bool isApiEnabled = pref?['is_api_enabled'] ?? true;
bool isFtpEnabled = pref?['is_ftp_enabled'] ?? true;
if (isApiEnabled) {
try { try {
// 1. Submit Form Data // 1. Submit Form Data
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
@ -315,11 +322,29 @@ class MarineInSituSamplingService {
} }
// --- END: MODIFIED TO USE TIMESTAMP ID --- // --- END: MODIFIED TO USE TIMESTAMP ID ---
} }
} else {
debugPrint("API submission disabled for $moduleName by user preference.");
apiDataResult = {'success': true, 'message': 'API submission disabled by user.'};
anyApiSuccess = true; // Treated as success since it was intentional
}
// 3. Submit FTP Files // 3. Submit FTP Files
Map<String, dynamic> ftpResults = {'statuses': []}; Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false; bool anyFtpSuccess = false;
// --- START FIX: Check if FTP is enabled AND if it was already successful ---
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
if (!isFtpEnabled) {
debugPrint("FTP submission disabled for $moduleName by user preference. Skipping FTP.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user preference.', 'success': true}]};
anyFtpSuccess = true;
} else if (previousFtpSuccess) {
debugPrint("FTP submission skipped because it was already successful (Status: ${data.submissionStatus}).");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful in previous attempt.', 'success': true}]};
anyFtpSuccess = true;
} else {
// Proceed with FTP logic only if enabled AND not previously successful
if (isSessionKnownToBeExpired) { if (isSessionKnownToBeExpired) {
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks."); debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
// --- START: MODIFIED TO USE TIMESTAMP ID --- // --- START: MODIFIED TO USE TIMESTAMP ID ---
@ -386,8 +411,11 @@ class MarineInSituSamplingService {
} catch (e) { } catch (e) {
debugPrint("Unexpected FTP submission error: $e"); debugPrint("Unexpected FTP submission error: $e");
anyFtpSuccess = false; anyFtpSuccess = false;
ftpResults = {'statuses': [{'status': 'Error', 'message': 'FTP process failed: $e.', 'success': false}]};
} }
} }
}
// --- END FIX ---
// 4. Determine Final Status // 4. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess; final bool overallSuccess = anyApiSuccess || anyFtpSuccess;

View File

@ -31,6 +31,7 @@ import 'retry_service.dart';
import 'base_api_service.dart'; // Import for SessionExpiredException import 'base_api_service.dart'; // Import for SessionExpiredException
import 'api_service.dart'; // Import for DatabaseHelper import 'api_service.dart'; // Import for DatabaseHelper
import 'package:environment_monitoring_app/services/database_helper.dart'; import 'package:environment_monitoring_app/services/database_helper.dart';
import 'user_preferences_service.dart'; // ADDED
/// A dedicated service for the Marine Investigative Sampling feature. /// A dedicated service for the Marine Investigative Sampling feature.
@ -49,6 +50,7 @@ class MarineInvestigativeSamplingService {
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
final RetryService _retryService = RetryService(); final RetryService _retryService = RetryService();
final TelegramService _telegramService; final TelegramService _telegramService;
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
MarineInvestigativeSamplingService(this._telegramService); MarineInvestigativeSamplingService(this._telegramService);
@ -268,6 +270,12 @@ class MarineInvestigativeSamplingService {
// data.reportId already contains the timestamp ID // data.reportId already contains the timestamp ID
// --- END: MODIFIED TO USE TIMESTAMP ID --- // --- END: MODIFIED TO USE TIMESTAMP ID ---
// 1. Check module preferences for API
final pref = await _userPreferencesService.getModulePreference(moduleName);
bool isApiEnabled = pref?['is_api_enabled'] ?? true;
bool isFtpEnabled = pref?['is_ftp_enabled'] ?? true;
if (isApiEnabled) {
try { try {
// 1. Submit Form Data // 1. Submit Form Data
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
@ -319,12 +327,29 @@ class MarineInvestigativeSamplingService {
} }
// --- END: MODIFIED TO USE TIMESTAMP ID --- // --- END: MODIFIED TO USE TIMESTAMP ID ---
} }
// We no longer catch SocketException or TimeoutException here. } else {
debugPrint("API submission disabled for $moduleName by user preference.");
apiDataResult = {'success': true, 'message': 'API submission disabled by user.'};
anyApiSuccess = true; // Treated as success since it was intentional
}
// 3. Submit FTP Files // 3. Submit FTP Files
Map<String, dynamic> ftpResults = {'statuses': []}; Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false; bool anyFtpSuccess = false;
// --- START FIX: Check if FTP is enabled AND if it was already successful ---
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
if (!isFtpEnabled) {
debugPrint("FTP submission disabled for $moduleName by user preference. Skipping FTP.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user preference.', 'success': true}]};
anyFtpSuccess = true;
} else if (previousFtpSuccess) {
debugPrint("FTP submission skipped because it was already successful (Status: ${data.submissionStatus}).");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful in previous attempt.', 'success': true}]};
anyFtpSuccess = true;
} else {
// Proceed with FTP logic only if enabled AND not previously successful
if (isSessionKnownToBeExpired) { if (isSessionKnownToBeExpired) {
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks."); debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
// --- START: MODIFIED TO USE TIMESTAMP ID --- // --- START: MODIFIED TO USE TIMESTAMP ID ---
@ -381,8 +406,11 @@ class MarineInvestigativeSamplingService {
} catch (e) { } catch (e) {
debugPrint("Unexpected FTP submission error: $e"); debugPrint("Unexpected FTP submission error: $e");
anyFtpSuccess = false; anyFtpSuccess = false;
ftpResults = {'statuses': [{'status': 'Error', 'message': 'FTP process failed: $e.', 'success': false}]}; // Provide error status
} }
} }
}
// --- END FIX ---
// 4. Determine Final Status // 4. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess; final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
@ -609,12 +637,13 @@ class MarineInvestigativeSamplingService {
final logData = { final logData = {
// --- START: MODIFIED TO USE TIMESTAMP ID --- // --- START: MODIFIED TO USE TIMESTAMP ID ---
'submission_id': data.reportId ?? baseFileName, // This is the timestamp ID 'submission_id': data.reportId ?? baseFileName, // Use timestamp ID
'module': 'marine', // *** MODIFIED: Module and Type ***
'type': 'Investigative', 'module': 'marine', // Keep main module as 'river'
'type': 'Investigative', // Specific type
'status': status, 'status': status,
'message': message, 'message': message,
'report_id': apiRecordId, // This is the server DB ID 'report_id': apiRecordId, // Use server DB ID
// --- END: MODIFIED TO USE TIMESTAMP ID --- // --- END: MODIFIED TO USE TIMESTAMP ID ---
'created_at': DateTime.now().toIso8601String(), 'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(logMapData), // Log comprehensive map 'form_data': jsonEncode(logMapData), // Log comprehensive map
@ -873,5 +902,5 @@ class MarineInvestigativeSamplingService {
return buffer.toString(); return buffer.toString();
} }
// --- END: MODIFIED ALERT HANDLER & HELPERS --- // --- END: NEW METHOD ---
} }

View File

@ -23,6 +23,7 @@ import 'package:environment_monitoring_app/services/telegram_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart';
import 'package:environment_monitoring_app/services/base_api_service.dart'; // Import for SessionExpiredException import 'package:environment_monitoring_app/services/base_api_service.dart'; // Import for SessionExpiredException
import 'user_preferences_service.dart'; // ADDED
/// A dedicated service to handle all business logic for the Marine Tarball Sampling feature. /// A dedicated service to handle all business logic for the Marine Tarball Sampling feature.
class MarineTarballSamplingService { class MarineTarballSamplingService {
@ -34,6 +35,7 @@ class MarineTarballSamplingService {
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
final RetryService _retryService = RetryService(); final RetryService _retryService = RetryService();
final TelegramService _telegramService; final TelegramService _telegramService;
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
MarineTarballSamplingService(this._telegramService); MarineTarballSamplingService(this._telegramService);
@ -130,6 +132,10 @@ class MarineTarballSamplingService {
required AuthProvider? authProvider, // Accept potentially null provider required AuthProvider? authProvider, // Accept potentially null provider
String? logDirectory, // Added for retry consistency String? logDirectory, // Added for retry consistency
}) async { }) async {
// --- START FIX: Capture the status before attempting submission ---
final String? previousStatus = data.submissionStatus;
// --- END FIX ---
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
final imageFiles = data.toImageFiles()..removeWhere((key, value) => value == null); final imageFiles = data.toImageFiles()..removeWhere((key, value) => value == null);
final finalImageFiles = imageFiles.cast<String, File>(); final finalImageFiles = imageFiles.cast<String, File>();
@ -146,6 +152,12 @@ class MarineTarballSamplingService {
// data.reportId already contains the timestamp ID // data.reportId already contains the timestamp ID
// --- END: MODIFIED TO USE TIMESTAMP ID --- // --- END: MODIFIED TO USE TIMESTAMP ID ---
// 1. Check module preferences for API
final pref = await _userPreferencesService.getModulePreference(moduleName);
bool isApiEnabled = pref?['is_api_enabled'] ?? true;
bool isFtpEnabled = pref?['is_ftp_enabled'] ?? true;
if (isApiEnabled) {
try { try {
// 1. Submit Form Data // 1. Submit Form Data
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
@ -200,11 +212,29 @@ class MarineTarballSamplingService {
} }
// --- END: MODIFIED TO USE TIMESTAMP ID --- // --- END: MODIFIED TO USE TIMESTAMP ID ---
} }
} else {
debugPrint("API submission disabled for $moduleName by user preference.");
apiDataResult = {'success': true, 'message': 'API submission disabled by user.'};
anyApiSuccess = true; // Treated as success since it was intentional
}
// 3. Submit FTP Files // 3. Submit FTP Files
Map<String, dynamic> ftpResults = {'statuses': []}; Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false; bool anyFtpSuccess = false;
// --- START FIX: Check if FTP is enabled AND if it was already successful ---
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
if (!isFtpEnabled) {
debugPrint("FTP submission disabled for $moduleName by user preference. Skipping FTP.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user preference.', 'success': true}]};
anyFtpSuccess = true;
} else if (previousFtpSuccess) {
debugPrint("FTP submission skipped because it was already successful (Status: ${data.submissionStatus}).");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful in previous attempt.', 'success': true}]};
anyFtpSuccess = true;
} else {
// Proceed with FTP logic only if enabled AND not previously successful
if (isSessionKnownToBeExpired) { if (isSessionKnownToBeExpired) {
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks."); debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
// --- START: MODIFIED TO USE TIMESTAMP ID --- // --- START: MODIFIED TO USE TIMESTAMP ID ---
@ -268,8 +298,11 @@ class MarineTarballSamplingService {
} catch (e) { } catch (e) {
debugPrint("Unexpected FTP submission error: $e"); debugPrint("Unexpected FTP submission error: $e");
anyFtpSuccess = false; anyFtpSuccess = false;
ftpResults = {'statuses': [{'status': 'Error', 'message': 'FTP process failed: $e.', 'success': false}]}; // Add error status
} }
} }
}
// --- END FIX ---
// 4. Determine Final Status // 4. Determine Final Status
@ -305,9 +338,12 @@ class MarineTarballSamplingService {
); );
// 6. Send Alert // 6. Send Alert
if (overallSuccess) { // --- START FIX: Check if log was already successful before sending alert ---
final bool wasAlreadySuccessful = previousStatus == 'S4' || previousStatus == 'S3' || previousStatus == 'L4';
if (overallSuccess && !wasAlreadySuccessful) {
_handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); _handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
} }
// --- END FIX ---
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId}; // Return timestamp ID return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId}; // Return timestamp ID
} }

View File

@ -35,6 +35,7 @@ import 'package:environment_monitoring_app/services/base_api_service.dart';
import 'package:environment_monitoring_app/services/ftp_service.dart'; import 'package:environment_monitoring_app/services/ftp_service.dart';
import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart';
import 'package:environment_monitoring_app/services/user_preferences_service.dart'; // ADDED
/// A dedicated service to manage the queue of failed API, FTP, and complex submission tasks. /// A dedicated service to manage the queue of failed API, FTP, and complex submission tasks.
class RetryService { class RetryService {
@ -42,6 +43,7 @@ class RetryService {
final BaseApiService _baseApiService = BaseApiService(); final BaseApiService _baseApiService = BaseApiService();
final FtpService _ftpService = FtpService(); final FtpService _ftpService = FtpService();
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
bool _isProcessing = false; bool _isProcessing = false;
// Sampling Services // Sampling Services
@ -601,10 +603,39 @@ class RetryService {
await _dbHelper.deleteRequestFromQueue(taskId); await _dbHelper.deleteRequestFromQueue(taskId);
return false; return false;
} }
if (await localFile.exists()) {
// --- START FIX: Check if this FTP module is enabled in preferences ---
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? []; final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
final config = ftpConfigs.firstWhere((c) => c['ftp_config_id'] == ftpConfigId, orElse: () => <String, dynamic>{}); final config = ftpConfigs.firstWhere(
if (config.isEmpty) return false; (c) => c['ftp_config_id'] == ftpConfigId,
orElse: () => <String, dynamic>{},
);
if (config.isNotEmpty) {
String? moduleKey = config['ftp_module'];
// Map legacy module names if needed (e.g., river_manual -> river_in_situ)
if (moduleKey == 'river_manual') {
moduleKey = 'river_in_situ';
} else if (moduleKey == 'marine_manual') {
moduleKey = 'marine_in_situ';
}
// Add other mappings if needed for consistency with user preferences keys
if (moduleKey != null) {
final pref = await _userPreferencesService.getModulePreference(moduleKey);
final bool isFtpEnabled = pref?['is_ftp_enabled'] ?? true;
if (!isFtpEnabled) {
debugPrint("RetryService: FTP upload for module '$moduleKey' is disabled by user. Removing task $taskId.");
await _dbHelper.deleteRequestFromQueue(taskId);
return false;
}
}
}
// --- END FIX ---
if (await localFile.exists()) {
if (config.isEmpty) return false; // Config missing
final result = await _ftpService.uploadFile(config: config, fileToUpload: localFile, remotePath: remotePath); final result = await _ftpService.uploadFile(config: config, fileToUpload: localFile, remotePath: remotePath);
success = result['success']; success = result['success'];

View File

@ -33,6 +33,7 @@ import 'submission_ftp_service.dart';
import 'telegram_service.dart'; import 'telegram_service.dart';
import 'retry_service.dart'; import 'retry_service.dart';
import 'base_api_service.dart'; // Import for SessionExpiredException import 'base_api_service.dart'; // Import for SessionExpiredException
import 'user_preferences_service.dart'; // ADDED
class RiverInSituSamplingService { class RiverInSituSamplingService {
@ -47,6 +48,7 @@ class RiverInSituSamplingService {
final ZippingService _zippingService = ZippingService(); final ZippingService _zippingService = ZippingService();
final RetryService _retryService = RetryService(); final RetryService _retryService = RetryService();
final TelegramService _telegramService; final TelegramService _telegramService;
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
@ -260,6 +262,12 @@ class RiverInSituSamplingService {
// data.reportId already contains the timestamp ID // data.reportId already contains the timestamp ID
// --- END: MODIFIED TO USE TIMESTAMP ID --- // --- END: MODIFIED TO USE TIMESTAMP ID ---
// 1. Check module preferences for API
final pref = await _userPreferencesService.getModulePreference(moduleName);
bool isApiEnabled = pref?['is_api_enabled'] ?? true;
bool isFtpEnabled = pref?['is_ftp_enabled'] ?? true; // --- MODIFIED: Check FTP pref early ---
if (isApiEnabled) {
try { try {
// 1. Submit Form Data // 1. Submit Form Data
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
@ -314,11 +322,31 @@ class RiverInSituSamplingService {
// --- END: MODIFIED TO USE TIMESTAMP ID --- // --- END: MODIFIED TO USE TIMESTAMP ID ---
} }
} }
} else {
debugPrint("API submission disabled for $moduleName by user preference.");
apiDataResult = {'success': true, 'message': 'API submission disabled by user.'};
anyApiSuccess = true; // Treated as success since it was intentional
}
// 3. Submit FTP Files // 3. Submit FTP Files
Map<String, dynamic> ftpResults = {'statuses': []}; Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false; bool anyFtpSuccess = false;
// --- START FIX: Check if FTP is enabled AND if it was already successful ---
// 'L4' status means API Failed but FTP Succeeded. If re-submitting an L4 record, we skip FTP.
// 'S4' means everything succeeded.
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
if (!isFtpEnabled) {
debugPrint("FTP submission disabled for $moduleName by user preference. Skipping FTP.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user preference.', 'success': true}]};
anyFtpSuccess = true;
} else if (previousFtpSuccess) {
debugPrint("FTP submission skipped because it was already successful (Status: ${data.submissionStatus}).");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful in previous attempt.', 'success': true}]};
anyFtpSuccess = true;
} else {
// Proceed with FTP logic only if enabled AND not previously successful
if (isSessionKnownToBeExpired) { if (isSessionKnownToBeExpired) {
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks."); debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
// --- START: MODIFIED TO USE TIMESTAMP ID --- // --- START: MODIFIED TO USE TIMESTAMP ID ---
@ -405,6 +433,7 @@ class RiverInSituSamplingService {
anyFtpSuccess = false; anyFtpSuccess = false;
} }
} }
}
// 4. Determine Final Status // 4. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess; final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
@ -574,7 +603,7 @@ class RiverInSituSamplingService {
mapImage(data.backgroundStationImage, 'background'); mapImage(data.backgroundStationImage, 'background');
mapImage(data.upstreamRiverImage, 'upstream'); mapImage(data.upstreamRiverImage, 'upstream');
mapImage(data.downstreamRiverImage, 'downstream'); mapImage(data.downstreamRiverImage, 'downstream');
mapImage(data.sampleTurbidityImage, 'turbidity'); mapImage(data.sampleTurbidityImage, 'sample_turbidity');
mapImage(data.optionalImage1, 'optional_1'); mapImage(data.optionalImage1, 'optional_1');
mapImage(data.optionalImage2, 'optional_2'); mapImage(data.optionalImage2, 'optional_2');
mapImage(data.optionalImage3, 'optional_3'); mapImage(data.optionalImage3, 'optional_3');

View File

@ -15,14 +15,14 @@ import 'package:usb_serial/usb_serial.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:provider/provider.dart'; // Keep provider import if needed internally, though less common in services import 'package:provider/provider.dart';
import '../auth_provider.dart'; import '../auth_provider.dart';
import 'location_service.dart'; import 'location_service.dart';
import '../models/river_inves_manual_sampling_data.dart'; // Use Investigative model import '../models/river_inves_manual_sampling_data.dart';
import '../bluetooth/bluetooth_manager.dart'; import '../bluetooth/bluetooth_manager.dart';
import '../serial/serial_manager.dart'; import '../serial/serial_manager.dart';
import 'api_service.dart'; // Keep ApiService import for DatabaseHelper access within service if needed, or remove if unused directly import 'api_service.dart';
import 'package:environment_monitoring_app/services/database_helper.dart'; import 'package:environment_monitoring_app/services/database_helper.dart';
import 'local_storage_service.dart'; import 'local_storage_service.dart';
import 'server_config_service.dart'; import 'server_config_service.dart';
@ -31,10 +31,10 @@ import 'submission_api_service.dart';
import 'submission_ftp_service.dart'; import 'submission_ftp_service.dart';
import 'telegram_service.dart'; import 'telegram_service.dart';
import 'retry_service.dart'; import 'retry_service.dart';
import 'base_api_service.dart'; // Import for SessionExpiredException import 'base_api_service.dart';
import 'user_preferences_service.dart'; // ADDED
class RiverInvestigativeSamplingService {
class RiverInvestigativeSamplingService { // Renamed class
final LocationService _locationService = LocationService(); final LocationService _locationService = LocationService();
final BluetoothManager _bluetoothManager = BluetoothManager(); final BluetoothManager _bluetoothManager = BluetoothManager();
final SerialManager _serialManager = SerialManager(); final SerialManager _serialManager = SerialManager();
@ -46,22 +46,22 @@ class RiverInvestigativeSamplingService { // Renamed class
final ZippingService _zippingService = ZippingService(); final ZippingService _zippingService = ZippingService();
final RetryService _retryService = RetryService(); final RetryService _retryService = RetryService();
final TelegramService _telegramService; final TelegramService _telegramService;
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
RiverInvestigativeSamplingService(this._telegramService); // Constructor remains similar RiverInvestigativeSamplingService(this._telegramService);
Future<Position> getCurrentLocation() => _locationService.getCurrentLocation(); Future<Position> getCurrentLocation() => _locationService.getCurrentLocation();
double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2); double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2);
// Adapted image processing for Investigative data Future<File?> pickAndProcessImage(ImageSource source, { required RiverInvesManualSamplingData data, required String imageInfo, bool isRequired = false, String? stationCode}) async {
Future<File?> pickAndProcessImage(ImageSource source, { required RiverInvesManualSamplingData data, required String imageInfo, bool isRequired = false, String? stationCode}) async { // Updated model type
try { try {
final XFile? pickedFile = await _picker.pickImage( final XFile? pickedFile = await _picker.pickImage(
source: source, source: source,
imageQuality: 85, // Keep quality settings imageQuality: 85,
maxWidth: 1024, // Keep resolution settings maxWidth: 1024,
); );
if (pickedFile == null) { if (pickedFile == null) {
@ -74,30 +74,24 @@ class RiverInvestigativeSamplingService { // Renamed class
return null; return null;
} }
// FIX: Apply landscape check to ALL photos, not just required ones.
if (originalImage.height > originalImage.width) { if (originalImage.height > originalImage.width) {
debugPrint("Image rejected: Must be in landscape orientation."); debugPrint("Image rejected: Must be in landscape orientation.");
return null; return null;
} }
// Watermark using investigative data
final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}"; final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}";
final font = img.arial24; // Use consistent font final font = img.arial24;
final textWidth = watermarkTimestamp.length * 12; // Approximate width final textWidth = watermarkTimestamp.length * 12;
// Draw background rectangle for text visibility
img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255)); img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255));
// Draw timestamp string
img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0)); img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0));
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
// Use the determined station code passed in (handles Manual/Triennial/New)
final finalStationCode = stationCode ?? 'NA'; final finalStationCode = stationCode ?? 'NA';
final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-'); final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-');
// Consistent filename format
final newFileName = "${finalStationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg"; final newFileName = "${finalStationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
final filePath = p.join(tempDir.path, newFileName); final filePath = p.join(tempDir.path, newFileName);
// Encode and write the processed image
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
} catch (e) { } catch (e) {
@ -106,7 +100,6 @@ class RiverInvestigativeSamplingService { // Renamed class
} }
} }
// Bluetooth and Serial Management - No changes needed, uses shared managers
ValueNotifier<BluetoothConnectionState> get bluetoothConnectionState => _bluetoothManager.connectionState; ValueNotifier<BluetoothConnectionState> get bluetoothConnectionState => _bluetoothManager.connectionState;
ValueNotifier<SerialConnectionState> get serialConnectionState => _serialManager.connectionState; ValueNotifier<SerialConnectionState> get serialConnectionState => _serialManager.connectionState;
@ -123,19 +116,17 @@ class RiverInvestigativeSamplingService { // Renamed class
String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value; String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value;
Future<bool> requestDevicePermissions() async { Future<bool> requestDevicePermissions() async {
// Permission logic remains the same
Map<Permission, PermissionStatus> statuses = await [ Map<Permission, PermissionStatus> statuses = await [
Permission.bluetoothScan, Permission.bluetoothScan,
Permission.bluetoothConnect, Permission.bluetoothConnect,
Permission.locationWhenInUse, // Keep location permission for GPS Permission.locationWhenInUse,
].request(); ].request();
if (statuses[Permission.bluetoothScan] == PermissionStatus.granted && if (statuses[Permission.bluetoothScan] == PermissionStatus.granted &&
statuses[Permission.bluetoothConnect] == PermissionStatus.granted && statuses[Permission.bluetoothConnect] == PermissionStatus.granted &&
statuses[Permission.locationWhenInUse] == PermissionStatus.granted) { // Ensure location is granted too statuses[Permission.locationWhenInUse] == PermissionStatus.granted) {
return true; return true;
} else { } else {
debugPrint("Bluetooth Scan: ${statuses[Permission.bluetoothScan]}, Bluetooth Connect: ${statuses[Permission.bluetoothConnect]}, Location: ${statuses[Permission.locationWhenInUse]}");
return false; return false;
} }
} }
@ -148,9 +139,7 @@ class RiverInvestigativeSamplingService { // Renamed class
Future<List<UsbDevice>> getAvailableSerialDevices() => _serialManager.getAvailableDevices(); Future<List<UsbDevice>> getAvailableSerialDevices() => _serialManager.getAvailableDevices();
Future<bool> requestUsbPermission(UsbDevice device) async { Future<bool> requestUsbPermission(UsbDevice device) async {
// USB permission logic remains the same
try { try {
// Ensure the platform channel name matches what's defined in your native code (Android/iOS)
return await platform.invokeMethod('requestUsbPermission', {'vid': device.vid, 'pid': device.pid}) ?? false; return await platform.invokeMethod('requestUsbPermission', {'vid': device.vid, 'pid': device.pid}) ?? false;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint("Failed to request USB permission: '${e.message}'."); debugPrint("Failed to request USB permission: '${e.message}'.");
@ -159,7 +148,6 @@ class RiverInvestigativeSamplingService { // Renamed class
} }
Future<void> connectToSerialDevice(UsbDevice device) async { Future<void> connectToSerialDevice(UsbDevice device) async {
// Serial connection logic remains the same
final bool permissionGranted = await requestUsbPermission(device); final bool permissionGranted = await requestUsbPermission(device);
if (permissionGranted) { if (permissionGranted) {
await _serialManager.connect(device); await _serialManager.connect(device);
@ -176,51 +164,40 @@ class RiverInvestigativeSamplingService { // Renamed class
_serialManager.dispose(); _serialManager.dispose();
} }
// --- START: NEW HELPER METHOD ---
/// Generates a unique timestamp ID from the sampling date and time.
String _generateTimestampId(String? date, String? time) { String _generateTimestampId(String? date, String? time) {
final String dateTimeString = "${date ?? ''} ${time ?? ''}"; final String dateTimeString = "${date ?? ''} ${time ?? ''}";
try { try {
// Time format from model is HH:mm
final DateTime samplingDateTime = DateFormat('yyyy-MM-dd HH:mm').parse(dateTimeString); final DateTime samplingDateTime = DateFormat('yyyy-MM-dd HH:mm').parse(dateTimeString);
return samplingDateTime.millisecondsSinceEpoch.toString(); return samplingDateTime.millisecondsSinceEpoch.toString();
} catch (e) { } catch (e) {
// Fallback: if parsing fails, use the current time in milliseconds
debugPrint("Could not parse '$dateTimeString' for timestamp ID, using current time. Error: $e"); debugPrint("Could not parse '$dateTimeString' for timestamp ID, using current time. Error: $e");
return DateTime.now().millisecondsSinceEpoch.toString(); return DateTime.now().millisecondsSinceEpoch.toString();
} }
} }
// --- END: NEW HELPER METHOD ---
// Adapted Submission Logic for Investigative
Future<Map<String, dynamic>> submitData({ Future<Map<String, dynamic>> submitData({
required RiverInvesManualSamplingData data, // Updated model type required RiverInvesManualSamplingData data,
required List<Map<String, dynamic>>? appSettings, required List<Map<String, dynamic>>? appSettings,
required AuthProvider authProvider, required AuthProvider authProvider,
String? logDirectory, String? logDirectory,
}) async { }) async {
// *** MODIFIED: Module name changed ***
const String moduleName = 'river_investigative'; const String moduleName = 'river_investigative';
// --- START: MODIFIED TO USE TIMESTAMP ID ---
// Generate the unique timestamp ID and assign it immediately.
final String timestampId = _generateTimestampId(data.samplingDate, data.samplingTime); final String timestampId = _generateTimestampId(data.samplingDate, data.samplingTime);
data.reportId = timestampId; // This is the primary ID now. data.reportId = timestampId;
// --- END: MODIFIED TO USE TIMESTAMP ID ---
final connectivityResult = await Connectivity().checkConnectivity(); final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = !connectivityResult.contains(ConnectivityResult.none); bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false); bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
// Auto-relogin logic remains the same
if (isOnline && isOfflineSession) { if (isOnline && isOfflineSession) {
debugPrint("River Investigative submission online during offline session. Attempting auto-relogin..."); // Log context update debugPrint("River Investigative submission online during offline session. Attempting auto-relogin...");
try { try {
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession(); final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
if (transitionSuccess) { if (transitionSuccess) {
isOfflineSession = false; // Successfully transitioned to online isOfflineSession = false;
} else { } else {
isOnline = false; // Auto-relogin failed, treat as offline isOnline = false;
} }
} on SessionExpiredException catch (_) { } on SessionExpiredException catch (_) {
debugPrint("Session expired during auto-relogin check. Treating as offline."); debugPrint("Session expired during auto-relogin check. Treating as offline.");
@ -228,9 +205,8 @@ class RiverInvestigativeSamplingService { // Renamed class
} }
} }
// Branch based on connectivity and session status
if (isOnline && !isOfflineSession) { if (isOnline && !isOfflineSession) {
debugPrint("Proceeding with direct ONLINE River Investigative submission..."); // Log context update debugPrint("Proceeding with direct ONLINE River Investigative submission...");
return await _performOnlineSubmission( return await _performOnlineSubmission(
data: data, data: data,
appSettings: appSettings, appSettings: appSettings,
@ -239,26 +215,25 @@ class RiverInvestigativeSamplingService { // Renamed class
logDirectory: logDirectory, logDirectory: logDirectory,
); );
} else { } else {
debugPrint("Proceeding with OFFLINE River Investigative queuing mechanism..."); // Log context update debugPrint("Proceeding with OFFLINE River Investigative queuing mechanism...");
return await _performOfflineQueuing( return await _performOfflineQueuing(
data: data, data: data,
moduleName: moduleName, moduleName: moduleName,
logDirectory: logDirectory, // Pass for potential update logDirectory: logDirectory,
); );
} }
} }
Future<Map<String, dynamic>> _performOnlineSubmission({ Future<Map<String, dynamic>> _performOnlineSubmission({
required RiverInvesManualSamplingData data, // Updated model type required RiverInvesManualSamplingData data,
required List<Map<String, dynamic>>? appSettings, required List<Map<String, dynamic>>? appSettings,
required String moduleName, // Passed in as 'river_investigative' required String moduleName,
required AuthProvider authProvider, required AuthProvider authProvider,
String? logDirectory, String? logDirectory,
}) async { }) async {
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
// Get image files using the Investigative model's method
final imageFilesWithNulls = data.toApiImageFiles(); final imageFilesWithNulls = data.toApiImageFiles();
imageFilesWithNulls.removeWhere((key, value) => value == null); // Remove nulls imageFilesWithNulls.removeWhere((key, value) => value == null);
final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>(); final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>();
bool anyApiSuccess = false; bool anyApiSuccess = false;
@ -268,118 +243,115 @@ class RiverInvestigativeSamplingService { // Renamed class
String finalStatus = ''; String finalStatus = '';
bool isSessionKnownToBeExpired = false; bool isSessionKnownToBeExpired = false;
// --- START: MODIFIED TO USE TIMESTAMP ID --- String? apiRecordId;
String? apiRecordId; // Will hold the DB ID (e.g., 102) from the server
// data.reportId already contains the timestamp ID
// --- END: MODIFIED TO USE TIMESTAMP ID ---
// 1. Check module preferences for API
final pref = await _userPreferencesService.getModulePreference(moduleName);
bool isApiEnabled = pref?['is_api_enabled'] ?? true;
bool isFtpEnabled = pref?['is_ftp_enabled'] ?? true;
if (isApiEnabled) {
try { try {
// 1. Submit Form Data (using Investigative endpoint and data) // 1. Submit Form Data
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName, // 'river_investigative' moduleName: moduleName,
// *** MODIFIED: API Endpoint *** endpoint: 'river/investigative/sample',
endpoint: 'river/investigative/sample', // Assumed endpoint for investigative data body: data.toApiFormData(),
body: data.toApiFormData(), // Use Investigative model's method
); );
if (apiDataResult['success'] == true) { if (apiDataResult['success'] == true) {
anyApiSuccess = true; anyApiSuccess = true;
// --- START: MODIFIED TO USE TIMESTAMP ID --- apiRecordId = apiDataResult['data']?['r_inves_id']?.toString();
// *** MODIFIED: Extract report ID using assumed key ***
apiRecordId = apiDataResult['data']?['r_inv_id']?.toString(); // Assumed key for investigative ID
// --- END: MODIFIED TO USE TIMESTAMP ID ---
if (apiRecordId != null) { if (apiRecordId != null) {
if (finalImageFiles.isNotEmpty) { if (finalImageFiles.isNotEmpty) {
// 2. Submit Images (using Investigative endpoint) // 2. Submit Images
apiImageResult = await _submissionApiService.submitMultipart( apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName, // 'river_investigative' moduleName: moduleName,
// *** MODIFIED: API Endpoint *** endpoint: 'river/investigative/images',
endpoint: 'river/investigative/images', // Assumed endpoint for investigative images fields: {'r_inves_id': apiRecordId},
// --- START: MODIFIED TO USE TIMESTAMP ID ---
// *** MODIFIED: Field key for ID ***
fields: {'r_inv_id': apiRecordId}, // Use assumed investigative ID key
// --- END: MODIFIED TO USE TIMESTAMP ID ---
files: finalImageFiles, files: finalImageFiles,
); );
if (apiImageResult['success'] != true) { if (apiImageResult['success'] != true) {
// If image upload fails after data success, mark API part as failed overall for simplicity, or handle partially.
anyApiSuccess = false; // Treat as overall API failure if images fail
}
}
// If no images, data submission success is enough
} else {
// API succeeded but didn't return an ID - treat as failure
anyApiSuccess = false; anyApiSuccess = false;
apiDataResult['success'] = false; // Mark as failed }
// --- START: MODIFIED TO USE TIMESTAMP ID --- }
} else {
anyApiSuccess = false;
apiDataResult['success'] = false;
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a server record ID.'; apiDataResult['message'] = 'API Error: Submission succeeded but did not return a server record ID.';
// --- END: MODIFIED TO USE TIMESTAMP ID ---
} }
} }
// If apiDataResult['success'] is false initially, SubmissionApiService queued it.
} on SessionExpiredException catch (_) { } on SessionExpiredException catch (_) {
debugPrint("Online River Investigative submission failed due to session expiry that could not be refreshed."); // Log context update debugPrint("Online River Investigative submission failed due to session expiry that could not be refreshed.");
isSessionKnownToBeExpired = true; isSessionKnownToBeExpired = true;
anyApiSuccess = false; anyApiSuccess = false;
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'}; apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
// Manually queue API calls // Manually queue API calls
// *** MODIFIED: Use Investigative endpoints for queueing ***
await _retryService.addApiToQueue(endpoint: 'river/investigative/sample', method: 'POST', body: data.toApiFormData()); await _retryService.addApiToQueue(endpoint: 'river/investigative/sample', method: 'POST', body: data.toApiFormData());
// --- START: MODIFIED TO USE TIMESTAMP ID ---
if (finalImageFiles.isNotEmpty && apiRecordId != null) { if (finalImageFiles.isNotEmpty && apiRecordId != null) {
// Queue images only if we might have gotten an ID before expiry await _retryService.addApiToQueue(endpoint: 'river/investigative/images', method: 'POST_MULTIPART', fields: {'r_inves_id': apiRecordId}, files: finalImageFiles);
await _retryService.addApiToQueue(endpoint: 'river/investigative/images', method: 'POST_MULTIPART', fields: {'r_inv_id': apiRecordId}, files: finalImageFiles);
} else if (finalImageFiles.isNotEmpty && apiRecordId == null) { } else if (finalImageFiles.isNotEmpty && apiRecordId == null) {
// --- END: MODIFIED TO USE TIMESTAMP ID ---
// If data call failed before getting ID, queue images without ID - might need manual linking later or separate retry logic
debugPrint("Queueing investigative images without report ID due to session expiry during data submission."); debugPrint("Queueing investigative images without report ID due to session expiry during data submission.");
// How to handle this depends on backend capabilities or manual intervention needs. await _retryService.addApiToQueue(endpoint: 'river/investigative/images', method: 'POST_MULTIPART', fields: {}, files: finalImageFiles);
// Option: Queue a complex task instead? For now, queueing individually.
await _retryService.addApiToQueue(endpoint: 'river/investigative/images', method: 'POST_MULTIPART', fields: {}, files: finalImageFiles); // Queue images without ID
} }
} }
} else {
debugPrint("API submission disabled for $moduleName by user preference.");
apiDataResult = {'success': true, 'message': 'API submission disabled by user.'};
anyApiSuccess = true; // Treated as success since it was intentional
}
// 3. Submit FTP Files (Logic remains similar, uses specific JSON methods)
// 3. Submit FTP Files
Map<String, dynamic> ftpResults = {'statuses': []}; Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false; bool anyFtpSuccess = false;
if (isSessionKnownToBeExpired) { // --- START FIX: Check if FTP is enabled AND if it was already successful ---
debugPrint("Skipping FTP attempt for River Investigative due to known expired session. Manually queuing FTP tasks."); // Log context update // 'L4' status means API Failed but FTP Succeeded. If re-submitting an L4 record, we skip FTP.
// --- START: MODIFIED TO USE TIMESTAMP ID --- // 'S4' means everything succeeded.
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
// --- END: MODIFIED TO USE TIMESTAMP ID ---
// --- START FIX: Add ftpConfigId when queuing --- (Copied from In-Situ, ensure DB structure matches) if (!isFtpEnabled) {
debugPrint("FTP submission disabled for $moduleName by user preference. Skipping FTP.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user preference.', 'success': true}]};
anyFtpSuccess = true;
} else if (previousFtpSuccess) {
debugPrint("FTP submission skipped because it was already successful (Status: ${data.submissionStatus}).");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful in previous attempt.', 'success': true}]};
anyFtpSuccess = true;
} else {
// Proceed with FTP logic only if enabled AND not previously successful
if (isSessionKnownToBeExpired) {
debugPrint("Skipping FTP attempt for River Investigative due to known expired session. Manually queuing FTP tasks.");
final baseFileNameForQueue = _generateBaseFileName(data);
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? []; final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
final dataZip = await _zippingService.createDataZip( final dataZip = await _zippingService.createDataZip(
jsonDataMap: { // Use specific JSON structures for River Investigative FTP jsonDataMap: {
'db.json': data.toDbJson(), // Use Investigative model's method 'db.json': data.toDbJson(),
'river_inves_basic_form.json': data.toBasicFormJson(), // Use Investigative model's method 'river_inves_basic_form.json': data.toBasicFormJson(),
'river_inves_reading.json': data.toReadingJson(), // Use Investigative model's method 'river_inves_reading.json': data.toReadingJson(),
'river_inves_manual_info.json': data.toManualInfoJson(), // Use Investigative model's method 'river_inves_manual_info.json': data.toManualInfoJson(),
}, },
baseFileName: baseFileNameForQueue, baseFileName: baseFileNameForQueue,
destinationDir: null, // Save to temp dir destinationDir: null,
); );
if (dataZip != null) { if (dataZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) { for (final config in ftpConfigs) {
final configId = config['ftp_config_id']; final configId = config['ftp_config_id'];
if (configId != null) { if (configId != null) {
await _retryService.addFtpToQueue( await _retryService.addFtpToQueue(
localFilePath: dataZip.path, localFilePath: dataZip.path,
remotePath: '/${p.basename(dataZip.path)}', // Standard remote path remotePath: '/${p.basename(dataZip.path)}',
ftpConfigId: configId // Provide the specific config ID ftpConfigId: configId
); );
} }
} }
} }
if (finalImageFiles.isNotEmpty) { if (finalImageFiles.isNotEmpty) {
// Use existing queue logic for fallback (no renaming complexity here to be safe)
final Map<String, File> retryImages = {}; final Map<String, File> retryImages = {};
final String dateStr = (data.samplingDate ?? '').replaceAll('-', ''); final String dateStr = (data.samplingDate ?? '').replaceAll('-', '');
final String timeStr = (data.samplingTime ?? '').replaceAll(':', ''); final String timeStr = (data.samplingTime ?? '').replaceAll(':', '');
@ -404,236 +376,172 @@ class RiverInvestigativeSamplingService { // Renamed class
); );
if (retryImageZip != null) { if (retryImageZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) { for (final config in ftpConfigs) {
final configId = config['ftp_config_id']; final configId = config['ftp_config_id'];
if (configId != null) { if (configId != null) {
await _retryService.addFtpToQueue( await _retryService.addFtpToQueue(
localFilePath: retryImageZip.path, localFilePath: retryImageZip.path,
remotePath: '/${p.basename(retryImageZip.path)}', // Standard remote path remotePath: '/${p.basename(retryImageZip.path)}',
ftpConfigId: configId // Provide the specific config ID ftpConfigId: configId
); );
} }
} }
} }
} }
// --- END FIX ---
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]}; ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
anyFtpSuccess = false; // Mark FTP as unsuccessful for overall status determination anyFtpSuccess = false;
} else { } else {
// Proceed with FTP attempt if session is okay
try { try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName); // Call helper ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
// Determine success based on statuses (excluding 'Not Configured')
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured'); anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) { } catch (e) {
debugPrint("Unexpected River Investigative FTP submission error: $e"); // Log context update debugPrint("Unexpected River Investigative FTP submission error: $e");
anyFtpSuccess = false; // Mark FTP as failed on error anyFtpSuccess = false;
ftpResults = {'statuses': [{'status': 'Error', 'message': 'FTP process failed: $e.', 'success': false}]}; // Provide error status ftpResults = {'statuses': [{'status': 'Error', 'message': 'FTP process failed: $e.', 'success': false}]};
} }
} }
}
// --- END FIX ---
// 4. Determine Final Status (Logic remains the same) // 4. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess; final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
if (anyApiSuccess && anyFtpSuccess) { if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'Data submitted successfully to all destinations.'; finalMessage = 'Data submitted successfully to all destinations.';
finalStatus = 'S4'; // API OK, FTP OK finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) { } else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Data sent to API, but some FTP uploads failed or were queued.'; finalMessage = 'Data sent to API, but some FTP uploads failed or were queued.';
finalStatus = 'S3'; // API OK, FTP Failed/Queued finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) { } else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
finalStatus = 'L4'; // API Failed/Queued, FTP OK finalStatus = 'L4';
} else { // Neither API nor FTP fully succeeded without queueing/errors } else {
finalMessage = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.'; finalMessage = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.';
finalStatus = 'L1'; // API Failed/Queued, FTP Failed/Queued finalStatus = 'L1';
} }
// 5. Log Locally (using Investigative log method) // 5. Log Locally
await _logAndSave( await _logAndSave(
data: data, data: data,
status: finalStatus, status: finalStatus,
message: finalMessage, message: finalMessage,
apiResults: [apiDataResult, apiImageResult].where((r) => r.isNotEmpty).toList(), // Filter out empty results apiResults: [apiDataResult, apiImageResult].where((r) => r.isNotEmpty).toList(),
ftpStatuses: ftpResults['statuses'] ?? [], ftpStatuses: ftpResults['statuses'] ?? [],
serverName: serverName, serverName: serverName,
// --- START: MODIFIED TO USE TIMESTAMP ID --- apiRecordId: apiRecordId,
apiRecordId: apiRecordId, // Pass the server ID
// --- END: MODIFIED TO USE TIMESTAMP ID ---
logDirectory: logDirectory, logDirectory: logDirectory,
); );
// 6. Send Alert (using Investigative alert method) // 6. Send Alert
if (overallSuccess) { // Send alert only if at least one part (API or FTP) succeeded without errors/queueing immediately if (overallSuccess) {
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
} }
// Return consistent result format
return { return {
'status': finalStatus, 'status': finalStatus,
'success': overallSuccess, // Reflects if *any* part succeeded now 'success': overallSuccess,
'message': finalMessage, 'message': finalMessage,
// --- START: MODIFIED TO USE TIMESTAMP ID --- 'reportId': data.reportId
'reportId': data.reportId // This is now the timestamp ID
// --- END: MODIFIED TO USE TIMESTAMP ID ---
}; };
} }
/// Handles queuing the submission data when the device is offline for Investigative.
Future<Map<String, dynamic>> _performOfflineQueuing({ Future<Map<String, dynamic>> _performOfflineQueuing({
required RiverInvesManualSamplingData data, // Updated model type required RiverInvesManualSamplingData data,
required String moduleName, // Passed in as 'river_investigative' required String moduleName,
String? logDirectory, // Added for potential update String? logDirectory,
}) async { }) async {
final serverConfig = await _serverConfigService.getActiveApiConfig(); final serverConfig = await _serverConfigService.getActiveApiConfig();
final serverName = serverConfig?['config_name'] as String? ?? 'Default'; final serverName = serverConfig?['config_name'] as String? ?? 'Default';
data.submissionStatus = 'Queued'; // Tentative status, will be L1 after saving data.submissionStatus = 'Queued';
data.submissionMessage = 'Submission queued for later retry.'; data.submissionMessage = 'Submission queued for later retry.';
String? savedLogPath = logDirectory; // Use existing path if provided for an update String? savedLogPath = logDirectory;
// Save/Update local log first using the specific Investigative save method
if (savedLogPath != null && savedLogPath.isNotEmpty) { if (savedLogPath != null && savedLogPath.isNotEmpty) {
// *** MODIFIED: Use correct update method *** await _localStorageService.updateRiverInvestigativeLog(data.toMap()..['logDirectory'] = savedLogPath);
await _localStorageService.updateRiverInvestigativeLog(data.toMap()..['logDirectory'] = savedLogPath); // Add path for update method debugPrint("Updated existing River Investigative log for queuing: $savedLogPath");
debugPrint("Updated existing River Investigative log for queuing: $savedLogPath"); // Log context update
} else { } else {
// *** MODIFIED: Use correct save method ***
savedLogPath = await _localStorageService.saveRiverInvestigativeSamplingData(data, serverName: serverName); savedLogPath = await _localStorageService.saveRiverInvestigativeSamplingData(data, serverName: serverName);
debugPrint("Saved new River Investigative log for queuing: $savedLogPath"); // Log context update debugPrint("Saved new River Investigative log for queuing: $savedLogPath");
} }
if (savedLogPath == null) { if (savedLogPath == null) {
// If saving the log itself failed const message = "Failed to save River Investigative submission to local device storage.";
const message = "Failed to save River Investigative submission to local device storage."; // Log context update
// Log failure to central DB log if possible
// --- START: MODIFIED TO USE TIMESTAMP ID ---
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, apiRecordId: null, logDirectory: logDirectory); await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, apiRecordId: null, logDirectory: logDirectory);
// --- END: MODIFIED TO USE TIMESTAMP ID ---
return {'status': 'Error', 'success': false, 'message': message}; return {'status': 'Error', 'success': false, 'message': message};
} }
// Queue the task for the RetryService
// *** MODIFIED: Use specific task type ***
await _retryService.queueTask( await _retryService.queueTask(
type: 'river_investigative_submission', // Specific type for retry handler type: 'river_investigative_submission',
payload: { payload: {
'module': moduleName, // 'river_investigative' 'module': moduleName,
'localLogPath': p.join(savedLogPath, 'data.json'), // Point to the json file within the saved directory 'localLogPath': p.join(savedLogPath, 'data.json'),
'serverConfig': serverConfig, // Pass current server config at time of queueing 'serverConfig': serverConfig,
}, },
); );
const successMessage = "Device offline. River Investigative submission has been saved locally and queued for automatic retry when connection is restored."; // Log context update const successMessage = "Device offline. River Investigative submission has been saved locally and queued for automatic retry when connection is restored.";
// Update final status in the data object and potentially update log again, or just log to central DB data.submissionStatus = 'L1';
data.submissionStatus = 'L1'; // Final queued status
data.submissionMessage = successMessage; data.submissionMessage = successMessage;
// Log final queued state to central DB log await _logAndSave(data: data, status: 'L1', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, apiRecordId: null, logDirectory: savedLogPath);
// --- START: MODIFIED TO USE TIMESTAMP ID ---
await _logAndSave(data: data, status: 'L1', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, apiRecordId: null, logDirectory: savedLogPath); // Ensure log reflects final state
// --- END: MODIFIED TO USE TIMESTAMP ID ---
return {'status': 'Queued', 'success': true, 'message': successMessage, 'reportId': data.reportId}; // Return timestamp ID return {'status': 'Queued', 'success': true, 'message': successMessage, 'reportId': data.reportId};
} }
// --- START: NEW HELPER METHOD (for timestamp ID) --- String _generateBaseFileName(RiverInvesManualSamplingData data) {
/// Generates a unique timestamp ID from the sampling date and time.
// Note: This function was duplicated. The duplicate has been removed.
// The first occurrence of this function is kept, even though the error message pointed to it.
// Keeping this one:
/*
String _generateTimestampId(String? date, String? time) {
final String dateTimeString = "${date ?? ''} ${time ?? ''}";
try {
// Time format from model is HH:mm
final DateTime samplingDateTime = DateFormat('yyyy-MM-dd HH:mm').parse(dateTimeString);
return samplingDateTime.millisecondsSinceEpoch.toString();
} catch (e) {
// Fallback: if parsing fails, use the current time in milliseconds
debugPrint("Could not parse '$dateTimeString' for timestamp ID, using current time. Error: $e");
return DateTime.now().millisecondsSinceEpoch.toString();
}
}
*/
// --- END: NEW HELPER METHOD ---
// --- START: MODIFIED _generateBaseFileName ---
/// Helper to generate the base filename for ZIP files (Investigative).
String _generateBaseFileName(RiverInvesManualSamplingData data) { // Updated model type
// Use the determined station code helper
final stationCode = data.getDeterminedStationCode() ?? 'UNKNOWN'; final stationCode = data.getDeterminedStationCode() ?? 'UNKNOWN';
// We now always use data.reportId, which we set as the timestamp.
if (data.reportId == null || data.reportId!.isEmpty) { if (data.reportId == null || data.reportId!.isEmpty) {
// This is a safety fallback, but should not happen if submitData is used.
debugPrint("Warning: reportId is null in _generateBaseFileName. Using current timestamp."); debugPrint("Warning: reportId is null in _generateBaseFileName. Using current timestamp.");
return '${stationCode}_${DateTime.now().millisecondsSinceEpoch.toString()}'; return '${stationCode}_${DateTime.now().millisecondsSinceEpoch.toString()}';
} }
return "${stationCode}_${data.reportId}"; // Consistent format return "${stationCode}_${data.reportId}";
} }
// --- END: MODIFIED _generateBaseFileName ---
/// Generates data and image ZIP files and uploads them using SubmissionFtpService (Investigative).
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverInvesManualSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async { Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverInvesManualSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
// 1. GENERATE TIMESTAMP FOR IMAGE RENAMING
// e.g., "2025-09-30" and "14:34:19" -> "20250930143419"
final String dateStr = (data.samplingDate ?? '').replaceAll('-', ''); final String dateStr = (data.samplingDate ?? '').replaceAll('-', '');
final String timeStr = (data.samplingTime ?? '').replaceAll(':', ''); final String timeStr = (data.samplingTime ?? '').replaceAll(':', '');
final String zipImageTimestamp = "$dateStr$timeStr"; final String zipImageTimestamp = "$dateStr$timeStr";
// 2. USE ORIGINAL BASE FILENAME (Report ID / Milliseconds) for Folder/Zip final baseFileName = _generateBaseFileName(data);
final baseFileName = _generateBaseFileName(data); // Use helper final Directory? logDirectory = await _localStorageService.getRiverInvestigativeBaseDir(serverName: serverName);
// 3. SETUP DIRECTORIES
final Directory? logDirectory = await _localStorageService.getRiverInvestigativeBaseDir(serverName: serverName); // NEW GETTER
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, baseFileName)) : null; final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, baseFileName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) { if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true); // Create if doesn't exist await localSubmissionDir.create(recursive: true);
} }
// 4. CREATE DATA ZIP
final dataZip = await _zippingService.createDataZip( final dataZip = await _zippingService.createDataZip(
// --- START FIX: Include all four JSON files ---
jsonDataMap: { jsonDataMap: {
// *** MODIFIED: Use Investigative model's JSON methods and filenames *** 'db.json': data.toDbJson(),
'db.json': data.toDbJson(), // Main data structure
'river_inves_basic_form.json': data.toBasicFormJson(), 'river_inves_basic_form.json': data.toBasicFormJson(),
'river_inves_reading.json': data.toReadingJson(), 'river_inves_reading.json': data.toReadingJson(),
'river_inves_manual_info.json': data.toManualInfoJson(), 'river_inves_manual_info.json': data.toManualInfoJson(),
}, },
// --- END FIX ---
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir, // Save ZIP in the specific log folder destinationDir: localSubmissionDir,
); );
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []}; // Default success if no file Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) { if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit( ftpDataResult = await _submissionFtpService.submit(
moduleName: moduleName, // 'river_investigative' moduleName: moduleName,
fileToUpload: dataZip, fileToUpload: dataZip,
remotePath: '/${p.basename(dataZip.path)}' // Standard remote path remotePath: '/${p.basename(dataZip.path)}'
); );
} }
// 5. CREATE IMAGE ZIP (RENAMING LOGIC)
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []}; Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
// Create mapping: "New Name Inside Zip" -> "Original File on Phone"
final Map<String, File> imagesForZip = {}; final Map<String, File> imagesForZip = {};
void mapImage(File? file, String prefix) { void mapImage(File? file, String prefix) {
if (file != null && file.existsSync()) { if (file != null && file.existsSync()) {
// Rename inside zip: prefix_20250930143419.jpg
imagesForZip['${prefix}_$zipImageTimestamp.jpg'] = file; imagesForZip['${prefix}_$zipImageTimestamp.jpg'] = file;
} }
} }
// Map images (Investigative model uses same names as others)
mapImage(data.backgroundStationImage, 'background'); mapImage(data.backgroundStationImage, 'background');
mapImage(data.upstreamRiverImage, 'upstream'); mapImage(data.upstreamRiverImage, 'upstream');
mapImage(data.downstreamRiverImage, 'downstream'); mapImage(data.downstreamRiverImage, 'downstream');
@ -644,152 +552,121 @@ class RiverInvestigativeSamplingService { // Renamed class
mapImage(data.optionalImage4, 'optional_4'); mapImage(data.optionalImage4, 'optional_4');
if (imagesForZip.isNotEmpty) { if (imagesForZip.isNotEmpty) {
// *** MODIFICATION: Call the NEW renaming function ***
final imageZip = await _zippingService.createRenamedImageZip( final imageZip = await _zippingService.createRenamedImageZip(
imageFiles: imagesForZip, imageFiles: imagesForZip,
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir, // Save ZIP in the specific log folder destinationDir: localSubmissionDir,
); );
if (imageZip != null) { if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit( ftpImageResult = await _submissionFtpService.submit(
moduleName: moduleName, // 'river_investigative' moduleName: moduleName,
fileToUpload: imageZip, fileToUpload: imageZip,
remotePath: '/${p.basename(imageZip.path)}' // Standard remote path remotePath: '/${p.basename(imageZip.path)}'
); );
} }
} }
// Combine statuses from both uploads
return { return {
'statuses': <Map<String, dynamic>>[ 'statuses': <Map<String, dynamic>>[
...(ftpDataResult['statuses'] as List? ?? []), // Use null-aware spread ...(ftpDataResult['statuses'] as List? ?? []),
...(ftpImageResult['statuses'] as List? ?? []), // Use null-aware spread ...(ftpImageResult['statuses'] as List? ?? []),
], ],
}; };
} }
/// Saves or updates the local log file and saves a record to the central DB log (Investigative).
Future<void> _logAndSave({ Future<void> _logAndSave({
required RiverInvesManualSamplingData data, // Updated model type required RiverInvesManualSamplingData data,
required String status, required String status,
required String message, required String message,
required List<Map<String, dynamic>> apiResults, required List<Map<String, dynamic>> apiResults,
required List<Map<String, dynamic>> ftpStatuses, required List<Map<String, dynamic>> ftpStatuses,
required String serverName, required String serverName,
// --- START: MODIFIED TO USE TIMESTAMP ID --- String? apiRecordId,
String? apiRecordId, // The server's DB ID (e.g., 102) String? logDirectory,
// --- END: MODIFIED TO USE TIMESTAMP ID ---
String? logDirectory, // Can be null initially, gets populated on first save
}) async { }) async {
data.submissionStatus = status; data.submissionStatus = status;
data.submissionMessage = message; data.submissionMessage = message;
final baseFileName = _generateBaseFileName(data); // Use helper for consistent naming final baseFileName = _generateBaseFileName(data);
// Prepare log data map using toMap()
final Map<String, dynamic> logMapData = data.toMap(); final Map<String, dynamic> logMapData = data.toMap();
// Add submission metadata that might not be in toMap() or needs overriding
logMapData['submissionStatus'] = status; logMapData['submissionStatus'] = status;
logMapData['submissionMessage'] = message; logMapData['submissionMessage'] = message;
// --- START: MODIFIED TO USE TIMESTAMP ID --- logMapData['apiRecordId'] = apiRecordId;
// data.reportId (the timestamp) is already in the map from toMap()
logMapData['apiRecordId'] = apiRecordId; // Add the server DB ID
// --- END: MODIFIED TO USE TIMESTAMP ID ---
logMapData['serverConfigName'] = serverName; logMapData['serverConfigName'] = serverName;
// Store API/FTP results as JSON strings logMapData['api_status'] = jsonEncode(apiResults);
logMapData['api_status'] = jsonEncode(apiResults); // Ensure apiResults is a list logMapData['ftp_status'] = jsonEncode(ftpStatuses);
logMapData['ftp_status'] = jsonEncode(ftpStatuses); // Ensure ftpStatuses is a list
String? savedLogPath = logDirectory; String? savedLogPath = logDirectory;
// Save or Update local log file (data.json)
if (savedLogPath != null && savedLogPath.isNotEmpty) { if (savedLogPath != null && savedLogPath.isNotEmpty) {
// Update existing log logMapData['logDirectory'] = savedLogPath;
logMapData['logDirectory'] = savedLogPath; // Ensure logDirectory path is in the map for update method await _localStorageService.updateRiverInvestigativeLog(logMapData);
// *** MODIFIED: Use correct update method ***
await _localStorageService.updateRiverInvestigativeLog(logMapData); // NEW UPDATE METHOD
} else { } else {
// Save new log and get the path savedLogPath = await _localStorageService.saveRiverInvestigativeSamplingData(data, serverName: serverName);
// *** MODIFIED: Use correct save method ***
savedLogPath = await _localStorageService.saveRiverInvestigativeSamplingData(data, serverName: serverName); // NEW SAVE METHOD
if (savedLogPath != null) { if (savedLogPath != null) {
logMapData['logDirectory'] = savedLogPath; // Add the new path for central log logMapData['logDirectory'] = savedLogPath;
} else { } else {
debugPrint("Failed to save River Investigative log locally, central DB log might be incomplete."); debugPrint("Failed to save River Investigative log locally, central DB log might be incomplete.");
// Handle case where local save failed? Maybe skip central log or log with error?
} }
} }
// Save record to central DB log (submission_log table)
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList(); final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
final centralLogData = { final centralLogData = {
// --- START: MODIFIED TO USE TIMESTAMP ID --- 'submission_id': data.reportId ?? baseFileName,
'submission_id': data.reportId ?? baseFileName, // Use timestamp ID 'module': 'river',
// *** MODIFIED: Module and Type *** 'type': 'Investigative',
'module': 'river', // Keep main module as 'river'
'type': 'Investigative', // Specific type
'status': status, 'status': status,
'message': message, 'message': message,
'report_id': apiRecordId, // Use server DB ID 'report_id': apiRecordId,
// --- END: MODIFIED TO USE TIMESTAMP ID ---
'created_at': DateTime.now().toIso8601String(), 'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(logMapData), // Log the comprehensive map including paths and status 'form_data': jsonEncode(logMapData),
'image_data': jsonEncode(imagePaths), // Log original image paths used for submission attempt 'image_data': jsonEncode(imagePaths),
'server_name': serverName, 'server_name': serverName,
'api_status': jsonEncode(apiResults), // Log API results 'api_status': jsonEncode(apiResults),
'ftp_status': jsonEncode(ftpStatuses), // Log FTP results 'ftp_status': jsonEncode(ftpStatuses),
}; };
try { try {
await _dbHelper.saveSubmissionLog(centralLogData); await _dbHelper.saveSubmissionLog(centralLogData);
} catch (e) { } catch (e) {
debugPrint("Error saving River Investigative submission log to DB: $e"); // Log context update debugPrint("Error saving River Investigative submission log to DB: $e");
} }
} }
Future<void> _handleSuccessAlert(RiverInvesManualSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
/// Handles sending or queuing the Telegram alert for River Investigative submissions.
Future<void> _handleSuccessAlert(RiverInvesManualSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { // Updated model type
try { try {
// --- FIX: Correct function name to the defined helper method --- final message = await _generateSuccessAlertMessage(data, isDataOnly: isDataOnly);
final message = await _generateSuccessAlertMessage(data, isDataOnly: isDataOnly); // Call specific helper final alertKey = 'river_investigative';
// --- END FIX ---
// *** MODIFIED: Telegram key ***
final alertKey = 'river_investigative'; // Specific key for this module
if (isSessionExpired) { if (isSessionExpired) {
debugPrint("Session is expired; queuing River Investigative Telegram alert directly for $alertKey."); // Log context update debugPrint("Session is expired; queuing River Investigative Telegram alert directly for $alertKey.");
await _telegramService.queueMessage(alertKey, message, appSettings); await _telegramService.queueMessage(alertKey, message, appSettings);
} else { } else {
final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings); final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
if (!wasSent) { if (!wasSent) {
// Fallback to queueing if immediate send fails
await _telegramService.queueMessage(alertKey, message, appSettings); await _telegramService.queueMessage(alertKey, message, appSettings);
} }
} }
} catch (e) { } catch (e) {
debugPrint("Failed to handle River Investigative Telegram alert: $e"); // Log context update debugPrint("Failed to handle River Investigative Telegram alert: $e");
} }
} }
/// Generates the specific Telegram alert message content for River Investigative. Future<String> _generateSuccessAlertMessage(RiverInvesManualSamplingData data, {required bool isDataOnly}) async {
Future<String> _generateSuccessAlertMessage(RiverInvesManualSamplingData data, {required bool isDataOnly}) async { // Updated model type
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
// Use helpers to get determined names/codes final stationName = data.getDeterminedRiverName() ?? data.getDeterminedStationName() ?? 'N/A';
final stationName = data.getDeterminedRiverName() ?? data.getDeterminedStationName() ?? 'N/A'; // Combine river/station name
final stationCode = data.getDeterminedStationCode() ?? 'N/A'; final stationCode = data.getDeterminedStationCode() ?? 'N/A';
final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
final submitter = data.firstSamplerName ?? 'N/A'; final submitter = data.firstSamplerName ?? 'N/A';
final sondeID = data.sondeId ?? 'N/A'; final sondeID = data.sondeId ?? 'N/A';
final distanceKm = data.distanceDifferenceInKm ?? 0; final distanceKm = data.distanceDifferenceInKm ?? 0;
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceRemarks = data.distanceDifferenceRemarks ?? ''; // Default to empty string final distanceRemarks = data.distanceDifferenceRemarks ?? '';
final buffer = StringBuffer() final buffer = StringBuffer()
..writeln('✅ *River Investigative Sample ${submissionType} Submitted:*') // Updated title ..writeln('✅ *River Investigative Sample ${submissionType} Submitted:*')
..writeln(); ..writeln();
// Adapt station info based on type
buffer.writeln('*Station Type:* ${data.stationTypeSelection ?? 'N/A'}'); buffer.writeln('*Station Type:* ${data.stationTypeSelection ?? 'N/A'}');
if (data.stationTypeSelection == 'New Location') { if (data.stationTypeSelection == 'New Location') {
buffer.writeln('*New Location Name:* ${data.newStationName ?? 'N/A'}'); buffer.writeln('*New Location Name:* ${data.newStationName ?? 'N/A'}');
@ -808,7 +685,6 @@ class RiverInvestigativeSamplingService { // Renamed class
..writeln('*Sonde ID:* $sondeID') ..writeln('*Sonde ID:* $sondeID')
..writeln('*Status of Submission:* Successful'); ..writeln('*Status of Submission:* Successful');
// Include distance warning only if NOT a new location and distance > 50m
if (data.stationTypeSelection != 'New Location' && (distanceKm * 1000 > 50 || distanceRemarks.isNotEmpty)) { if (data.stationTypeSelection != 'New Location' && (distanceKm * 1000 > 50 || distanceRemarks.isNotEmpty)) {
buffer buffer
..writeln() ..writeln()
@ -819,8 +695,7 @@ class RiverInvestigativeSamplingService { // Renamed class
} }
} }
// Add parameter limit check section (uses the same river limits) final outOfBoundsAlert = await _getOutOfBoundsAlertSection(data);
final outOfBoundsAlert = await _getOutOfBoundsAlertSection(data); // Call helper
if (outOfBoundsAlert.isNotEmpty) { if (outOfBoundsAlert.isNotEmpty) {
buffer.write(outOfBoundsAlert); buffer.write(outOfBoundsAlert);
} }
@ -828,21 +703,16 @@ class RiverInvestigativeSamplingService { // Renamed class
return buffer.toString(); return buffer.toString();
} }
/// Helper to generate the parameter limit alert section for Telegram (River Investigative). Future<String> _getOutOfBoundsAlertSection(RiverInvesManualSamplingData data) async {
Future<String> _getOutOfBoundsAlertSection(RiverInvesManualSamplingData data) async { // Updated model type
// Define mapping from data model keys to parameter names used in limits table
// This mapping should be consistent with River In-Situ
const Map<String, String> _parameterKeyToLimitName = { const Map<String, String> _parameterKeyToLimitName = {
'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', 'ph': 'pH', 'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', 'ph': 'pH',
'salinity': 'Salinity', 'electricalConductivity': 'Conductivity', 'temperature': 'Temperature', 'salinity': 'Salinity', 'electricalConductivity': 'Conductivity', 'temperature': 'Temperature',
'tds': 'TDS', 'turbidity': 'Turbidity', 'ammonia': 'Ammonia', 'batteryVoltage': 'Battery', 'tds': 'TDS', 'turbidity': 'Turbidity', 'ammonia': 'Ammonia', 'batteryVoltage': 'Battery',
}; };
// Load the same river parameter limits as In-Situ
final allLimits = await _dbHelper.loadRiverParameterLimits() ?? []; final allLimits = await _dbHelper.loadRiverParameterLimits() ?? [];
if (allLimits.isEmpty) return ""; // No limits defined if (allLimits.isEmpty) return "";
// Get current readings from the investigative data model
final readings = { final readings = {
'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation, 'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation,
'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity, 'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity,
@ -852,7 +722,6 @@ class RiverInvestigativeSamplingService { // Renamed class
final List<String> outOfBoundsMessages = []; final List<String> outOfBoundsMessages = [];
// Helper to parse limit values (copied from In-Situ)
double? parseLimitValue(dynamic value) { double? parseLimitValue(dynamic value) {
if (value == null) return null; if (value == null) return null;
if (value is num) return value.toDouble(); if (value is num) return value.toDouble();
@ -860,17 +729,15 @@ class RiverInvestigativeSamplingService { // Renamed class
return null; return null;
} }
// Iterate through readings and check against limits
readings.forEach((key, value) { readings.forEach((key, value) {
if (value == null || value == -999.0) return; // Skip missing/default values if (value == null || value == -999.0) return;
final limitName = _parameterKeyToLimitName[key]; final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return; // Skip if parameter not in mapping if (limitName == null) return;
// Find the limit data for this parameter
final limitData = allLimits.firstWhere( final limitData = allLimits.firstWhere(
(l) => l['param_parameter_list'] == limitName, (l) => l['param_parameter_list'] == limitName,
orElse: () => <String, dynamic>{}, // Return empty map if not found orElse: () => <String, dynamic>{},
); );
if (limitData.isNotEmpty) { if (limitData.isNotEmpty) {
@ -878,12 +745,10 @@ class RiverInvestigativeSamplingService { // Renamed class
final upperLimit = parseLimitValue(limitData['param_upper_limit']); final upperLimit = parseLimitValue(limitData['param_upper_limit']);
bool isOutOfBounds = false; bool isOutOfBounds = false;
// Check bounds
if (lowerLimit != null && value < lowerLimit) isOutOfBounds = true; if (lowerLimit != null && value < lowerLimit) isOutOfBounds = true;
if (upperLimit != null && value > upperLimit) isOutOfBounds = true; if (upperLimit != null && value > upperLimit) isOutOfBounds = true;
if (isOutOfBounds) { if (isOutOfBounds) {
// Format message for Telegram
final valueStr = value.toStringAsFixed(5); final valueStr = value.toStringAsFixed(5);
final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A'; final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A';
final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A'; final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A';
@ -892,19 +757,17 @@ class RiverInvestigativeSamplingService { // Renamed class
} }
}); });
// If no parameters were out of bounds, return empty string
if (outOfBoundsMessages.isEmpty) { if (outOfBoundsMessages.isEmpty) {
return ""; return "";
} }
// Construct the alert section header and messages
final buffer = StringBuffer() final buffer = StringBuffer()
..writeln() // Add spacing ..writeln()
..writeln('⚠️ *Parameter Limit Alert:*') ..writeln('⚠️ *Parameter Limit Alert:*')
..writeln('The following parameters were outside their defined limits:'); ..writeln('The following parameters were outside their defined limits:');
buffer.writeAll(outOfBoundsMessages, '\n'); // Add each message on a new line buffer.writeAll(outOfBoundsMessages, '\n');
return buffer.toString(); // --- FIX: Missing return statement was fixed --- return buffer.toString();
} }
} // End of RiverInvestigativeSamplingService class }

View File

@ -33,6 +33,7 @@ import 'submission_ftp_service.dart';
import 'telegram_service.dart'; import 'telegram_service.dart';
import 'retry_service.dart'; import 'retry_service.dart';
import 'base_api_service.dart'; // Import for SessionExpiredException import 'base_api_service.dart'; // Import for SessionExpiredException
import 'user_preferences_service.dart'; // ADDED
class RiverManualTriennialSamplingService { class RiverManualTriennialSamplingService {
@ -47,6 +48,7 @@ class RiverManualTriennialSamplingService {
final ZippingService _zippingService = ZippingService(); final ZippingService _zippingService = ZippingService();
final RetryService _retryService = RetryService(); final RetryService _retryService = RetryService();
final TelegramService _telegramService; final TelegramService _telegramService;
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
@ -239,6 +241,10 @@ class RiverManualTriennialSamplingService {
required AuthProvider authProvider, required AuthProvider authProvider,
String? logDirectory, String? logDirectory,
}) async { }) async {
// --- START FIX: Capture the status before attempting submission ---
final String? previousStatus = data.submissionStatus;
// --- END FIX ---
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
final imageFilesWithNulls = data.toApiImageFiles(); final imageFilesWithNulls = data.toApiImageFiles();
imageFilesWithNulls.removeWhere((key, value) => value == null); imageFilesWithNulls.removeWhere((key, value) => value == null);
@ -256,6 +262,12 @@ class RiverManualTriennialSamplingService {
// data.reportId already contains the timestamp ID // data.reportId already contains the timestamp ID
// --- END: MODIFIED TO USE TIMESTAMP ID --- // --- END: MODIFIED TO USE TIMESTAMP ID ---
// 1. Check module preferences for API
final pref = await _userPreferencesService.getModulePreference(moduleName);
bool isApiEnabled = pref?['is_api_enabled'] ?? true;
bool isFtpEnabled = pref?['is_ftp_enabled'] ?? true; // --- MODIFIED: Check FTP pref early ---
if (isApiEnabled) {
try { try {
// 1. Submit Form Data // 1. Submit Form Data
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
@ -338,11 +350,31 @@ class RiverManualTriennialSamplingService {
} }
// --- END FIX --- // --- END FIX ---
} }
} else {
debugPrint("API submission disabled for $moduleName by user preference.");
apiDataResult = {'success': true, 'message': 'API submission disabled by user.'};
anyApiSuccess = true; // Treated as success since it was intentional
}
// 3. Submit FTP Files // 3. Submit FTP Files
Map<String, dynamic> ftpResults = {'statuses': []}; Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false; bool anyFtpSuccess = false;
// --- START FIX: Check if FTP is enabled AND if it was already successful ---
// 'L4' status means API Failed but FTP Succeeded. If re-submitting an L4 record, we skip FTP.
// 'S4' means everything succeeded.
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
if (!isFtpEnabled) {
debugPrint("FTP submission disabled for $moduleName by user preference. Skipping FTP.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user preference.', 'success': true}]};
anyFtpSuccess = true;
} else if (previousFtpSuccess) {
debugPrint("FTP submission skipped because it was already successful (Status: ${data.submissionStatus}).");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful in previous attempt.', 'success': true}]};
anyFtpSuccess = true;
} else {
// Proceed with FTP logic only if enabled AND not previously successful
if (isSessionKnownToBeExpired) { if (isSessionKnownToBeExpired) {
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks."); debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
// --- START: MODIFIED TO USE TIMESTAMP ID --- // --- START: MODIFIED TO USE TIMESTAMP ID ---
@ -429,6 +461,7 @@ class RiverManualTriennialSamplingService {
anyFtpSuccess = false; anyFtpSuccess = false;
} }
} }
}
// 4. Determine Final Status // 4. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess; final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
@ -462,9 +495,12 @@ class RiverManualTriennialSamplingService {
); );
// 6. Send Alert // 6. Send Alert
if (overallSuccess) { // --- START FIX: Check if log was already successful before sending alert ---
final bool wasAlreadySuccessful = previousStatus == 'S4' || previousStatus == 'S3' || previousStatus == 'L4';
if (overallSuccess && !wasAlreadySuccessful) {
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
} }
// --- END FIX ---
// Return consistent format // Return consistent format
return { return {