diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0b0a3d9..b11d52e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -27,6 +27,7 @@
+
toDbJson() {
+ final data = {
+ 'refID': refID,
+ 'air_man_id': airManId,
+ 'samplingDate': samplingDate,
+ 'clientId': clientId,
+ 'installationDate': installationDate,
+ 'installationTime': installationTime,
+ 'stateID': stateID,
+ 'locationName': locationName,
+ 'stationID': stationID,
+ 'region': region,
+ 'weather': weather,
+ 'temp': temp,
+ 'powerFailure': powerFailure,
+ 'pm10FilterId': pm10FilterId,
+ 'pm25FilterId': pm25FilterId,
+ 'remark': remark,
+ 'installationUserId': installationUserId,
+ 'installationUserName': installationUserName,
+ 'status': status,
+ 'optionalRemark1': optionalRemark1,
+ 'optionalRemark2': optionalRemark2,
+ 'optionalRemark3': optionalRemark3,
+ 'optionalRemark4': optionalRemark4,
+ 'collectionData': collectionData?.toMap(),
+ };
+ return data;
+ }
+
+ /// Creates a JSON object for basic installation form info, mimicking 'installation_form.json'.
+ Map toInstallationFormJson() {
+ return {
+ 'air_man_station_code': stationID,
+ 'air_man_sampling_date': samplingDate,
+ 'air_man_client_id': clientId,
+ 'air_man_installation_date': installationDate,
+ 'air_man_installation_time': installationTime,
+ 'air_man_installation_weather': weather,
+ 'air_man_installation_temperature': temp,
+ 'air_man_installation_power_failure': powerFailure ? 'Yes' : 'No',
+ 'air_man_installation_pm10_filter_id': pm10FilterId,
+ 'air_man_installation_pm25_filter_id': pm25FilterId,
+ 'air_man_installation_remarks': remark,
+ 'air_man_installation_image_optional_01_remarks': optionalRemark1,
+ 'air_man_installation_image_optional_02_remarks': optionalRemark2,
+ 'air_man_installation_image_optional_03_remarks': optionalRemark3,
+ 'air_man_installation_image_optional_04_remarks': optionalRemark4,
+ };
+ }
+
+ /// Creates a JSON object for manual info, mimicking a generic 'manual_info.json' file.
+ Map toManualInfoJson() {
+ return {
+ 'weather': weather,
+ 'remarks_event': remark,
+ 'remarks_lab': null,
+ };
+ }
+}
\ No newline at end of file
diff --git a/lib/models/in_situ_sampling_data.dart b/lib/models/in_situ_sampling_data.dart
index ee2ea39..54ddf14 100644
--- a/lib/models/in_situ_sampling_data.dart
+++ b/lib/models/in_situ_sampling_data.dart
@@ -1,4 +1,5 @@
import 'dart:io';
+import 'dart:convert'; // Added for jsonEncode
/// A data model class to hold all information for the multi-step
/// In-Situ Sampling form.
@@ -71,6 +72,61 @@ class InSituSamplingData {
this.samplingTime,
});
+ // --- ADDED: Factory constructor to create a new instance from a map ---
+ factory InSituSamplingData.fromJson(Map json) {
+ double? doubleFromJson(dynamic value) {
+ if (value is num) return value.toDouble();
+ if (value is String) return double.tryParse(value);
+ return null;
+ }
+
+ int? intFromJson(dynamic value) {
+ if (value is int) return value;
+ if (value is String) return int.tryParse(value);
+ return null;
+ }
+
+ return InSituSamplingData()
+ ..firstSamplerName = json['first_sampler_name']
+ ..firstSamplerUserId = intFromJson(json['first_sampler_user_id'])
+ ..secondSampler = json['secondSampler']
+ ..samplingDate = json['man_date']
+ ..samplingTime = json['man_time']
+ ..samplingType = json['man_type']
+ ..sampleIdCode = json['man_sample_id_code']
+ ..selectedStateName = json['selectedStateName']
+ ..selectedCategoryName = json['selectedCategoryName']
+ ..selectedStation = json['selectedStation']
+ ..stationLatitude = json['stationLatitude']
+ ..stationLongitude = json['stationLongitude']
+ ..currentLatitude = json['man_current_latitude']?.toString()
+ ..currentLongitude = json['man_current_longitude']?.toString()
+ ..distanceDifferenceInKm = doubleFromJson(json['man_distance_difference'])
+ ..distanceDifferenceRemarks = json['man_distance_difference_remarks']
+ ..weather = json['man_weather']
+ ..tideLevel = json['man_tide_level']
+ ..seaCondition = json['man_sea_condition']
+ ..eventRemarks = json['man_event_remark']
+ ..labRemarks = json['man_lab_remark']
+ ..sondeId = json['man_sondeID']
+ ..dataCaptureDate = json['data_capture_date']
+ ..dataCaptureTime = json['data_capture_time']
+ ..oxygenConcentration = doubleFromJson(json['man_oxygen_conc'])
+ ..oxygenSaturation = doubleFromJson(json['man_oxygen_sat'])
+ ..ph = doubleFromJson(json['man_ph'])
+ ..salinity = doubleFromJson(json['man_salinity'])
+ ..electricalConductivity = doubleFromJson(json['man_conductivity'])
+ ..temperature = doubleFromJson(json['man_temperature'])
+ ..tds = doubleFromJson(json['man_tds'])
+ ..turbidity = doubleFromJson(json['man_turbidity'])
+ ..tss = doubleFromJson(json['man_tss'])
+ ..batteryVoltage = doubleFromJson(json['man_battery_volt'])
+ ..optionalRemark1 = json['man_optional_photo_01_remarks']
+ ..optionalRemark2 = json['man_optional_photo_02_remarks']
+ ..optionalRemark3 = json['man_optional_photo_03_remarks']
+ ..optionalRemark4 = json['man_optional_photo_04_remarks'];
+ }
+
/// Generates a formatted Telegram alert message for successful submissions.
String generateTelegramAlertMessage({required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
@@ -171,4 +227,95 @@ class InSituSamplingData {
'man_optional_photo_04': optionalImage4,
};
}
+
+ // --- ADDED: Methods to format data for FTP submission as separate JSON files ---
+
+ /// Creates a single JSON object with all submission data, mimicking 'db.json'
+ Map toDbJson() {
+ // This is a direct conversion of the model's properties to a map,
+ // with keys matching the expected JSON file format.
+ return {
+ 'first_sampler_name': firstSamplerName,
+ 'first_sampler_user_id': firstSamplerUserId,
+ 'second_sampler': secondSampler,
+ 'sampling_date': samplingDate,
+ 'sampling_time': samplingTime,
+ 'sampling_type': samplingType,
+ 'sample_id_code': sampleIdCode,
+ 'selected_state_name': selectedStateName,
+ 'selected_category_name': selectedCategoryName,
+ 'selected_station': selectedStation,
+ 'station_latitude': stationLatitude,
+ 'station_longitude': stationLongitude,
+ 'current_latitude': currentLatitude,
+ 'current_longitude': currentLongitude,
+ 'distance_difference_in_km': distanceDifferenceInKm,
+ 'distance_difference_remarks': distanceDifferenceRemarks,
+ 'weather': weather,
+ 'tide_level': tideLevel,
+ 'sea_condition': seaCondition,
+ 'event_remarks': eventRemarks,
+ 'lab_remarks': labRemarks,
+ 'sonde_id': sondeId,
+ 'data_capture_date': dataCaptureDate,
+ 'data_capture_time': dataCaptureTime,
+ 'oxygen_concentration': oxygenConcentration,
+ 'oxygen_saturation': oxygenSaturation,
+ 'ph': ph,
+ 'salinity': salinity,
+ 'electrical_conductivity': electricalConductivity,
+ 'temperature': temperature,
+ 'tds': tds,
+ 'turbidity': turbidity,
+ 'tss': tss,
+ 'battery_voltage': batteryVoltage,
+ 'submission_status': submissionStatus,
+ 'submission_message': submissionMessage,
+ 'report_id': reportId,
+ };
+ }
+
+ /// Creates a JSON object for basic form info, mimicking 'basic_form.json'.
+ Map toBasicFormJson() {
+ return {
+ 'tech_name': firstSamplerName,
+ 'sampler_2ndname': secondSampler?['user_name'],
+ 'sample_date': samplingDate,
+ 'sample_time': samplingTime,
+ 'sampling_type': samplingType,
+ 'sample_state': selectedStateName,
+ 'station_id': selectedStation?['man_station_code'],
+ 'station_latitude': stationLatitude,
+ 'station_longitude': stationLongitude,
+ 'latitude': currentLatitude,
+ 'longitude': currentLongitude,
+ 'sample_id': sampleIdCode,
+ };
+ }
+
+ /// Creates a JSON object for sensor readings, mimicking 'reading.json'.
+ Map toReadingJson() {
+ return {
+ 'do_mgl': oxygenConcentration,
+ 'do_sat': oxygenSaturation,
+ 'ph': ph,
+ 'salinity': salinity,
+ 'temperature': temperature,
+ 'turbidity': turbidity,
+ 'tds': tds,
+ 'electric_conductivity': electricalConductivity,
+ 'flowrate': null, // This is not collected in marine in-situ
+ 'date_sampling_reading': dataCaptureDate,
+ 'time_sampling_reading': dataCaptureTime,
+ };
+ }
+
+ /// Creates a JSON object for manual info, mimicking 'manual_info.json'.
+ Map toManualInfoJson() {
+ return {
+ 'weather': weather,
+ 'remarks_event': eventRemarks,
+ 'remarks_lab': labRemarks,
+ };
+ }
}
\ No newline at end of file
diff --git a/lib/models/river_in_situ_sampling_data.dart b/lib/models/river_in_situ_sampling_data.dart
index c7b79ac..6b5bd32 100644
--- a/lib/models/river_in_situ_sampling_data.dart
+++ b/lib/models/river_in_situ_sampling_data.dart
@@ -1,6 +1,7 @@
// lib/models/river_in_situ_sampling_data.dart
import 'dart:io';
+import 'dart:convert'; // Added for jsonEncode
/// A data model class to hold all information for the multi-step
/// River In-Situ Sampling form.
@@ -234,4 +235,89 @@ class RiverInSituSamplingData {
'r_man_optional_photo_04': optionalImage4,
};
}
+
+ // --- ADDED: Methods to format data for FTP submission as separate JSON files ---
+
+ /// Creates a single JSON object with all submission data, mimicking 'db.json'
+ String toDbJson() {
+ // This is a direct conversion of the model's properties to a map,
+ // with keys matching the expected JSON file format.
+ final data = {
+ 'battery_cap': batteryVoltage,
+ 'device_name': sondeId,
+ 'sampling_type': samplingType,
+ 'report_id': reportId,
+ 'sampler_2ndname': secondSampler?['user_name'],
+ 'sample_state': selectedStateName,
+ 'station_id': selectedStation?['sampling_station_code'],
+ 'tech_id': firstSamplerUserId,
+ 'tech_name': firstSamplerName,
+ 'latitude': stationLatitude,
+ 'longitude': stationLongitude,
+ 'record_dt': '$samplingDate $samplingTime',
+ 'do_mgl': oxygenConcentration,
+ 'do_sat': oxygenSaturation,
+ 'ph': ph,
+ 'salinity': salinity,
+ 'temperature': temperature,
+ 'turbidity': turbidity,
+ 'tds': tds,
+ 'electric_conductivity': electricalConductivity,
+ 'flowrate': flowrateValue,
+ 'odour': '', // Assuming these are not collected in this form
+ 'floatable': '', // Assuming these are not collected in this form
+ 'sample_id': sampleIdCode,
+ 'weather': weather,
+ 'remarks_event': eventRemarks,
+ 'remarks_lab': labRemarks,
+ };
+ return jsonEncode(data);
+ }
+
+ /// Creates a JSON object for basic form info, mimicking 'river_insitu_basic_form.json'.
+ String toBasicFormJson() {
+ final data = {
+ 'tech_name': firstSamplerName,
+ 'sampler_2ndname': secondSampler?['user_name'],
+ 'sample_date': samplingDate,
+ 'sample_time': samplingTime,
+ 'sampling_type': samplingType,
+ 'sample_state': selectedStateName,
+ 'station_id': selectedStation?['sampling_station_code'],
+ 'station_latitude': stationLatitude,
+ 'station_longitude': stationLongitude,
+ 'latitude': currentLatitude,
+ 'longitude': currentLongitude,
+ 'sample_id': sampleIdCode,
+ };
+ return jsonEncode(data);
+ }
+
+ /// Creates a JSON object for sensor readings, mimicking 'river_sampling_reading.json'.
+ String toReadingJson() {
+ final data = {
+ 'do_mgl': oxygenConcentration,
+ 'do_sat': oxygenSaturation,
+ 'ph': ph,
+ 'salinity': salinity,
+ 'temperature': temperature,
+ 'turbidity': turbidity,
+ 'tds': tds,
+ 'electric_conductivity': electricalConductivity,
+ 'flowrate': flowrateValue,
+ 'date_sampling_reading': samplingDate,
+ 'time_sampling_reading': samplingTime,
+ };
+ return jsonEncode(data);
+ }
+
+ /// Creates a JSON object for manual info, mimicking 'river_manual_info.json'.
+ String toManualInfoJson() {
+ final data = {
+ 'weather': weather,
+ 'remarks_event': eventRemarks,
+ 'remarks_lab': labRemarks,
+ };
+ return jsonEncode(data);
+ }
}
\ No newline at end of file
diff --git a/lib/models/tarball_data.dart b/lib/models/tarball_data.dart
index fbe9f21..5661423 100644
--- a/lib/models/tarball_data.dart
+++ b/lib/models/tarball_data.dart
@@ -1,5 +1,6 @@
//import 'dart' as dart;
import 'dart:io';
+import 'dart:convert';
/// This class holds all the data collected across the multi-step tarball sampling form.
/// It acts as a temporary data container that is passed between screens.
@@ -125,4 +126,76 @@ class TarballSamplingData {
'optional_photo_04': optionalImage4,
};
}
+
+ // --- ADDED: Methods to format data for FTP submission as separate JSON files ---
+
+ /// Creates a single JSON object with all submission data, mimicking 'db.json'
+ Map toDbJson() {
+ return {
+ 'firstSampler': firstSampler,
+ 'firstSamplerUserId': firstSamplerUserId,
+ 'secondSampler': secondSampler,
+ 'samplingDate': samplingDate,
+ 'samplingTime': samplingTime,
+ 'selectedStateName': selectedStateName,
+ 'selectedCategoryName': selectedCategoryName,
+ 'selectedStation': selectedStation,
+ 'stationLatitude': stationLatitude,
+ 'stationLongitude': stationLongitude,
+ 'currentLatitude': currentLatitude,
+ 'currentLongitude': currentLongitude,
+ 'distanceDifference': distanceDifference,
+ 'distanceDifferenceRemarks': distanceDifferenceRemarks,
+ 'classificationId': classificationId,
+ 'selectedClassification': selectedClassification,
+ 'optionalRemark1': optionalRemark1,
+ 'optionalRemark2': optionalRemark2,
+ 'optionalRemark3': optionalRemark3,
+ 'optionalRemark4': optionalRemark4,
+ 'reportId': reportId,
+ 'submissionStatus': submissionStatus,
+ 'submissionMessage': submissionMessage,
+ };
+ }
+
+ /// Creates a JSON object for basic form info, mimicking 'basic_form.json'.
+ Map toBasicFormJson() {
+ return {
+ 'tech_name': firstSampler,
+ 'sampler_2ndname': secondSampler?['user_name'],
+ 'sample_date': samplingDate,
+ 'sample_time': samplingTime,
+ 'sample_state': selectedStateName,
+ 'station_id': selectedStation?['tbl_station_code'],
+ 'station_latitude': stationLatitude,
+ 'station_longitude': stationLongitude,
+ 'latitude': currentLatitude,
+ 'longitude': currentLongitude,
+ 'sample_id': reportId, // Using reportId as a unique identifier for the sample.
+ };
+ }
+
+ /// Creates a JSON object for sensor readings, mimicking 'reading.json'.
+ Map toReadingJson() {
+ return {
+ 'classification': selectedClassification?['classification_name'],
+ 'classification_id': classificationId,
+ 'optional_remark_1': optionalRemark1,
+ 'optional_remark_2': optionalRemark2,
+ 'optional_remark_3': optionalRemark3,
+ 'optional_remark_4': optionalRemark4,
+ 'distance_difference': distanceDifference,
+ 'distance_difference_remarks': distanceDifferenceRemarks,
+ };
+ }
+
+ /// Creates a JSON object for manual info, mimicking 'manual_info.json'.
+ Map toManualInfoJson() {
+ return {
+ // Tarball forms don't have a specific 'weather' or general remarks field,
+ // so we use the distance remarks as a stand-in if available.
+ 'remarks_event': distanceDifferenceRemarks,
+ 'remarks_lab': null,
+ };
+ }
}
\ No newline at end of file
diff --git a/lib/screens/marine/manual/in_situ_sampling.dart b/lib/screens/marine/manual/in_situ_sampling.dart
index 1b27b8d..62c5427 100644
--- a/lib/screens/marine/manual/in_situ_sampling.dart
+++ b/lib/screens/marine/manual/in_situ_sampling.dart
@@ -1,13 +1,22 @@
+// lib/screens/marine/manual/in_situ_sampling.dart
+
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
-import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import for AuthProvider
+import 'package:environment_monitoring_app/auth_provider.dart';
+import 'dart:convert';
+import 'dart:io';
+import 'package:path/path.dart' as p;
import '../../../models/in_situ_sampling_data.dart';
import '../../../services/in_situ_sampling_service.dart';
import '../../../services/local_storage_service.dart';
-// --- ADDED: Import to get the active server configuration name ---
import '../../../services/server_config_service.dart';
+// --- ADDED: Imports for zipping and retry queue logic ---
+import '../../../services/zipping_service.dart';
+import '../../../services/retry_service.dart';
+// --- ADDED: Import for the DatabaseHelper ---
+import '../../../services/api_service.dart';
import 'widgets/in_situ_step_1_sampling_info.dart';
import 'widgets/in_situ_step_2_site_info.dart';
import 'widgets/in_situ_step_3_data_capture.dart';
@@ -26,7 +35,6 @@ class MarineInSituSampling extends StatefulWidget {
class _MarineInSituSamplingState extends State {
final PageController _pageController = PageController();
- // REPAIRED: Changed from `final` to `late` to allow re-initialization.
late InSituSamplingData _data;
// A single instance of the service to be used by all child widgets.
@@ -34,19 +42,19 @@ class _MarineInSituSamplingState extends State {
// Service for saving submission logs locally.
final LocalStorageService _localStorageService = LocalStorageService();
-
- // --- ADDED: Service to get the active server configuration ---
final ServerConfigService _serverConfigService = ServerConfigService();
+ // --- ADDED: Services for zipping and queueing ---
+ final ZippingService _zippingService = ZippingService();
+ final RetryService _retryService = RetryService();
+ final DatabaseHelper _dbHelper = DatabaseHelper(); // --- ADDED: Instance of DatabaseHelper for configs ---
+
int _currentPage = 0;
bool _isLoading = false;
- // ADDED: initState to create a fresh data object each time the widget is created.
@override
void initState() {
super.initState();
- // This is the core of the fix. It creates a NEW data object with the
- // CURRENT date and time every time the user starts a new sampling.
_data = InSituSamplingData(
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()),
@@ -56,7 +64,6 @@ class _MarineInSituSamplingState extends State {
@override
void dispose() {
_pageController.dispose();
- // Dispose the service to clean up its resources (e.g., stream controllers).
_samplingService.dispose();
super.dispose();
}
@@ -81,49 +88,119 @@ class _MarineInSituSamplingState extends State {
}
}
- /// Handles the final submission process.
+ // --- REPLACED: _submitForm() method with the new workflow ---
Future _submitForm() async {
setState(() => _isLoading = true);
- // MODIFIED: Get the appSettings list from AuthProvider.
final authProvider = Provider.of(context, listen: false);
final appSettings = authProvider.appSettings;
+ final activeApiConfig = await _serverConfigService.getActiveApiConfig();
+ final serverName = activeApiConfig?['config_name'] as String? ?? 'Default';
- // MODIFIED: Pass the appSettings to the submitData method.
- final result = await _samplingService.submitData(_data, appSettings);
+ // Get all API and FTP configs from the database and limit them to the latest 2.
+ final apiConfigs = (await _dbHelper.loadApiConfigs() ?? []).take(2).toList();
+ final ftpConfigs = (await _dbHelper.loadFtpConfigs() ?? []).take(2).toList();
+
+ bool apiSuccess = false;
+ bool ftpSuccess = false;
+
+ // --- Path A: Attempt API Submission ---
+ debugPrint("Step 1: Attempting API submission...");
+ try {
+ final apiResult = await _samplingService.submitData(_data, appSettings);
+ apiSuccess = apiResult['success'] == true;
+ _data.submissionStatus = apiResult['status'];
+ _data.submissionMessage = apiResult['message'];
+ _data.reportId = apiResult['reportId']?.toString();
+ } catch (e) {
+ debugPrint("API submission failed with a critical error: $e");
+ apiSuccess = false;
+ }
+
+ // --- Path B: Attempt FTP Submission if configurations exist ---
+ if (ftpConfigs.isNotEmpty) {
+ debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing.");
+
+ final stationCode = _data.selectedStation?['man_station_code'] ?? 'NA';
+ final reportId = _data.reportId ?? DateTime.now().millisecondsSinceEpoch;
+ final baseFileName = '${stationCode}_$reportId';
+
+ try {
+ // Create a dedicated folder and copy the data for FTP
+ final ftpDir = await _localStorageService.getInSituBaseDir(serverName: '${serverName}_ftp');
+ // REPAIRED: This line was the cause of the error.
+ final dataForFtp = InSituSamplingData.fromJson(Map.from(_data.toApiFormData()));
+
+ final Map jsonDataMap = {
+ // Now that fromJson exists, we can call these methods.
+ 'db.json': jsonEncode(dataForFtp.toDbJson()),
+ 'basic_form.json': jsonEncode(dataForFtp.toBasicFormJson()),
+ 'reading.json': jsonEncode(dataForFtp.toReadingJson()),
+ 'manual_info.json': jsonEncode(dataForFtp.toManualInfoJson()),
+ };
+
+ final File? dataZip = await _zippingService.createDataZip(
+ jsonDataMap: jsonDataMap,
+ baseFileName: baseFileName,
+ );
+ final File? imageZip = await _zippingService.createImageZip(
+ imageFiles: dataForFtp.toApiImageFiles().values.whereType().toList(),
+ baseFileName: baseFileName,
+ );
+
+ if (dataZip != null) {
+ await _retryService.addFtpToQueue(
+ localFilePath: dataZip.path,
+ remotePath: '/uploads/data/${p.basename(dataZip.path)}',
+ );
+ ftpSuccess = true;
+ }
+ if (imageZip != null) {
+ await _retryService.addFtpToQueue(
+ localFilePath: imageZip.path,
+ remotePath: '/uploads/images/${p.basename(imageZip.path)}',
+ );
+ ftpSuccess = true;
+ }
+ } catch (e) {
+ debugPrint("FTP zipping or queuing failed with an error: $e");
+ ftpSuccess = false;
+ }
+ }
+
+
+ // --- Final Status Update and Navigation ---
if (!mounted) return;
- // Update the data model with the submission result.
- _data.submissionStatus = result['status'];
- _data.submissionMessage = result['message'];
- _data.reportId = result['reportId']?.toString();
+ if (apiSuccess && ftpSuccess) {
+ _data.submissionStatus = 'S4'; // Submitted API, Queued FTP
+ _data.submissionMessage = 'Data submitted and files are queued for FTP upload.';
+ } else if (apiSuccess) {
+ _data.submissionStatus = 'S3'; // Submitted API Only
+ _data.submissionMessage = 'Data submitted successfully to API. No FTP configured or FTP failed.';
+ } else if (ftpSuccess) {
+ _data.submissionStatus = 'L4'; // Failed API, Queued FTP
+ _data.submissionMessage = 'API submission failed but files were successfully queued for FTP.';
+ } else {
+ _data.submissionStatus = 'L1'; // All submissions failed
+ _data.submissionMessage = 'All submission attempts failed. Data saved locally for retry.';
+ }
- // --- MODIFIED: Get the active server name before saving the local log ---
- final activeConfig = await _serverConfigService.getActiveApiConfig();
- final serverName = activeConfig?['config_name'] as String? ?? 'Default';
-
- // Save a log of the submission locally, now with the server name.
await _localStorageService.saveInSituSamplingData(_data, serverName: serverName);
setState(() => _isLoading = false);
- // Show feedback to the user based on the result.
- final message = result['message'] ?? 'An unknown error occurred.';
- final color = (result['status'] == 'L3')
- ? Colors.green
- : (result['status'] == 'L2' ? Colors.orange : Colors.red);
-
+ final message = _data.submissionMessage ?? 'An unknown error occurred.';
+ final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
);
- // If submission was fully successful, navigate back to the home screen.
- if (result['status'] == 'L3') {
- Navigator.of(context).popUntil((route) => route.isFirst);
- }
+ Navigator.of(context).popUntil((route) => route.isFirst);
}
+
@override
Widget build(BuildContext context) {
// Use Provider.value to provide the existing service instance to all child widgets.
diff --git a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart
index beaf67c..06fb1fd 100644
--- a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart
+++ b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart
@@ -1,13 +1,20 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
+import 'dart:convert';
+import 'package:path/path.dart' as p;
import 'package:environment_monitoring_app/auth_provider.dart';
import 'package:environment_monitoring_app/models/tarball_data.dart';
-import 'package:environment_monitoring_app/services/marine_api_service.dart';
+// REMOVED: Direct import of marine_api_service.dart to fix the naming conflict.
import 'package:environment_monitoring_app/services/local_storage_service.dart';
// --- ADDED: Import to get the active server configuration name ---
import 'package:environment_monitoring_app/services/server_config_service.dart';
+// --- ADDED: Imports for zipping and retry queue logic ---
+import 'package:environment_monitoring_app/services/zipping_service.dart';
+import 'package:environment_monitoring_app/services/retry_service.dart';
+// --- ADDED: Import for the DatabaseHelper and ApiService ---
+import 'package:environment_monitoring_app/services/api_service.dart';
class TarballSamplingStep3Summary extends StatefulWidget {
@@ -19,72 +26,128 @@ class TarballSamplingStep3Summary extends StatefulWidget {
}
class _TarballSamplingStep3SummaryState extends State {
- final MarineApiService _marineApiService = MarineApiService();
+ // CORRECTED: Use the main ApiService to access its marine property.
+ final ApiService _apiService = ApiService();
final LocalStorageService _localStorageService = LocalStorageService();
// --- ADDED: Service to get the active server configuration ---
final ServerConfigService _serverConfigService = ServerConfigService();
+ // --- ADDED: Services for zipping and queueing ---
+ final ZippingService _zippingService = ZippingService();
+ final RetryService _retryService = RetryService();
+ final DatabaseHelper _dbHelper = DatabaseHelper(); // --- ADDED: Instance of DatabaseHelper for configs ---
+
bool _isLoading = false;
- // MODIFIED: This method now fetches appSettings and passes it to the API service.
+ // --- REPLACED: _submitForm() method with the new workflow ---
Future _submitForm() async {
setState(() => _isLoading = true);
- // Get the appSettings list from AuthProvider.
final authProvider = Provider.of(context, listen: false);
final appSettings = authProvider.appSettings;
+ final activeApiConfig = await _serverConfigService.getActiveApiConfig();
+ final serverName = activeApiConfig?['config_name'] as String? ?? 'Default';
- // Step 1: Orchestrated Server Submission
- // Pass the appSettings list to the submit method.
- final result = await _marineApiService.submitTarballSample(
- formData: widget.data.toFormData(),
- imageFiles: widget.data.toImageFiles(),
- appSettings: appSettings,
- );
+ // Get all FTP configs from the database and limit them to the latest 2.
+ final ftpConfigs = (await _dbHelper.loadFtpConfigs() ?? []).take(2).toList();
- if (!mounted) return;
+ // Create a temporary, separate copy of the data for the FTP process
+ final dataForFtp = widget.data;
- // Step 2: Update the data model with submission results
- widget.data.submissionStatus = result['status'];
- widget.data.submissionMessage = result['message'];
- widget.data.reportId = result['reportId']?.toString();
+ bool apiSuccess = false;
+ bool ftpSuccess = false;
- // --- MODIFIED: Get the active server name before saving the local log ---
- final activeConfig = await _serverConfigService.getActiveApiConfig();
- final serverName = activeConfig?['config_name'] as String? ?? 'Default';
+ // --- Step 1: Attempt API Submission ---
+ debugPrint("Step 1: Attempting API submission...");
+ try {
+ final apiResult = await _apiService.marine.submitTarballSample(
+ formData: widget.data.toFormData(),
+ imageFiles: widget.data.toImageFiles(),
+ appSettings: appSettings,
+ );
+ apiSuccess = apiResult['success'] == true;
+ widget.data.submissionStatus = apiResult['status'];
+ widget.data.submissionMessage = apiResult['message'];
+ widget.data.reportId = apiResult['reportId']?.toString();
+ debugPrint("API submission successful.");
+ } catch (e) {
+ debugPrint("API submission failed with a critical error: $e");
+ apiSuccess = false;
+ }
- // Step 3: Local Save with the complete data, including submission status.
- final String? localPath = await _localStorageService.saveTarballSamplingData(widget.data, serverName: serverName);
+ // --- Step 2: Attempt FTP Submission if configurations exist ---
+ if (ftpConfigs.isNotEmpty) {
+ debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing.");
- if (mounted) {
- if (localPath != null) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text("Submission log saved locally to: $localPath")),
+ final stationCode = dataForFtp.selectedStation?['tbl_station_code'] ?? 'NA';
+ final reportId = dataForFtp.reportId ?? DateTime.now().millisecondsSinceEpoch;
+ final baseFileName = '${stationCode}_$reportId';
+
+ try {
+ final Map jsonDataMap = {
+ 'db.json': jsonEncode(dataForFtp.toDbJson()),
+ 'basic_form.json': jsonEncode(dataForFtp.toBasicFormJson()),
+ 'reading.json': jsonEncode(dataForFtp.toReadingJson()),
+ 'manual_info.json': jsonEncode(dataForFtp.toManualInfoJson()),
+ };
+
+ final File? dataZip = await _zippingService.createDataZip(
+ jsonDataMap: jsonDataMap,
+ baseFileName: baseFileName,
);
- } else {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text("Warning: Could not save submission log locally."), backgroundColor: Colors.orange),
+ final File? imageZip = await _zippingService.createImageZip(
+ imageFiles: dataForFtp.toImageFiles().values.whereType().toList(),
+ baseFileName: baseFileName,
);
+
+ if (dataZip != null) {
+ await _retryService.addFtpToQueue(
+ localFilePath: dataZip.path,
+ remotePath: '/uploads/data/${p.basename(dataZip.path)}',
+ );
+ ftpSuccess = true;
+ }
+ if (imageZip != null) {
+ await _retryService.addFtpToQueue(
+ localFilePath: imageZip.path,
+ remotePath: '/uploads/images/${p.basename(imageZip.path)}',
+ );
+ ftpSuccess = true;
+ }
+ } catch (e) {
+ debugPrint("FTP zipping or queuing failed with an error: $e");
+ ftpSuccess = false;
}
}
+ // --- Step 3: Save the final status to the local log and handle UI feedback ---
+ if (!mounted) return;
+
+ if (apiSuccess && ftpSuccess) {
+ widget.data.submissionStatus = 'S4'; // Submitted API, Queued FTP
+ widget.data.submissionMessage = 'Data submitted and files are queued for FTP upload.';
+ } else if (apiSuccess) {
+ widget.data.submissionStatus = 'S3'; // Submitted API Only
+ widget.data.submissionMessage = 'Data submitted successfully to API. No FTP configured or FTP failed.';
+ } else if (ftpSuccess) {
+ widget.data.submissionStatus = 'L4'; // Failed API, Queued FTP
+ widget.data.submissionMessage = 'API submission failed but files were successfully queued for FTP.';
+ } else {
+ widget.data.submissionStatus = 'L1'; // All submissions failed
+ widget.data.submissionMessage = 'All submission attempts failed. Data saved locally for retry.';
+ }
+
+ // Always save the final status to the local log regardless of submission outcome
+ await _localStorageService.saveTarballSamplingData(widget.data, serverName: serverName);
+
setState(() => _isLoading = false);
- // Step 4: Handle UI feedback based on the final status
- final status = result['status'];
- final message = result['message'] ?? 'An unknown error occurred.';
+ final message = widget.data.submissionMessage ?? 'An unknown error occurred.';
+ final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
+ );
- debugPrint("Submission final status: $status. Report ID: ${widget.data.reportId}. Message: $message");
-
- if (status == 'L3') {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(message), backgroundColor: Colors.green),
- );
- Navigator.of(context).popUntil((route) => route.isFirst);
- } else { // L1 or L2 failures
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(message), backgroundColor: (status == 'L2' ? Colors.orange : Colors.red), duration: const Duration(seconds: 5)),
- );
- }
+ Navigator.of(context).popUntil((route) => route.isFirst);
}
@override
diff --git a/lib/screens/river/manual/in_situ_sampling.dart b/lib/screens/river/manual/in_situ_sampling.dart
index 313b25e..947a994 100644
--- a/lib/screens/river/manual/in_situ_sampling.dart
+++ b/lib/screens/river/manual/in_situ_sampling.dart
@@ -3,13 +3,19 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
-import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import for AuthProvider
+import 'package:environment_monitoring_app/auth_provider.dart';
+import 'dart:convert';
+import 'dart:io';
+import 'package:path/path.dart' as p;
import '../../../models/river_in_situ_sampling_data.dart';
import '../../../services/river_in_situ_sampling_service.dart';
import '../../../services/local_storage_service.dart';
// --- ADDED: Import to get the active server configuration name ---
import '../../../services/server_config_service.dart';
+// --- ADDED: Imports for zipping and retry queue logic ---
+import '../../../services/zipping_service.dart';
+import '../../../services/retry_service.dart';
import 'widgets/river_in_situ_step_1_sampling_info.dart';
import 'widgets/river_in_situ_step_2_site_info.dart';
import 'widgets/river_in_situ_step_3_data_capture.dart';
@@ -33,6 +39,9 @@ class _RiverInSituSamplingScreenState extends State {
final LocalStorageService _localStorageService = LocalStorageService();
// --- ADDED: Service to get the active server configuration ---
final ServerConfigService _serverConfigService = ServerConfigService();
+ // --- ADDED: Services for zipping and queueing ---
+ final ZippingService _zippingService = ZippingService();
+ final RetryService _retryService = RetryService();
int _currentPage = 0;
bool _isLoading = false;
@@ -71,43 +80,112 @@ class _RiverInSituSamplingScreenState extends State {
}
}
- // MODIFIED: This method now fetches appSettings and passes it to the service.
+ // --- REPLACED: _submitForm() method with the new workflow ---
Future _submitForm() async {
setState(() => _isLoading = true);
- // Get the appSettings list from AuthProvider.
final authProvider = Provider.of(context, listen: false);
final appSettings = authProvider.appSettings;
+ final activeApiConfig = await _serverConfigService.getActiveApiConfig();
+ final serverName = activeApiConfig?['config_name'] as String? ?? 'Default';
- // Pass the appSettings list to the submitData method.
- final result = await _samplingService.submitData(_data, appSettings);
+ // A copy of the data is made to avoid modifying the original during the FTP process.
+ final dataForFtp = RiverInSituSamplingData.fromJson(_data.toApiFormData());
+ bool apiSuccess = false;
+ bool ftpSuccess = false;
+
+ // --- Path A: Attempt API Submission ---
+ debugPrint("Step 1: Attempting API submission...");
+ final apiResult = await _samplingService.submitData(_data, appSettings);
+
+ if (apiResult['success'] == true) {
+ apiSuccess = true;
+ _data.submissionStatus = apiResult['status'];
+ _data.submissionMessage = apiResult['message'];
+ _data.reportId = apiResult['reportId']?.toString();
+ debugPrint("API submission successful.");
+ } else {
+ _data.submissionStatus = apiResult['status'];
+ _data.submissionMessage = apiResult['message'];
+ _data.reportId = null;
+ debugPrint("API submission failed. Reason: ${apiResult['message']}");
+ }
+
+ // --- Path B: Attempt FTP Submission if configurations exist ---
+ final activeFtpConfig = await _serverConfigService.getActiveFtpConfig();
+ if (activeFtpConfig != null) {
+ debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing.");
+ final stationCode = _data.selectedStation?['sampling_station_code'] ?? 'NA';
+ final reportId = _data.reportId ?? DateTime.now().millisecondsSinceEpoch;
+ final baseFileName = '${stationCode}_$reportId';
+
+ try {
+ // --- Create a separate folder for FTP data to avoid conflicts ---
+ final ftpDir = await _localStorageService.getRiverInSituBaseDir(dataForFtp.samplingType, serverName: '${serverName}_ftp');
+ if (ftpDir != null) {
+ final dataForFtpJson = dataForFtp.toApiFormData(); // This is the data used for the ZIP files
+ final File? dataZip = await _zippingService.createDataZip(
+ jsonDataMap: {
+ 'river_insitu_basic_form.json': jsonEncode(dataForFtpJson),
+ // Add other JSON files if necessary
+ },
+ baseFileName: baseFileName,
+ );
+
+ final File? imageZip = await _zippingService.createImageZip(
+ imageFiles: dataForFtp.toApiImageFiles().values.whereType().toList(),
+ baseFileName: baseFileName,
+ );
+
+ if (dataZip != null) {
+ await _retryService.addFtpToQueue(
+ localFilePath: dataZip.path,
+ remotePath: '/uploads/data/${p.basename(dataZip.path)}',
+ );
+ ftpSuccess = true; // Mark as successful if at least one file is queued
+ }
+ if (imageZip != null) {
+ await _retryService.addFtpToQueue(
+ localFilePath: imageZip.path,
+ remotePath: '/uploads/images/${p.basename(imageZip.path)}',
+ );
+ ftpSuccess = true;
+ }
+ }
+ } catch (e) {
+ debugPrint("FTP zipping or queuing failed with an error: $e");
+ ftpSuccess = false;
+ }
+ }
+
+ // --- Final Status Update and Navigation ---
if (!mounted) return;
- _data.submissionStatus = result['status'];
- _data.submissionMessage = result['message'];
- _data.reportId = result['reportId']?.toString();
+ if (apiSuccess && ftpSuccess) {
+ _data.submissionStatus = 'S4'; // Submitted API, Queued FTP
+ _data.submissionMessage = 'Data submitted and files are queued for FTP upload.';
+ } else if (apiSuccess) {
+ _data.submissionStatus = 'S3'; // Submitted API Only
+ _data.submissionMessage = 'Data submitted successfully to API. No FTP configured or FTP failed.';
+ } else if (ftpSuccess) {
+ _data.submissionStatus = 'L4'; // Failed API, Queued FTP
+ _data.submissionMessage = 'API submission failed but files were successfully queued for FTP.';
+ } else {
+ _data.submissionStatus = 'L1'; // All submissions failed
+ _data.submissionMessage = 'All submission attempts failed. Data saved locally for retry.';
+ }
- // --- MODIFIED: Get the active server name before saving the local log ---
- final activeConfig = await _serverConfigService.getActiveApiConfig();
- final serverName = activeConfig?['config_name'] as String? ?? 'Default';
-
- // --- MODIFIED: Pass the serverName to the save method ---
await _localStorageService.saveRiverInSituSamplingData(_data, serverName: serverName);
setState(() => _isLoading = false);
- final message = result['message'] ?? 'An unknown error occurred.';
- final color = (result['status'] == 'L3')
- ? Colors.green
- : (result['status'] == 'L2' ? Colors.orange : Colors.red);
-
+ final message = _data.submissionMessage ?? 'An unknown error occurred.';
+ final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
);
- // ✅ CORRECTED: The navigation now happens regardless of the submission status.
- // This ensures the form closes even after a failed (offline) submission.
Navigator.of(context).popUntil((route) => route.isFirst);
}
diff --git a/lib/serial/serial_manager.dart b/lib/serial/serial_manager.dart
index 024e62a..8227728 100644
--- a/lib/serial/serial_manager.dart
+++ b/lib/serial/serial_manager.dart
@@ -24,6 +24,9 @@ class SerialManager {
// This will be updated when the Sonde ID is parsed from Level 0 data.
final ValueNotifier sondeId = ValueNotifier(null);
+ // --- ADDED: Flag to prevent updates after disposal ---
+ bool _isDisposed = false;
+
// StreamController to broadcast parsed data readings to multiple listeners.
final StreamController