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> _dataStreamController = StreamController>.broadcast(); @@ -62,6 +65,7 @@ class SerialManager { } try { + _isDisposed = false; // Reset disposed flag on new connection connectionState.value = SerialConnectionState.connecting; connectedDeviceName.value = device.productName ?? 'Unknown Device'; // Update device name early @@ -109,7 +113,11 @@ class SerialManager { }, ); - connectionState.value = SerialConnectionState.connected; + // --- REVISED: Added null check before updating state --- + if (!_isDisposed) { + connectionState.value = SerialConnectionState.connected; + connectedDeviceName.value = device.productName; + } debugPrint("SerialManager: Connected to ${connectedDeviceName.value}"); // Start the live reading cycle immediately after connecting @@ -118,9 +126,11 @@ class SerialManager { } catch (e) { debugPrint("SerialManager: Connection Error: $e"); // Ensure all states are reset on error during connection - connectionState.value = SerialConnectionState.disconnected; - connectedDeviceName.value = null; - sondeId.value = null; // Clear Sonde ID + if (!_isDisposed) { + connectionState.value = SerialConnectionState.disconnected; + connectedDeviceName.value = null; + sondeId.value = null; // Clear Sonde ID + } _port = null; _responseBuffer.clear(); _isReading = false; @@ -278,6 +288,12 @@ class SerialManager { /// Dispatches the complete and validated response string to the appropriate handler /// based on the current communication level. void _handleResponse(String response) { + // --- REVISED: Added null check before dispatching --- + if (_isDisposed) { + debugPrint("SerialManager: Ignoring response on disposed manager."); + return; + } + switch (_communicationLevel) { case 0: _handleResponseLevel0(response); @@ -326,11 +342,14 @@ class SerialManager { debugPrint("SerialManager: Parsed L0 -> Address: $_parentAddress, Serial: $_serialNumber"); // --- Update sondeId ValueNotifier here --- - if (_serialNumber != null && _serialNumber!.isNotEmpty) { - sondeId.value = _serialNumber; - debugPrint("SerialManager: Updated Sonde ID: ${sondeId.value}"); - } else { - sondeId.value = "N/A"; // Or handle empty/null serial number appropriately + // --- REVISED: Added null check before updating ValueNotifier --- + if (!_isDisposed) { + if (_serialNumber != null && _serialNumber!.isNotEmpty) { + sondeId.value = _serialNumber; + debugPrint("SerialManager: Updated Sonde ID: ${sondeId.value}"); + } else { + sondeId.value = "N/A"; // Or handle empty/null serial number appropriately + } } @@ -420,7 +439,10 @@ class SerialManager { // Ensure the number of parsed parameters matches the number of parsed values. if (_parameterList.length == values.length) { Map finalReadings = Map.fromIterables(_parameterList, values); - _dataStreamController.add(finalReadings); // Broadcast the final parsed readings + // --- REVISED: Added null check before adding to the stream controller --- + if (!_isDisposed) { + _dataStreamController.add(finalReadings); // Broadcast the final parsed readings + } debugPrint("SerialManager: Final Parsed Readings: $finalReadings"); } else { debugPrint("SerialManager: L2 Data Mismatch: ${values.length} values for ${_parameterList.length} parameters. Parameter list: $_parameterList, Values: $values"); @@ -509,10 +531,7 @@ class SerialManager { /// Cleans up all resources held by the SerialManager when it's no longer needed. void dispose() { debugPrint("SerialManager: Disposing."); - // No need to await disconnect here, as dispose shouldn't necessarily block - // on a full disconnection completion before proceeding with other disposals. - // However, if you *must* ensure full disconnection before other disposals, - // you could add `await disconnect();`. For now, a fire-and-forget call is fine. + _isDisposed = true; // Set the flag immediately disconnect(); // Ensure full disconnection and cleanup _dataStreamController.close(); // Close the data stream controller connectionState.dispose(); // Dispose the ValueNotifier diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 6e4d95a..2c114ef 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -7,8 +7,12 @@ import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:intl/intl.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart'; +import 'package:environment_monitoring_app/services/telegram_service.dart'; +import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; +import 'package:environment_monitoring_app/models/tarball_data.dart'; // ======================================================================= // Part 1: Unified API Service @@ -194,7 +198,7 @@ class ApiService { } // ======================================================================= -// Part 2: Feature-Specific API Services (Unchanged) +// Part 2: Feature-Specific API Services (Refactored to include Telegram) // ======================================================================= class AirApiService { @@ -230,17 +234,20 @@ class AirApiService { class MarineApiService { -// ... (No changes needed here) +// --- ADDED: TelegramService instance --- final BaseApiService _baseService; + final TelegramService _telegramService = TelegramService(); MarineApiService(this._baseService); Future> getTarballStations() => _baseService.get('marine/tarball/stations'); Future> getManualStations() => _baseService.get('marine/manual/stations'); Future> getTarballClassifications() => _baseService.get('marine/tarball/classifications'); + // --- REVISED: Now includes appSettings parameter and triggers Telegram alert --- Future> submitTarballSample({ required Map formData, required Map imageFiles, + required List>? appSettings, }) async { final dataResult = await _baseService.post('marine/tarball/sample', formData); if (dataResult['success'] != true) return {'status': 'L1', 'success': false, 'message': 'Failed to submit data: ${dataResult['message']}'}; @@ -251,13 +258,68 @@ class MarineApiService { final filesToUpload = {}; imageFiles.forEach((key, value) { if (value != null) filesToUpload[key] = value; }); - if (filesToUpload.isEmpty) return {'status': 'L3', 'success': true, 'message': 'Data submitted successfully.', 'reportId': recordId}; + if (filesToUpload.isEmpty) { + _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true); + return {'status': 'L3', 'success': true, 'message': 'Data submitted successfully.', 'reportId': recordId}; + } final imageResult = await _baseService.postMultipart(endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload); - if (imageResult['success'] != true) return {'status': 'L2', 'success': false, 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', 'reportId': recordId}; + if (imageResult['success'] != true) { + // Still send the alert for data submission even if images fail + _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true); + return {'status': 'L2', 'success': false, 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', 'reportId': recordId}; + } + // On complete success, send the full alert + _handleTarballSuccessAlert(formData, appSettings, isDataOnly: false); return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId}; } + + // --- ADDED: Helper method for Telegram alerts --- + Future _handleTarballSuccessAlert(Map formData, List>? appSettings, {required bool isDataOnly}) async { + debugPrint("Triggering Telegram alert logic..."); + try { + final message = _generateTarballAlertMessage(formData, isDataOnly: isDataOnly); + final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message, appSettings); + if (!wasSent) { + await _telegramService.queueMessage('marine_tarball', message, appSettings); + } + } catch (e) { + debugPrint("Failed to handle Tarball Telegram alert: $e"); + } + } + + // --- ADDED: Helper method to generate the Telegram message --- + String _generateTarballAlertMessage(Map formData, {required bool isDataOnly}) { + final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; + final stationName = formData['tbl_station_name'] ?? 'N/A'; + final stationCode = formData['tbl_station_code'] ?? 'N/A'; + final classification = formData['classification_name'] ?? formData['classification_id'] ?? 'N/A'; + + final buffer = StringBuffer() + ..writeln('✅ *Tarball Sample $submissionType Submitted:*') + ..writeln() + ..writeln('*Station Name & Code:* $stationName ($stationCode)') + ..writeln('*Date of Submission:* ${formData['sampling_date']}') + ..writeln('*Submitted by User:* ${formData['first_sampler_name'] ?? 'N/A'}') + ..writeln('*Classification:* $classification') + ..writeln('*Status of Submission:* Successful'); + + if (formData['distance_difference'] != null && + double.tryParse(formData['distance_difference']!) != null && + double.parse(formData['distance_difference']!) > 0) { + buffer + ..writeln() + ..writeln('🔔 *Alert:*') + ..writeln('*Distance from station:* ${(double.parse(formData['distance_difference']!) * 1000).toStringAsFixed(0)} meters'); + + if (formData['distance_difference_remarks'] != null && formData['distance_difference_remarks']!.isNotEmpty) { + buffer.writeln('*Remarks for distance:* ${formData['distance_difference_remarks']}'); + } + } + + return buffer.toString(); + } } class RiverApiService { @@ -517,4 +579,4 @@ class DatabaseHelper { final db = await database; await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]); } -} \ No newline at end of file +} diff --git a/lib/services/base_api_service.dart b/lib/services/base_api_service.dart index c4beec0..3679f28 100644 --- a/lib/services/base_api_service.dart +++ b/lib/services/base_api_service.dart @@ -1,51 +1,43 @@ +// lib/services/base_api_service.dart + import 'dart:convert'; import 'dart:io'; -import 'package:async/async.dart'; // Used for TimeoutException +import 'package:async/async.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:path/path.dart' as path; import 'package:environment_monitoring_app/auth_provider.dart'; -// --- ADDED: Import for the service that manages active server configurations --- import 'package:environment_monitoring_app/services/server_config_service.dart'; -// --- ADDED: Import for the new service that manages the retry queue --- import 'package:environment_monitoring_app/services/retry_service.dart'; +import 'package:environment_monitoring_app/services/api_service.dart'; class BaseApiService { - // --- ADDED: An instance of the service to get the active URL dynamically --- final ServerConfigService _serverConfigService = ServerConfigService(); + final DatabaseHelper _dbHelper = DatabaseHelper(); // --- ADDED: Instance of DatabaseHelper to get all configs --- - // --- REMOVED: This creates an infinite loop with RetryService --- - // final RetryService _retryService = RetryService(); - - - // Private helper to construct headers with the auth token Future> _getHeaders() async { final prefs = await SharedPreferences.getInstance(); final String? token = prefs.getString(AuthProvider.tokenKey); - // For multipart requests, 'Content-Type' is set by the http client. - // We only need Authorization here. return { if (token != null) 'Authorization': 'Bearer $token', }; } - // Private helper for JSON headers Future> _getJsonHeaders() async { final headers = await _getHeaders(); headers['Content-Type'] = 'application/json'; return headers; } - // Generic GET request handler + // Generic GET request handler (remains unchanged) Future> get(String endpoint) async { try { - // --- MODIFIED: Fetches the active base URL before making the request --- final baseUrl = await _serverConfigService.getActiveApiUrl(); final url = Uri.parse('$baseUrl/$endpoint'); final response = await http.get(url, headers: await _getJsonHeaders()) - .timeout(const Duration(seconds: 60)); // --- MODIFIED: Added 60 second timeout --- + .timeout(const Duration(seconds: 60)); return _handleResponse(response); } catch (e) { debugPrint('GET request failed: $e'); @@ -53,111 +45,187 @@ class BaseApiService { } } - // Generic POST request handler for JSON data + // --- MODIFIED: Generic POST request handler now attempts multiple servers --- Future> post(String endpoint, Map body) async { - try { - // --- MODIFIED: Fetches the active base URL before making the request --- + final configs = await _dbHelper.loadApiConfigs() ?? []; // Get all API configs + + // --- ADDED: Handle case where local configs are empty --- + if (configs.isEmpty) { + debugPrint('No local API configs found. Attempting to use default bootstrap URL.'); final baseUrl = await _serverConfigService.getActiveApiUrl(); - final url = Uri.parse('$baseUrl/$endpoint'); - final response = await http.post( - url, - headers: await _getJsonHeaders(), - body: jsonEncode(body), - ).timeout(const Duration(seconds: 60)); // --- MODIFIED: Added 60 second timeout --- - return _handleResponse(response); - } catch (e) { - // --- MODIFIED: Create a local instance of RetryService to break the circular dependency --- - final retryService = RetryService(); - debugPrint('POST request to $endpoint failed, queueing for retry. Error: $e'); - await retryService.addApiToQueue( - endpoint: endpoint, - method: 'POST', - body: body, - ); - return {'success': false, 'message': 'Request failed and has been queued for manual retry.'}; + try { + final url = Uri.parse('$baseUrl/$endpoint'); + debugPrint('Attempting POST to: $url'); + final response = await http.post( + url, + headers: await _getJsonHeaders(), + body: jsonEncode(body), + ).timeout(const Duration(seconds: 60)); + return _handleResponse(response); + } catch (e) { + debugPrint('POST to default URL failed. Error: $e'); + return {'success': false, 'message': 'API connection failed. Request has been queued for manual retry.'}; + } } + + // If configs exist, try them (up to the two latest) + final latestConfigs = configs.take(2).toList(); + debugPrint('Debug: Loaded API configs: $latestConfigs'); + + for (final config in latestConfigs) { + debugPrint('Debug: Current config item: $config (Type: ${config.runtimeType})'); + + // --- REVISED: The null check logic is now more specific --- + if (config == null || config['api_url'] == null) { + debugPrint('Skipping null or invalid API configuration.'); + continue; + } + try { + final baseUrl = config['api_url']; + final url = Uri.parse('$baseUrl/$endpoint'); + debugPrint('Attempting POST to: $url'); + final response = await http.post( + url, + headers: await _getJsonHeaders(), + body: jsonEncode(body), + ).timeout(const Duration(seconds: 60)); + + final result = _handleResponse(response); + if (result['success'] == true) { + debugPrint('POST to $baseUrl succeeded.'); + return result; + } else { + debugPrint('POST to $baseUrl failed with an API error. Trying next server if available. Error: ${result['message']}'); + } + } catch (e) { + debugPrint('POST to this server failed. Trying next server if available. Error: $e'); + } + } + + // If all attempts fail, queue for manual retry + final retryService = RetryService(); + await retryService.addApiToQueue( + endpoint: endpoint, + method: 'POST', + body: body, + ); + return {'success': false, 'message': 'All API attempts failed. Request has been queued for manual retry.'}; } - // Generic handler for multipart (file upload) requests + // --- MODIFIED: Generic multipart handler now attempts multiple servers --- Future> postMultipart({ required String endpoint, required Map fields, required Map files, }) async { - try { - // --- MODIFIED: Fetches the active base URL before making the request --- + final configs = await _dbHelper.loadApiConfigs() ?? []; // Get all API configs + + // --- ADDED: Handle case where local configs are empty --- + if (configs.isEmpty) { + debugPrint('No local API configs found. Attempting to use default bootstrap URL.'); final baseUrl = await _serverConfigService.getActiveApiUrl(); - final url = Uri.parse('$baseUrl/$endpoint'); - debugPrint('Starting multipart upload to: $url'); - - var request = http.MultipartRequest('POST', url); - - // Get and add headers (Authorization token) - final headers = await _getHeaders(); - request.headers.addAll(headers); - debugPrint('Headers added to multipart request.'); - - // --- CORRECTED --- - // This adds each field directly to the request body, which is the standard - // for multipart/form-data and matches what the PHP backend expects. - if (fields.isNotEmpty) { - request.fields.addAll(fields); - debugPrint('Fields added directly to multipart request: $fields'); + try { + final url = Uri.parse('$baseUrl/$endpoint'); + debugPrint('Attempting multipart upload to: $url'); + var request = http.MultipartRequest('POST', url); + final headers = await _getHeaders(); + request.headers.addAll(headers); + if (fields.isNotEmpty) { + request.fields.addAll(fields); + } + for (var entry in files.entries) { + if (await entry.value.exists()) { + request.files.add(await http.MultipartFile.fromPath( + entry.key, + entry.value.path, + filename: path.basename(entry.value.path), + )); + } + } + var streamedResponse = await request.send().timeout(const Duration(seconds: 60)); + final responseBody = await streamedResponse.stream.bytesToString(); + return _handleResponse(http.Response(responseBody, streamedResponse.statusCode)); + } catch (e) { + debugPrint('Multipart upload to default URL failed. Error: $e'); + return {'success': false, 'message': 'API connection failed. Upload has been queued for manual retry.'}; } - - // Add files - for (var entry in files.entries) { - debugPrint('Adding file: ${entry.key}, path: ${entry.value.path}'); - request.files.add(await http.MultipartFile.fromPath( - entry.key, - entry.value.path, - filename: path.basename(entry.value.path), - )); - } - debugPrint('${files.length} files added to the request.'); - - debugPrint('Sending multipart request...'); - // --- MODIFIED: Added 60 second timeout --- - var streamedResponse = await request.send().timeout(const Duration(seconds: 60)); - debugPrint('Received response with status code: ${streamedResponse.statusCode}'); - - final responseBody = await streamedResponse.stream.bytesToString(); - // We create a standard http.Response to use our standard handler - return _handleResponse(http.Response(responseBody, streamedResponse.statusCode)); - - } catch (e, s) { // Catching both Exception and Error (e.g., OutOfMemoryError) - // --- MODIFIED: Create a local instance of RetryService to break the circular dependency --- - final retryService = RetryService(); - debugPrint('Multipart request to $endpoint failed, queueing for retry. Error: $e'); - debugPrint('Stack trace: $s'); - await retryService.addApiToQueue( - endpoint: endpoint, - method: 'POST_MULTIPART', - fields: fields, - files: files, - ); - return {'success': false, 'message': 'Upload failed and has been queued for manual retry.'}; } + + final latestConfigs = configs.take(2).toList(); // Limit to the two latest configs + debugPrint('Debug: Loaded API configs: $latestConfigs'); + + for (final config in latestConfigs) { + debugPrint('Debug: Current config item: $config (Type: ${config.runtimeType})'); + + // --- REVISED: The null check logic is now more specific --- + if (config == null || config['api_url'] == null) { + debugPrint('Skipping null or invalid API configuration.'); + continue; + } + try { + final baseUrl = config['api_url']; + final url = Uri.parse('$baseUrl/$endpoint'); + debugPrint('Attempting multipart upload to: $url'); + + var request = http.MultipartRequest('POST', url); + final headers = await _getHeaders(); + request.headers.addAll(headers); + if (fields.isNotEmpty) { + request.fields.addAll(fields); + } + for (var entry in files.entries) { + if (await entry.value.exists()) { // Check if the file exists before adding + request.files.add(await http.MultipartFile.fromPath( + entry.key, + entry.value.path, + filename: path.basename(entry.value.path), + )); + } else { + debugPrint('File does not exist: ${entry.value.path}. Skipping this file.'); + } + } + + var streamedResponse = await request.send().timeout(const Duration(seconds: 60)); + final responseBody = await streamedResponse.stream.bytesToString(); + final result = _handleResponse(http.Response(responseBody, streamedResponse.statusCode)); + + if (result['success'] == true) { + debugPrint('Multipart upload to $baseUrl succeeded.'); + return result; + } else { + debugPrint('Multipart upload to $baseUrl failed with an API error. Trying next server if available. Error: ${result['message']}'); + } + } catch (e, s) { + debugPrint('Multipart upload to this server failed. Trying next server if available. Error: $e'); + debugPrint('Stack trace: $s'); + } + } + + // If all attempts fail, queue for manual retry + final retryService = RetryService(); + await retryService.addApiToQueue( + endpoint: endpoint, + method: 'POST_MULTIPART', + fields: fields, + files: files, + ); + return {'success': false, 'message': 'All API attempts failed. Upload has been queued for manual retry.'}; } - // Centralized response handling Map _handleResponse(http.Response response) { debugPrint('Handling response. Status: ${response.statusCode}, Body: ${response.body}'); try { final Map responseData = jsonDecode(response.body); if (response.statusCode >= 200 && response.statusCode < 300) { - // Assuming the API returns a 'status' field in the JSON body if (responseData['status'] == 'success' || responseData['success'] == true) { return {'success': true, 'data': responseData['data'], 'message': responseData['message']}; } else { return {'success': false, 'message': responseData['message'] ?? 'An unknown API error occurred.'}; } } else { - // Handle server errors (4xx, 5xx) return {'success': false, 'message': responseData['message'] ?? 'Server error: ${response.statusCode}'}; } } catch (e) { - // Handle cases where the response body is not valid JSON debugPrint('Failed to parse server response: $e'); return {'success': false, 'message': 'Failed to parse server response. Body: ${response.body}'}; } diff --git a/lib/services/ftp_service.dart b/lib/services/ftp_service.dart index 288251f..0a99bcc 100644 --- a/lib/services/ftp_service.dart +++ b/lib/services/ftp_service.dart @@ -6,74 +6,89 @@ import 'package:ftpconnect/ftpconnect.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart'; // --- ADDED: Import for the new service that manages the retry queue --- import 'package:environment_monitoring_app/services/retry_service.dart'; +// --- ADDED: Import for the local database helper --- +import 'package:environment_monitoring_app/services/api_service.dart'; class FtpService { final ServerConfigService _serverConfigService = ServerConfigService(); // --- REMOVED: This creates an infinite loop with RetryService --- // final RetryService _retryService = RetryService(); + final DatabaseHelper _dbHelper = DatabaseHelper(); // --- ADDED: Instance of DatabaseHelper to get all configs --- /// Uploads a single file to the active server's FTP. /// /// [fileToUpload] The local file to be uploaded. /// [remotePath] The destination path on the FTP server (e.g., '/uploads/images/'). /// Returns a map with 'success' and 'message' keys. + // --- MODIFIED: Method now attempts to upload to multiple servers --- Future> uploadFile(File fileToUpload, String remotePath) async { - final config = await _serverConfigService.getActiveFtpConfig(); + final configs = await _dbHelper.loadFtpConfigs() ?? []; // Get all FTP configs + final latestConfigs = configs.take(2).toList(); // Limit to the two latest configs - if (config == null || config['ftp_host'] == null || config['ftp_user'] == null || config['ftp_pass'] == null) { + if (latestConfigs.isEmpty) { return {'success': false, 'message': 'FTP credentials are not configured or selected.'}; } - final ftpHost = config['ftp_host'] as String; - final ftpUser = config['ftp_user'] as String; - final ftpPass = config['ftp_pass'] as String; - final ftpPort = config['ftp_port'] as int? ?? 21; // Default to port 21 + // Loop through each of the two latest configurations and attempt to upload + for (final configMap in latestConfigs) { + final config = configMap['config_json']; // The data is nested under this key + final ftpHost = config?['ftp_host'] as String?; + final ftpUser = config?['ftp_user'] as String?; + final ftpPass = config?['ftp_pass'] as String?; + final ftpPort = config?['ftp_port'] as int? ?? 21; - final ftpConnect = FTPConnect( - ftpHost, - user: ftpUser, - pass: ftpPass, - port: ftpPort, - showLog: kDebugMode, // Show logs only in debug mode - timeout: 60, // --- MODIFIED: Set the timeout to 60 seconds --- - ); - - try { - debugPrint('FTP: Connecting to $ftpHost...'); - await ftpConnect.connect(); - - debugPrint('FTP: Uploading file ${fileToUpload.path} to $remotePath...'); - bool res = await ftpConnect.uploadFileWithRetry( - fileToUpload, - pRemoteName: remotePath, - pRetryCount: 3, // --- MODIFIED: Retry three times on failure --- - ); - - if (res) { - return {'success': true, 'message': 'File uploaded successfully via FTP.'}; - } else { - // --- MODIFIED: Create a local instance of RetryService to break the circular dependency --- - final retryService = RetryService(); - debugPrint('FTP upload for ${fileToUpload.path} failed after retries, queueing.'); - await retryService.addFtpToQueue( - localFilePath: fileToUpload.path, - remotePath: remotePath - ); - return {'success': false, 'message': 'FTP upload failed and has been queued for manual retry.'}; + if (ftpHost == null || ftpUser == null || ftpPass == null) { + debugPrint('FTP: Configuration is incomplete. Skipping to next server if available.'); + continue; } - } catch (e) { - // --- MODIFIED: Create a local instance of RetryService to break the circular dependency --- - final retryService = RetryService(); - debugPrint('FTP upload for ${fileToUpload.path} failed with an exception, queueing. Error: $e'); - await retryService.addFtpToQueue( - localFilePath: fileToUpload.path, - remotePath: remotePath + + final ftpConnect = FTPConnect( + ftpHost, + user: ftpUser, + pass: ftpPass, + port: ftpPort, + showLog: kDebugMode, // Show logs only in debug mode + timeout: 60, // --- MODIFIED: Set the timeout to 60 seconds --- ); - return {'success': false, 'message': 'FTP upload failed and has been queued for manual retry.'}; - } finally { - // Always ensure disconnection, even if an error occurs. - debugPrint('FTP: Disconnecting...'); - await ftpConnect.disconnect(); + + try { + debugPrint('FTP: Connecting to $ftpHost...'); + await ftpConnect.connect(); + + debugPrint('FTP: Uploading file ${fileToUpload.path} to $remotePath...'); + bool res = await ftpConnect.uploadFileWithRetry( + fileToUpload, + pRemoteName: remotePath, + pRetryCount: 3, // --- MODIFIED: Retry three times on failure --- + ); + + await ftpConnect.disconnect(); // Disconnect immediately upon success + if (res) { + debugPrint('FTP upload to $ftpHost succeeded.'); + return {'success': true, 'message': 'File uploaded successfully via FTP.'}; + } else { + debugPrint('FTP upload to $ftpHost failed after retries. Trying next server.'); + continue; // Move to the next configuration in the loop + } + } catch (e) { + debugPrint('FTP upload to $ftpHost failed with an exception. Trying next server. Error: $e'); + try { + // Attempt to disconnect even if an error occurred during connect/upload + await ftpConnect.disconnect(); + } catch (_) { + // Ignore errors during disconnect + } + continue; // Move to the next configuration in the loop + } } + + // If the loop completes and no server was successful, queue for manual retry. + final retryService = RetryService(); + debugPrint('All FTP upload attempts failed. Queueing for manual retry.'); + await retryService.addFtpToQueue( + localFilePath: fileToUpload.path, + remotePath: remotePath + ); + return {'success': false, 'message': 'All FTP upload attempts failed and have been queued for manual retry.'}; } } \ No newline at end of file diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index 16e705f..fea8e4a 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -283,7 +283,8 @@ class LocalStorageService { // Part 4: Marine In-Situ Specific Methods // ======================================================================= - Future _getInSituBaseDir({required String serverName}) async { + // --- MODIFIED: Removed leading underscore to make the method public --- + Future getInSituBaseDir({required String serverName}) async { final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); if (mmsv4Dir == null) return null; @@ -295,7 +296,7 @@ class LocalStorageService { } Future saveInSituSamplingData(InSituSamplingData data, {required String serverName}) async { - final baseDir = await _getInSituBaseDir(serverName: serverName); + final baseDir = await getInSituBaseDir(serverName: serverName); if (baseDir == null) { debugPrint("Could not get public storage directory for In-Situ. Check permissions."); return null; @@ -390,7 +391,7 @@ class LocalStorageService { // Part 5: River In-Situ Specific Methods // ======================================================================= - Future _getRiverInSituBaseDir(String? samplingType, {required String serverName}) async { + Future getRiverInSituBaseDir(String? samplingType, {required String serverName}) async { final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); if (mmsv4Dir == null) return null; @@ -409,7 +410,7 @@ class LocalStorageService { } Future saveRiverInSituSamplingData(RiverInSituSamplingData data, {required String serverName}) async { - final baseDir = await _getRiverInSituBaseDir(data.samplingType, serverName: serverName); + final baseDir = await getRiverInSituBaseDir(data.samplingType, serverName: serverName); if (baseDir == null) { debugPrint("Could not get public storage directory for River In-Situ. Check permissions."); return null; diff --git a/lib/services/marine_api_service.dart b/lib/services/marine_api_service.dart index b1bd074..e0c206f 100644 --- a/lib/services/marine_api_service.dart +++ b/lib/services/marine_api_service.dart @@ -25,16 +25,17 @@ class MarineApiService { return _baseService.get('marine/tarball/classifications'); } - // MODIFIED: Method now requires the appSettings list. + // --- MODIFIED: Added appSettings to the method signature --- Future> submitTarballSample({ required Map formData, required Map imageFiles, - required List>? appSettings, + required List>? appSettings, // ADDED: New required parameter }) async { debugPrint("Step 1: Submitting tarball form data to the server..."); final dataResult = await _baseService.post('marine/tarball/sample', formData); if (dataResult['success'] != true) { + debugPrint("API submission failed for Tarball. Message: ${dataResult['message']}"); return { 'status': 'L1', 'success': false, @@ -42,10 +43,11 @@ class MarineApiService { 'reportId': null, }; } - debugPrint("Step 1 successful. Tarball data submitted."); + debugPrint("Step 1 successful. Tarball data submitted. Report ID: ${dataResult['data']?['autoid']}"); final recordId = dataResult['data']?['autoid']; if (recordId == null) { + debugPrint("API submitted, but no record ID returned."); return { 'status': 'L2', 'success': false, @@ -60,6 +62,7 @@ class MarineApiService { }); if (filesToUpload.isEmpty) { + debugPrint("No images to upload. Finalizing submission."); _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true); return { 'status': 'L3', @@ -77,6 +80,7 @@ class MarineApiService { ); if (imageResult['success'] != true) { + debugPrint("Image upload failed for Tarball. Message: ${imageResult['message']}"); return { 'status': 'L2', 'success': false, @@ -85,6 +89,7 @@ class MarineApiService { }; } + debugPrint("Step 2 successful. All images uploaded."); _handleTarballSuccessAlert(formData, appSettings, isDataOnly: false); return { 'status': 'L3', @@ -105,6 +110,7 @@ class MarineApiService { final dataResult = await _baseService.post('marine/manual/sample', formData); if (dataResult['success'] != true) { + debugPrint("API submission failed for In-Situ. Message: ${dataResult['message']}"); return { 'status': 'L1', 'success': false, @@ -112,10 +118,11 @@ class MarineApiService { 'reportId': null, }; } - debugPrint("Step 1 successful. In-situ data submitted."); + debugPrint("Step 1 successful. In-situ data submitted. Report ID: ${dataResult['data']?['man_id']}"); final recordId = dataResult['data']?['man_id']; if (recordId == null) { + debugPrint("API submitted, but no record ID returned."); return { 'status': 'L2', 'success': false, @@ -130,6 +137,7 @@ class MarineApiService { }); if (filesToUpload.isEmpty) { + debugPrint("No images to upload. Finalizing submission."); _handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: true); return { 'status': 'L3', @@ -147,6 +155,7 @@ class MarineApiService { ); if (imageResult['success'] != true) { + debugPrint("Image upload failed for In-Situ. Message: ${imageResult['message']}"); return { 'status': 'L2', 'success': false, @@ -155,11 +164,12 @@ class MarineApiService { }; } + debugPrint("Step 2 successful. All images uploaded."); _handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: false); return { 'status': 'L3', 'success': true, - 'message': 'In-situ data and images submitted successfully.', + 'message': 'Data and images submitted to server successfully.', 'reportId': recordId.toString(), }; } diff --git a/lib/services/server_config_service.dart b/lib/services/server_config_service.dart index 2867770..9e52097 100644 --- a/lib/services/server_config_service.dart +++ b/lib/services/server_config_service.dart @@ -52,4 +52,4 @@ class ServerConfigService { } return null; } -} \ No newline at end of file +} diff --git a/lib/services/zipping_service.dart b/lib/services/zipping_service.dart new file mode 100644 index 0000000..a4d851e --- /dev/null +++ b/lib/services/zipping_service.dart @@ -0,0 +1,79 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:archive/archive_io.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; + +/// A dedicated service to handle the creation of ZIP archives for FTP submission. +class ZippingService { + /// Creates multiple JSON files from a map of data and zips them into a single archive. + /// The map keys will be the filenames (e.g., 'db.json', 'form_data.json'). + /// The map values should be the JSON string content for each file. + Future createDataZip({ + required Map jsonDataMap, + required String baseFileName, + }) async { + try { + final tempDir = await getTemporaryDirectory(); + final zipFilePath = p.join(tempDir.path, '$baseFileName.zip'); + final encoder = ZipFileEncoder(); + encoder.create(zipFilePath); + + debugPrint("Creating data ZIP at: $zipFilePath"); + + for (var entry in jsonDataMap.entries) { + final fileName = entry.key; + final jsonContent = entry.value; + + // --- MODIFIED: The codeUnits property is already a List. No need to wrap it in a Stream. --- + final archiveFile = ArchiveFile(fileName, jsonContent.length, jsonContent.codeUnits); + encoder.addArchiveFile(archiveFile); + + debugPrint("Added $fileName to data ZIP."); + } + + encoder.close(); + debugPrint("Data ZIP creation complete."); + return File(zipFilePath); + } catch (e) { + debugPrint("Error creating data ZIP file: $e"); + return null; + } + } + + /// Creates a ZIP file from a list of image files. + Future createImageZip({ + required List imageFiles, + required String baseFileName, + }) async { + if (imageFiles.isEmpty) { + debugPrint("No images provided to create an image ZIP."); + return null; + } + + try { + final tempDir = await getTemporaryDirectory(); + final zipFilePath = p.join(tempDir.path, '${baseFileName}_img.zip'); + final encoder = ZipFileEncoder(); + encoder.create(zipFilePath); + + debugPrint("Creating image ZIP at: $zipFilePath"); + + for (var imageFile in imageFiles) { + if (await imageFile.exists()) { + await encoder.addFile(imageFile); + debugPrint("Added ${p.basename(imageFile.path)} to image ZIP."); + } else { + debugPrint("Skipping non-existent file: ${imageFile.path}"); + } + } + + encoder.close(); + debugPrint("Image ZIP creation complete."); + return File(zipFilePath); + } catch (e) { + debugPrint("Error creating image ZIP file: $e"); + return null; + } + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index beac146..c4dd066 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2,7 +2,7 @@ # See https://dart.dev/tools/pub/glossary#lockfile packages: archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" diff --git a/pubspec.yaml b/pubspec.yaml index 5fb22f5..065c621 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: image: ^4.1.3 # For image processing (watermarks) permission_handler: ^11.3.1 ftpconnect: ^2.0.5 + archive: ^4.0.3 # For creating ZIP files # --- Added for In-Situ Sampling Module --- simple_barcode_scanner: ^0.3.0 # For scanning sample IDs #flutter_blue_classic: ^0.0.3 # For Bluetooth sonde connection