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:
parent
6c4bc335b8
commit
05d29bc107
@ -329,69 +329,71 @@ class RiverInvesManualSamplingData {
|
||||
|
||||
// Sampler & Time Info (Assuming same API keys as manual)
|
||||
add('first_sampler_user_id', firstSamplerUserId);
|
||||
add('r_inv_second_sampler_id', secondSampler?['user_id']); // Prefixed inv?
|
||||
add('r_inv_date', samplingDate);
|
||||
add('r_inv_time', samplingTime);
|
||||
add('r_inv_type', samplingType); // Should be 'Investigative'
|
||||
add('r_inv_sample_id_code', sampleIdCode);
|
||||
// *** FIX: Changed 'r_inv_' to 'r_inves_' to match API ***
|
||||
add('r_inves_second_sampler_id', secondSampler?['user_id']);
|
||||
add('r_inves_date', samplingDate);
|
||||
add('r_inves_time', samplingTime);
|
||||
add('r_inves_type', samplingType);
|
||||
add('r_inves_sample_id_code', sampleIdCode);
|
||||
|
||||
// Station Info (Conditional)
|
||||
add('r_inv_station_type', stationTypeSelection);
|
||||
add('r_inves_station_type', stationTypeSelection);
|
||||
if (stationTypeSelection == 'Existing Manual Station') {
|
||||
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') {
|
||||
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') {
|
||||
add('r_inv_new_state_name', newStateName);
|
||||
add('r_inv_new_basin_name', newBasinName);
|
||||
add('r_inv_new_river_name', newRiverName);
|
||||
add('r_inv_new_station_name', newStationName); // Include newStationName
|
||||
add('r_inv_new_station_code', newStationCode); // Optional code
|
||||
add('r_inv_station_latitude', stationLatitude); // Use the captured/entered lat/lon
|
||||
add('r_inv_station_longitude', stationLongitude);
|
||||
add('r_inves_new_state_name', newStateName);
|
||||
add('r_inves_new_basin_name', newBasinName);
|
||||
add('r_inves_new_river_name', newRiverName);
|
||||
add('r_inves_new_station_name', newStationName); // Include newStationName
|
||||
add('r_inves_new_station_code', newStationCode); // Optional code
|
||||
add('r_inves_station_latitude', stationLatitude); // Use the captured/entered lat/lon
|
||||
add('r_inves_station_longitude', stationLongitude);
|
||||
}
|
||||
|
||||
// Location Verification (Assuming same keys)
|
||||
add('r_inv_current_latitude', currentLatitude);
|
||||
add('r_inv_current_longitude', currentLongitude);
|
||||
add('r_inv_distance_difference', distanceDifferenceInKm);
|
||||
add('r_inv_distance_difference_remarks', distanceDifferenceRemarks);
|
||||
add('r_inves_current_latitude', currentLatitude);
|
||||
add('r_inves_current_longitude', currentLongitude);
|
||||
add('r_inves_distance_difference', distanceDifferenceInKm);
|
||||
add('r_inves_distance_difference_remarks', distanceDifferenceRemarks);
|
||||
|
||||
// Site Info (Assuming same keys)
|
||||
add('r_inv_weather', weather);
|
||||
add('r_inv_event_remark', eventRemarks);
|
||||
add('r_inv_lab_remark', labRemarks);
|
||||
add('r_inves_weather', weather);
|
||||
add('r_inves_event_remark', eventRemarks);
|
||||
add('r_inves_lab_remark', labRemarks);
|
||||
|
||||
// Optional Remarks (Assuming same keys)
|
||||
add('r_inv_optional_photo_01_remarks', optionalRemark1);
|
||||
add('r_inv_optional_photo_02_remarks', optionalRemark2);
|
||||
add('r_inv_optional_photo_03_remarks', optionalRemark3);
|
||||
add('r_inv_optional_photo_04_remarks', optionalRemark4);
|
||||
add('r_inves_optional_photo_01_remarks', optionalRemark1);
|
||||
add('r_inves_optional_photo_02_remarks', optionalRemark2);
|
||||
add('r_inves_optional_photo_03_remarks', optionalRemark3);
|
||||
add('r_inves_optional_photo_04_remarks', optionalRemark4);
|
||||
|
||||
// Parameters (Assuming same keys)
|
||||
add('r_inv_sondeID', sondeId);
|
||||
add('data_capture_date', dataCaptureDate); // Reuse generic keys?
|
||||
add('data_capture_time', dataCaptureTime); // Reuse generic keys?
|
||||
add('r_inv_oxygen_conc', oxygenConcentration);
|
||||
add('r_inv_oxygen_sat', oxygenSaturation);
|
||||
add('r_inv_ph', ph);
|
||||
add('r_inv_salinity', salinity);
|
||||
add('r_inv_conductivity', electricalConductivity);
|
||||
add('r_inv_temperature', temperature);
|
||||
add('r_inv_tds', tds);
|
||||
add('r_inv_turbidity', turbidity);
|
||||
add('r_inv_ammonia', ammonia);
|
||||
add('r_inv_battery_volt', batteryVoltage);
|
||||
add('r_inves_sondeID', sondeId);
|
||||
// Note: data_capture_date/time might not be used by API if not in controller, but keeping generally safe
|
||||
add('data_capture_date', dataCaptureDate);
|
||||
add('data_capture_time', dataCaptureTime);
|
||||
add('r_inves_oxygen_conc', oxygenConcentration);
|
||||
add('r_inves_oxygen_sat', oxygenSaturation);
|
||||
add('r_inves_ph', ph);
|
||||
add('r_inves_salinity', salinity);
|
||||
add('r_inves_conductivity', electricalConductivity);
|
||||
add('r_inves_temperature', temperature);
|
||||
add('r_inves_tds', tds);
|
||||
add('r_inves_turbidity', turbidity);
|
||||
add('r_inves_ammonia', ammonia);
|
||||
add('r_inves_battery_volt', batteryVoltage);
|
||||
|
||||
// Flowrate (Assuming same keys)
|
||||
add('r_inv_flowrate_method', flowrateMethod);
|
||||
add('r_inv_flowrate_sd_height', flowrateSurfaceDrifterHeight);
|
||||
add('r_inv_flowrate_sd_distance', flowrateSurfaceDrifterDistance);
|
||||
add('r_inv_flowrate_sd_time_first', flowrateSurfaceDrifterTimeFirst);
|
||||
add('r_inv_flowrate_sd_time_last', flowrateSurfaceDrifterTimeLast);
|
||||
add('r_inv_flowrate_value', flowrateValue);
|
||||
add('r_inves_flowrate_method', flowrateMethod);
|
||||
add('r_inves_flowrate_sd_height', flowrateSurfaceDrifterHeight);
|
||||
add('r_inves_flowrate_sd_distance', flowrateSurfaceDrifterDistance);
|
||||
add('r_inves_flowrate_sd_time_first', flowrateSurfaceDrifterTimeFirst);
|
||||
add('r_inves_flowrate_sd_time_last', flowrateSurfaceDrifterTimeLast);
|
||||
add('r_inves_flowrate_value', flowrateValue);
|
||||
|
||||
// Additional data that might be useful for display or if API needs it redundantly
|
||||
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.
|
||||
/// Keys should match the expected API endpoint fields for Investigative images.
|
||||
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 {
|
||||
'r_inv_background_station': backgroundStationImage,
|
||||
'r_inv_upstream_river': upstreamRiverImage,
|
||||
'r_inv_downstream_river': downstreamRiverImage,
|
||||
'r_inv_sample_turbidity': sampleTurbidityImage,
|
||||
'r_inv_optional_photo_01': optionalImage1,
|
||||
'r_inv_optional_photo_02': optionalImage2,
|
||||
'r_inv_optional_photo_03': optionalImage3,
|
||||
'r_inv_optional_photo_04': optionalImage4,
|
||||
'r_inves_background_station': backgroundStationImage,
|
||||
'r_inves_upstream_river': upstreamRiverImage,
|
||||
'r_inves_downstream_river': downstreamRiverImage,
|
||||
'r_inves_sample_turbidity': sampleTurbidityImage,
|
||||
'r_inves_optional_photo_01': optionalImage1,
|
||||
'r_inves_optional_photo_02': optionalImage2,
|
||||
'r_inves_optional_photo_03': optionalImage3,
|
||||
'r_inves_optional_photo_04': optionalImage4,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -224,8 +224,9 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
|
||||
if (value == 'NA') {
|
||||
_flowrateValueController.text = 'NA';
|
||||
} else if (value == 'Flowmeter') {
|
||||
// Keep existing value if user switches back, or clear if desired
|
||||
// _flowrateValueController.clear();
|
||||
// --- MODIFICATION: Clear flowrate value for Flowmeter ---
|
||||
_flowrateValueController.clear();
|
||||
// --- END MODIFICATION ---
|
||||
_sdHeightController.clear();
|
||||
_sdDistanceController.clear();
|
||||
_sdTimeFirstController.clear();
|
||||
@ -466,10 +467,13 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
|
||||
return;
|
||||
}
|
||||
|
||||
// --- START MODIFICATION: Disable Next if Connected ---
|
||||
// --- MODIFICATION: Changed to allow proceeding if reading is STOPPED, even if connected ---
|
||||
if (_isAutoReading) {
|
||||
_showStopReadingDialog();
|
||||
return;
|
||||
}
|
||||
// --- END MODIFICATION ---
|
||||
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
@ -649,9 +653,10 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _getActiveConnectionDetails() {
|
||||
// Logic copied from RiverInSituStep3DataCaptureState._getActiveConnectionDetails
|
||||
// Uses the correct _samplingService instance via context.watch
|
||||
final service = context.watch<RiverInvestigativeSamplingService>(); // Watch Investigative service
|
||||
// --- START FIX: Use read() instead of watch() ---
|
||||
final service = context.read<RiverInvestigativeSamplingService>();
|
||||
// --- END FIX ---
|
||||
|
||||
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
|
||||
return {'type': 'bluetooth', 'state': service.bluetoothConnectionState.value, 'name': service.connectedBluetoothDeviceName};
|
||||
}
|
||||
@ -675,6 +680,15 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
|
||||
final activeConnection = _getActiveConnectionDetails();
|
||||
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(
|
||||
onWillPop: () async {
|
||||
if (_isLockedOut) {
|
||||
@ -719,14 +733,18 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: service.sondeId, // Listen to the correct service instance
|
||||
builder: (context, sondeId, child) {
|
||||
final newSondeId = sondeId ?? '';
|
||||
// Use addPostFrameCallback to avoid setting state during build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _sondeIdController.text != newSondeId) {
|
||||
_sondeIdController.text = newSondeId;
|
||||
widget.data.sondeId = newSondeId; // Update model
|
||||
}
|
||||
});
|
||||
// --- 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
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _sondeIdController.text != newSondeId) {
|
||||
_sondeIdController.text = newSondeId;
|
||||
widget.data.sondeId = newSondeId; // Update model
|
||||
}
|
||||
});
|
||||
}
|
||||
// --- END FIX ---
|
||||
return TextFormField(
|
||||
controller: _sondeIdController,
|
||||
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),
|
||||
|
||||
// Flowrate Section
|
||||
_buildFlowrateSection(),
|
||||
// --- MODIFIED: Pass connection state to Flowrate Section ---
|
||||
_buildFlowrateSection(isInputDisabled: shouldDisableInput),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Next Button with Lockout Timer
|
||||
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)),
|
||||
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() {
|
||||
// Copied from RiverInSituStep3DataCaptureState._buildFlowrateSection, modified to use Wrap
|
||||
// Updated to include disable logic
|
||||
Widget _buildFlowrateSection({bool isInputDisabled = false}) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Padding(
|
||||
@ -1065,26 +1089,52 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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),
|
||||
// --- START FIX: Replaced Row with Wrap to fix horizontal overflow for radio buttons ---
|
||||
Wrap(
|
||||
alignment: WrapAlignment.spaceAround,
|
||||
spacing: 8.0,
|
||||
runSpacing: 4.0,
|
||||
children: [
|
||||
_buildFlowrateRadioButton("Surface Drifter"),
|
||||
_buildFlowrateRadioButton("Flowmeter"),
|
||||
_buildFlowrateRadioButton("NA"), // Not Applicable
|
||||
],
|
||||
// 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')
|
||||
_buildSurfaceDrifterFields(),
|
||||
if (_selectedFlowrateMethod == 'Flowmeter')
|
||||
_buildFlowmeterField(),
|
||||
if (_selectedFlowrateMethod == 'NA')
|
||||
_buildNAField(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// --- END FIX ---
|
||||
// Conditional fields based on selected method
|
||||
if (_selectedFlowrateMethod == 'Surface Drifter')
|
||||
_buildSurfaceDrifterFields(),
|
||||
if (_selectedFlowrateMethod == 'Flowmeter')
|
||||
_buildFlowmeterField(),
|
||||
if (_selectedFlowrateMethod == 'NA')
|
||||
_buildNAField(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -1092,7 +1142,6 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
|
||||
}
|
||||
|
||||
Widget _buildFlowrateRadioButton(String title) {
|
||||
// Copied from RiverInSituStep3DataCaptureState._buildFlowrateRadioButton, added overflow handling
|
||||
return Column(
|
||||
children: [
|
||||
Radio<String>(
|
||||
@ -1110,7 +1159,6 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
|
||||
}
|
||||
|
||||
Widget _buildSurfaceDrifterFields() {
|
||||
// Copied from RiverInSituStep3DataCaptureState._buildSurfaceDrifterFields
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Column(
|
||||
@ -1175,13 +1223,17 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
|
||||
}
|
||||
|
||||
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(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: TextFormField(
|
||||
controller: _flowrateValueController,
|
||||
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
|
||||
),
|
||||
);
|
||||
|
||||
@ -211,12 +211,20 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
void _onFlowrateMethodChanged(String? value) {
|
||||
setState(() {
|
||||
_selectedFlowrateMethod = value;
|
||||
widget.data.flowrateMethod = value; // Update model immediately
|
||||
if (value == 'NA') {
|
||||
_flowrateValueController.text = 'NA';
|
||||
} else if (value == 'Flowmeter') {
|
||||
// --- MODIFICATION: Clear flowrate value for Flowmeter ---
|
||||
_flowrateValueController.clear();
|
||||
} else {
|
||||
_flowrateValueController.clear();
|
||||
// --- END MODIFICATION ---
|
||||
_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 {
|
||||
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 dateTimeFirst = DateTime(now.year, now.month, now.day, timeFirst.hour, timeFirst.minute, timeFirst.second);
|
||||
|
||||
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) {
|
||||
_showSnackBar("Time Last Deploy must be after Time First Deploy.", isError: true);
|
||||
return;
|
||||
@ -280,6 +300,13 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
if (mounted) {
|
||||
_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 {
|
||||
if (type == 'bluetooth') {
|
||||
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);
|
||||
return false;
|
||||
}
|
||||
@ -302,8 +330,9 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
}
|
||||
} else if (type == 'serial') {
|
||||
final devices = await service.getAvailableSerialDevices();
|
||||
if (devices.isEmpty && mounted) {
|
||||
_showSnackBar('No USB Serial devices found.', isError: true);
|
||||
if (!mounted) return false;
|
||||
if (devices.isEmpty) {
|
||||
_showSnackBar('No USB Serial devices found. Ensure device is plugged in.', isError: true);
|
||||
return false;
|
||||
}
|
||||
final selectedDevice = await showSerialPortListDialog(context: context, devices: devices);
|
||||
@ -357,6 +386,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
_startLockoutTimer(); // --- MODIFICATION: Start countdown
|
||||
} else {
|
||||
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 ---
|
||||
|
||||
// --- 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) {
|
||||
_showStopReadingDialog();
|
||||
return;
|
||||
@ -519,8 +551,16 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
widget.data.flowrateSurfaceDrifterTimeLast = _sdTimeLastController.text;
|
||||
widget.data.flowrateValue = double.tryParse(_flowrateValueController.text);
|
||||
} 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);
|
||||
} else { // NA
|
||||
widget.data.flowrateSurfaceDrifterHeight = null;
|
||||
widget.data.flowrateSurfaceDrifterDistance = null;
|
||||
widget.data.flowrateSurfaceDrifterTimeFirst = null;
|
||||
widget.data.flowrateSurfaceDrifterTimeLast = null;
|
||||
widget.data.flowrateValue = null;
|
||||
}
|
||||
|
||||
@ -539,8 +579,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
widget.onNext();
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _showSnackBar(String message, {bool isError = false}) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
@ -566,7 +604,10 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
}
|
||||
|
||||
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) {
|
||||
return {'type': 'bluetooth', 'state': service.bluetoothConnectionState.value, 'name': service.connectedBluetoothDeviceName};
|
||||
}
|
||||
@ -582,6 +623,15 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
final activeConnection = _getActiveConnectionDetails();
|
||||
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 ---
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
@ -589,6 +639,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
_showSnackBar("Please wait for the initial reading period to complete.", isError: true);
|
||||
return false; // Prevent back navigation
|
||||
}
|
||||
_disconnectFromAll();
|
||||
return true; // Allow back navigation
|
||||
},
|
||||
child: Form(
|
||||
@ -620,13 +671,17 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: service.sondeId,
|
||||
builder: (context, sondeId, child) {
|
||||
final newSondeId = sondeId ?? '';
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _sondeIdController.text != newSondeId) {
|
||||
_sondeIdController.text = newSondeId;
|
||||
widget.data.sondeId = newSondeId;
|
||||
}
|
||||
});
|
||||
// --- START FIX: Only update if non-null to prevent clearing on disconnect ---
|
||||
if (sondeId != null && sondeId.isNotEmpty) {
|
||||
final newSondeId = sondeId;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _sondeIdController.text != newSondeId) {
|
||||
_sondeIdController.text = newSondeId;
|
||||
widget.data.sondeId = newSondeId;
|
||||
}
|
||||
});
|
||||
}
|
||||
// --- END FIX ---
|
||||
return TextFormField(
|
||||
controller: _sondeIdController,
|
||||
decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'),
|
||||
@ -661,13 +716,21 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
}).toList(),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
_buildFlowrateSection(),
|
||||
|
||||
// --- MODIFIED: Use 'shouldDisableInput' instead of 'isDeviceConnected' ---
|
||||
_buildFlowrateSection(isInputDisabled: shouldDisableInput),
|
||||
const SizedBox(height: 32),
|
||||
// --- START MODIFICATION: Add countdown to Next button ---
|
||||
|
||||
// --- MODIFIED: Enable Next button if reading stopped (even if connected) ---
|
||||
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)),
|
||||
child: Text(_isLockedOut ? 'Next ($_lockoutSecondsRemaining\s)' : 'Next'),
|
||||
child: Text(
|
||||
_isLockedOut
|
||||
? 'Next ($_lockoutSecondsRemaining\s)'
|
||||
: (_isAutoReading ? 'Stop Reading to Proceed' : 'Next')
|
||||
),
|
||||
),
|
||||
// --- 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}) {
|
||||
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 Color valueColor = isOutOfBounds
|
||||
@ -804,6 +867,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
final controller = param['controller'] as TextEditingController;
|
||||
final previousValue = previousReadings[key];
|
||||
final bool isCurrentValueOutOfBounds = _outOfBoundsKeys.contains(key);
|
||||
final currentValue = double.tryParse(controller.text) ?? -999.0;
|
||||
|
||||
return TableRow(
|
||||
children: [
|
||||
@ -818,7 +882,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
controller.text.contains('-999') ? '-.--' : (double.tryParse(controller.text) ?? 0).toStringAsFixed(5),
|
||||
currentValue == -999.0 ? '-.--' : currentValue.toStringAsFixed(5),
|
||||
style: TextStyle(
|
||||
color: isCurrentValueOutOfBounds
|
||||
? 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(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Padding(
|
||||
@ -930,25 +995,52 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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),
|
||||
// --- START FIX: Wrap radio buttons in Expanded/Wrap widgets to prevent horizontal overflow ---
|
||||
Wrap(
|
||||
alignment: WrapAlignment.spaceAround,
|
||||
spacing: 8.0,
|
||||
runSpacing: 4.0,
|
||||
children: [
|
||||
_buildFlowrateRadioButton("Surface Drifter"),
|
||||
_buildFlowrateRadioButton("Flowmeter"),
|
||||
_buildFlowrateRadioButton("NA"),
|
||||
],
|
||||
// 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')
|
||||
_buildSurfaceDrifterFields(),
|
||||
if (_selectedFlowrateMethod == 'Flowmeter')
|
||||
_buildFlowmeterField(),
|
||||
if (_selectedFlowrateMethod == 'NA')
|
||||
_buildNAField(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// --- END FIX ---
|
||||
if (_selectedFlowrateMethod == 'Surface Drifter')
|
||||
_buildSurfaceDrifterFields(),
|
||||
if (_selectedFlowrateMethod == 'Flowmeter')
|
||||
_buildFlowmeterField(),
|
||||
if (_selectedFlowrateMethod == 'NA')
|
||||
_buildNAField(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -980,38 +1072,43 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
|
||||
TextFormField(
|
||||
controller: _sdHeightController,
|
||||
decoration: const InputDecoration(labelText: 'Height (m)'),
|
||||
keyboardType: TextInputType.number,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
// Add validation if needed
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _sdDistanceController,
|
||||
decoration: const InputDecoration(labelText: 'Distance (m)'),
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Distance (m) *'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => v == null || v.isEmpty ? 'Distance is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
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,
|
||||
onTap: () => _selectTime(context, _sdTimeFirstController),
|
||||
validator: (v) => v == null || v.isEmpty ? 'Start time is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
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,
|
||||
onTap: () => _selectTime(context, _sdTimeLastController),
|
||||
validator: (v) => v == null || v.isEmpty ? 'End time is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _calculateFlowrate,
|
||||
child: const Text('Get Flowrate'),
|
||||
child: const Text('Calculate Flowrate'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _flowrateValueController,
|
||||
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'),
|
||||
decoration: const InputDecoration(labelText: 'Calculated Flowrate (m/s)'),
|
||||
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),
|
||||
child: TextFormField(
|
||||
controller: _flowrateValueController,
|
||||
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'),
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Flowrate (m/s) *'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => v == null || v.isEmpty ? 'Flowrate value is required' : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNAField() {
|
||||
// Fix: Use controller to set value instead of initialValue to avoid conflict crash
|
||||
if (_flowrateValueController.text != 'NA') {
|
||||
_flowrateValueController.text = 'NA';
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: TextFormField(
|
||||
controller: _flowrateValueController,
|
||||
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
|
||||
@ -7,15 +7,15 @@ import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
import 'package:usb_serial/usb_serial.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../auth_provider.dart';
|
||||
import '../../../../models/river_in_situ_sampling_data.dart';
|
||||
//import '../../../../services/api_service.dart'; // Import to access DatabaseHelper
|
||||
import '../../../../../auth_provider.dart';
|
||||
import '../../../../../models/river_in_situ_sampling_data.dart';
|
||||
//import '../../../../../services/api_service.dart'; // Import to access DatabaseHelper
|
||||
import 'package:environment_monitoring_app/services/database_helper.dart';
|
||||
import '../../../../services/river_in_situ_sampling_service.dart';
|
||||
import '../../../../bluetooth/bluetooth_manager.dart';
|
||||
import '../../../../serial/serial_manager.dart';
|
||||
import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart';
|
||||
import '../../../../serial/widget/serial_port_list_dialog.dart';
|
||||
import '../../../../../services/river_in_situ_sampling_service.dart';
|
||||
import '../../../../../bluetooth/bluetooth_manager.dart';
|
||||
import '../../../../../serial/serial_manager.dart';
|
||||
import '../../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart';
|
||||
import '../../../../../serial/widget/serial_port_list_dialog.dart';
|
||||
|
||||
class RiverInSituStep3DataCapture extends StatefulWidget {
|
||||
final RiverInSituSamplingData data;
|
||||
@ -211,12 +211,20 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
void _onFlowrateMethodChanged(String? value) {
|
||||
setState(() {
|
||||
_selectedFlowrateMethod = value;
|
||||
widget.data.flowrateMethod = value; // Update model immediately
|
||||
if (value == 'NA') {
|
||||
_flowrateValueController.text = 'NA';
|
||||
} else if (value == 'Flowmeter') {
|
||||
// --- MODIFICATION: Clear flowrate value for Flowmeter ---
|
||||
_flowrateValueController.clear();
|
||||
} else {
|
||||
_flowrateValueController.clear();
|
||||
// --- END MODIFICATION ---
|
||||
_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 timeFirst = timeFormat.parse(timeFirstStr);
|
||||
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) {
|
||||
_showSnackBar("Time Last Deploy must be after Time First Deploy.", isError: true);
|
||||
return;
|
||||
@ -264,51 +283,61 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
}
|
||||
|
||||
Future<void> _handleConnectionAttempt(String type) async {
|
||||
final service = context.read<RiverInSituSamplingService>();
|
||||
final bool hasPermissions = await service.requestDevicePermissions();
|
||||
// Uses the correct _samplingService instance
|
||||
final bool hasPermissions = await _samplingService.requestDevicePermissions();
|
||||
if (!hasPermissions && mounted) {
|
||||
_showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true);
|
||||
return;
|
||||
}
|
||||
_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);
|
||||
|
||||
if (connectionSuccess && mounted) {
|
||||
_dataSubscription?.cancel();
|
||||
final stream = type == 'bluetooth' ? service.bluetoothDataStream : service.serialDataStream;
|
||||
_dataSubscription?.cancel(); // Cancel previous subscription if any
|
||||
final stream = type == 'bluetooth' ? _samplingService.bluetoothDataStream : _samplingService.serialDataStream;
|
||||
_dataSubscription = stream.listen((readings) {
|
||||
if (mounted) {
|
||||
_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 {
|
||||
// Uses the correct _samplingService instance
|
||||
setState(() => _isLoading = true);
|
||||
final service = context.read<RiverInSituSamplingService>();
|
||||
bool success = false;
|
||||
try {
|
||||
if (type == 'bluetooth') {
|
||||
final devices = await service.getPairedBluetoothDevices();
|
||||
if (devices.isEmpty && mounted) {
|
||||
final devices = await _samplingService.getPairedBluetoothDevices();
|
||||
if (!mounted) return false; // Check mounted after async gap
|
||||
if (devices.isEmpty) {
|
||||
_showSnackBar('No paired Bluetooth devices found.', isError: true);
|
||||
return false;
|
||||
}
|
||||
final selectedDevice = await showBluetoothDeviceListDialog(context: context, devices: devices);
|
||||
if (selectedDevice != null) {
|
||||
await service.connectToBluetoothDevice(selectedDevice);
|
||||
await _samplingService.connectToBluetoothDevice(selectedDevice);
|
||||
success = true;
|
||||
}
|
||||
} else if (type == 'serial') {
|
||||
final devices = await service.getAvailableSerialDevices();
|
||||
if (devices.isEmpty && mounted) {
|
||||
_showSnackBar('No USB Serial devices found.', isError: true);
|
||||
final devices = await _samplingService.getAvailableSerialDevices();
|
||||
if (!mounted) return false;
|
||||
if (devices.isEmpty) {
|
||||
_showSnackBar('No USB Serial devices found. Ensure device is plugged in.', isError: true);
|
||||
return false;
|
||||
}
|
||||
final selectedDevice = await showSerialPortListDialog(context: context, devices: devices);
|
||||
if (selectedDevice != null) {
|
||||
await service.connectToSerialDevice(selectedDevice);
|
||||
await _samplingService.connectToSerialDevice(selectedDevice);
|
||||
success = true;
|
||||
}
|
||||
}
|
||||
@ -357,6 +386,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
_startLockoutTimer(); // --- MODIFICATION: Start countdown
|
||||
} else {
|
||||
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 ---
|
||||
|
||||
// --- 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) {
|
||||
_showStopReadingDialog();
|
||||
_showStopReadingDialog(); // Still show dialog if reading is actively running
|
||||
return;
|
||||
}
|
||||
// Remove the forced disconnect check here because user can proceed if they stopped reading manually
|
||||
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
@ -519,8 +555,16 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
widget.data.flowrateSurfaceDrifterTimeLast = _sdTimeLastController.text;
|
||||
widget.data.flowrateValue = double.tryParse(_flowrateValueController.text);
|
||||
} 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);
|
||||
} else { // NA
|
||||
widget.data.flowrateSurfaceDrifterHeight = null;
|
||||
widget.data.flowrateSurfaceDrifterDistance = null;
|
||||
widget.data.flowrateSurfaceDrifterTimeFirst = null;
|
||||
widget.data.flowrateSurfaceDrifterTimeLast = null;
|
||||
widget.data.flowrateValue = null;
|
||||
}
|
||||
|
||||
@ -564,7 +608,10 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
}
|
||||
|
||||
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) {
|
||||
return {'type': 'bluetooth', 'state': service.bluetoothConnectionState.value, 'name': service.connectedBluetoothDeviceName};
|
||||
}
|
||||
@ -580,13 +627,22 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
final activeConnection = _getActiveConnectionDetails();
|
||||
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(
|
||||
onWillPop: () async {
|
||||
if (_isLockedOut) {
|
||||
_showSnackBar("Please wait for the initial reading period to complete.", isError: true);
|
||||
return false; // Prevent back navigation
|
||||
}
|
||||
_disconnectFromAll();
|
||||
return true; // Allow back navigation
|
||||
},
|
||||
child: Form(
|
||||
@ -618,13 +674,17 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: service.sondeId,
|
||||
builder: (context, sondeId, child) {
|
||||
final newSondeId = sondeId ?? '';
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _sondeIdController.text != newSondeId) {
|
||||
_sondeIdController.text = newSondeId;
|
||||
widget.data.sondeId = newSondeId;
|
||||
}
|
||||
});
|
||||
// --- START FIX: Only update if non-null to prevent clearing on disconnect ---
|
||||
if (sondeId != null && sondeId.isNotEmpty) {
|
||||
final newSondeId = sondeId;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _sondeIdController.text != newSondeId) {
|
||||
_sondeIdController.text = newSondeId;
|
||||
widget.data.sondeId = newSondeId;
|
||||
}
|
||||
});
|
||||
}
|
||||
// --- END FIX ---
|
||||
return TextFormField(
|
||||
controller: _sondeIdController,
|
||||
decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'),
|
||||
@ -659,25 +719,32 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
}).toList(),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
_buildFlowrateSection(),
|
||||
|
||||
// --- MODIFIED: Use 'shouldDisableInput' instead of 'isDeviceConnected' ---
|
||||
_buildFlowrateSection(isInputDisabled: shouldDisableInput),
|
||||
const SizedBox(height: 32),
|
||||
// --- START MODIFICATION: Add countdown to Next button ---
|
||||
|
||||
// --- MODIFIED: Enable Next button if reading stopped (even if connected) ---
|
||||
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)),
|
||||
child: Text(_isLockedOut ? 'Next ($_lockoutSecondsRemaining\s)' : 'Next'),
|
||||
child: Text(
|
||||
_isLockedOut
|
||||
? 'Next ($_lockoutSecondsRemaining\s)'
|
||||
: (_isAutoReading ? 'Stop Reading to Proceed' : 'Next')
|
||||
),
|
||||
),
|
||||
// --- END MODIFICATION ---
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
// --- END MODIFICATION ---
|
||||
}
|
||||
|
||||
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 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 Color valueColor = isOutOfBounds
|
||||
@ -719,14 +786,14 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
if (isConnecting || _isLoading)
|
||||
const CircularProgressIndicator()
|
||||
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(
|
||||
alignment: WrapAlignment.spaceEvenly,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8.0, // Horizontal space between buttons
|
||||
runSpacing: 4.0, // Vertical space if it wraps
|
||||
children: [
|
||||
// --- START MODIFICATION: Add countdown to Stop Reading button ---
|
||||
// Add countdown to Stop Reading button
|
||||
ElevatedButton.icon(
|
||||
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
|
||||
label: Text(_isAutoReading
|
||||
@ -740,7 +807,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
// --- END MODIFICATION ---
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.link_off),
|
||||
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 previousValue = previousReadings[key];
|
||||
final bool isCurrentValueOutOfBounds = _outOfBoundsKeys.contains(key);
|
||||
final currentValue = double.tryParse(controller.text) ?? -999.0;
|
||||
|
||||
return TableRow(
|
||||
children: [
|
||||
@ -816,7 +882,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
controller.text.contains('-999') ? '-.--' : (double.tryParse(controller.text) ?? 0).toStringAsFixed(5),
|
||||
currentValue == -999.0 ? '-.--' : currentValue.toStringAsFixed(5),
|
||||
style: TextStyle(
|
||||
color: isCurrentValueOutOfBounds
|
||||
? 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(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Padding(
|
||||
@ -928,23 +995,52 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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),
|
||||
// --- START FIX: Wrap radio buttons in Expanded widgets to prevent horizontal overflow ---
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Expanded(child: _buildFlowrateRadioButton("Surface Drifter")),
|
||||
Expanded(child: _buildFlowrateRadioButton("Flowmeter")),
|
||||
Expanded(child: _buildFlowrateRadioButton("NA")),
|
||||
],
|
||||
// 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')
|
||||
_buildSurfaceDrifterFields(),
|
||||
if (_selectedFlowrateMethod == 'Flowmeter')
|
||||
_buildFlowmeterField(),
|
||||
if (_selectedFlowrateMethod == 'NA')
|
||||
_buildNAField(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// --- END FIX ---
|
||||
if (_selectedFlowrateMethod == 'Surface Drifter')
|
||||
_buildSurfaceDrifterFields(),
|
||||
if (_selectedFlowrateMethod == 'Flowmeter')
|
||||
_buildFlowmeterField(),
|
||||
if (_selectedFlowrateMethod == 'NA')
|
||||
_buildNAField(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -976,38 +1072,43 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
|
||||
TextFormField(
|
||||
controller: _sdHeightController,
|
||||
decoration: const InputDecoration(labelText: 'Height (m)'),
|
||||
keyboardType: TextInputType.number,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
// Add validation if needed
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _sdDistanceController,
|
||||
decoration: const InputDecoration(labelText: 'Distance (m)'),
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Distance (m) *'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => v == null || v.isEmpty ? 'Distance is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
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,
|
||||
onTap: () => _selectTime(context, _sdTimeFirstController),
|
||||
validator: (v) => v == null || v.isEmpty ? 'Start time is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
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,
|
||||
onTap: () => _selectTime(context, _sdTimeLastController),
|
||||
validator: (v) => v == null || v.isEmpty ? 'End time is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _calculateFlowrate,
|
||||
child: const Text('Get Flowrate'),
|
||||
child: const Text('Calculate Flowrate'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _flowrateValueController,
|
||||
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'),
|
||||
decoration: const InputDecoration(labelText: 'Calculated Flowrate (m/s)'),
|
||||
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),
|
||||
child: TextFormField(
|
||||
controller: _flowrateValueController,
|
||||
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'),
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Flowrate (m/s) *'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => v == null || v.isEmpty ? 'Flowrate value is required' : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNAField() {
|
||||
// Fix: Use controller to set value instead of initialValue to avoid conflict crash
|
||||
if (_flowrateValueController.text != 'NA') {
|
||||
_flowrateValueController.text = 'NA';
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: TextFormField(
|
||||
controller: _flowrateValueController,
|
||||
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
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ import 'submission_ftp_service.dart';
|
||||
import 'telegram_service.dart';
|
||||
import 'retry_service.dart';
|
||||
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.
|
||||
/// This includes location, image processing, device communication, and data submission.
|
||||
@ -51,6 +51,7 @@ class MarineInSituSamplingService {
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final RetryService _retryService = RetryService();
|
||||
final TelegramService _telegramService;
|
||||
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
|
||||
|
||||
MarineInSituSamplingService(this._telegramService);
|
||||
|
||||
@ -262,132 +263,159 @@ class MarineInSituSamplingService {
|
||||
// data.reportId already contains the timestamp ID
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
try {
|
||||
// 1. Submit Form Data
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/manual/sample', // Correct endpoint for In-Situ data
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
// 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 (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
// Store the server's database ID in a separate variable.
|
||||
apiRecordId = apiDataResult['data']?['man_id']?.toString(); // Correct ID key for In-Situ
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
if (isApiEnabled) {
|
||||
try {
|
||||
// 1. Submit Form Data
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/manual/sample', // Correct endpoint for In-Situ data
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
|
||||
if (apiRecordId != null) {
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/manual/images', // Correct endpoint for In-Situ images
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
fields: {'man_id': apiRecordId}, // Correct field key for In-Situ
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
anyApiSuccess = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
if (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a server record ID.';
|
||||
// Store the server's database ID in a separate variable.
|
||||
apiRecordId = apiDataResult['data']?['man_id']?.toString(); // Correct ID key for In-Situ
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
}
|
||||
}
|
||||
// If apiDataResult['success'] is false, SubmissionApiService queued it.
|
||||
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
// Manually queue API calls
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData());
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
if (finalImageFiles.isNotEmpty && apiRecordId != null) {
|
||||
// Also queue images if data call might have partially succeeded before expiry
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/manual/images', method: 'POST_MULTIPART', fields: {'man_id': apiRecordId}, files: finalImageFiles);
|
||||
if (apiRecordId != null) {
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/manual/images', // Correct endpoint for In-Situ images
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
fields: {'man_id': apiRecordId}, // Correct field key for In-Situ
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
anyApiSuccess = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP 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, SubmissionApiService queued it.
|
||||
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
// Manually queue API calls
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData());
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
if (finalImageFiles.isNotEmpty && apiRecordId != null) {
|
||||
// Also queue images if data call might have partially succeeded before expiry
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/manual/images', method: 'POST_MULTIPART', fields: {'man_id': apiRecordId}, files: finalImageFiles);
|
||||
}
|
||||
// --- 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
|
||||
Map<String, dynamic> ftpResults = {'statuses': []};
|
||||
bool anyFtpSuccess = false;
|
||||
|
||||
if (isSessionKnownToBeExpired) {
|
||||
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
// --- START FIX: Check if FTP is enabled AND if it was already successful ---
|
||||
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
|
||||
|
||||
// --- START FIX: Add ftpConfigId when queuing ---
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
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 due to known expired session. Manually queuing FTP tasks.");
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
// --- MODIFIED: Use new data model methods for multi-json zip ---
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: {
|
||||
'db.json': data.toDbJson(),
|
||||
'marine_insitu_basic_form.json': data.toBasicFormJson(),
|
||||
'marine_sampling_reading.json': data.toReadingJson(),
|
||||
'marine_manual_info.json': data.toManualInfoJson(),
|
||||
},
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null, // Use temp dir
|
||||
);
|
||||
// --- END MODIFIED ---
|
||||
// --- START FIX: Add ftpConfigId when queuing ---
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
|
||||
if (dataZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
final imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: finalImageFiles.values.toList(),
|
||||
// --- MODIFIED: Use new data model methods for multi-json zip ---
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: {
|
||||
'db.json': data.toDbJson(),
|
||||
'marine_insitu_basic_form.json': data.toBasicFormJson(),
|
||||
'marine_sampling_reading.json': data.toReadingJson(),
|
||||
'marine_manual_info.json': data.toManualInfoJson(),
|
||||
},
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null, // Use temp dir
|
||||
);
|
||||
if (imageZip != null) {
|
||||
// --- END MODIFIED ---
|
||||
|
||||
if (dataZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: imageZip.path,
|
||||
remotePath: '/${p.basename(imageZip.path)}',
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
|
||||
anyFtpSuccess = false;
|
||||
} else {
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
final imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: finalImageFiles.values.toList(),
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null, // Use temp dir
|
||||
);
|
||||
if (imageZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: imageZip.path,
|
||||
remotePath: '/${p.basename(imageZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
|
||||
anyFtpSuccess = false;
|
||||
} else {
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
anyFtpSuccess = false;
|
||||
ftpResults = {'statuses': [{'status': 'Error', 'message': 'FTP process failed: $e.', 'success': false}]};
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
|
||||
// 4. Determine Final Status
|
||||
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
||||
|
||||
@ -31,6 +31,7 @@ import 'retry_service.dart';
|
||||
import 'base_api_service.dart'; // Import for SessionExpiredException
|
||||
import 'api_service.dart'; // Import for DatabaseHelper
|
||||
import 'package:environment_monitoring_app/services/database_helper.dart';
|
||||
import 'user_preferences_service.dart'; // ADDED
|
||||
|
||||
|
||||
/// A dedicated service for the Marine Investigative Sampling feature.
|
||||
@ -49,6 +50,7 @@ class MarineInvestigativeSamplingService {
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final RetryService _retryService = RetryService();
|
||||
final TelegramService _telegramService;
|
||||
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
|
||||
|
||||
MarineInvestigativeSamplingService(this._telegramService);
|
||||
|
||||
@ -268,121 +270,147 @@ class MarineInvestigativeSamplingService {
|
||||
// data.reportId already contains the timestamp ID
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
try {
|
||||
// 1. Submit Form Data
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine-investigative/sample',
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
// 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 (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
// Store the server's database ID in a separate variable.
|
||||
apiRecordId = apiDataResult['data']?['man_inves_id']?.toString();
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
if (isApiEnabled) {
|
||||
try {
|
||||
// 1. Submit Form Data
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine-investigative/sample',
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
|
||||
if (apiRecordId != null) {
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine-investigative/images',
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
fields: {'man_inves_id': apiRecordId}, // Use server's ID
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
anyApiSuccess = false; // Mark as failed if images fail
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
if (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a server record ID.';
|
||||
// Store the server's database ID in a separate variable.
|
||||
apiRecordId = apiDataResult['data']?['man_inves_id']?.toString();
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
if (apiRecordId != null) {
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine-investigative/images',
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
fields: {'man_inves_id': apiRecordId}, // Use server's ID
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
anyApiSuccess = false; // Mark as failed if images fail
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a server record ID.';
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
}
|
||||
}
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||
isSessionKnownToBeExpired = true; // Mark session as expired
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
// Manually queue the API call since SubmissionApiService was never called or failed internally due to session
|
||||
await _retryService.addApiToQueue(endpoint: 'marine-investigative/sample', method: 'POST', body: data.toApiFormData());
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
if (finalImageFiles.isNotEmpty && apiRecordId != null) {
|
||||
// Also queue images if data call might have partially succeeded before expiry
|
||||
await _retryService.addApiToQueue(endpoint: 'marine-investigative/images', method: 'POST_MULTIPART', fields: {'man_inves_id': apiRecordId}, files: finalImageFiles);
|
||||
}
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
}
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||
isSessionKnownToBeExpired = true; // Mark session as expired
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
// Manually queue the API call since SubmissionApiService was never called or failed internally due to session
|
||||
await _retryService.addApiToQueue(endpoint: 'marine-investigative/sample', method: 'POST', body: data.toApiFormData());
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
if (finalImageFiles.isNotEmpty && apiRecordId != null) {
|
||||
// Also queue images if data call might have partially succeeded before expiry
|
||||
await _retryService.addApiToQueue(endpoint: 'marine-investigative/images', method: 'POST_MULTIPART', fields: {'man_inves_id': apiRecordId}, files: finalImageFiles);
|
||||
}
|
||||
// --- 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
|
||||
}
|
||||
// We no longer catch SocketException or TimeoutException here.
|
||||
|
||||
// 3. Submit FTP Files
|
||||
Map<String, dynamic> ftpResults = {'statuses': []};
|
||||
bool anyFtpSuccess = false;
|
||||
|
||||
if (isSessionKnownToBeExpired) {
|
||||
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
final baseFileNameForQueue = _generateBaseFileName(data);
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
// --- START FIX: Check if FTP is enabled AND if it was already successful ---
|
||||
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
|
||||
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
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 due to known expired session. Manually queuing FTP tasks.");
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
final baseFileNameForQueue = _generateBaseFileName(data);
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null, // Use temp dir
|
||||
);
|
||||
if (dataZip != null) {
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
ftpConfigId: configId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
final imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: finalImageFiles.values.toList(),
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null, // Use temp dir
|
||||
);
|
||||
if (imageZip != null) {
|
||||
if (dataZip != null) {
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: imageZip.path,
|
||||
remotePath: '/${p.basename(imageZip.path)}',
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
ftpConfigId: configId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
|
||||
anyFtpSuccess = false;
|
||||
|
||||
} else {
|
||||
// Session is OK, proceed with normal FTP attempt
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
final imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: finalImageFiles.values.toList(),
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null, // Use temp dir
|
||||
);
|
||||
if (imageZip != null) {
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: imageZip.path,
|
||||
remotePath: '/${p.basename(imageZip.path)}',
|
||||
ftpConfigId: configId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
|
||||
anyFtpSuccess = false;
|
||||
|
||||
} else {
|
||||
// Session is OK, proceed with normal FTP attempt
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
anyFtpSuccess = false;
|
||||
ftpResults = {'statuses': [{'status': 'Error', 'message': 'FTP process failed: $e.', 'success': false}]}; // Provide error status
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
|
||||
// 4. Determine Final Status
|
||||
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
||||
@ -609,12 +637,13 @@ class MarineInvestigativeSamplingService {
|
||||
|
||||
final logData = {
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
'submission_id': data.reportId ?? baseFileName, // This is the timestamp ID
|
||||
'module': 'marine',
|
||||
'type': 'Investigative',
|
||||
'submission_id': data.reportId ?? baseFileName, // Use timestamp ID
|
||||
// *** MODIFIED: Module and Type ***
|
||||
'module': 'marine', // Keep main module as 'river'
|
||||
'type': 'Investigative', // Specific type
|
||||
'status': status,
|
||||
'message': message,
|
||||
'report_id': apiRecordId, // This is the server DB ID
|
||||
'report_id': apiRecordId, // Use server DB ID
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(logMapData), // Log comprehensive map
|
||||
@ -873,5 +902,5 @@ class MarineInvestigativeSamplingService {
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
// --- END: MODIFIED ALERT HANDLER & HELPERS ---
|
||||
// --- END: NEW METHOD ---
|
||||
}
|
||||
@ -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/auth_provider.dart';
|
||||
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.
|
||||
class MarineTarballSamplingService {
|
||||
@ -34,6 +35,7 @@ class MarineTarballSamplingService {
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final RetryService _retryService = RetryService();
|
||||
final TelegramService _telegramService;
|
||||
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
|
||||
|
||||
MarineTarballSamplingService(this._telegramService);
|
||||
|
||||
@ -130,6 +132,10 @@ class MarineTarballSamplingService {
|
||||
required AuthProvider? authProvider, // Accept potentially null provider
|
||||
String? logDirectory, // Added for retry consistency
|
||||
}) 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 imageFiles = data.toImageFiles()..removeWhere((key, value) => value == null);
|
||||
final finalImageFiles = imageFiles.cast<String, File>();
|
||||
@ -146,130 +152,157 @@ class MarineTarballSamplingService {
|
||||
// data.reportId already contains the timestamp ID
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
try {
|
||||
// 1. Submit Form Data
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/tarball/sample', // Correct endpoint
|
||||
body: data.toFormData(), // Use specific method for tarball form data
|
||||
);
|
||||
// 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 (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
// Store the server's database ID in a separate variable.
|
||||
apiRecordId = apiDataResult['data']?['autoid']?.toString(); // Correct ID key
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
if (isApiEnabled) {
|
||||
try {
|
||||
// 1. Submit Form Data
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/tarball/sample', // Correct endpoint
|
||||
body: data.toFormData(), // Use specific method for tarball form data
|
||||
);
|
||||
|
||||
if (apiRecordId != null) {
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/tarball/images', // Correct endpoint
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
fields: {'autoid': apiRecordId}, // Correct field key
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
anyApiSuccess = false; // Downgrade success if images fail
|
||||
}
|
||||
}
|
||||
// If data succeeded but no images, API part is still successful
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
if (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a server record ID.';
|
||||
// Store the server's database ID in a separate variable.
|
||||
apiRecordId = apiDataResult['data']?['autoid']?.toString(); // Correct ID key
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
}
|
||||
}
|
||||
// If apiDataResult['success'] is false, SubmissionApiService queued it.
|
||||
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("API submission failed with SessionExpiredException during online submission.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired. API submission queued.'};
|
||||
// Manually queue API calls
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
if (finalImageFiles.isNotEmpty && apiRecordId != null) {
|
||||
// Queue images if data might have partially succeeded
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/images', method: 'POST_MULTIPART', fields: {'autoid': apiRecordId}, files: finalImageFiles);
|
||||
if (apiRecordId != null) {
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/tarball/images', // Correct endpoint
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
fields: {'autoid': apiRecordId}, // Correct field key
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
anyApiSuccess = false; // Downgrade success if images fail
|
||||
}
|
||||
}
|
||||
// If data succeeded but no images, API part is still successful
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP 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, SubmissionApiService queued it.
|
||||
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("API submission failed with SessionExpiredException during online submission.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired. API submission queued.'};
|
||||
// Manually queue API calls
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
if (finalImageFiles.isNotEmpty && apiRecordId != null) {
|
||||
// Queue images if data might have partially succeeded
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/images', method: 'POST_MULTIPART', fields: {'autoid': apiRecordId}, files: finalImageFiles);
|
||||
}
|
||||
// --- 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
|
||||
Map<String, dynamic> ftpResults = {'statuses': []};
|
||||
bool anyFtpSuccess = false;
|
||||
|
||||
if (isSessionKnownToBeExpired) {
|
||||
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
// --- START FIX: Check if FTP is enabled AND if it was already successful ---
|
||||
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
|
||||
|
||||
// --- START FIX: Add ftpConfigId when queuing ---
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
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 due to known expired session. Manually queuing FTP tasks.");
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: { // Use specific JSON structures for Tarball FTP
|
||||
'data.json': jsonEncode(data.toDbJson()),
|
||||
'basic_form.json': jsonEncode(data.toBasicFormJson()),
|
||||
'reading.json': jsonEncode(data.toReadingJson()),
|
||||
'manual_info.json': jsonEncode(data.toManualInfoJson()),
|
||||
},
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null,
|
||||
);
|
||||
if (dataZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- START FIX: Add ftpConfigId when queuing ---
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
final imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: finalImageFiles.values.toList(),
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: { // Use specific JSON structures for Tarball FTP
|
||||
'data.json': jsonEncode(data.toDbJson()),
|
||||
'basic_form.json': jsonEncode(data.toBasicFormJson()),
|
||||
'reading.json': jsonEncode(data.toReadingJson()),
|
||||
'manual_info.json': jsonEncode(data.toManualInfoJson()),
|
||||
},
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null,
|
||||
);
|
||||
if (imageZip != null) {
|
||||
if (dataZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: imageZip.path,
|
||||
remotePath: '/${p.basename(imageZip.path)}',
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
|
||||
anyFtpSuccess = false;
|
||||
} else {
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
final imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: finalImageFiles.values.toList(),
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null,
|
||||
);
|
||||
if (imageZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: imageZip.path,
|
||||
remotePath: '/${p.basename(imageZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
|
||||
anyFtpSuccess = false;
|
||||
} else {
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
anyFtpSuccess = false;
|
||||
ftpResults = {'statuses': [{'status': 'Error', 'message': 'FTP process failed: $e.', 'success': false}]}; // Add error status
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
|
||||
|
||||
// 4. Determine Final Status
|
||||
@ -305,9 +338,12 @@ class MarineTarballSamplingService {
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// --- END FIX ---
|
||||
|
||||
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId}; // Return timestamp ID
|
||||
}
|
||||
|
||||
@ -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/server_config_service.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.
|
||||
class RetryService {
|
||||
@ -42,6 +43,7 @@ class RetryService {
|
||||
final BaseApiService _baseApiService = BaseApiService();
|
||||
final FtpService _ftpService = FtpService();
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
|
||||
bool _isProcessing = false;
|
||||
|
||||
// Sampling Services
|
||||
@ -601,10 +603,39 @@ class RetryService {
|
||||
await _dbHelper.deleteRequestFromQueue(taskId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- START FIX: Check if this FTP module is enabled in preferences ---
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
final config = ftpConfigs.firstWhere(
|
||||
(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()) {
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
final config = ftpConfigs.firstWhere((c) => c['ftp_config_id'] == ftpConfigId, orElse: () => <String, dynamic>{});
|
||||
if (config.isEmpty) return false;
|
||||
if (config.isEmpty) return false; // Config missing
|
||||
|
||||
final result = await _ftpService.uploadFile(config: config, fileToUpload: localFile, remotePath: remotePath);
|
||||
success = result['success'];
|
||||
|
||||
@ -33,6 +33,7 @@ import 'submission_ftp_service.dart';
|
||||
import 'telegram_service.dart';
|
||||
import 'retry_service.dart';
|
||||
import 'base_api_service.dart'; // Import for SessionExpiredException
|
||||
import 'user_preferences_service.dart'; // ADDED
|
||||
|
||||
|
||||
class RiverInSituSamplingService {
|
||||
@ -47,6 +48,7 @@ class RiverInSituSamplingService {
|
||||
final ZippingService _zippingService = ZippingService();
|
||||
final RetryService _retryService = RetryService();
|
||||
final TelegramService _telegramService;
|
||||
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
|
||||
@ -260,149 +262,176 @@ class RiverInSituSamplingService {
|
||||
// data.reportId already contains the timestamp ID
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
try {
|
||||
// 1. Submit Form Data
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/manual/sample', // Correct endpoint
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
// 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 (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
// Store the server's database ID in a separate variable.
|
||||
// data.reportId (the timestamp) REMAINS UNCHANGED.
|
||||
apiRecordId = apiDataResult['data']?['r_man_id']?.toString(); // Correct ID key
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
if (isApiEnabled) {
|
||||
try {
|
||||
// 1. Submit Form Data
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/manual/sample', // Correct endpoint
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
|
||||
if (apiRecordId != null) { // Check if server returned an ID
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/manual/images', // Correct endpoint
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
fields: {'r_man_id': apiRecordId}, // Use server's ID for relation
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
anyApiSuccess = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
if (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a server record ID.';
|
||||
// Store the server's database ID in a separate variable.
|
||||
// data.reportId (the timestamp) REMAINS UNCHANGED.
|
||||
apiRecordId = apiDataResult['data']?['r_man_id']?.toString(); // Correct ID key
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
if (apiRecordId != null) { // Check if server returned an ID
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/manual/images', // Correct endpoint
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
fields: {'r_man_id': apiRecordId}, // Use server's ID for relation
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
anyApiSuccess = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP 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, SubmissionApiService queued it.
|
||||
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
// Manually queue API calls
|
||||
await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData());
|
||||
if (finalImageFiles.isNotEmpty && apiRecordId != null) {
|
||||
// Also queue images if data call might have partially succeeded before expiry
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
await _retryService.addApiToQueue(endpoint: 'river/manual/images', method: 'POST_MULTIPART', fields: {'r_man_id': apiRecordId}, files: finalImageFiles);
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
}
|
||||
}
|
||||
// If apiDataResult['success'] is false, SubmissionApiService queued it.
|
||||
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
// Manually queue API calls
|
||||
await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData());
|
||||
if (finalImageFiles.isNotEmpty && apiRecordId != null) {
|
||||
// Also queue images if data call might have partially succeeded before expiry
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
await _retryService.addApiToQueue(endpoint: 'river/manual/images', method: 'POST_MULTIPART', fields: {'r_man_id': apiRecordId}, files: finalImageFiles);
|
||||
// --- 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
|
||||
Map<String, dynamic> ftpResults = {'statuses': []};
|
||||
bool anyFtpSuccess = false;
|
||||
|
||||
if (isSessionKnownToBeExpired) {
|
||||
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
// We can now safely call _generateBaseFileName, as data.reportId is the timestamp
|
||||
final baseFileNameForQueue = _generateBaseFileName(data);
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
// --- 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';
|
||||
|
||||
// --- START FIX: Add ftpConfigId when queuing ---
|
||||
// Get all potential FTP configs
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
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 due to known expired session. Manually queuing FTP tasks.");
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
// We can now safely call _generateBaseFileName, as data.reportId is the timestamp
|
||||
final baseFileNameForQueue = _generateBaseFileName(data);
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: { // Use specific JSON structures for River In-Situ FTP
|
||||
'db.json': data.toDbJson(),
|
||||
'river_insitu_basic_form.json': data.toBasicFormJson(),
|
||||
'river_sampling_reading.json': data.toReadingJson(),
|
||||
'river_manual_info.json': data.toManualInfoJson(),
|
||||
},
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null,
|
||||
);
|
||||
if (dataZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- START FIX: Add ftpConfigId when queuing ---
|
||||
// Get all potential FTP configs
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// Re-construct the map for retry to attempt renaming even in fallback
|
||||
final Map<String, File> retryImages = {};
|
||||
final String dateStr = (data.samplingDate ?? '').replaceAll('-', '');
|
||||
final String timeStr = (data.samplingTime ?? '').replaceAll(':', '');
|
||||
final String timestampId = "$dateStr$timeStr";
|
||||
|
||||
void addRetryMap(File? file, String prefix) {
|
||||
if(file != null) retryImages['${prefix}_$timestampId.jpg'] = file;
|
||||
}
|
||||
addRetryMap(data.backgroundStationImage, 'background');
|
||||
addRetryMap(data.upstreamRiverImage, 'upstream');
|
||||
addRetryMap(data.downstreamRiverImage, 'downstream');
|
||||
addRetryMap(data.sampleTurbidityImage, 'sample_turbidity');
|
||||
addRetryMap(data.optionalImage1, 'optional_1');
|
||||
addRetryMap(data.optionalImage2, 'optional_2');
|
||||
addRetryMap(data.optionalImage3, 'optional_3');
|
||||
addRetryMap(data.optionalImage4, 'optional_4');
|
||||
|
||||
final retryImageZip = await _zippingService.createRenamedImageZip(
|
||||
imageFiles: retryImages,
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: { // Use specific JSON structures for River In-Situ FTP
|
||||
'db.json': data.toDbJson(),
|
||||
'river_insitu_basic_form.json': data.toBasicFormJson(),
|
||||
'river_sampling_reading.json': data.toReadingJson(),
|
||||
'river_manual_info.json': data.toManualInfoJson(),
|
||||
},
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null,
|
||||
);
|
||||
|
||||
if (retryImageZip != null) {
|
||||
if (dataZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: retryImageZip.path,
|
||||
remotePath: '/${p.basename(retryImageZip.path)}',
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
|
||||
anyFtpSuccess = false;
|
||||
} else {
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// Re-construct the map for retry to attempt renaming even in fallback
|
||||
final Map<String, File> retryImages = {};
|
||||
final String dateStr = (data.samplingDate ?? '').replaceAll('-', '');
|
||||
final String timeStr = (data.samplingTime ?? '').replaceAll(':', '');
|
||||
final String timestampId = "$dateStr$timeStr";
|
||||
|
||||
void addRetryMap(File? file, String prefix) {
|
||||
if(file != null) retryImages['${prefix}_$timestampId.jpg'] = file;
|
||||
}
|
||||
addRetryMap(data.backgroundStationImage, 'background');
|
||||
addRetryMap(data.upstreamRiverImage, 'upstream');
|
||||
addRetryMap(data.downstreamRiverImage, 'downstream');
|
||||
addRetryMap(data.sampleTurbidityImage, 'sample_turbidity');
|
||||
addRetryMap(data.optionalImage1, 'optional_1');
|
||||
addRetryMap(data.optionalImage2, 'optional_2');
|
||||
addRetryMap(data.optionalImage3, 'optional_3');
|
||||
addRetryMap(data.optionalImage4, 'optional_4');
|
||||
|
||||
final retryImageZip = await _zippingService.createRenamedImageZip(
|
||||
imageFiles: retryImages,
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null,
|
||||
);
|
||||
|
||||
if (retryImageZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: retryImageZip.path,
|
||||
remotePath: '/${p.basename(retryImageZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
|
||||
anyFtpSuccess = false;
|
||||
} else {
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
anyFtpSuccess = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -574,7 +603,7 @@ class RiverInSituSamplingService {
|
||||
mapImage(data.backgroundStationImage, 'background');
|
||||
mapImage(data.upstreamRiverImage, 'upstream');
|
||||
mapImage(data.downstreamRiverImage, 'downstream');
|
||||
mapImage(data.sampleTurbidityImage, 'turbidity');
|
||||
mapImage(data.sampleTurbidityImage, 'sample_turbidity');
|
||||
mapImage(data.optionalImage1, 'optional_1');
|
||||
mapImage(data.optionalImage2, 'optional_2');
|
||||
mapImage(data.optionalImage3, 'optional_3');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -33,6 +33,7 @@ import 'submission_ftp_service.dart';
|
||||
import 'telegram_service.dart';
|
||||
import 'retry_service.dart';
|
||||
import 'base_api_service.dart'; // Import for SessionExpiredException
|
||||
import 'user_preferences_service.dart'; // ADDED
|
||||
|
||||
|
||||
class RiverManualTriennialSamplingService {
|
||||
@ -47,6 +48,7 @@ class RiverManualTriennialSamplingService {
|
||||
final ZippingService _zippingService = ZippingService();
|
||||
final RetryService _retryService = RetryService();
|
||||
final TelegramService _telegramService;
|
||||
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
|
||||
@ -239,6 +241,10 @@ class RiverManualTriennialSamplingService {
|
||||
required AuthProvider authProvider,
|
||||
String? logDirectory,
|
||||
}) 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 imageFilesWithNulls = data.toApiImageFiles();
|
||||
imageFilesWithNulls.removeWhere((key, value) => value == null);
|
||||
@ -256,177 +262,204 @@ class RiverManualTriennialSamplingService {
|
||||
// data.reportId already contains the timestamp ID
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
try {
|
||||
// 1. Submit Form Data
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/triennial/sample', // Correct endpoint
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
// 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 (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
// Store the server's database ID in a separate variable.
|
||||
apiRecordId = apiDataResult['data']?['r_tri_id']?.toString(); // Correct ID key
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
if (isApiEnabled) {
|
||||
try {
|
||||
// 1. Submit Form Data
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/triennial/sample', // Correct endpoint
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
|
||||
if (apiRecordId != null) {
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/triennial/images', // Correct endpoint
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
fields: {'r_tri_id': apiRecordId}, // Correct field key
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
anyApiSuccess = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
if (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a server record ID.';
|
||||
// Store the server's database ID in a separate variable.
|
||||
apiRecordId = apiDataResult['data']?['r_tri_id']?.toString(); // Correct ID key
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
if (apiRecordId != null) {
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/triennial/images', // Correct endpoint
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
fields: {'r_tri_id': apiRecordId}, // Correct field key
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
anyApiSuccess = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
// --- START: MODIFIED TO USE TIMESTAMP 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, SubmissionApiService queued it.
|
||||
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
// Manually queue API calls
|
||||
await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData());
|
||||
if (finalImageFiles.isNotEmpty && apiRecordId != null) {
|
||||
// Also queue images if data call might have partially succeeded before expiry
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
await _retryService.addApiToQueue(endpoint: 'river/triennial/images', method: 'POST_MULTIPART', fields: {'r_tri_id': apiRecordId}, files: finalImageFiles);
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
}
|
||||
}
|
||||
// If apiDataResult['success'] is false, SubmissionApiService queued it.
|
||||
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
// Manually queue API calls
|
||||
await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData());
|
||||
if (finalImageFiles.isNotEmpty && apiRecordId != null) {
|
||||
// Also queue images if data call might have partially succeeded before expiry
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
await _retryService.addApiToQueue(endpoint: 'river/triennial/images', method: 'POST_MULTIPART', fields: {'r_tri_id': apiRecordId}, files: finalImageFiles);
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
}
|
||||
// --- START FIX: Queue all four JSON files ---
|
||||
// Get all potential FTP configs
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
|
||||
// --- START FIX: Queue all four JSON files ---
|
||||
// Get all potential FTP configs
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: { // Use specific JSON structures for River Triennial FTP
|
||||
'db.json': data.toDbJson(), // Assuming similar structure is needed, adjust if different
|
||||
'river_triennial_basic_form.json': data.toBasicFormJson(),
|
||||
'river_triennial_reading.json': data.toReadingJson(),
|
||||
'river_triennial_manual_info.json': data.toManualInfoJson(),
|
||||
},
|
||||
baseFileName: _generateBaseFileName(data),
|
||||
destinationDir: null,
|
||||
);
|
||||
if (dataZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: { // Use specific JSON structures for River Triennial FTP
|
||||
'db.json': data.toDbJson(), // Assuming similar structure is needed, adjust if different
|
||||
'river_triennial_basic_form.json': data.toBasicFormJson(),
|
||||
'river_triennial_reading.json': data.toReadingJson(),
|
||||
'river_triennial_manual_info.json': data.toManualInfoJson(),
|
||||
},
|
||||
baseFileName: _generateBaseFileName(data),
|
||||
destinationDir: null,
|
||||
);
|
||||
if (dataZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- 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
|
||||
Map<String, dynamic> ftpResults = {'statuses': []};
|
||||
bool anyFtpSuccess = false;
|
||||
|
||||
if (isSessionKnownToBeExpired) {
|
||||
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
// --- 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';
|
||||
|
||||
// --- START FIX: Add ftpConfigId when queuing ---
|
||||
// Get all potential FTP configs
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
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 due to known expired session. Manually queuing FTP tasks.");
|
||||
// --- START: MODIFIED TO USE TIMESTAMP ID ---
|
||||
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
|
||||
// --- END: MODIFIED TO USE TIMESTAMP ID ---
|
||||
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: { // Use specific JSON structures for River Triennial FTP
|
||||
'db.json': data.toDbJson(), // Assuming similar structure is needed, adjust if different
|
||||
'river_triennial_basic_form.json': data.toBasicFormJson(), // ADDED
|
||||
'river_triennial_reading.json': data.toReadingJson(), // ADDED
|
||||
'river_triennial_manual_info.json': data.toManualInfoJson(), // ADDED
|
||||
},
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null,
|
||||
);
|
||||
if (dataZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- START FIX: Add ftpConfigId when queuing ---
|
||||
// Get all potential FTP configs
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// Note: For the session expired case, renaming logic would ideally be here too,
|
||||
// but requires complex reconstruction of the map. Following the previous pattern,
|
||||
// we attempt to respect the rename if possible.
|
||||
final Map<String, File> retryImages = {};
|
||||
final String dateStr = (data.samplingDate ?? '').replaceAll('-', '');
|
||||
final String timeStr = (data.samplingTime ?? '').replaceAll(':', '');
|
||||
final String timestampId = "$dateStr$timeStr";
|
||||
|
||||
void addRetryMap(File? file, String prefix) {
|
||||
if(file != null) retryImages['${prefix}_$timestampId.jpg'] = file;
|
||||
}
|
||||
addRetryMap(data.backgroundStationImage, 'background');
|
||||
addRetryMap(data.upstreamRiverImage, 'upstream');
|
||||
addRetryMap(data.downstreamRiverImage, 'downstream');
|
||||
addRetryMap(data.sampleTurbidityImage, 'sample_turbidity');
|
||||
addRetryMap(data.optionalImage1, 'optional_1');
|
||||
addRetryMap(data.optionalImage2, 'optional_2');
|
||||
addRetryMap(data.optionalImage3, 'optional_3');
|
||||
addRetryMap(data.optionalImage4, 'optional_4');
|
||||
|
||||
final retryImageZip = await _zippingService.createRenamedImageZip(
|
||||
imageFiles: retryImages,
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: { // Use specific JSON structures for River Triennial FTP
|
||||
'db.json': data.toDbJson(), // Assuming similar structure is needed, adjust if different
|
||||
'river_triennial_basic_form.json': data.toBasicFormJson(), // ADDED
|
||||
'river_triennial_reading.json': data.toReadingJson(), // ADDED
|
||||
'river_triennial_manual_info.json': data.toManualInfoJson(), // ADDED
|
||||
},
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null,
|
||||
);
|
||||
if (retryImageZip != null) {
|
||||
if (dataZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: retryImageZip.path,
|
||||
remotePath: '/${p.basename(retryImageZip.path)}',
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
|
||||
anyFtpSuccess = false;
|
||||
} else {
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// Note: For the session expired case, renaming logic would ideally be here too,
|
||||
// but requires complex reconstruction of the map. Following the previous pattern,
|
||||
// we attempt to respect the rename if possible.
|
||||
final Map<String, File> retryImages = {};
|
||||
final String dateStr = (data.samplingDate ?? '').replaceAll('-', '');
|
||||
final String timeStr = (data.samplingTime ?? '').replaceAll(':', '');
|
||||
final String timestampId = "$dateStr$timeStr";
|
||||
|
||||
void addRetryMap(File? file, String prefix) {
|
||||
if(file != null) retryImages['${prefix}_$timestampId.jpg'] = file;
|
||||
}
|
||||
addRetryMap(data.backgroundStationImage, 'background');
|
||||
addRetryMap(data.upstreamRiverImage, 'upstream');
|
||||
addRetryMap(data.downstreamRiverImage, 'downstream');
|
||||
addRetryMap(data.sampleTurbidityImage, 'sample_turbidity');
|
||||
addRetryMap(data.optionalImage1, 'optional_1');
|
||||
addRetryMap(data.optionalImage2, 'optional_2');
|
||||
addRetryMap(data.optionalImage3, 'optional_3');
|
||||
addRetryMap(data.optionalImage4, 'optional_4');
|
||||
|
||||
final retryImageZip = await _zippingService.createRenamedImageZip(
|
||||
imageFiles: retryImages,
|
||||
baseFileName: baseFileNameForQueue,
|
||||
destinationDir: null,
|
||||
);
|
||||
if (retryImageZip != null) {
|
||||
// Queue for each config separately
|
||||
for (final config in ftpConfigs) {
|
||||
final configId = config['ftp_config_id'];
|
||||
if (configId != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: retryImageZip.path,
|
||||
remotePath: '/${p.basename(retryImageZip.path)}',
|
||||
ftpConfigId: configId // Provide the specific config ID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
|
||||
anyFtpSuccess = false;
|
||||
} else {
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
anyFtpSuccess = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -462,9 +495,12 @@ class RiverManualTriennialSamplingService {
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// --- END FIX ---
|
||||
|
||||
// Return consistent format
|
||||
return {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user