From 0d4d70cca614b01a22842e4760d5d9323efd2c79 Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Tue, 2 Sep 2025 21:13:20 +0800 Subject: [PATCH] configure database for river module and fix api and ftp transmission for river --- lib/main.dart | 42 +- lib/models/in_situ_sampling_data.dart | 43 +- lib/models/river_in_situ_sampling_data.dart | 10 +- lib/models/tarball_data.dart | 23 +- .../marine/manual/data_status_log.dart | 164 ++-- .../marine/manual/in_situ_sampling.dart | 158 +--- .../marine/manual/tarball_sampling.dart | 20 +- .../tarball_sampling_step3_summary.dart | 235 +----- .../widgets/in_situ_step_1_sampling_info.dart | 19 +- .../widgets/in_situ_step_2_site_info.dart | 20 +- .../widgets/in_situ_step_3_data_capture.dart | 55 +- .../widgets/in_situ_step_4_summary.dart | 2 + .../river/manual/in_situ_sampling.dart | 95 +-- .../river_in_situ_step_3_data_capture.dart | 35 +- .../widgets/river_in_situ_step_5_summary.dart | 43 +- lib/screens/settings.dart | 190 ++++- lib/serial/serial_manager.dart | 15 +- lib/services/air_api_service.dart | 13 - lib/services/air_sampling_service.dart | 709 ++++++------------ lib/services/api_service.dart | 326 ++++++-- lib/services/base_api_service.dart | 209 ++---- lib/services/ftp_service.dart | 119 ++- lib/services/in_situ_sampling_service.dart | 154 ---- lib/services/marine_api_service.dart | 239 +----- .../marine_in_situ_sampling_service.dart | 323 ++++++++ .../marine_tarball_sampling_service.dart | 194 +++++ lib/services/retry_service.dart | 39 +- lib/services/river_api_service.dart | 148 +--- .../river_in_situ_sampling_service.dart | 342 +++++---- lib/services/submission_api_service.dart | 109 +++ lib/services/submission_ftp_service.dart | 80 ++ lib/services/telegram_service.dart | 36 +- lib/services/user_preferences_service.dart | 152 ++++ lib/services/zipping_service.dart | 18 +- 34 files changed, 2290 insertions(+), 2089 deletions(-) delete mode 100644 lib/services/air_api_service.dart delete mode 100644 lib/services/in_situ_sampling_service.dart create mode 100644 lib/services/marine_in_situ_sampling_service.dart create mode 100644 lib/services/marine_tarball_sampling_service.dart create mode 100644 lib/services/submission_api_service.dart create mode 100644 lib/services/submission_ftp_service.dart create mode 100644 lib/services/user_preferences_service.dart diff --git a/lib/main.dart b/lib/main.dart index c978051..8463371 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,19 +4,18 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; -// CHANGED: Added imports for MultiProvider and the services to be provided. import 'package:provider/single_child_widget.dart'; import 'package:environment_monitoring_app/services/api_service.dart'; -import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart'; import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart'; -// --- ADDED: Import for the new AirSamplingService --- import 'package:environment_monitoring_app/services/air_sampling_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart'; -// FIX: ADDED MISSING IMPORTS import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart'; -import 'package:environment_monitoring_app/services/in_situ_sampling_service.dart'; // FIX: ADDED MISSING IMPORT +// START CHANGE: Import the new dedicated Marine services and remove the obsolete one +import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart'; +import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart'; +// END CHANGE import 'package:environment_monitoring_app/theme.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; @@ -37,7 +36,6 @@ import 'package:environment_monitoring_app/screens/marine/marine_home_page.dart' // Air Screens import 'package:environment_monitoring_app/screens/air/manual/air_manual_dashboard.dart'; -// --- UPDATED: Imports now point to the new separate screens --- import 'package:environment_monitoring_app/screens/air/manual/air_manual_installation_screen.dart'; import 'package:environment_monitoring_app/screens/air/manual/air_manual_collection_screen.dart'; import 'package:environment_monitoring_app/screens/air/manual/report.dart' as airManualReport; @@ -54,14 +52,10 @@ import 'package:environment_monitoring_app/screens/air/investigative/report.dart // River Screens import 'package:environment_monitoring_app/screens/river/manual/river_manual_dashboard.dart'; -// NOTE: This import points to the main stepper screen for the River In-Situ workflow. import 'package:environment_monitoring_app/screens/river/manual/in_situ_sampling.dart' as riverManualInSituSampling; import 'package:environment_monitoring_app/screens/river/manual/data_status_log.dart' as riverManualDataStatusLog; - -//import 'package:environment_monitoring_app/screens/river/manual/in_situ_sampling.dart' as riverManualInSituSampling; import 'package:environment_monitoring_app/screens/river/manual/report.dart' as riverManualReport; import 'package:environment_monitoring_app/screens/river/manual/triennial_sampling.dart' as riverManualTriennialSampling; -//import 'package:environment_monitoring_app/screens/river/manual/data_status_log.dart' as riverManualDataStatusLog; import 'package:environment_monitoring_app/screens/river/manual/image_request.dart' as riverManualImageRequest; import 'package:environment_monitoring_app/screens/river/continuous/river_continuous_dashboard.dart'; import 'package:environment_monitoring_app/screens/river/continuous/overview.dart' as riverContinuousOverview; @@ -98,32 +92,21 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); // Create singleton instances of core services before running the app - // 1. Create dependent services final DatabaseHelper databaseHelper = DatabaseHelper(); - // FIX: TelegramService is created first, without ApiService. final TelegramService telegramService = TelegramService(); - - // 2. Create the primary service, injecting its dependency - // FIX: ApiService now requires the TelegramService instance. final ApiService apiService = ApiService(telegramService: telegramService); - - // 3. Complete the circular reference injection (TelegramService needs ApiService) - // FIX: Inject the ApiService back into the TelegramService instance. telegramService.setApiService(apiService); setupServices(telegramService); runApp( - // CHANGED: Converted to MultiProvider to support all necessary services. MultiProvider( providers: [ - // The original AuthProvider - // FIX: AuthProvider now requires all its services in the constructor. ChangeNotifierProvider( create: (context) => AuthProvider( apiService: apiService, dbHelper: databaseHelper, - serverConfigService: ServerConfigService(), // Create local instances for AuthProvider DI + serverConfigService: ServerConfigService(), retryService: RetryService(), ), ), @@ -131,12 +114,14 @@ void main() async { Provider(create: (_) => apiService), Provider(create: (_) => databaseHelper), Provider(create: (_) => telegramService), - // Providers for feature-specific services, with their dependencies correctly injected Provider(create: (_) => LocalStorageService()), - Provider(create: (context) => RiverInSituSamplingService(apiService.river)), // FIXED: Passed the required dependency - // --- ADDED: Provider for the new AirSamplingService with dependencies --- - Provider(create: (context) => AirSamplingService(apiService, databaseHelper, telegramService)), - Provider(create: (context) => InSituSamplingService()), // FIX: InSituSamplingService constructor does not take arguments + + // MODIFIED: Provide all dedicated services with their required dependencies + Provider(create: (context) => RiverInSituSamplingService(telegramService)), + Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)), + // FIX: Pass the global telegramService to the marine service constructors + Provider(create: (context) => MarineInSituSamplingService(telegramService)), + Provider(create: (context) => MarineTarballSamplingService(telegramService)), ], child: const RootApp(), ), @@ -217,7 +202,6 @@ class RootApp extends StatelessWidget { // Air Manual '/air/manual/dashboard': (context) => AirManualDashboard(), - // --- UPDATED: Routes now point to the new separate screens --- '/air/manual/installation': (context) => const AirManualInstallationScreen(), '/air/manual/collection': (context) => const AirManualCollectionScreen(), '/air/manual/report': (context) => airManualReport.AirManualReport(), @@ -302,4 +286,4 @@ class SplashScreen extends StatelessWidget { ), ); } -} +} \ 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 54ddf14..6ffd109 100644 --- a/lib/models/in_situ_sampling_data.dart +++ b/lib/models/in_situ_sampling_data.dart @@ -1,3 +1,5 @@ +// lib/models/in_situ_sampling_data.dart + import 'dart:io'; import 'dart:convert'; // Added for jsonEncode @@ -86,6 +88,11 @@ class InSituSamplingData { return null; } + // Helper to create a File object from a path string + File? fileFromPath(dynamic path) { + return (path is String && path.isNotEmpty) ? File(path) : null; + } + return InSituSamplingData() ..firstSamplerName = json['first_sampler_name'] ..firstSamplerUserId = intFromJson(json['first_sampler_user_id']) @@ -124,7 +131,16 @@ class InSituSamplingData { ..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']; + ..optionalRemark4 = json['man_optional_photo_04_remarks'] + ..leftLandViewImage = fileFromPath(json['man_left_side_land_view']) + ..rightLandViewImage = fileFromPath(json['man_right_side_land_view']) + ..waterFillingImage = fileFromPath(json['man_filling_water_into_sample_bottle']) + ..seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle']) + ..phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper']) + ..optionalImage1 = fileFromPath(json['man_optional_photo_01']) + ..optionalImage2 = fileFromPath(json['man_optional_photo_02']) + ..optionalImage3 = fileFromPath(json['man_optional_photo_03']) + ..optionalImage4 = fileFromPath(json['man_optional_photo_04']); } /// Generates a formatted Telegram alert message for successful submissions. @@ -142,7 +158,6 @@ class InSituSamplingData { ..writeln('*Sonde ID:* ${sondeId ?? "N/A"}') ..writeln('*Status of Submission:* Successful'); - // Add distance alert if relevant if (distanceDifferenceInKm != null && distanceDifferenceInKm! > 0) { buffer ..writeln() @@ -161,26 +176,29 @@ class InSituSamplingData { Map toApiFormData() { final Map map = {}; + // Helper to add non-null values to the map void add(String key, dynamic value) { - if (value != null) { + if (value != null && value.toString().isNotEmpty) { map[key] = value.toString(); } } - // Step 1 Data - add('first_sampler_user_id', firstSamplerUserId); - add('man_second_sampler_id', secondSampler?['user_id']); + // --- Required fields that were missing or incorrect --- + add('station_id', selectedStation?['station_id']); add('man_date', samplingDate); add('man_time', samplingTime); + add('first_sampler_user_id', firstSamplerUserId); + + // --- Other Step 1 Data --- + add('man_second_sampler_id', secondSampler?['user_id']); add('man_type', samplingType); add('man_sample_id_code', sampleIdCode); - add('station_id', selectedStation?['station_id']); add('man_current_latitude', currentLatitude); add('man_current_longitude', currentLongitude); add('man_distance_difference', distanceDifferenceInKm); add('man_distance_difference_remarks', distanceDifferenceRemarks); - // Step 2 Data + // --- Step 2 Data --- add('man_weather', weather); add('man_tide_level', tideLevel); add('man_sea_condition', seaCondition); @@ -191,7 +209,7 @@ class InSituSamplingData { add('man_optional_photo_03_remarks', optionalRemark3); add('man_optional_photo_04_remarks', optionalRemark4); - // Step 3 Data + // --- Step 3 Data --- add('man_sondeID', sondeId); add('data_capture_date', dataCaptureDate); add('data_capture_time', dataCaptureTime); @@ -206,6 +224,7 @@ class InSituSamplingData { add('man_tss', tss); add('man_battery_volt', batteryVoltage); + // --- Human-readable fields for server-side alerts --- add('first_sampler_name', firstSamplerName); add('man_station_code', selectedStation?['man_station_code']); add('man_station_name', selectedStation?['man_station_name']); @@ -228,12 +247,8 @@ class InSituSamplingData { }; } - // --- ADDED: Methods to format data for FTP submission as separate JSON files --- - - /// Creates a single JSON object with all submission data, mimicking 'db.json' + /// 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, diff --git a/lib/models/river_in_situ_sampling_data.dart b/lib/models/river_in_situ_sampling_data.dart index 08de3a1..12ee4c9 100644 --- a/lib/models/river_in_situ_sampling_data.dart +++ b/lib/models/river_in_situ_sampling_data.dart @@ -59,7 +59,7 @@ class RiverInSituSamplingData { double? temperature; double? tds; double? turbidity; - double? tss; + double? ammonia; // MODIFIED: Replaced tss with ammonia double? batteryVoltage; // ADDED: New properties for Flowrate @@ -132,7 +132,7 @@ class RiverInSituSamplingData { ..temperature = doubleFromJson(json['r_man_temperature']) ..tds = doubleFromJson(json['r_man_tds']) ..turbidity = doubleFromJson(json['r_man_turbidity']) - ..tss = doubleFromJson(json['r_man_tss']) + ..ammonia = doubleFromJson(json['r_man_ammonia']) // MODIFIED: Replaced tss with ammonia ..batteryVoltage = doubleFromJson(json['r_man_battery_volt']) // END FIX ..optionalRemark1 = json['r_man_optional_photo_01_remarks'] @@ -204,7 +204,7 @@ class RiverInSituSamplingData { add('r_man_temperature', temperature); add('r_man_tds', tds); add('r_man_turbidity', turbidity); - add('r_man_tss', tss); + add('r_man_ammonia', ammonia); // MODIFIED: Replaced tss with ammonia add('r_man_battery_volt', batteryVoltage); // ADDED: Flowrate fields to API form data @@ -284,7 +284,7 @@ class RiverInSituSamplingData { 'temperature': temperature, 'tds': tds, 'turbidity': turbidity, - 'tss': tss, + 'ammonia': ammonia, // MODIFIED: Replaced tss with ammonia 'batteryVoltage': batteryVoltage, 'flowrateMethod': flowrateMethod, 'flowrateSurfaceDrifterHeight': flowrateSurfaceDrifterHeight, @@ -324,6 +324,7 @@ class RiverInSituSamplingData { 'turbidity': turbidity, 'tds': tds, 'electric_conductivity': electricalConductivity, + 'ammonia': ammonia, // MODIFIED: Added ammonia 'flowrate': flowrateValue, 'odour': '', // Assuming these are not collected in this form 'floatable': '', // Assuming these are not collected in this form @@ -365,6 +366,7 @@ class RiverInSituSamplingData { 'turbidity': turbidity, 'tds': tds, 'electric_conductivity': electricalConductivity, + 'ammonia': ammonia, // MODIFIED: Added ammonia 'flowrate': flowrateValue, 'date_sampling_reading': samplingDate, 'time_sampling_reading': samplingTime, diff --git a/lib/models/tarball_data.dart b/lib/models/tarball_data.dart index 5661423..1148fdb 100644 --- a/lib/models/tarball_data.dart +++ b/lib/models/tarball_data.dart @@ -1,4 +1,5 @@ -//import 'dart' as dart; +// lib/models/tarball_data.dart + import 'dart:io'; import 'dart:convert'; @@ -77,40 +78,36 @@ class TarballSamplingData { /// Converts the form's text and selection data into a Map suitable for JSON encoding. /// This map will be sent as the body of the first API request. + // START CHANGE: Corrected the toFormData method to include all required fields for the API. Map toFormData() { final Map data = { - // Required fields + // Required fields that were missing or not being sent correctly 'station_id': selectedStation?['station_id']?.toString() ?? '', 'sampling_date': samplingDate ?? '', 'sampling_time': samplingTime ?? '', - - // User ID fields - 'first_sampler_user_id': firstSamplerUserId?.toString() ?? '', - 'second_sampler_user_id': secondSampler?['user_id']?.toString() ?? '', - - // Foreign Key ID for classification 'classification_id': classificationId?.toString() ?? '', + 'first_sampler_user_id': firstSamplerUserId?.toString() ?? '', - // Other nullable fields + // Optional fields + 'second_sampler_user_id': secondSampler?['user_id']?.toString() ?? '', 'current_latitude': currentLatitude ?? '', 'current_longitude': currentLongitude ?? '', 'distance_difference': distanceDifference?.toString() ?? '', + 'distance_remarks': distanceDifferenceRemarks ?? '', 'optional_photo_remark_01': optionalRemark1 ?? '', 'optional_photo_remark_02': optionalRemark2 ?? '', 'optional_photo_remark_03': optionalRemark3 ?? '', 'optional_photo_remark_04': optionalRemark4 ?? '', - 'distance_remarks': distanceDifferenceRemarks ?? '', - // Human-readable names for the Telegram alert + // Human-readable names for the Telegram alert on the server-side 'tbl_station_name': selectedStation?['tbl_station_name']?.toString() ?? '', 'tbl_station_code': selectedStation?['tbl_station_code']?.toString() ?? '', 'first_sampler_name': firstSampler ?? '', - - // NECESSARY CHANGE: Add the classification name for the alert. 'classification_name': selectedClassification?['classification_name']?.toString() ?? '', }; return data; } + // END CHANGE /// Gathers all non-null image files into a Map. /// This map is used to build the multipart request for the second API call (image upload). diff --git a/lib/screens/marine/manual/data_status_log.dart b/lib/screens/marine/manual/data_status_log.dart index 55a45c0..28b1156 100644 --- a/lib/screens/marine/manual/data_status_log.dart +++ b/lib/screens/marine/manual/data_status_log.dart @@ -9,17 +9,20 @@ import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart'; import 'package:environment_monitoring_app/services/api_service.dart'; -import 'package:environment_monitoring_app/services/in_situ_sampling_service.dart'; +// START CHANGE: Import the new dedicated services +import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart'; +import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart'; +// END CHANGE import 'dart:convert'; -/// A unified model for a submission log entry, specific to the Marine module. + class SubmissionLogEntry { - final String type; // e.g., 'Manual Sampling', 'Tarball Sampling' + final String type; final String title; final String stationCode; final DateTime submissionDateTime; final String? reportId; - final String status; // High-level status (S3, L1, etc.) + final String status; final String message; final Map rawData; final String serverName; @@ -52,10 +55,10 @@ class MarineManualDataStatusLog extends StatefulWidget { class _MarineManualDataStatusLogState extends State { final LocalStorageService _localStorageService = LocalStorageService(); - late ApiService _apiService; - late InSituSamplingService _marineInSituService; + // MODIFIED: Declare the service variables but do not instantiate them here. + late MarineInSituSamplingService _marineInSituService; + late MarineTarballSamplingService _marineTarballService; - List _allLogs = []; List _manualLogs = []; List _tarballLogs = []; List _filteredManualLogs = []; @@ -70,13 +73,23 @@ class _MarineManualDataStatusLogState extends State { @override void initState() { super.initState(); - _apiService = Provider.of(context, listen: false); - _marineInSituService = Provider.of(context, listen: false); + // MODIFIED: Service instantiations are removed from initState. + // They will be initialized in didChangeDependencies. _manualSearchController.addListener(_filterLogs); _tarballSearchController.addListener(_filterLogs); _loadAllLogs(); } + // ADDED: didChangeDependencies to safely get services from the Provider. + // This is the correct lifecycle method for this purpose. + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Fetch the single, global instances of the services from the Provider tree. + _marineInSituService = Provider.of(context); + _marineTarballService = Provider.of(context); + } + @override void dispose() { _manualSearchController.dispose(); @@ -94,8 +107,8 @@ class _MarineManualDataStatusLogState extends State { final List tempTarball = []; for (var log in inSituLogs) { - final String dateStr = log['data_capture_date'] ?? log['sampling_date'] ?? ''; - final String timeStr = log['data_capture_time'] ?? log['sampling_time'] ?? ''; + final String dateStr = log['samplingDate'] ?? ''; + final String timeStr = log['samplingTime'] ?? ''; tempManual.add(SubmissionLogEntry( type: 'Manual Sampling', @@ -171,84 +184,74 @@ class _MarineManualDataStatusLogState extends State { final authProvider = Provider.of(context, listen: false); final appSettings = authProvider.appSettings; - final logData = log.rawData; Map result = {}; if (log.type == 'Manual Sampling') { - final dataToResubmit = InSituSamplingData.fromJson(logData); - final Map imageFiles = {}; - dataToResubmit.toApiImageFiles().keys.forEach((key) { - final imagePath = logData[key]; - if (imagePath is String && imagePath.isNotEmpty) { - imageFiles[key] = File(imagePath); - } - }); - result = await _apiService.marine.submitInSituSample( - formData: dataToResubmit.toApiFormData(), - imageFiles: imageFiles, - inSituData: dataToResubmit, + // START CHANGE: Reconstruct data object and call the new service + final dataToResubmit = InSituSamplingData.fromJson(log.rawData); + // Re-attach File objects from paths + dataToResubmit.leftLandViewImage = File(log.rawData['man_left_side_land_view'] ?? ''); + dataToResubmit.rightLandViewImage = File(log.rawData['man_right_side_land_view'] ?? ''); + dataToResubmit.waterFillingImage = File(log.rawData['man_filling_water_into_sample_bottle'] ?? ''); + dataToResubmit.seawaterColorImage = File(log.rawData['man_seawater_in_clear_glass_bottle'] ?? ''); + dataToResubmit.phPaperImage = File(log.rawData['man_examine_preservative_ph_paper'] ?? ''); + dataToResubmit.optionalImage1 = File(log.rawData['man_optional_photo_01'] ?? ''); + dataToResubmit.optionalImage2 = File(log.rawData['man_optional_photo_02'] ?? ''); + dataToResubmit.optionalImage3 = File(log.rawData['man_optional_photo_03'] ?? ''); + dataToResubmit.optionalImage4 = File(log.rawData['man_optional_photo_04'] ?? ''); + + result = await _marineInSituService.submitInSituSample( + data: dataToResubmit, appSettings: appSettings, ); + // END CHANGE } else if (log.type == 'Tarball Sampling') { - // FIX: Manually map the raw data to a new TarballSamplingData instance - final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? ''); - final int? classificationId = int.tryParse(logData['classification_id']?.toString() ?? ''); + // START CHANGE: Reconstruct data object and call the new service + final dataToResubmit = TarballSamplingData(); // Create a fresh instance + final logData = log.rawData; - final dataToResubmit = TarballSamplingData() - ..firstSamplerUserId = firstSamplerId - ..secondSampler = logData['secondSampler'] - ..samplingDate = logData['samplingDate'] - ..samplingTime = logData['samplingTime'] - ..selectedStateName = logData['selectedStateName'] - ..selectedCategoryName = logData['selectedCategoryName'] - ..selectedStation = logData['selectedStation'] - ..stationLatitude = logData['stationLatitude'] - ..stationLongitude = logData['stationLongitude'] - ..currentLatitude = logData['currentLatitude'] - ..currentLongitude = logData['currentLongitude'] - ..distanceDifference = double.tryParse(logData['distanceDifference']?.toString() ?? '0.0') - ..distanceDifferenceRemarks = logData['distanceDifferenceRemarks'] - ..classificationId = classificationId - ..selectedClassification = logData['selectedClassification'] - ..optionalRemark1 = logData['optionalRemark1'] - ..optionalRemark2 = logData['optionalRemark2'] - ..optionalRemark3 = logData['optionalRemark3'] - ..optionalRemark4 = logData['optionalRemark4'] - ..reportId = logData['reportId']?.toString(); + // Manually map fields from the raw log data to the new object + dataToResubmit.firstSampler = logData['firstSampler']; + dataToResubmit.firstSamplerUserId = logData['firstSamplerUserId']; + dataToResubmit.secondSampler = logData['secondSampler']; + dataToResubmit.samplingDate = logData['samplingDate']; + dataToResubmit.samplingTime = logData['samplingTime']; + dataToResubmit.selectedStateName = logData['selectedStateName']; + dataToResubmit.selectedCategoryName = logData['selectedCategoryName']; + dataToResubmit.selectedStation = logData['selectedStation']; + dataToResubmit.stationLatitude = logData['stationLatitude']; + dataToResubmit.stationLongitude = logData['stationLongitude']; + dataToResubmit.currentLatitude = logData['currentLatitude']; + dataToResubmit.currentLongitude = logData['currentLongitude']; + dataToResubmit.distanceDifference = logData['distanceDifference']; + dataToResubmit.distanceDifferenceRemarks = logData['distanceDifferenceRemarks']; + dataToResubmit.classificationId = logData['classificationId']; + dataToResubmit.selectedClassification = logData['selectedClassification']; + dataToResubmit.optionalRemark1 = logData['optionalRemark1']; + dataToResubmit.optionalRemark2 = logData['optionalRemark2']; + dataToResubmit.optionalRemark3 = logData['optionalRemark3']; + dataToResubmit.optionalRemark4 = logData['optionalRemark4']; - final Map imageFiles = {}; - dataToResubmit.toImageFiles().keys.forEach((key) { - final imagePath = logData[key]; - if (imagePath is String && imagePath.isNotEmpty) { - imageFiles[key] = File(imagePath); - } - }); - result = await _apiService.marine.submitTarballSample( - formData: dataToResubmit.toFormData(), - imageFiles: imageFiles, + // Re-attach File objects from paths + dataToResubmit.leftCoastalViewImage = File(logData['left_side_coastal_view'] ?? ''); + dataToResubmit.rightCoastalViewImage = File(logData['right_side_coastal_view'] ?? ''); + dataToResubmit.verticalLinesImage = File(logData['drawing_vertical_lines'] ?? ''); + dataToResubmit.horizontalLineImage = File(logData['drawing_horizontal_line'] ?? ''); + dataToResubmit.optionalImage1 = File(logData['optional_photo_01'] ?? ''); + dataToResubmit.optionalImage2 = File(logData['optional_photo_02'] ?? ''); + dataToResubmit.optionalImage3 = File(logData['optional_photo_03'] ?? ''); + dataToResubmit.optionalImage4 = File(logData['optional_photo_04'] ?? ''); + + result = await _marineTarballService.submitTarballSample( + data: dataToResubmit, appSettings: appSettings, ); + // END CHANGE } - final updatedLogData = log.rawData; - updatedLogData['submissionStatus'] = result['status']; - updatedLogData['submissionMessage'] = result['message']; - updatedLogData['reportId'] = result['reportId']?.toString() ?? updatedLogData['reportId']; - updatedLogData['api_status'] = jsonEncode(result['api_status']); - updatedLogData['ftp_status'] = jsonEncode(result['ftp_status']); - // This line is likely incorrect, assuming you want the name of the successful server - // updatedLogData['serverConfigName'] = (await _apiService.dbHelper.loadApiConfigs() ?? []).firstWhere((c) => c['api_config_id'] == 1)['config_name']; - - if (log.type == 'Manual Sampling') { - await _localStorageService.updateInSituLog(updatedLogData); - } else if (log.type == 'Tarball Sampling') { - await _localStorageService.updateTarballLog(updatedLogData); - } - - if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Resubmission successful!')), + SnackBar(content: Text(result['message'] ?? 'Resubmission process completed.')), ); } } catch (e) { @@ -308,13 +311,12 @@ class _MarineManualDataStatusLogState extends State { prefixIcon: const Icon(Icons.search, size: 20), isDense: true, border: const OutlineInputBorder(), - suffixIcon: IconButton( + suffixIcon: searchController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { searchController.clear(); - _filterLogs(); }, - ), + ) : null, ), ), ), @@ -399,7 +401,7 @@ class _MarineManualDataStatusLogState extends State { try { statuses = jsonDecode(jsonStatus); } catch (_) { - return _buildDetailRow('$type Status:', jsonStatus!); + return _buildDetailRow('$type Status:', jsonStatus); } if (statuses.isEmpty) { @@ -413,7 +415,7 @@ class _MarineManualDataStatusLogState extends State { children: [ Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)), ...statuses.map((s) { - final serverName = s['server_name'] ?? 'Server N/A'; + final serverName = s['server_name'] ?? s['config_name'] ?? 'Server N/A'; final status = s['status'] ?? 'N/A'; final bool isSuccess = status.toLowerCase().contains('success') || status.toLowerCase().contains('queued') || status.toLowerCase().contains('not_configured') || status.toLowerCase().contains('not_applicable') || status.toLowerCase().contains('not_required'); final IconData icon = isSuccess ? Icons.check_circle_outline : (status.toLowerCase().contains('failed') ? Icons.error_outline : Icons.sync); @@ -449,4 +451,4 @@ class _MarineManualDataStatusLogState extends State { ), ); } -} +} \ 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 62c5427..be683a8 100644 --- a/lib/screens/marine/manual/in_situ_sampling.dart +++ b/lib/screens/marine/manual/in_situ_sampling.dart @@ -4,19 +4,11 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; 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'; -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'; +// START CHANGE: Import the new, consolidated MarineInSituSamplingService +import '../../../services/marine_in_situ_sampling_service.dart'; +// END CHANGE 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'; @@ -37,17 +29,9 @@ class _MarineInSituSamplingState extends State { late InSituSamplingData _data; - // A single instance of the service to be used by all child widgets. - final InSituSamplingService _samplingService = InSituSamplingService(); - - // Service for saving submission logs locally. - final LocalStorageService _localStorageService = LocalStorageService(); - 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 --- - + // MODIFIED: Declare the service variable but do not instantiate it here. + // It will be initialized from the Provider. + late MarineInSituSamplingService _samplingService; int _currentPage = 0; bool _isLoading = false; @@ -61,10 +45,24 @@ class _MarineInSituSamplingState extends State { ); } + // ADDED: didChangeDependencies to safely get the service from the Provider. + // This is the correct lifecycle method to access inherited widgets like Provider. + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Fetch the single, global instance of the service from the Provider tree. + _samplingService = Provider.of(context); + } + @override void dispose() { _pageController.dispose(); - _samplingService.dispose(); + // START FIX + // REMOVED: _samplingService.dispose(); + // The service is managed by a higher-level Provider and should not be disposed of + // here, as other widgets might still be listening to it. This prevents the + // "ValueNotifier was used after being disposed" error. + // END FIX super.dispose(); } @@ -88,128 +86,47 @@ class _MarineInSituSamplingState extends State { } } - // --- REPLACED: _submitForm() method with the new workflow --- + // START CHANGE: The _submitForm method is now greatly simplified. Future _submitForm() async { setState(() => _isLoading = true); final authProvider = Provider.of(context, listen: false); final appSettings = authProvider.appSettings; - final activeApiConfig = await _serverConfigService.getActiveApiConfig(); - final serverName = activeApiConfig?['config_name'] as String? ?? 'Default'; - // 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(); + // Delegate the entire submission process to the new dedicated service. + // The service handles API calls, zipping, FTP queuing, logging, and alerts. + final result = await _samplingService.submitInSituSample( + data: _data, + appSettings: appSettings, + ); - - 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; - 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.'; - } - - await _localStorageService.saveInSituSamplingData(_data, serverName: serverName); - setState(() => _isLoading = false); - final message = _data.submissionMessage ?? 'An unknown error occurred.'; - final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red; + // Display the final result to the user + final message = result['message'] ?? 'An unknown error occurred.'; + final color = (result['success'] == true) ? Colors.green : Colors.red; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), ); Navigator.of(context).popUntil((route) => route.isFirst); } + // END CHANGE @override Widget build(BuildContext context) { - // Use Provider.value to provide the existing service instance to all child widgets. - return Provider.value( + // START CHANGE: Provide the new MarineInSituSamplingService to all child widgets. + // The child widgets (Step 1, 2, 3) can now access all its methods (for location, + // image picking, and device connection) via Provider. + return Provider.value( value: _samplingService, + // END CHANGE child: Scaffold( appBar: AppBar( title: Text('In-Situ Sampling (${_currentPage + 1}/4)'), - // Show a back button on all pages except the first one. leading: _currentPage > 0 ? IconButton( icon: const Icon(Icons.arrow_back), @@ -219,7 +136,6 @@ class _MarineInSituSamplingState extends State { ), body: PageView( controller: _pageController, - // Disable manual swiping between pages. physics: const NeverScrollableScrollPhysics(), onPageChanged: (page) { setState(() { diff --git a/lib/screens/marine/manual/tarball_sampling.dart b/lib/screens/marine/manual/tarball_sampling.dart index b2ae23f..06fbacd 100644 --- a/lib/screens/marine/manual/tarball_sampling.dart +++ b/lib/screens/marine/manual/tarball_sampling.dart @@ -12,7 +12,9 @@ import 'package:path/path.dart' as path; import 'package:image/image.dart' as img; import 'package:environment_monitoring_app/auth_provider.dart'; -import 'package:environment_monitoring_app/services/marine_api_service.dart'; +// START CHANGE: Import the new dedicated service +import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart'; +// END CHANGE import 'package:environment_monitoring_app/models/tarball_data.dart'; class MarineTarballSampling extends StatefulWidget { @@ -27,7 +29,8 @@ class _MarineTarballSamplingState extends State { final _formKey2 = GlobalKey(); int _currentStep = 1; - final MarineApiService _marineApiService = MarineApiService(); + // MODIFIED: The service instance is no longer created here. + // It will be fetched from the Provider right before it is used. bool _isLoading = false; final TarballSamplingData _data = TarballSamplingData(); @@ -86,7 +89,7 @@ class _MarineTarballSamplingState extends State { Future _getCurrentLocation() async { /* ... Location logic ... */ } void _calculateDistance() { /* ... Distance logic ... */ } - // MODIFIED: This method now fetches appSettings and passes it to the API service. + // MODIFIED: This method now uses the new dedicated service for submission. Future _submitForm() async { setState(() => _isLoading = true); @@ -94,12 +97,15 @@ class _MarineTarballSamplingState extends State { final authProvider = Provider.of(context, listen: false); final appSettings = authProvider.appSettings; - // Pass the appSettings list to the submit method. - final result = await _marineApiService.submitTarballSample( - formData: _data.toFormData(), - imageFiles: _data.toImageFiles(), + // ADDED: Fetch the global service instance from Provider. + final tarballService = Provider.of(context, listen: false); + + // START CHANGE: Call the method on the new dedicated service + final result = await tarballService.submitTarballSample( + data: _data, appSettings: appSettings, ); + // END CHANGE if (!mounted) return; setState(() => _isLoading = false); diff --git a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart index 2c4f841..7068934 100644 --- a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart +++ b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart @@ -1,20 +1,14 @@ +// lib/screens/marine/manual/tarball_sampling_step3_summary.dart + 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'; -// 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'; +// START CHANGE: Import the new dedicated service +import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart'; +// END CHANGE class TarballSamplingStep3Summary extends StatefulWidget { @@ -26,226 +20,42 @@ class TarballSamplingStep3Summary extends StatefulWidget { } class _TarballSamplingStep3SummaryState extends State { - // FIX: Removed direct instantiation of 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 --- + // MODIFIED: The service instance is no longer created here. + // It will be fetched from the Provider where it's needed. bool _isLoading = false; - // --- REPLACED: _submitForm() method with the new workflow --- + // START CHANGE: The _submitForm method is now greatly simplified Future _submitForm() async { setState(() => _isLoading = true); final authProvider = Provider.of(context, listen: false); - // FIX: Retrieve ApiService from the Provider tree - final apiService = Provider.of(context, listen: false); - final appSettings = authProvider.appSettings; - final activeApiConfig = await _serverConfigService.getActiveApiConfig(); - final serverName = activeApiConfig?['config_name'] as String? ?? 'Default'; - // 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(); + // ADDED: Fetch the global service instance from Provider before using it. + // We use listen: false as this is a one-time action within a method. + final tarballService = Provider.of(context, listen: false); - // Create a temporary, separate copy of the data for the FTP process - final dataForFtp = widget.data; - - bool apiSuccess = false; - bool ftpQueueSuccess = false; - List> apiStatuses = []; - List> ftpStatuses = []; - String finalStatus = 'L1'; - String finalMessage = 'All submission attempts failed. Data saved locally for retry.'; - - - // --- Step 1: Attempt API Submission --- - debugPrint("Step 1: Attempting API submission..."); - final apiResult = await apiService.marine.submitTarballSample( // FIX: Use retrieved ApiService - formData: widget.data.toFormData(), - imageFiles: widget.data.toImageFiles(), + // Delegate the entire submission process to the new dedicated service + final result = await tarballService.submitTarballSample( + data: widget.data, appSettings: appSettings, ); - apiSuccess = apiResult['success'] == true; - widget.data.reportId = apiResult['reportId']?.toString(); - final serverReportId = widget.data.reportId; - - // Determine granular API statuses (Simulation based on BaseApiService trying 2 servers) - for (int i = 0; i < apiConfigs.length; i++) { - final config = apiConfigs[i]; - String status; - String message; - - if (apiSuccess && i == 0) { - status = "SUCCESS"; - message = "Data posted successfully to primary API."; - } else if (apiSuccess && i > 0) { - status = "SUCCESS (Fallback)"; - message = "Data posted successfully to fallback API."; - } else { - status = "FAILED"; - message = apiResult['message'] ?? "Connection or server error."; - } - - apiStatuses.add({ - "server_name": config['config_name'], - "status": status, - "message": message, - }); - } - - // --- Step 2: Attempt FTP Submission Queueing --- - if (ftpConfigs.isNotEmpty) { - debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing."); - - final stationCode = dataForFtp.selectedStation?['tbl_station_code'] ?? 'NA'; - final reportId = serverReportId ?? DateTime.now().millisecondsSinceEpoch.toString(); - final baseFileName = '${stationCode}_$reportId'; - - // Flags to check if zipping/queuing was successful for AT LEAST ONE FTP server - bool dataZipQueued = false; - bool imageZipQueued = false; - - - 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, - ); - final File? imageZip = await _zippingService.createImageZip( - imageFiles: dataForFtp.toImageFiles().values.whereType().toList(), - baseFileName: baseFileName, - ); - - // Queue for each configured FTP server - for (var config in ftpConfigs) { - // Note: We use the RetryService method here, which queues if the upload fails, - // but since we are not *uploading* here, we just queue everything for subsequent FTP process. - - String status; - String message; - - if (dataZip != null) { - await _retryService.addFtpToQueue( - localFilePath: dataZip.path, - remotePath: '/uploads/data/${p.basename(dataZip.path)}', - ); - dataZipQueued = true; - status = "QUEUED"; - message = "Data zip queued successfully."; - } else { - status = "FAILED (ZIP)"; - message = "Data zip file could not be created."; - } - - ftpStatuses.add({ - "server_name": config['config_name'], - "status": status, - "message": message, - "type": "data", - }); - - // Queue images only if they exist - if (imageZip != null) { - await _retryService.addFtpToQueue( - localFilePath: imageZip.path, - remotePath: '/uploads/images/${p.basename(imageZip.path)}', - ); - imageZipQueued = true; - status = "QUEUED"; - message = "Image zip queued successfully."; - } else { - status = "NOT_REQUIRED/N/A"; - message = "No images to queue or zip failed."; - } - - ftpStatuses.add({ - "server_name": config['config_name'], - "status": status, - "message": message, - "type": "images", - }); - } - - ftpQueueSuccess = dataZipQueued || imageZipQueued; - - } catch (e) { - debugPrint("FTP zipping or queuing failed with an error: $e"); - ftpQueueSuccess = false; - } - } else { - ftpStatuses.add({ - "server_name": "N/A", - "status": "NOT_CONFIGURED", - "message": "No FTP servers configured.", - }); - } - - // --- Step 3: Determine Final Status and Log to DB --- - - if (apiSuccess && ftpQueueSuccess) { - finalStatus = 'S4'; // Submitted API, Queued FTP - finalMessage = 'Data submitted to API and files queued for FTP upload.'; - } else if (apiSuccess) { - finalStatus = 'S3'; // Submitted API Only - finalMessage = 'Data submitted successfully to API. FTP queueing failed or not configured.'; - } else if (ftpQueueSuccess) { - finalStatus = 'L4'; // Failed API, Queued FTP - finalMessage = 'API submission failed but files were successfully queued for FTP.'; - } else { - finalStatus = 'L1'; // All submissions failed - finalMessage = 'All submission attempts failed. Data saved locally for retry.'; - } - - // Set final high-level status in the model - widget.data.submissionStatus = finalStatus; - widget.data.submissionMessage = finalMessage; - - - // 4. Save the final high-level status (to file system for resubmission tracking) - await _localStorageService.saveTarballSamplingData(widget.data, serverName: serverName); - - // 5. Save granular status to the central database log - final logData = { - 'submission_id': widget.data.reportId ?? widget.data.samplingDate!, - 'module': 'marine', - 'type': 'Tarball Sampling', - 'status': finalStatus, - 'message': finalMessage, - 'report_id': widget.data.reportId, - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(widget.data.toDbJson()), - 'image_data': jsonEncode(widget.data.toImageFiles().keys.map((k) => widget.data.toImageFiles()[k]?.path).where((p) => p != null).toList()), - 'server_name': serverName, - 'api_status': jsonEncode(apiStatuses), // GRANULAR API STATUSES - 'ftp_status': jsonEncode(ftpStatuses), // GRANULAR FTP STATUSES - }; - await _dbHelper.saveSubmissionLog(logData); - + if (!mounted) return; setState(() => _isLoading = false); - final message = widget.data.submissionMessage ?? 'An unknown error occurred.'; - final color = (apiSuccess || ftpQueueSuccess) ? Colors.green : Colors.red; + final message = result['message'] ?? 'An unknown error occurred.'; + final color = (result['success'] == true) ? Colors.green : Colors.red; + ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), ); Navigator.of(context).popUntil((route) => route.isFirst); } + // END CHANGE @override Widget build(BuildContext context) { @@ -293,7 +103,6 @@ class _TarballSamplingStep3SummaryState extends State( builder: (context, auth, child) { String classificationName = 'N/A'; @@ -377,10 +185,9 @@ class _TarballSamplingStep3SummaryState extends State { Future _getCurrentLocation() async { setState(() => _isLoadingLocation = true); - final service = Provider.of(context, listen: false); + // START CHANGE: Use the correct, new service type from Provider + final service = Provider.of(context, listen: false); + // END CHANGE try { final position = await service.getCurrentLocation(); @@ -150,7 +154,9 @@ class _InSituStep1SamplingInfoState extends State { final lon2Str = widget.data.currentLongitude; if (lat1Str != null && lon1Str != null && lat2Str != null && lon2Str != null) { - final service = Provider.of(context, listen: false); + // START CHANGE: Use the correct, new service type from Provider + final service = Provider.of(context, listen: false); + // END CHANGE final lat1 = double.tryParse(lat1Str); final lon1 = double.tryParse(lon1Str); final lat2 = double.tryParse(lat2Str); @@ -271,7 +277,7 @@ class _InSituStep1SamplingInfoState extends State { child: ListView( padding: const EdgeInsets.all(24.0), children: [ - // Sampling Information section... (unchanged) + // Sampling Information section... Text("Sampling Information", style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 24), TextFormField(controller: _firstSamplerController, readOnly: true, decoration: const InputDecoration(labelText: '1st Sampler')), @@ -302,7 +308,9 @@ class _InSituStep1SamplingInfoState extends State { ), const SizedBox(height: 24), - // Station Selection section... (unchanged) + // Station Selection section... + Text("Station Selection", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), DropdownSearch( items: _statesList, selectedItem: widget.data.selectedStateName, @@ -382,7 +390,6 @@ class _InSituStep1SamplingInfoState extends State { TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')), const SizedBox(height: 16), TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')), - // MODIFIED: Distance text is now more prominent and styled if (widget.data.distanceDifferenceInKm != null) Padding( padding: const EdgeInsets.only(top: 16.0), diff --git a/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart b/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart index febeb37..af1e0f4 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart @@ -1,10 +1,14 @@ +// lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart + import 'dart:io'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; import '../../../../models/in_situ_sampling_data.dart'; -import '../../../../services/in_situ_sampling_service.dart'; +// START CHANGE: Import the new, correct service file +import '../../../../services/marine_in_situ_sampling_service.dart'; +// END CHANGE /// The second step of the In-Situ Sampling form. /// Gathers on-site conditions (weather, tide) and handles all photo attachments. @@ -66,7 +70,9 @@ class _InSituStep2SiteInfoState extends State { if (_isPickingImage) return; setState(() => _isPickingImage = true); - final service = Provider.of(context, listen: false); + // START CHANGE: Use the correct service type from Provider + final service = Provider.of(context, listen: false); + // END CHANGE final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: isRequired); @@ -163,10 +169,10 @@ class _InSituStep2SiteInfoState extends State { // --- Section: Optional Photos --- Text("Optional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), - _buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller, isRequired: true), - _buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller, isRequired: true), - _buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller, isRequired: true), - _buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller, isRequired: true), + _buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller, isRequired: false), + _buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller, isRequired: false), + _buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller, isRequired: false), + _buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller, isRequired: false), const SizedBox(height: 24), // --- Section: Remarks --- @@ -245,4 +251,4 @@ class _InSituStep2SiteInfoState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart b/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart index d184ac3..eca6d45 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart @@ -1,3 +1,5 @@ +// lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart + import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -5,7 +7,7 @@ import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:usb_serial/usb_serial.dart'; import '../../../../models/in_situ_sampling_data.dart'; -import '../../../../services/in_situ_sampling_service.dart'; +import '../../../../services/marine_in_situ_sampling_service.dart'; import '../../../../bluetooth/bluetooth_manager.dart'; // For connection state enum import '../../../../serial/serial_manager.dart'; // For connection state enum import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart'; @@ -25,7 +27,9 @@ class InSituStep3DataCapture extends StatefulWidget { State createState() => _InSituStep3DataCaptureState(); } -class _InSituStep3DataCaptureState extends State { +// START CHANGE: Add WidgetsBindingObserver to listen for app lifecycle events +class _InSituStep3DataCaptureState extends State with WidgetsBindingObserver { +// END CHANGE final _formKey = GlobalKey(); bool _isLoading = false; bool _isAutoReading = false; @@ -51,15 +55,34 @@ class _InSituStep3DataCaptureState extends State { void initState() { super.initState(); _initializeControllers(); + // START CHANGE: Register the lifecycle observer + WidgetsBinding.instance.addObserver(this); + // END CHANGE } @override void dispose() { _dataSubscription?.cancel(); _disposeControllers(); + // START CHANGE: Remove the lifecycle observer + WidgetsBinding.instance.removeObserver(this); + // END CHANGE super.dispose(); } + // START CHANGE: Add the observer method to handle app resume events + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + // When the app is resumed (e.g., after the user grants the native USB permission), + // call setState to force the widget to rebuild and show the latest connection status. + if (mounted) { + setState(() {}); + } + } + } + // END CHANGE + void _initializeControllers() { // Use the date and time from Step 1 widget.data.dataCaptureDate = widget.data.samplingDate; @@ -127,7 +150,7 @@ class _InSituStep3DataCaptureState extends State { /// Handles the entire connection flow, including a permission check. Future _handleConnectionAttempt(String type) async { - final service = context.read(); + final service = context.read(); final bool hasPermissions = await service.requestDevicePermissions(); if (!hasPermissions && mounted) { @@ -154,7 +177,7 @@ class _InSituStep3DataCaptureState extends State { Future _connectToDevice(String type) async { setState(() => _isLoading = true); - final service = context.read(); + final service = context.read(); bool success = false; try { @@ -191,7 +214,7 @@ class _InSituStep3DataCaptureState extends State { } void _toggleAutoReading(String activeType) { - final service = context.read(); + final service = context.read(); setState(() { _isAutoReading = !_isAutoReading; if (_isAutoReading) { @@ -205,7 +228,7 @@ class _InSituStep3DataCaptureState extends State { } void _disconnect(String type) { - final service = context.read(); + final service = context.read(); if (type == 'bluetooth') { service.disconnectFromBluetooth(); } else { @@ -219,7 +242,7 @@ class _InSituStep3DataCaptureState extends State { } void _disconnectFromAll() { - final service = context.read(); + final service = context.read(); if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { _disconnect('bluetooth'); } @@ -298,7 +321,7 @@ class _InSituStep3DataCaptureState extends State { } Map? _getActiveConnectionDetails() { - final service = context.watch(); + final service = context.watch(); if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { return { 'type': 'bluetooth', @@ -318,7 +341,7 @@ class _InSituStep3DataCaptureState extends State { @override Widget build(BuildContext context) { - final service = context.watch(); + final service = context.watch(); final activeConnection = _getActiveConnectionDetails(); final String? activeType = activeConnection?['type'] as String?; @@ -373,10 +396,14 @@ class _InSituStep3DataCaptureState extends State { valueListenable: service.sondeId, builder: (context, sondeId, child) { final newSondeId = sondeId ?? ''; - if (_sondeIdController.text != newSondeId) { - _sondeIdController.text = newSondeId; - widget.data.sondeId = newSondeId; - } + // START CHANGE: Safely update the controller after the build frame is complete to prevent crash + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _sondeIdController.text != newSondeId) { + _sondeIdController.text = newSondeId; + widget.data.sondeId = newSondeId; + } + }); + // END CHANGE return TextFormField( controller: _sondeIdController, decoration: const InputDecoration( @@ -401,7 +428,6 @@ class _InSituStep3DataCaptureState extends State { ), const Divider(height: 32), - // REPAIRED: Replaced GridView with a Column of modern list items. Column( children: _parameters.map((param) { return _buildParameterListItem( @@ -424,7 +450,6 @@ class _InSituStep3DataCaptureState extends State { ); } - // ADDED: New helper for modern list item view Widget _buildParameterListItem({ required IconData icon, required String label, diff --git a/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart b/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart index a57b6bb..671cebd 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart @@ -1,3 +1,5 @@ +// lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart + import 'dart:io'; import 'package:flutter/material.dart'; diff --git a/lib/screens/river/manual/in_situ_sampling.dart b/lib/screens/river/manual/in_situ_sampling.dart index e16aaeb..638a742 100644 --- a/lib/screens/river/manual/in_situ_sampling.dart +++ b/lib/screens/river/manual/in_situ_sampling.dart @@ -35,20 +35,16 @@ class _RiverInSituSamplingScreenState extends State { late RiverInSituSamplingData _data; - // FIX: _samplingService is now retrieved via Provider in _submitForm - // final RiverInSituSamplingService _samplingService = RiverInSituSamplingService(); 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; - // FIX: Use late initialization to retrieve service instances in the build method. - late RiverInSituSamplingService _samplingService; + // FIX: Removed the late variable, it will be fetched from context directly. + // late RiverInSituSamplingService _samplingService; @override void initState() { @@ -57,16 +53,12 @@ class _RiverInSituSamplingScreenState extends State { samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()), samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()), ); - // Initialize services that require context/Provider access later - WidgetsBinding.instance.addPostFrameCallback((_) { - _samplingService = Provider.of(context, listen: false); - }); + // FIX: Removed the problematic post-frame callback initialization. } @override void dispose() { _pageController.dispose(); - // FIX: Removed _samplingService.dispose() call as it is now retrieved from Provider/context. super.dispose(); } @@ -88,22 +80,20 @@ class _RiverInSituSamplingScreenState extends State { } } - // --- REPLACED: _submitForm() method with the simplified workflow --- Future _submitForm() async { setState(() => _isLoading = true); + // FIX: Get the sampling service directly from the context here. + final samplingService = Provider.of(context, listen: false); final authProvider = Provider.of(context, listen: false); final appSettings = authProvider.appSettings; - // The service now handles all API/FTP attempts and internal logging. - final result = await _samplingService.submitData(_data, appSettings); + final result = await samplingService.submitData(_data, appSettings); - // FIX: Update local data model status with the granular API result _data.submissionStatus = result['status']; _data.submissionMessage = result['message']; _data.reportId = result['reportId']?.toString(); - // NOTE: The separate local file saving is now redundant here, but kept for historical context/backup. final activeApiConfig = await _serverConfigService.getActiveApiConfig(); final serverName = activeApiConfig?['config_name'] as String? ?? 'Default'; await _localStorageService.saveRiverInSituSamplingData(_data, serverName: serverName); @@ -113,11 +103,9 @@ class _RiverInSituSamplingScreenState extends State { setState(() => _isLoading = false); final message = _data.submissionMessage ?? 'An unknown error occurred.'; - // FIX: Use granular status (api_status or image_upload_status is not directly available here, - // rely on the high-level status 'status' returned by the service for UI feedback). final highLevelStatus = result['status'] as String? ?? 'L1'; - final bool isSuccess = highLevelStatus.startsWith('S') || highLevelStatus.startsWith('L4'); // L4 means FTP queue success, which is good. + final bool isSuccess = highLevelStatus.startsWith('S') || highLevelStatus.startsWith('L4'); final color = isSuccess ? Colors.green : Colors.red; @@ -130,47 +118,34 @@ class _RiverInSituSamplingScreenState extends State { @override Widget build(BuildContext context) { - // Note: Since _samplingService is initialized using addPostFrameCallback, - // we must ensure it's provided if a child widget relies on Provider.of. - // However, since the Provider.value below directly uses _samplingService, - // and the constructor was updated to take no args, we'll keep the - // Provider.value here. - - // FIX: Revert _samplingService initialization to the final definition - // used in other modules where it is retrieved by the main builder function. - // Given the complexity of the DI in main.dart, the service should be retrieved - // inside the build method or initState if it needs to be used in methods. - // Since we fixed the constructor issue in the previous files, we rely on - // the provider instance created in main.dart. - - return Provider.value( - value: _samplingService, - child: Scaffold( - appBar: AppBar( - title: Text('In-Situ Sampling (${_currentPage + 1}/5)'), - leading: _currentPage > 0 - ? IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: _previousPage, - ) - : null, - ), - body: PageView( - controller: _pageController, - physics: const NeverScrollableScrollPhysics(), - onPageChanged: (page) { - setState(() { - _currentPage = page; - }); - }, - children: [ - RiverInSituStep1SamplingInfo(data: _data, onNext: _nextPage), - RiverInSituStep2SiteInfo(data: _data, onNext: _nextPage), - RiverInSituStep3DataCapture(data: _data, onNext: _nextPage), - RiverInSituStep4AdditionalInfo(data: _data, onNext: _nextPage), - RiverInSituStep5Summary(data: _data, onSubmit: _submitForm, isLoading: _isLoading), - ], - ), + // FIX: The Provider.value wrapper is removed. The service is already + // available in the widget tree from a higher-level provider, and child + // widgets can access it using Provider.of or context.read. + return Scaffold( + appBar: AppBar( + title: Text('In-Situ Sampling (${_currentPage + 1}/5)'), + leading: _currentPage > 0 + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _previousPage, + ) + : null, + ), + body: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + children: [ + RiverInSituStep1SamplingInfo(data: _data, onNext: _nextPage), + RiverInSituStep2SiteInfo(data: _data, onNext: _nextPage), + RiverInSituStep3DataCapture(data: _data, onNext: _nextPage), + RiverInSituStep4AdditionalInfo(data: _data, onNext: _nextPage), + RiverInSituStep5Summary(data: _data, onSubmit: _submitForm, isLoading: _isLoading), + ], ), ); } diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart index b84ac73..7d72ee8 100644 --- a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart +++ b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart @@ -28,7 +28,8 @@ class RiverInSituStep3DataCapture extends StatefulWidget { State createState() => _RiverInSituStep3DataCaptureState(); } -class _RiverInSituStep3DataCaptureState extends State { +// MODIFIED: Added 'with WidgetsBindingObserver' to listen for app lifecycle events. +class _RiverInSituStep3DataCaptureState extends State with WidgetsBindingObserver { final _formKey = GlobalKey(); bool _isLoading = false; bool _isAutoReading = false; @@ -48,7 +49,7 @@ class _RiverInSituStep3DataCaptureState extends State children = [ + _buildDetailRow("Flowrate Method:", method), + ]; + + if (method == 'Surface Drifter') { + children.add( + Padding( + padding: const EdgeInsets.only(left: 16.0, top: 4.0), + child: Column( + children: [ + _buildDetailRow("Height:", data.flowrateSurfaceDrifterHeight != null ? "${data.flowrateSurfaceDrifterHeight} m" : "N/A"), + _buildDetailRow("Distance:", data.flowrateSurfaceDrifterDistance != null ? "${data.flowrateSurfaceDrifterDistance} m" : "N/A"), + _buildDetailRow("Time First:", data.flowrateSurfaceDrifterTimeFirst ?? "N/A"), + _buildDetailRow("Time Last:", data.flowrateSurfaceDrifterTimeLast ?? "N/A"), + ], + ), + ) + ); + } + + children.add( + _buildDetailRow("Flowrate Value:", data.flowrateValue != null ? '${data.flowrateValue!.toStringAsFixed(4)} m/s' : 'NA') + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDetailRow("Flowrate Method:", method), - if (method == 'Surface Drifter') ...[ - _buildDetailRow(" Height:", data.flowrateSurfaceDrifterHeight?.toString()), - _buildDetailRow(" Distance:", data.flowrateSurfaceDrifterDistance?.toString()), - _buildDetailRow(" Time First:", data.flowrateSurfaceDrifterTimeFirst), - _buildDetailRow(" Time Last:", data.flowrateSurfaceDrifterTimeLast), - ], - _buildDetailRow("Flowrate Value:", value), - ], + children: children, ); } -} +} \ No newline at end of file diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 6dc2d30..5a68189 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -1,8 +1,30 @@ +// lib/screens/settings.dart + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/services/settings_service.dart'; +// START CHANGE: Import the new UserPreferencesService to manage submission settings +import 'package:environment_monitoring_app/services/user_preferences_service.dart'; +// END CHANGE + +// START CHANGE: A helper class to manage the state of each module's settings in the UI +class _ModuleSettings { + bool isApiEnabled; + bool isFtpEnabled; + List> apiConfigs; + List> ftpConfigs; + + _ModuleSettings({ + this.isApiEnabled = true, + this.isFtpEnabled = true, + required this.apiConfigs, + required this.ftpConfigs, + }); +} +// END CHANGE + class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -15,6 +37,24 @@ class _SettingsScreenState extends State { final SettingsService _settingsService = SettingsService(); bool _isSyncingData = false; + // START CHANGE: New state variables for managing submission preferences UI + final UserPreferencesService _preferencesService = UserPreferencesService(); + bool _isLoadingSettings = true; + bool _isSaving = false; + + // This map holds the live state of the settings UI for each module + final Map _moduleSettings = {}; + + // This list defines which modules will appear in the new settings section + final List> _configurableModules = [ + {'key': 'marine_tarball', 'name': 'Marine Tarball'}, + {'key': 'marine_in_situ', 'name': 'Marine In-Situ'}, + {'key': 'river_in_situ', 'name': 'River In-Situ'}, + {'key': 'air_installation', 'name': 'Air Installation'}, + {'key': 'air_collection', 'name': 'Air Collection'}, + ]; + // END CHANGE + final TextEditingController _tarballSearchController = TextEditingController(); String _tarballSearchQuery = ''; final TextEditingController _manualSearchController = TextEditingController(); @@ -31,6 +71,7 @@ class _SettingsScreenState extends State { @override void initState() { super.initState(); + _loadAllModuleSettings(); // Load the new submission preferences on init _tarballSearchController.addListener(_onTarballSearchChanged); _manualSearchController.addListener(_onManualSearchChanged); _riverManualSearchController.addListener(_onRiverManualSearchChanged); @@ -86,6 +127,55 @@ class _SettingsScreenState extends State { }); } + // START CHANGE: New methods for loading and saving the submission preferences + Future _loadAllModuleSettings() async { + setState(() => _isLoadingSettings = true); + for (var module in _configurableModules) { + final moduleKey = module['key']!; + final prefs = await _preferencesService.getModulePreference(moduleKey); + final apiConfigsWithPrefs = await _preferencesService.getAllApiConfigsWithModulePreferences(moduleKey); + final ftpConfigsWithPrefs = await _preferencesService.getAllFtpConfigsWithModulePreferences(moduleKey); + + _moduleSettings[moduleKey] = _ModuleSettings( + isApiEnabled: prefs['is_api_enabled'], + isFtpEnabled: prefs['is_ftp_enabled'], + apiConfigs: apiConfigsWithPrefs, + ftpConfigs: ftpConfigsWithPrefs, + ); + } + if (mounted) { + setState(() => _isLoadingSettings = false); + } + } + + Future _saveAllModuleSettings() async { + setState(() => _isSaving = true); + + try { + for (var module in _configurableModules) { + final moduleKey = module['key']!; + final settings = _moduleSettings[moduleKey]!; + + await _preferencesService.saveModulePreference( + moduleName: moduleKey, + isApiEnabled: settings.isApiEnabled, + isFtpEnabled: settings.isFtpEnabled, + ); + + await _preferencesService.saveApiLinksForModule(moduleKey, settings.apiConfigs); + await _preferencesService.saveFtpLinksForModule(moduleKey, settings.ftpConfigs); + } + _showSnackBar('Submission preferences saved successfully.', isError: false); + } catch (e) { + _showSnackBar('Failed to save settings: $e', isError: true); + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } + // END CHANGE + Future _manualDataSync() async { if (_isSyncingData) return; setState(() => _isSyncingData = true); @@ -94,6 +184,8 @@ class _SettingsScreenState extends State { try { await auth.syncAllData(forceRefresh: true); + // MODIFIED: After syncing, also reload module settings to reflect any new server configurations. + await _loadAllModuleSettings(); if (mounted) { _showSnackBar('Data synced successfully.', isError: false); @@ -194,6 +286,20 @@ class _SettingsScreenState extends State { return Scaffold( appBar: AppBar( title: const Text("Settings"), + // START CHANGE: Add a save button to the AppBar + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: _isSaving + ? const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white))) + : IconButton( + icon: const Icon(Icons.save), + onPressed: _isLoadingSettings ? null : _saveAllModuleSettings, + tooltip: 'Save Submission Preferences', + ), + ) + ], + // END CHANGE ), body: SingleChildScrollView( padding: const EdgeInsets.all(24.0), @@ -224,6 +330,27 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 32), + // START CHANGE: Insert the new Submission Preferences section + _buildSectionHeader(context, "Submission Preferences"), + _isLoadingSettings + ? const Center(child: Padding(padding: EdgeInsets.all(16.0), child: CircularProgressIndicator())) + : Card( + margin: EdgeInsets.zero, + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _configurableModules.length, + itemBuilder: (context, index) { + final module = _configurableModules[index]; + final settings = _moduleSettings[module['key']]; + if (settings == null) return const SizedBox.shrink(); + return _buildModulePreferenceTile(module['name']!, module['key']!, settings); + }, + ), + ), + const SizedBox(height: 32), + // END CHANGE + Text("Telegram Alert Settings", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), const SizedBox(height: 16), Card( @@ -480,6 +607,67 @@ class _SettingsScreenState extends State { ); } + // START CHANGE: New helper widgets for the preferences UI + Widget _buildModulePreferenceTile(String title, String moduleKey, _ModuleSettings settings) { + return ExpansionTile( + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + children: [ + SwitchListTile( + title: const Text('Enable API Submission'), + value: settings.isApiEnabled, + onChanged: (value) => setState(() => settings.isApiEnabled = value), + ), + if (settings.isApiEnabled) + _buildDestinationList('API Destinations', settings.apiConfigs, 'api_config_id'), + + const Divider(), + + SwitchListTile( + title: const Text('Enable FTP Submission'), + value: settings.isFtpEnabled, + onChanged: (value) => setState(() => settings.isFtpEnabled = value), + ), + if (settings.isFtpEnabled) + _buildDestinationList('FTP Destinations', settings.ftpConfigs, 'ftp_config_id'), + ], + ); + } + + Widget _buildDestinationList(String title, List> configs, String idKey) { + if (configs.isEmpty) { + return const ListTile( + dense: true, + title: Center(child: Text('No destinations configured. Sync to fetch.')), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, bottom: 8.0), + child: Text(title, style: Theme.of(context).textTheme.titleMedium), + ), + ...configs.map((config) { + return CheckboxListTile( + title: Text(config['config_name'] ?? 'Unnamed'), + subtitle: Text(config['api_url'] ?? config['ftp_host'] ?? 'No URL/Host'), + value: config['is_enabled'] ?? false, + onChanged: (bool? value) { + setState(() { + config['is_enabled'] = value ?? false; + }); + }, + dense: true, + ); + }).toList(), + ], + ), + ); + } + // END CHANGE + Widget _buildSectionHeader(BuildContext context, String title) { return Padding( padding: const EdgeInsets.only(bottom: 16.0), @@ -715,4 +903,4 @@ class _SettingsScreenState extends State { dense: true, ); } -} +} \ No newline at end of file diff --git a/lib/serial/serial_manager.dart b/lib/serial/serial_manager.dart index 8227728..568eaba 100644 --- a/lib/serial/serial_manager.dart +++ b/lib/serial/serial_manager.dart @@ -1,4 +1,5 @@ // lib/serial/serial_manager.dart + import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; // For debugPrint @@ -146,11 +147,12 @@ class SerialManager { if (connectionState.value != SerialConnectionState.disconnected) { debugPrint("SerialManager: Disconnecting..."); stopAutoReading(); // Stop any active auto-reading timer and timeout + // FIX: Update ValueNotifiers before closing the stream and port. + connectionState.value = SerialConnectionState.disconnected; + connectedDeviceName.value = null; + sondeId.value = null; // Clear Sonde ID on disconnect await _port?.close(); // Now correctly awaiting the close operation _port = null; // Clear port reference - connectedDeviceName.value = null; // Clear device name - sondeId.value = null; // Clear Sonde ID on disconnect - connectionState.value = SerialConnectionState.disconnected; // Update connection state _responseBuffer.clear(); // Clear any buffered data _isReading = false; // Reset reading flag _communicationLevel = 0; // Reset communication level @@ -534,8 +536,9 @@ class SerialManager { _isDisposed = true; // Set the flag immediately disconnect(); // Ensure full disconnection and cleanup _dataStreamController.close(); // Close the data stream controller - connectionState.dispose(); // Dispose the ValueNotifier - connectedDeviceName.dispose(); // Dispose the ValueNotifier - sondeId.dispose(); // Dispose the Sonde ID ValueNotifier + // FIX: Dispose of all ValueNotifiers to prevent "used after dispose" errors + connectionState.dispose(); + connectedDeviceName.dispose(); + sondeId.dispose(); } } \ No newline at end of file diff --git a/lib/services/air_api_service.dart b/lib/services/air_api_service.dart deleted file mode 100644 index ef8d49e..0000000 --- a/lib/services/air_api_service.dart +++ /dev/null @@ -1,13 +0,0 @@ -// lib/services/air_api_service.dart - -import 'package:environment_monitoring_app/services/base_api_service.dart'; - -class AirApiService { - final BaseApiService _baseService = BaseApiService(); - -// You can add methods for air-related API calls here in the future -// For example: -// Future> getAirQualityData() { -// return _baseService.get('air/dashboard'); -// } -} diff --git a/lib/services/air_sampling_service.dart b/lib/services/air_sampling_service.dart index d13cf2c..452bd1c 100644 --- a/lib/services/air_sampling_service.dart +++ b/lib/services/air_sampling_service.dart @@ -1,6 +1,5 @@ // lib/services/air_sampling_service.dart -import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:image_picker/image_picker.dart'; @@ -15,26 +14,33 @@ import '../models/air_collection_data.dart'; import 'api_service.dart'; import 'local_storage_service.dart'; import 'telegram_service.dart'; -// --- ADDED: Import for the service that manages active server configurations --- import 'server_config_service.dart'; +import 'zipping_service.dart'; +// START CHANGE: Import the new common submission services +import 'submission_api_service.dart'; +import 'submission_ftp_service.dart'; +// END CHANGE /// A dedicated service for handling all business logic for the Air Manual Sampling feature. class AirSamplingService { - final ApiService _apiService; + // START CHANGE: Instantiate new services and remove ApiService final DatabaseHelper _dbHelper; final TelegramService _telegramService; + final SubmissionApiService _submissionApiService = SubmissionApiService(); + final SubmissionFtpService _submissionFtpService = SubmissionFtpService(); final ServerConfigService _serverConfigService = ServerConfigService(); + final ZippingService _zippingService = ZippingService(); + final LocalStorageService _localStorageService = LocalStorageService(); + // END CHANGE + // MODIFIED: Constructor no longer needs ApiService + AirSamplingService(this._dbHelper, this._telegramService); - // REVISED: Constructor now takes dependencies as parameters - AirSamplingService(this._apiService, this._dbHelper, this._telegramService); - - // Helper method to create a map suitable for LocalStorageService (retains File objects) + // This helper method remains unchanged as it's for local saving logic Map _toMapForLocalSave(dynamic data) { if (data is AirInstallationData) { - final map = data.toMap(); // Get map with paths for DB logging - // Overwrite paths with live File objects for local saving process + final map = data.toMap(); map['imageFront'] = data.imageFront; map['imageBack'] = data.imageBack; map['imageLeft'] = data.imageLeft; @@ -49,8 +55,7 @@ class AirSamplingService { } return map; } else if (data is AirCollectionData) { - final map = data.toMap(); // Get map with paths for DB logging - // Overwrite paths with live File objects for local saving process + final map = data.toMap(); map['imageFront'] = data.imageFront; map['imageBack'] = data.imageBack; map['imageLeft'] = data.imageLeft; @@ -66,9 +71,7 @@ class AirSamplingService { return {}; } - - /// Picks an image from the specified source, adds a timestamp watermark, - /// and saves it to a temporary directory with a standardized name. + // This image processing utility remains unchanged Future pickAndProcessImage( ImageSource source, { required String stationCode, @@ -85,10 +88,9 @@ class AirSamplingService { img.Image? originalImage = img.decodeImage(bytes); if (originalImage == null) return null; - // MODIFIED: Enforce landscape orientation for required photos if (isRequired && originalImage.height > originalImage.width) { debugPrint("Image orientation check failed: Image must be in landscape mode."); - return null; // Return null to indicate failure + return null; } final String watermarkTimestamp = @@ -116,11 +118,205 @@ class AirSamplingService { return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); } - // MODIFIED: Method now requires the appSettings list to pass to TelegramService. + // --- REFACTORED submitInstallation method --- + Future> submitInstallation(AirInstallationData data, List>? appSettings) async { + const String moduleName = 'air_installation'; + final activeConfig = await _serverConfigService.getActiveApiConfig(); + final serverName = activeConfig?['config_name'] as String? ?? 'Default'; + + // --- 1. API SUBMISSION (DATA) --- + debugPrint("Step 1: Delegating Installation Data submission to SubmissionApiService..."); + final dataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'air/manual/installation', + body: data.toJsonForApi(), + ); + + if (dataResult['success'] != true) { + await _logAndSave(data: data, status: 'L1', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Installation'); + return {'status': 'L1', 'message': dataResult['message']}; + } + + final recordId = dataResult['data']?['air_man_id']?.toString(); + if (recordId == null) { + await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Installation'); + return {'status': 'L1', 'message': 'API Error: Missing record ID.'}; + } + data.airManId = int.tryParse(recordId); + + // --- 2. API SUBMISSION (IMAGES) --- + debugPrint("Step 2: Delegating Installation Image submission to SubmissionApiService..."); + final imageFiles = data.getImagesForUpload(); + final imageResult = await _submissionApiService.submitMultipart( + moduleName: moduleName, + endpoint: 'air/manual/installation-images', + fields: {'air_man_id': recordId}, + files: imageFiles, + ); + final bool apiImagesSuccess = imageResult['success'] == true; + + // --- 3. FTP SUBMISSION --- + debugPrint("Step 3: Delegating Installation FTP submission to SubmissionFtpService..."); + final stationCode = data.stationID ?? 'UNKNOWN'; + final samplingDateTime = "${data.installationDate}_${data.installationTime}".replaceAll(':', '-').replaceAll(' ', '_'); + final baseFileName = "${stationCode}_INSTALLATION_${samplingDateTime}"; + + // Zip and submit data + final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, baseFileName: baseFileName); + Map ftpDataResult = {'statuses': []}; + if (dataZip != null) { + ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}'); + } + + // Zip and submit images + final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName); + Map ftpImageResult = {'statuses': []}; + if (imageZip != null) { + ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}'); + } + + final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); + + // --- 4. DETERMINE FINAL STATUS, LOG, AND ALERT --- + String finalStatus; + String finalMessage; + if (apiImagesSuccess) { + finalStatus = ftpSuccess ? 'S4' : 'S2'; + finalMessage = ftpSuccess ? 'Data and files submitted successfully.' : 'Data submitted to API. FTP upload failed or was queued.'; + } else { + finalStatus = ftpSuccess ? 'L2_FTP_ONLY' : 'L2_PENDING_IMAGES'; + finalMessage = ftpSuccess ? 'API image upload failed, but files were sent via FTP.' : 'Data submitted, but API image and FTP uploads failed.'; + } + + await _logAndSave(data: data, status: finalStatus, message: finalMessage, apiResults: [dataResult, imageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Installation'); + _handleInstallationSuccessAlert(data, appSettings, isDataOnly: !apiImagesSuccess); + + return {'status': finalStatus, 'message': finalMessage}; + } + + // --- REFACTORED submitCollection method --- + Future> submitCollection(AirCollectionData data, AirInstallationData installationData, List>? appSettings) async { + const String moduleName = 'air_collection'; + final activeConfig = await _serverConfigService.getActiveApiConfig(); + final serverName = activeConfig?['config_name'] as String? ?? 'Default'; + + // --- 1. API SUBMISSION (DATA) --- + debugPrint("Step 1: Delegating Collection Data submission to SubmissionApiService..."); + data.airManId = installationData.airManId; // Ensure collection is linked to installation + final dataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'air/manual/collection', + body: data.toJson(), + ); + + if (dataResult['success'] != true) { + await _logAndSave(data: data, installationData: installationData, status: 'L3', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Collection'); + return {'status': 'L3', 'message': dataResult['message']}; + } + + // --- 2. API SUBMISSION (IMAGES) --- + debugPrint("Step 2: Delegating Collection Image submission to SubmissionApiService..."); + final imageFiles = data.getImagesForUpload(); + final imageResult = await _submissionApiService.submitMultipart( + moduleName: moduleName, + endpoint: 'air/manual/collection-images', + fields: {'air_man_id': data.airManId.toString()}, + files: imageFiles, + ); + final bool apiImagesSuccess = imageResult['success'] == true; + + // --- 3. FTP SUBMISSION --- + debugPrint("Step 3: Delegating Collection FTP submission to SubmissionFtpService..."); + final stationCode = installationData.stationID ?? 'UNKNOWN'; + final samplingDateTime = "${data.collectionDate}_${data.collectionTime}".replaceAll(':', '-').replaceAll(' ', '_'); + final baseFileName = "${stationCode}_COLLECTION_${samplingDateTime}"; + + // Zip and submit data (includes both installation and collection data) + final combinedJson = jsonEncode({"installation": installationData.toDbJson(), "collection": data.toMap()}); + final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': combinedJson}, baseFileName: baseFileName); + Map ftpDataResult = {'statuses': []}; + if (dataZip != null) { + ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}'); + } + + // Zip and submit images + final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName); + Map ftpImageResult = {'statuses': []}; + if (imageZip != null) { + ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}'); + } + + final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); + + // --- 4. DETERMINE FINAL STATUS, LOG, AND ALERT --- + String finalStatus; + String finalMessage; + if (apiImagesSuccess) { + finalStatus = ftpSuccess ? 'S4_API_FTP' : 'S3'; + finalMessage = ftpSuccess ? 'Data and files submitted successfully.' : 'Data submitted to API. FTP upload failed or was queued.'; + } else { + finalStatus = ftpSuccess ? 'L4_FTP_ONLY' : 'L4_PENDING_IMAGES'; + finalMessage = ftpSuccess ? 'API image upload failed, but files were sent via FTP.' : 'Data submitted, but API image and FTP uploads failed.'; + } + + await _logAndSave(data: data, installationData: installationData, status: finalStatus, message: finalMessage, apiResults: [dataResult, imageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Collection'); + _handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: !apiImagesSuccess); + + return {'status': finalStatus, 'message': finalMessage}; + } + + /// Centralized method for logging and saving data locally. + Future _logAndSave({ + required dynamic data, + AirInstallationData? installationData, + required String status, + required String message, + required List> apiResults, + required List> ftpStatuses, + required String serverName, + required String type, + }) async { + String refID; + Map formData; + List imagePaths; + + if (type == 'Installation') { + final installation = data as AirInstallationData; + installation.status = status; + refID = installation.refID!; + formData = installation.toMap(); + imagePaths = _getInstallationImagePaths(installation); + await _localStorageService.saveAirSamplingRecord(_toMapForLocalSave(installation), refID, serverName: serverName); + } else { + final collection = data as AirCollectionData; + collection.status = status; + refID = collection.installationRefID!; + formData = collection.toMap(); + imagePaths = _getCollectionImagePaths(collection); + await _localStorageService.saveAirSamplingRecord(_toMapForLocalSave(installationData!..collectionData = collection), refID, serverName: serverName); + } + + final logData = { + 'submission_id': refID, + 'module': 'air', + 'type': type, + 'status': status, + 'message': message, + 'report_id': (data.airManId ?? installationData?.airManId)?.toString(), + 'created_at': DateTime.now(), + 'form_data': jsonEncode(formData), + 'image_data': jsonEncode(imagePaths), + 'server_name': serverName, + 'api_status': jsonEncode(apiResults), + 'ftp_status': jsonEncode(ftpStatuses), + }; + await _dbHelper.saveSubmissionLog(logData); + } + + // Helper and Alert methods remain unchanged Future _handleInstallationSuccessAlert(AirInstallationData data, List>? appSettings, {required bool isDataOnly}) async { try { final message = data.generateInstallationTelegramAlert(isDataOnly: isDataOnly); - // Pass the appSettings list to the telegram service methods final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message, appSettings); if (!wasSent) { await _telegramService.queueMessage('air_manual', message, appSettings); @@ -130,11 +326,9 @@ class AirSamplingService { } } - // MODIFIED: Method now requires the appSettings list to pass to TelegramService. Future _handleCollectionSuccessAlert(AirCollectionData data, AirInstallationData installationData, List>? appSettings, {required bool isDataOnly}) async { try { final message = data.generateCollectionTelegramAlert(installationData, isDataOnly: isDataOnly); - // Pass the appSettings list to the telegram service methods final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message, appSettings); if (!wasSent) { await _telegramService.queueMessage('air_manual', message, appSettings); @@ -144,7 +338,6 @@ class AirSamplingService { } } - // --- ADDED HELPER METHODS TO GET IMAGE PATHS FROM MODELS --- List _getInstallationImagePaths(AirInstallationData data) { final List files = [ data.imageFront, data.imageBack, data.imageLeft, data.imageRight, @@ -162,489 +355,17 @@ class AirSamplingService { return files.where((f) => f != null).map((f) => f!.path).toList(); } - - /// Orchestrates a two-step submission process for air installation samples. - // MODIFIED: Method now requires the appSettings list to pass down the call stack. - Future> submitInstallation(AirInstallationData data, List>? appSettings) async { - // --- MODIFIED: Get the active server name to use for local storage --- - final activeConfig = await _serverConfigService.getActiveApiConfig(); - final serverName = activeConfig?['config_name'] as String? ?? 'Default'; - final localStorageService = LocalStorageService(); // Instance for file system save - - // If the record's text data is already on the server, skip directly to image upload. - if (data.status == 'L2_PENDING_IMAGES' && data.airManId != null) { - debugPrint("Retrying image upload for existing record ID: ${data.airManId}"); - final result = await _uploadInstallationImagesAndUpdate(data, appSettings, serverName: serverName); - - // LOG DEBUG START - final logData = { - 'submission_id': data.refID!, - 'module': 'air', - 'type': 'Installation', - 'status': result['status'], - 'message': result['message'], - 'report_id': data.airManId.toString(), - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(_getInstallationImagePaths(data)), - 'server_name': serverName, - 'api_status': jsonEncode([{"server_name": serverName, "status": "PENDING", "message": "Resubmitting images."}]), - 'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "FTP not used for images."}]), - }; - debugPrint("DB LOGGING (Installation Retry): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}"); - // LOG DEBUG END - - // --- ADDED: Log the final result to the central database --- - await _dbHelper.saveSubmissionLog(logData); - return result; - } - - // --- STEP 1: SUBMIT TEXT DATA --- - debugPrint("Step 1: Submitting installation text data..."); - final textDataResult = await _apiService.air.submitInstallation(data); - - // --- CRITICAL FIX: Save to local file system immediately regardless of API success --- - data.status = 'L1'; // Temporary set status to Local Only - // Use the special helper method to pass live File objects for copying - final localSaveMap = _toMapForLocalSave(data); - final localSaveResult = await localStorageService.saveAirSamplingRecord(localSaveMap, data.refID!, serverName: serverName); - - if (localSaveResult == null) { - debugPrint("CRITICAL ERROR: Failed to save Air Installation record to local file system."); - } - // --- END CRITICAL FIX --- - - - if (textDataResult['success'] != true) { - debugPrint("Failed to submit text data. Reason: ${textDataResult['message']}"); - final result = {'status': 'L1', 'message': 'No connection or server error. Installation data saved locally.'}; - - // LOG DEBUG START - final logData = { - 'submission_id': data.refID!, - 'module': 'air', - 'type': 'Installation', - 'status': result['status'], - 'message': result['message'], - 'report_id': data.airManId.toString(), - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(_getInstallationImagePaths(data)), - 'server_name': serverName, - 'api_status': jsonEncode([{"server_name": serverName, "status": "FAILED", "message": "API submission failed."}]), - 'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]), - }; - debugPrint("DB LOGGING (Installation L1): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}"); - // LOG DEBUG END - - // --- ADDED: Log the failed submission to the central database --- - await _dbHelper.saveSubmissionLog(logData); - - return result; - } - - // --- NECESSARY FIX: Safely parse the record ID from the server response --- - final dynamic recordIdFromServer = textDataResult['data']?['air_man_id']; - if (recordIdFromServer == null) { - debugPrint("Text data submitted, but did not receive a record ID."); - final result = {'status': 'L1', 'message': 'Data submitted, but server response was invalid.'}; - - // LOG DEBUG START - final logData = { - 'submission_id': data.refID!, - 'module': 'air', - 'type': 'Installation', - 'status': result['status'], - 'message': result['message'], - 'report_id': data.airManId.toString(), - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(_getInstallationImagePaths(data)), - 'server_name': serverName, - 'api_status': jsonEncode([{"server_name": serverName, "status": "FAILED", "message": "Invalid response from server."}]), - 'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]), - }; - debugPrint("DB LOGGING (Installation L1/Invalid ID): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}"); - // LOG DEBUG END - - // --- ADDED: Log the failed submission to the central database --- - await _dbHelper.saveSubmissionLog(logData); - - return result; - } - - final int? parsedRecordId = int.tryParse(recordIdFromServer.toString()); - - if (parsedRecordId == null) { - debugPrint("Could not parse the received record ID: $recordIdFromServer"); - final result = {'status': 'L1', 'message': 'Data submitted, but server response was invalid.'}; - - // LOG DEBUG START - final logData = { - 'submission_id': data.refID!, - 'module': 'air', - 'type': 'Installation', - 'status': result['status'], - 'message': result['message'], - 'report_id': data.airManId.toString(), - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(_getInstallationImagePaths(data)), - 'server_name': serverName, - 'api_status': jsonEncode([{"server_name": serverName, "status": "FAILED", "message": "Invalid response from server."}]), - 'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]), - }; - debugPrint("DB LOGGING (Installation L1/Parse Fail): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}"); - // LOG DEBUG END - - // --- ADDED: Log the failed submission to the central database --- - await _dbHelper.saveSubmissionLog(logData); - - return result; - } - - data.airManId = parsedRecordId; - - // --- STEP 2: UPLOAD IMAGE FILES --- - return await _uploadInstallationImagesAndUpdate(data, appSettings, serverName: serverName); - } - - /// A reusable function for handling the image upload and local data update logic. - // MODIFIED: Method now requires the serverName to pass to the save method. - Future> _uploadInstallationImagesAndUpdate(AirInstallationData data, List>? appSettings, {required String serverName}) async { - final filesToUpload = data.getImagesForUpload(); - final localStorageService = LocalStorageService(); - - // Since text data was successfully submitted, the status moves to S1 (Server Pending) - data.status = 'S1'; - // We already saved the file in submitInstallation (L1 status). Now we update the status in the local file. - await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.refID!, serverName: serverName); - - if (filesToUpload.isEmpty) { - debugPrint("No images to upload. Submission complete."); - - // LOG DEBUG START - final logData = { - 'submission_id': data.refID!, - 'module': 'air', - 'type': 'Installation', - 'status': data.status, - 'message': 'Installation data submitted successfully.', - 'report_id': data.airManId.toString(), - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(_getInstallationImagePaths(data)), - 'server_name': serverName, - 'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text data submitted."}]), - 'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_REQUIRED", "message": "No images were attached."}]), - }; - debugPrint("DB LOGGING (Installation S1/Data Only): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}"); - // LOG DEBUG END - - // --- MODIFIED: Log the successful submission to the central database --- - await _dbHelper.saveSubmissionLog(logData); - - _handleInstallationSuccessAlert(data, appSettings, isDataOnly: true); - return {'status': 'S1', 'message': 'Installation data submitted successfully.'}; - } - - debugPrint("Step 2: Uploading ${filesToUpload.length} images for record ID ${data.airManId}..."); - final imageUploadResult = await _apiService.air.uploadInstallationImages( - airManId: data.airManId.toString(), - files: filesToUpload, - ); - - if (imageUploadResult['success'] != true) { - debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}"); - data.status = 'L2_PENDING_IMAGES'; - final result = { - 'status': 'L2_PENDING_IMAGES', - 'message': 'Data submitted, but image upload failed. Saved locally for retry.', - }; - - // Update the local file with the image failure status - await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.refID!, serverName: serverName); - - // LOG DEBUG START - final logData = { - 'submission_id': data.refID!, - 'module': 'air', - 'type': 'Installation', - 'status': result['status'], - 'message': result['message'], - 'report_id': data.airManId.toString(), - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(_getInstallationImagePaths(data)), - 'server_name': serverName, - 'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text data submitted."}]), - 'ftp_status': jsonEncode([{"server_name": "N/A", "status": "FAILED", "message": "Image upload failed."}]), - }; - debugPrint("DB LOGGING (Installation L2/Image Fail): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}"); - // LOG DEBUG END - - // --- MODIFIED: Log the failed submission to the central database --- - await _dbHelper.saveSubmissionLog(logData); - - return result; - } - - debugPrint("Images uploaded successfully."); - data.status = 'S2'; // Server Pending (images uploaded) - final result = { - 'status': 'S2', - 'message': 'Installation data and images submitted successfully.', - }; - - // LOG DEBUG START - final logData = { - 'submission_id': data.refID!, - 'module': 'air', - 'type': 'Installation', - 'status': result['status'], - 'message': result['message'], - 'report_id': data.airManId.toString(), - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(_getInstallationImagePaths(data)), - 'server_name': serverName, - 'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text and image data submitted."}]), - 'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]), - }; - debugPrint("DB LOGGING (Installation S2/Success): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}"); - // LOG DEBUG END - - // --- MODIFIED: Log the successful submission to the central database --- - await _dbHelper.saveSubmissionLog(logData); - // Update the local file with the final success status - await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.refID!, serverName: serverName); - - _handleInstallationSuccessAlert(data, appSettings, isDataOnly: false); - return result; - } - - /// Submits only the collection data, linked to a previous installation. - // MODIFIED: Method now requires the appSettings list to pass down the call stack. - Future> submitCollection(AirCollectionData data, AirInstallationData installationData, List>? appSettings) async { - // --- MODIFIED: Get the active server name to use for local storage --- - final activeConfig = await _serverConfigService.getActiveApiConfig(); - final serverName = activeConfig?['config_name'] as String? ?? 'Default'; - - final apiConfigs = (await _dbHelper.loadApiConfigs() ?? []).take(2).toList(); - final localStorageService = LocalStorageService(); - - // If the record's text data is already on the server, skip directly to image upload. - if (data.status == 'L4_PENDING_IMAGES' && data.airManId != null) { - debugPrint("Retrying collection image upload for existing record ID: ${data.airManId}"); - final result = await _uploadCollectionImagesAndUpdate(data, installationData, appSettings, serverName: serverName); - - // LOG DEBUG START - final logData = { - 'submission_id': data.installationRefID!, - 'module': 'air', - 'type': 'Collection', - 'status': result['status'], - 'message': result['message'], - 'report_id': data.airManId.toString(), - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(_getCollectionImagePaths(data)), - 'server_name': serverName, - 'api_status': jsonEncode([{"server_name": serverName, "status": "PENDING", "message": "Resubmitting images."}]), - 'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "FTP not used."}]), - }; - debugPrint("DB LOGGING (Collection Retry): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}"); - // LOG DEBUG END - - // --- ADDED: Log the final result to the central database --- - await _dbHelper.saveSubmissionLog(logData); - return result; - } - - // --- STEP 1: SUBMIT TEXT DATA --- - debugPrint("Step 1: Submitting collection text data..."); - final textDataResult = await _apiService.air.submitCollection(data); - - // --- CRITICAL FIX: Save to local file system immediately regardless of API success --- - data.status = 'L3'; // Temporary set status to Local Only - final localSaveMap = _toMapForLocalSave(data); - final localSaveResult = await localStorageService.saveAirSamplingRecord(localSaveMap, data.installationRefID!, serverName: serverName); - if (localSaveResult == null) { - debugPrint("CRITICAL ERROR: Failed to save Air Collection record to local file system."); - } - // --- END CRITICAL FIX --- - - - if (textDataResult['success'] != true) { - debugPrint("Failed to submit collection text data. Reason: ${textDataResult['message']}"); - final result = {'status': 'L3', 'message': 'No connection or server error. Collection data saved locally.'}; - - // LOG DEBUG START - final logData = { - 'submission_id': data.installationRefID!, - 'module': 'air', - 'type': 'Collection', - 'status': result['status'], - 'message': result['message'], - 'report_id': data.airManId.toString(), - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(_getCollectionImagePaths(data)), - 'server_name': serverName, - 'api_status': jsonEncode([{"server_name": serverName, "status": "FAILED", "message": "API submission failed."}]), - 'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]), - }; - debugPrint("DB LOGGING (Collection L3): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}"); - // LOG DEBUG END - - // --- ADDED: Log the failed submission to the central database --- - await _dbHelper.saveSubmissionLog(logData); - - return result; - } - debugPrint("Collection text data submitted successfully."); - data.airManId = textDataResult['data']['air_man_id']; - - // --- STEP 2: UPLOAD IMAGE FILES --- - return await _uploadCollectionImagesAndUpdate(data, installationData, appSettings, serverName: serverName); - } - - /// A reusable function for handling the collection image upload and local data update logic. - // MODIFIED: Method now requires the serverName to pass to the save method. - Future> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData, List>? appSettings, {required String serverName}) async { - final filesToUpload = data.getImagesForUpload(); - final localStorageService = LocalStorageService(); - - // Since text data was successfully submitted, the status moves to S3 (Server Pending) - data.status = 'S3'; - // Update local file status (which was already saved with L3 status) - await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.installationRefID!, serverName: serverName); - - if (filesToUpload.isEmpty) { - debugPrint("No collection images to upload. Submission complete."); - final result = {'status': 'S3', 'message': 'Collection data submitted successfully.'}; - - // LOG DEBUG START - final logData = { - 'submission_id': data.installationRefID!, - 'module': 'air', - 'type': 'Collection', - 'status': result['status'], - 'message': result['message'], - 'report_id': data.airManId.toString(), - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(_getCollectionImagePaths(data)), - 'server_name': serverName, - 'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text data submitted."}]), - 'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_REQUIRED", "message": "No images were attached."}]), - }; - debugPrint("DB LOGGING (Collection S3/Data Only): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}"); - // LOG DEBUG END - - // --- MODIFIED: Log the successful submission to the central database --- - await _dbHelper.saveSubmissionLog(logData); - - _handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: true); - return result; - } - - debugPrint("Step 2: Uploading ${filesToUpload.length} collection images..."); - final imageUploadResult = await _apiService.air.uploadCollectionImages( - airManId: data.airManId.toString(), - files: filesToUpload, - ); - - if (imageUploadResult['success'] != true) { - debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}"); - data.status = 'L4_PENDING_IMAGES'; - final result = { - 'status': 'L4_PENDING_IMAGES', - 'message': 'Data submitted, but image upload failed. Saved locally for retry.', - }; - - // Update the local file with the image failure status - await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.installationRefID!, serverName: serverName); - - // LOG DEBUG START - final logData = { - 'submission_id': data.installationRefID!, - 'module': 'air', - 'type': 'Collection', - 'status': result['status'], - 'message': result['message'], - 'report_id': data.airManId.toString(), - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(_getCollectionImagePaths(data)), - 'server_name': serverName, - 'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text data submitted."}]), - 'ftp_status': jsonEncode([{"server_name": "N/A", "status": "FAILED", "message": "Image upload failed."}]), - }; - debugPrint("DB LOGGING (Collection L4/Image Fail): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}"); - // LOG DEBUG END - - // --- MODIFIED: Log the failed submission to the central database --- - await _dbHelper.saveSubmissionLog(logData); - - return result; - } - - debugPrint("Images uploaded successfully."); - final result = { - 'status': 'S3', - 'message': 'Collection data and images submitted successfully.', - }; - - // LOG DEBUG START - final logData = { - 'submission_id': data.installationRefID!, - 'module': 'air', - 'type': 'Collection', - 'status': result['status'], - 'message': result['message'], - 'report_id': data.airManId.toString(), - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(_getCollectionImagePaths(data)), - 'server_name': serverName, - 'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text and image data submitted."}]), - 'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]), - }; - debugPrint("DB LOGGING (Collection S3/Success): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}"); - // LOG DEBUG END - - // --- MODIFIED: Log the successful submission to the central database --- - await _dbHelper.saveSubmissionLog(logData); - // Update the local file with the final success status - await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.installationRefID!, serverName: serverName); - - _handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: false); - return result; - } - - - /// Fetches installations that are pending collection from local storage. + // getPendingInstallations can be moved to a different service or screen logic later Future> getPendingInstallations() async { debugPrint("Fetching pending installations from local storage..."); - final logs = await _dbHelper.loadSubmissionLogs(module: 'air'); - final pendingInstallations = logs ?.where((log) { final status = log['status']; - // --- CORRECTED --- - // Only show installations that have been synced to the server (S1, S2). - // 'L1' (Local only) records cannot be collected until they are synced. return status == 'S1' || status == 'S2'; }) .map((log) => AirInstallationData.fromJson(jsonDecode(log['form_data']))) .toList() ?? []; - return pendingInstallations; } - - void dispose() { - // Clean up any resources if necessary - } } \ No newline at end of file diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index c7e3a6c..187e299 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -16,6 +16,9 @@ import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/models/air_collection_data.dart'; import 'package:environment_monitoring_app/models/air_installation_data.dart'; import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart'; +// START CHANGE: Added import for ServerConfigService to get the base URL +import 'package:environment_monitoring_app/services/server_config_service.dart'; +// END CHANGE // ======================================================================= // Part 1: Unified API Service @@ -27,6 +30,9 @@ import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.da class ApiService { final BaseApiService _baseService = BaseApiService(); final DatabaseHelper dbHelper = DatabaseHelper(); + // START CHANGE: Added ServerConfigService to provide the base URL for API calls + final ServerConfigService _serverConfigService = ServerConfigService(); + // END CHANGE late final MarineApiService marine; late final RiverApiService river; @@ -35,15 +41,19 @@ class ApiService { static const String imageBaseUrl = 'https://dev14.pstw.com.my/'; ApiService({required TelegramService telegramService}) { - marine = MarineApiService(_baseService, telegramService); - river = RiverApiService(_baseService, telegramService); - air = AirApiService(_baseService, telegramService); + // START CHANGE: Pass the ServerConfigService to the sub-services + marine = MarineApiService(_baseService, telegramService, _serverConfigService); + river = RiverApiService(_baseService, telegramService, _serverConfigService); + air = AirApiService(_baseService, telegramService, _serverConfigService); + // END CHANGE } // --- Core API Methods (Unchanged) --- - Future> login(String email, String password) { - return _baseService.post('auth/login', {'email': email, 'password': password}); + // START CHANGE: Update all calls to _baseService to pass the required baseUrl + Future> login(String email, String password) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.post(baseUrl, 'auth/login', {'email': email, 'password': password}); } Future> register({ @@ -56,7 +66,8 @@ class ApiService { int? departmentId, int? companyId, int? positionId, - }) { + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); final Map body = { 'username': username, 'email': email, @@ -69,27 +80,47 @@ class ApiService { 'position_id': positionId, }; body.removeWhere((key, value) => value == null); - return _baseService.post('auth/register', body); + return _baseService.post(baseUrl, 'auth/register', body); } - Future> post(String endpoint, Map data) { - return _baseService.post(endpoint, data); + Future> post(String endpoint, Map data) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.post(baseUrl, endpoint, data); } - Future> getProfile() => _baseService.get('profile'); - Future> getAllUsers() => _baseService.get('users'); + Future> getProfile() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'profile'); + } + Future> getAllUsers() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'users'); + } - Future> getAllDepartments() => _baseService.get('departments'); - Future> getAllCompanies() => _baseService.get('companies'); - Future> getAllPositions() => _baseService.get('positions'); - Future> getAllStates() => _baseService.get('states'); + Future> getAllDepartments() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'departments'); + } + Future> getAllCompanies() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'companies'); + } + Future> getAllPositions() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'positions'); + } + Future> getAllStates() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'states'); + } Future> sendTelegramAlert({ required String chatId, required String message, - }) { - return _baseService.post('marine/telegram-alert', { + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.post(baseUrl, 'marine/telegram-alert', { 'chat_id': chatId, 'message': message, }); @@ -110,13 +141,16 @@ class ApiService { return null; } - Future> uploadProfilePicture(File imageFile) { + Future> uploadProfilePicture(File imageFile) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.postMultipart( + baseUrl: baseUrl, endpoint: 'profile/upload-picture', fields: {}, files: {'profile_picture': imageFile} ); } + // END CHANGE Future> refreshProfile() async { debugPrint('ApiService: Refreshing profile data from server...'); @@ -131,13 +165,16 @@ class ApiService { // --- REWRITTEN FOR DELTA SYNC --- /// Helper method to make a delta-sync API call. - Future> _fetchDelta(String endpoint, String? lastSyncTimestamp) { + Future> _fetchDelta(String endpoint, String? lastSyncTimestamp) async { + // START CHANGE: Get baseUrl and pass it to the get method + final baseUrl = await _serverConfigService.getActiveApiUrl(); String url = endpoint; if (lastSyncTimestamp != null) { // Append the 'since' parameter to the URL for delta requests url += '?since=$lastSyncTimestamp'; } - return _baseService.get(url); + return _baseService.get(baseUrl, url); + // END CHANGE } /// Orchestrates a full DELTA sync from the server to the local database. @@ -161,7 +198,6 @@ class ApiService { 'states': {'endpoint': 'states', 'handler': (d, id) async { await dbHelper.upsertStates(d); await dbHelper.deleteStates(id); }}, 'appSettings': {'endpoint': 'settings', 'handler': (d, id) async { await dbHelper.upsertAppSettings(d); await dbHelper.deleteAppSettings(id); }}, 'parameterLimits': {'endpoint': 'parameter-limits', 'handler': (d, id) async { await dbHelper.upsertParameterLimits(d); await dbHelper.deleteParameterLimits(id); }}, - // --- ADDED: New sync tasks for independent API and FTP configurations --- 'apiConfigs': {'endpoint': 'api-configs', 'handler': (d, id) async { await dbHelper.upsertApiConfigs(d); await dbHelper.deleteApiConfigs(id); }}, 'ftpConfigs': {'endpoint': 'ftp-configs', 'handler': (d, id) async { await dbHelper.upsertFtpConfigs(d); await dbHelper.deleteFtpConfigs(id); }}, }; @@ -207,24 +243,38 @@ class ApiService { class AirApiService { final BaseApiService _baseService; final TelegramService? _telegramService; - AirApiService(this._baseService, [this._telegramService]); + // START CHANGE: Add ServerConfigService dependency + final ServerConfigService _serverConfigService; + AirApiService(this._baseService, this._telegramService, this._serverConfigService); + // END CHANGE - Future> getManualStations() => _baseService.get('air/manual-stations'); - Future> getClients() => _baseService.get('air/clients'); - - Future> submitInstallation(AirInstallationData data) { - return _baseService.post('air/manual/installation', data.toJsonForApi()); + // START CHANGE: Update all calls to _baseService to pass the required baseUrl + Future> getManualStations() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'air/manual-stations'); + } + Future> getClients() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'air/clients'); } - Future> submitCollection(AirCollectionData data) { - return _baseService.post('air/manual/collection', data.toJson()); + Future> submitInstallation(AirInstallationData data) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.post(baseUrl, 'air/manual/installation', data.toJsonForApi()); + } + + Future> submitCollection(AirCollectionData data) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.post(baseUrl, 'air/manual/collection', data.toJson()); } Future> uploadInstallationImages({ required String airManId, required Map files, - }) { + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.postMultipart( + baseUrl: baseUrl, endpoint: 'air/manual/installation-images', fields: {'air_man_id': airManId}, files: files, @@ -234,26 +284,41 @@ class AirApiService { Future> uploadCollectionImages({ required String airManId, required Map files, - }) { + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.postMultipart( + baseUrl: baseUrl, endpoint: 'air/manual/collection-images', fields: {'air_man_id': airManId}, files: files, ); } +// END CHANGE } class MarineApiService { final BaseApiService _baseService; final TelegramService _telegramService; - MarineApiService(this._baseService, this._telegramService); + // START CHANGE: Add ServerConfigService dependency + final ServerConfigService _serverConfigService; + MarineApiService(this._baseService, this._telegramService, this._serverConfigService); + // END CHANGE - Future> getTarballStations() => _baseService.get('marine/tarball/stations'); - Future> getManualStations() => _baseService.get('marine/manual/stations'); - Future> getTarballClassifications() => _baseService.get('marine/tarball/classifications'); + // START CHANGE: Update all calls to _baseService to pass the required baseUrl + Future> getTarballStations() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'marine/tarball/stations'); + } + Future> getManualStations() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'marine/manual/stations'); + } + Future> getTarballClassifications() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'marine/tarball/classifications'); + } - // FIX: Added submitInSituSample implementation for Marine from marine_api_service.dart Future> submitInSituSample({ required Map formData, required Map imageFiles, @@ -261,7 +326,8 @@ class MarineApiService { required List>? appSettings, }) async { debugPrint("Step 1: Submitting in-situ form data to the server..."); - final dataResult = await _baseService.post('marine/manual/sample', formData); + final baseUrl = await _serverConfigService.getActiveApiUrl(); + final dataResult = await _baseService.post(baseUrl, 'marine/manual/sample', formData); if (dataResult['success'] != true) { debugPrint("API submission failed for In-Situ. Message: ${dataResult['message']}"); @@ -303,6 +369,7 @@ class MarineApiService { debugPrint("Step 2: Uploading ${filesToUpload.length} in-situ images for record ID: $recordId"); final imageResult = await _baseService.postMultipart( + baseUrl: baseUrl, endpoint: 'marine/manual/images', fields: {'man_id': recordId.toString()}, files: filesToUpload, @@ -339,14 +406,14 @@ class MarineApiService { debugPrint("Failed to handle In-Situ Telegram alert: $e"); } } - // END FIX: Added submitInSituSample implementation for Marine Future> submitTarballSample({ required Map formData, required Map imageFiles, required List>? appSettings, }) async { - final dataResult = await _baseService.post('marine/tarball/sample', formData); + final baseUrl = await _serverConfigService.getActiveApiUrl(); + final dataResult = await _baseService.post(baseUrl, 'marine/tarball/sample', formData); if (dataResult['success'] != true) return {'status': 'L1', 'success': false, 'message': 'Failed to submit data: ${dataResult['message']}'}; final recordId = dataResult['data']?['autoid']; @@ -360,7 +427,7 @@ class MarineApiService { 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); + final imageResult = await _baseService.postMultipart(baseUrl: baseUrl, endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload); if (imageResult['success'] != true) { _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true); return {'status': 'L2', 'success': false, 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', 'reportId': recordId}; @@ -369,6 +436,7 @@ class MarineApiService { _handleTarballSuccessAlert(formData, appSettings, isDataOnly: false); return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId}; } + // END CHANGE Future _handleTarballSuccessAlert(Map formData, List>? appSettings, {required bool isDataOnly}) async { debugPrint("Triggering Telegram alert logic..."); @@ -418,19 +486,28 @@ class MarineApiService { class RiverApiService { final BaseApiService _baseService; final TelegramService _telegramService; - RiverApiService(this._baseService, this._telegramService); + // START CHANGE: Add ServerConfigService dependency + final ServerConfigService _serverConfigService; + RiverApiService(this._baseService, this._telegramService, this._serverConfigService); + // END CHANGE - Future> getManualStations() => _baseService.get('river/manual-stations'); - Future> getTriennialStations() => _baseService.get('river/triennial-stations'); + // START CHANGE: Update all calls to _baseService to pass the required baseUrl + Future> getManualStations() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'river/manual-stations'); + } + Future> getTriennialStations() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'river/triennial-stations'); + } - // FIX: Added submitInSituSample implementation for River from river_api_service.dart Future> submitInSituSample({ required Map formData, required Map imageFiles, required List>? appSettings, }) async { - // --- Step 1: Submit Form Data as JSON --- - final dataResult = await _baseService.post('river/manual/sample', formData); + final baseUrl = await _serverConfigService.getActiveApiUrl(); + final dataResult = await _baseService.post(baseUrl, 'river/manual/sample', formData); if (dataResult['success'] != true) { return { @@ -441,7 +518,6 @@ class RiverApiService { }; } - // --- Step 2: Upload Image Files --- final recordId = dataResult['data']?['r_man_id']; if (recordId == null) { return { @@ -468,6 +544,7 @@ class RiverApiService { } final imageResult = await _baseService.postMultipart( + baseUrl: baseUrl, endpoint: 'river/manual/images', // Separate endpoint for images fields: {'r_man_id': recordId.toString()}, // Link images to the submitted record ID files: filesToUpload, @@ -490,6 +567,7 @@ class RiverApiService { 'reportId': recordId.toString() }; } + // END CHANGE Future _handleInSituSuccessAlert(Map formData, List>? appSettings, {required bool isDataOnly}) async { try { @@ -524,7 +602,6 @@ class RiverApiService { final String message = buffer.toString(); - // MODIFIED: Pass the appSettings list to the TelegramService methods. final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings); if (!wasSent) { await _telegramService.queueMessage('river_in_situ', message, appSettings); @@ -533,7 +610,6 @@ class RiverApiService { debugPrint("Failed to handle River Telegram alert: $e"); } } -// END FIX: Added submitInSituSample implementation for River } // ======================================================================= @@ -543,7 +619,7 @@ class RiverApiService { class DatabaseHelper { static Database? _database; static const String _dbName = 'app_data.db'; - static const int _dbVersion = 18; + static const int _dbVersion = 19; static const String _profileTable = 'user_profile'; static const String _usersTable = 'all_users'; @@ -564,9 +640,12 @@ class DatabaseHelper { static const String _apiConfigsTable = 'api_configurations'; static const String _ftpConfigsTable = 'ftp_configurations'; static const String _retryQueueTable = 'retry_queue'; - // FIX: Updated submission log table schema for granular status tracking static const String _submissionLogTable = 'submission_log'; + static const String _modulePreferencesTable = 'module_preferences'; + static const String _moduleApiLinksTable = 'module_api_links'; + static const String _moduleFtpLinksTable = 'module_ftp_links'; + Future get database async { if (_database != null) return _database!; @@ -625,6 +704,32 @@ class DatabaseHelper { ftp_status TEXT ) '''); + + // START CHANGE: Added CREATE TABLE statements for the new tables. + await db.execute(''' + CREATE TABLE $_modulePreferencesTable ( + module_name TEXT PRIMARY KEY, + is_api_enabled INTEGER NOT NULL DEFAULT 1, + is_ftp_enabled INTEGER NOT NULL DEFAULT 1 + ) + '''); + await db.execute(''' + CREATE TABLE $_moduleApiLinksTable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + module_name TEXT NOT NULL, + api_config_id INTEGER NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT 1 + ) + '''); + await db.execute(''' + CREATE TABLE $_moduleFtpLinksTable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + module_name TEXT NOT NULL, + ftp_config_id INTEGER NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT 1 + ) + '''); + // END CHANGE } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { @@ -682,6 +787,32 @@ class DatabaseHelper { } catch (_) { // Ignore if columns already exist during a complex migration path } + + // START CHANGE: Add upgrade path for the new preference tables. + await db.execute(''' + CREATE TABLE IF NOT EXISTS $_modulePreferencesTable ( + module_name TEXT PRIMARY KEY, + is_api_enabled INTEGER NOT NULL DEFAULT 1, + is_ftp_enabled INTEGER NOT NULL DEFAULT 1 + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS $_moduleApiLinksTable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + module_name TEXT NOT NULL, + api_config_id INTEGER NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT 1 + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS $_moduleFtpLinksTable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + module_name TEXT NOT NULL, + ftp_config_id INTEGER NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT 1 + ) + '''); + // END CHANGE } } @@ -858,4 +989,97 @@ class DatabaseHelper { if (maps.isNotEmpty) return maps; return null; } + + // START CHANGE: Added helper methods for the new preference tables. + + /// Saves or updates a module's master submission preferences. + Future saveModulePreference({ + required String moduleName, + required bool isApiEnabled, + required bool isFtpEnabled, + }) async { + final db = await database; + await db.insert( + _modulePreferencesTable, + { + 'module_name': moduleName, + 'is_api_enabled': isApiEnabled ? 1 : 0, + 'is_ftp_enabled': isFtpEnabled ? 1 : 0, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// Retrieves a module's master submission preferences. + Future?> getModulePreference(String moduleName) async { + final db = await database; + final result = await db.query( + _modulePreferencesTable, + where: 'module_name = ?', + whereArgs: [moduleName], + ); + if (result.isNotEmpty) { + final row = result.first; + return { + 'module_name': row['module_name'], + 'is_api_enabled': (row['is_api_enabled'] as int) == 1, + 'is_ftp_enabled': (row['is_ftp_enabled'] as int) == 1, + }; + } + return null; // Return null if no specific preference is set + } + + /// Saves the complete set of API links for a specific module, replacing any old ones. + Future saveApiLinksForModule(String moduleName, List> links) async { + final db = await database; + await db.transaction((txn) async { + // First, delete all existing links for this module + await txn.delete(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); + // Then, insert all the new links + for (final link in links) { + await txn.insert(_moduleApiLinksTable, { + 'module_name': moduleName, + 'api_config_id': link['api_config_id'], + 'is_enabled': (link['is_enabled'] as bool? ?? true) ? 1 : 0, + }); + } + }); + } + + /// Saves the complete set of FTP links for a specific module, replacing any old ones. + Future saveFtpLinksForModule(String moduleName, List> links) async { + final db = await database; + await db.transaction((txn) async { + await txn.delete(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); + for (final link in links) { + await txn.insert(_moduleFtpLinksTable, { + 'module_name': moduleName, + 'ftp_config_id': link['ftp_config_id'], + 'is_enabled': (link['is_enabled'] as bool? ?? true) ? 1 : 0, + }); + } + }); + } + + /// Retrieves all API links for a specific module, regardless of enabled status. + Future>> getAllApiLinksForModule(String moduleName) async { + final db = await database; + final result = await db.query(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); + return result.map((row) => { + 'api_config_id': row['api_config_id'], + 'is_enabled': (row['is_enabled'] as int) == 1, + }).toList(); + } + + /// Retrieves all FTP links for a specific module, regardless of enabled status. + Future>> getAllFtpLinksForModule(String moduleName) async { + final db = await database; + final result = await db.query(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); + return result.map((row) => { + 'ftp_config_id': row['ftp_config_id'], + 'is_enabled': (row['is_enabled'] as int) == 1, + }).toList(); + } + +// END CHANGE } \ No newline at end of file diff --git a/lib/services/base_api_service.dart b/lib/services/base_api_service.dart index 29117e3..4077e3c 100644 --- a/lib/services/base_api_service.dart +++ b/lib/services/base_api_service.dart @@ -2,20 +2,16 @@ import 'dart:convert'; import 'dart:io'; -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'; -import 'package:environment_monitoring_app/services/server_config_service.dart'; -import 'package:environment_monitoring_app/services/retry_service.dart'; -import 'package:environment_monitoring_app/services/api_service.dart'; - +/// A low-level service for making direct HTTP requests. +/// This service is now "dumb" and only sends a request to the specific +/// baseUrl provided. It no longer contains logic for server fallbacks. class BaseApiService { - final ServerConfigService _serverConfigService = ServerConfigService(); - final DatabaseHelper _dbHelper = DatabaseHelper(); Future> _getHeaders() async { final prefs = await SharedPreferences.getInstance(); @@ -31,185 +27,76 @@ class BaseApiService { return headers; } - // Generic GET request handler (remains unchanged) - Future> get(String endpoint) async { + /// Generic GET request handler. + Future> get(String baseUrl, String endpoint) async { try { - 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)); return _handleResponse(response); } catch (e) { - debugPrint('GET request failed: $e'); + debugPrint('GET request to $baseUrl failed: $e'); return {'success': false, 'message': 'Network error or timeout: $e'}; } } - // --- MODIFIED: Generic POST request handler now attempts multiple servers --- - Future> post(String endpoint, Map body) async { - final configs = await _dbHelper.loadApiConfigs() ?? []; - - // --- 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(); - 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.'}; - } + /// Generic POST request handler to a specific server. + Future> post(String baseUrl, String endpoint, Map body) async { + 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 $baseUrl failed. Error: $e'); + return {'success': false, 'message': 'API connection failed: $e'}; } - - // 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})'); - - // --- FIX: The check now correctly targets the 'api_url' key in the decoded map --- - 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.'}; } - // --- MODIFIED: Generic multipart handler now attempts multiple servers --- + /// Generic multipart request handler to a specific server. Future> postMultipart({ + required String baseUrl, required String endpoint, required Map fields, required Map files, }) async { - final configs = await _dbHelper.loadApiConfigs() ?? []; + try { + final url = Uri.parse('$baseUrl/$endpoint'); + debugPrint('Attempting multipart upload to: $url'); - // --- 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(); - 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.'}; + var request = http.MultipartRequest('POST', url); + final headers = await _getHeaders(); + request.headers.addAll(headers); + + if (fields.isNotEmpty) { + request.fields.addAll(fields); } - } - 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})'); - - // --- FIX: The check now correctly targets the 'api_url' key in the decoded map --- - 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()) { - 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; + 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), + )); } else { - debugPrint('Multipart upload to $baseUrl failed with an API error. Trying next server if available. Error: ${result['message']}'); + debugPrint('File does not exist: ${entry.value.path}. Skipping this file.'); } - } 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.'}; + 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, s) { + debugPrint('Multipart upload to $baseUrl failed. Error: $e'); + debugPrint('Stack trace: $s'); + return {'success': false, 'message': 'API connection failed: $e'}; + } } Map _handleResponse(http.Response response) { diff --git a/lib/services/ftp_service.dart b/lib/services/ftp_service.dart index 0a99bcc..b8fe501 100644 --- a/lib/services/ftp_service.dart +++ b/lib/services/ftp_service.dart @@ -3,92 +3,67 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; 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'; +/// A low-level service for making a direct FTP connection and uploading a single file. +/// This service only performs the upload; it does not decide which server to use. 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. + /// Uploads a single file to a specific FTP server defined in the config. /// + /// [config] A map containing FTP credentials ('ftp_host', 'ftp_user', 'ftp_pass', 'ftp_port'). /// [fileToUpload] The local file to be uploaded. - /// [remotePath] The destination path on the FTP server (e.g., '/uploads/images/'). + /// [remotePath] The destination path on the FTP server. /// 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 configs = await _dbHelper.loadFtpConfigs() ?? []; // Get all FTP configs - final latestConfigs = configs.take(2).toList(); // Limit to the two latest configs + Future> uploadFile({ + required Map config, + required File fileToUpload, + required String remotePath, + }) async { + 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; - if (latestConfigs.isEmpty) { - return {'success': false, 'message': 'FTP credentials are not configured or selected.'}; + if (ftpHost == null || ftpUser == null || ftpPass == null) { + final message = 'FTP configuration is incomplete. Missing host, user, or password.'; + debugPrint('FTP: $message'); + return {'success': false, 'message': message}; } - // 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, + timeout: 60, + ); - if (ftpHost == null || ftpUser == null || ftpPass == null) { - debugPrint('FTP: Configuration is incomplete. Skipping to next server if available.'); - continue; - } + try { + debugPrint('FTP: Connecting to $ftpHost...'); + await ftpConnect.connect(); - 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 --- + debugPrint('FTP: Uploading file ${fileToUpload.path} to $remotePath...'); + bool res = await ftpConnect.uploadFileWithRetry( + fileToUpload, + pRemoteName: remotePath, + pRetryCount: 3, ); - 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 + await ftpConnect.disconnect(); + if (res) { + debugPrint('FTP upload to $ftpHost succeeded.'); + return {'success': true, 'message': 'File uploaded successfully to $ftpHost.'}; + } else { + debugPrint('FTP upload to $ftpHost failed after retries.'); + return {'success': false, 'message': 'FTP upload to $ftpHost failed after retries.'}; } + } catch (e) { + debugPrint('FTP upload to $ftpHost failed with an exception. Error: $e'); + try { + await ftpConnect.disconnect(); + } catch (_) {} + return {'success': false, 'message': 'FTP upload to $ftpHost failed: $e'}; } - - // 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/in_situ_sampling_service.dart b/lib/services/in_situ_sampling_service.dart deleted file mode 100644 index 1a417e9..0000000 --- a/lib/services/in_situ_sampling_service.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:path/path.dart' as path; -import 'package:image/image.dart' as img; -import 'package:geolocator/geolocator.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; -import 'package:usb_serial/usb_serial.dart'; - -import 'location_service.dart'; -import 'marine_api_service.dart'; -import '../models/in_situ_sampling_data.dart'; -import '../bluetooth/bluetooth_manager.dart'; -import '../serial/serial_manager.dart'; - -/// A dedicated service to handle all business logic for the In-Situ Sampling feature. -/// This includes location, image processing, device communication, and data submission. -class InSituSamplingService { - final LocationService _locationService = LocationService(); - final MarineApiService _marineApiService = MarineApiService(); - final BluetoothManager _bluetoothManager = BluetoothManager(); - final SerialManager _serialManager = SerialManager(); - - // This channel name MUST match the one defined in MainActivity.kt - static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); - - - // --- Location Services --- - Future getCurrentLocation() => _locationService.getCurrentLocation(); - double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2); - - // --- Image Processing --- - Future pickAndProcessImage(ImageSource source, { - required InSituSamplingData data, - required String imageInfo, - bool isRequired = false, - }) async { - final picker = ImagePicker(); - final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024); - if (photo == null) return null; - - final bytes = await photo.readAsBytes(); - img.Image? originalImage = img.decodeImage(bytes); - if (originalImage == null) return null; - - if (isRequired && originalImage.height > originalImage.width) { - debugPrint("Image rejected: Must be in landscape orientation."); - return null; - } - - final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}"; - final font = img.arial24; - final textWidth = watermarkTimestamp.length * 12; - img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255)); - img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0)); - - final tempDir = await getTemporaryDirectory(); - final stationCode = data.selectedStation?['man_station_code'] ?? 'NA'; - final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-'); - final newFileName = "${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg"; - final filePath = path.join(tempDir.path, newFileName); - - return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); - } - - // --- Device Connection (Delegated to Managers) --- - ValueNotifier get bluetoothConnectionState => _bluetoothManager.connectionState; - ValueNotifier get serialConnectionState => _serialManager.connectionState; - - // REPAIRED: This getter now dynamically returns the correct Sonde ID notifier - // based on the active connection, which is essential for the UI. - ValueNotifier get sondeId { - if (_bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected) { - return _bluetoothManager.sondeId; - } - return _serialManager.sondeId; - } - - Stream> get bluetoothDataStream => _bluetoothManager.dataStream; - Stream> get serialDataStream => _serialManager.dataStream; - - // REPAIRED: Added .value to both getters for consistency and to prevent errors. - String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value; - String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value; - - // --- Permissions --- - Future requestDevicePermissions() async { - Map statuses = await [ - Permission.bluetoothScan, - Permission.bluetoothConnect, - Permission.locationWhenInUse, - ].request(); - - // Return true only if the essential permissions are granted. - if (statuses[Permission.bluetoothScan] == PermissionStatus.granted && - statuses[Permission.bluetoothConnect] == PermissionStatus.granted) { - return true; - } else { - return false; - } - } - - // --- Bluetooth Methods --- - Future> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices(); - Future connectToBluetoothDevice(BluetoothDevice device) => _bluetoothManager.connect(device); - void disconnectFromBluetooth() => _bluetoothManager.disconnect(); - void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 5)); - void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading(); - - // --- USB Serial Methods --- - Future> getAvailableSerialDevices() => _serialManager.getAvailableDevices(); - - Future requestUsbPermission(UsbDevice device) async { - try { - return await platform.invokeMethod('requestUsbPermission', {'vid': device.vid, 'pid': device.pid}) ?? false; - } on PlatformException catch (e) { - debugPrint("Failed to request USB permission: '${e.message}'."); - return false; - } - } - - Future connectToSerialDevice(UsbDevice device) async { - final bool permissionGranted = await requestUsbPermission(device); - if (permissionGranted) { - await _serialManager.connect(device); - } else { - throw Exception("USB permission was not granted."); - } - } - - void disconnectFromSerial() => _serialManager.disconnect(); - void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 5)); - void stopSerialAutoReading() => _serialManager.stopAutoReading(); - - void dispose() { - _bluetoothManager.dispose(); - _serialManager.dispose(); - } - - // --- Data Submission --- - // MODIFIED: Method now requires the appSettings list to pass to the MarineApiService. - Future> submitData(InSituSamplingData data, List>? appSettings) { - return _marineApiService.submitInSituSample( - formData: data.toApiFormData(), - imageFiles: data.toApiImageFiles(), - inSituData: data, - appSettings: appSettings, // Added this required parameter - ); - } -} \ No newline at end of file diff --git a/lib/services/marine_api_service.dart b/lib/services/marine_api_service.dart index e0c206f..55cb10e 100644 --- a/lib/services/marine_api_service.dart +++ b/lib/services/marine_api_service.dart @@ -1,233 +1,28 @@ -import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:intl/intl.dart'; +// lib/services/marine_api_service.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/services/settings_service.dart'; -import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; -import 'package:environment_monitoring_app/models/tarball_data.dart'; +import 'package:environment_monitoring_app/services/server_config_service.dart'; class MarineApiService { - final BaseApiService _baseService = BaseApiService(); - final TelegramService _telegramService = TelegramService(); - // REMOVED: SettingsService is no longer called directly from this file for chat IDs. - // final SettingsService _settingsService = SettingsService(); + final BaseApiService _baseService; + final TelegramService _telegramService; + final ServerConfigService _serverConfigService; - Future> getTarballStations() { - return _baseService.get('marine/tarball/stations'); + MarineApiService(this._baseService, this._telegramService, this._serverConfigService); + + Future> getTarballStations() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'marine/tarball/stations'); } - Future> getManualStations() { - return _baseService.get('marine/manual/stations'); + Future> getManualStations() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'marine/manual/stations'); } - Future> getTarballClassifications() { - return _baseService.get('marine/tarball/classifications'); - } - - // --- MODIFIED: Added appSettings to the method signature --- - Future> submitTarballSample({ - required Map formData, - required Map imageFiles, - 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, - 'message': 'Failed to submit data to server: ${dataResult['message']}', - 'reportId': null, - }; - } - 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, - 'message': 'Data submitted, but failed to get a record ID to link images.', - 'reportId': null, - }; - } - - final filesToUpload = {}; - imageFiles.forEach((key, value) { - if (value != null) filesToUpload[key] = value; - }); - - if (filesToUpload.isEmpty) { - debugPrint("No images to upload. Finalizing submission."); - _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true); - return { - 'status': 'L3', - 'success': true, - 'message': 'Data submitted successfully. No images were attached.', - 'reportId': recordId, - }; - } - - debugPrint("Step 2: Uploading ${filesToUpload.length} tarball images for record ID: $recordId"); - final imageResult = await _baseService.postMultipart( - endpoint: 'marine/tarball/images', - fields: {'autoid': recordId.toString()}, - files: filesToUpload, - ); - - if (imageResult['success'] != true) { - debugPrint("Image upload failed for Tarball. Message: ${imageResult['message']}"); - return { - 'status': 'L2', - 'success': false, - 'message': 'Data submitted to server, but image upload failed: ${imageResult['message']}', - 'reportId': recordId, - }; - } - - debugPrint("Step 2 successful. All images uploaded."); - _handleTarballSuccessAlert(formData, appSettings, isDataOnly: false); - return { - 'status': 'L3', - 'success': true, - 'message': 'Data and images submitted to server successfully.', - 'reportId': recordId, - }; - } - - // MODIFIED: Method now requires the appSettings list. - Future> submitInSituSample({ - required Map formData, - required Map imageFiles, - required InSituSamplingData inSituData, - required List>? appSettings, - }) async { - debugPrint("Step 1: Submitting in-situ form data to the server..."); - 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, - 'message': 'Failed to submit in-situ data: ${dataResult['message']}', - 'reportId': null, - }; - } - 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, - 'message': 'In-situ data submitted, but failed to get a record ID for images.', - 'reportId': null, - }; - } - - final filesToUpload = {}; - imageFiles.forEach((key, value) { - if (value != null) filesToUpload[key] = value; - }); - - if (filesToUpload.isEmpty) { - debugPrint("No images to upload. Finalizing submission."); - _handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: true); - return { - 'status': 'L3', - 'success': true, - 'message': 'In-situ data submitted successfully. No images were attached.', - 'reportId': recordId.toString(), - }; - } - - debugPrint("Step 2: Uploading ${filesToUpload.length} in-situ images for record ID: $recordId"); - final imageResult = await _baseService.postMultipart( - endpoint: 'marine/manual/images', - fields: {'man_id': recordId.toString()}, - files: filesToUpload, - ); - - if (imageResult['success'] != true) { - debugPrint("Image upload failed for In-Situ. Message: ${imageResult['message']}"); - return { - 'status': 'L2', - 'success': false, - 'message': 'In-situ data submitted, but image upload failed: ${imageResult['message']}', - 'reportId': recordId.toString(), - }; - } - - debugPrint("Step 2 successful. All images uploaded."); - _handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: false); - return { - 'status': 'L3', - 'success': true, - 'message': 'Data and images submitted to server successfully.', - 'reportId': recordId.toString(), - }; - } - - // MODIFIED: Method now requires appSettings and calls the updated TelegramService. - Future _handleTarballSuccessAlert(Map formData, List>? appSettings, {required bool isDataOnly}) async { - 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"); - } - } - - 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(); - } - - // MODIFIED: Method now requires appSettings and calls the updated TelegramService. - Future _handleInSituSuccessAlert(InSituSamplingData data, List>? appSettings, {required bool isDataOnly}) async { - try { - final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); - final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings); - if (!wasSent) { - await _telegramService.queueMessage('marine_in_situ', message, appSettings); - } - } catch (e) { - debugPrint("Failed to handle In-Situ Telegram alert: $e"); - } + Future> getTarballClassifications() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'marine/tarball/classifications'); } } \ No newline at end of file diff --git a/lib/services/marine_in_situ_sampling_service.dart b/lib/services/marine_in_situ_sampling_service.dart new file mode 100644 index 0000000..3009c87 --- /dev/null +++ b/lib/services/marine_in_situ_sampling_service.dart @@ -0,0 +1,323 @@ +// lib/services/marine_in_situ_sampling_service.dart + +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'package:image/image.dart' as img; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; +import 'package:usb_serial/usb_serial.dart'; +import 'dart:convert'; + +import 'location_service.dart'; +import '../models/in_situ_sampling_data.dart'; +import '../bluetooth/bluetooth_manager.dart'; +import '../serial/serial_manager.dart'; +import 'local_storage_service.dart'; +import 'server_config_service.dart'; +import 'zipping_service.dart'; +import 'api_service.dart'; +import 'submission_api_service.dart'; +import 'submission_ftp_service.dart'; +import 'telegram_service.dart'; + + +/// A dedicated service to handle all business logic for the Marine In-Situ Sampling feature. +/// This includes location, image processing, device communication, and data submission. +class MarineInSituSamplingService { + // Business Logic Services + final LocationService _locationService = LocationService(); + final BluetoothManager _bluetoothManager = BluetoothManager(); + final SerialManager _serialManager = SerialManager(); + + // Submission & Utility Services + final SubmissionApiService _submissionApiService = SubmissionApiService(); + final SubmissionFtpService _submissionFtpService = SubmissionFtpService(); + final ZippingService _zippingService = ZippingService(); + final LocalStorageService _localStorageService = LocalStorageService(); + final ServerConfigService _serverConfigService = ServerConfigService(); + final DatabaseHelper _dbHelper = DatabaseHelper(); + // MODIFIED: Declare the service, but do not initialize it here. + final TelegramService _telegramService; + + // ADDED: A constructor to accept the global TelegramService instance. + MarineInSituSamplingService(this._telegramService); + + static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); + + // --- Location Services --- + Future getCurrentLocation() => _locationService.getCurrentLocation(); + double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2); + + // --- Image Processing --- + Future pickAndProcessImage(ImageSource source, { + required InSituSamplingData data, + required String imageInfo, + bool isRequired = false, + }) async { + final picker = ImagePicker(); + final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024); + if (photo == null) return null; + + final bytes = await photo.readAsBytes(); + img.Image? originalImage = img.decodeImage(bytes); + if (originalImage == null) return null; + + if (isRequired && originalImage.height > originalImage.width) { + debugPrint("Image rejected: Must be in landscape orientation."); + return null; + } + + final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}"; + final font = img.arial24; + final textWidth = watermarkTimestamp.length * 12; + img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255)); + img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0)); + + final tempDir = await getTemporaryDirectory(); + final stationCode = data.selectedStation?['man_station_code'] ?? 'NA'; + final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-'); + final newFileName = "${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg"; + final filePath = path.join(tempDir.path, newFileName); + + return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); + } + + // --- Device Connection (Delegated to Managers) --- + ValueNotifier get bluetoothConnectionState => _bluetoothManager.connectionState; + ValueNotifier get serialConnectionState => _serialManager.connectionState; + + ValueNotifier get sondeId { + if (_bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected) { + return _bluetoothManager.sondeId; + } + return _serialManager.sondeId; + } + + Stream> get bluetoothDataStream => _bluetoothManager.dataStream; + Stream> get serialDataStream => _serialManager.dataStream; + + String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value; + String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value; + + // --- Permissions --- + Future requestDevicePermissions() async { + Map statuses = await [ + Permission.bluetoothScan, + Permission.bluetoothConnect, + Permission.locationWhenInUse, + ].request(); + + if (statuses[Permission.bluetoothScan] == PermissionStatus.granted && + statuses[Permission.bluetoothConnect] == PermissionStatus.granted) { + return true; + } else { + return false; + } + } + + // --- Bluetooth Methods --- + Future> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices(); + Future connectToBluetoothDevice(BluetoothDevice device) => _bluetoothManager.connect(device); + void disconnectFromBluetooth() => _bluetoothManager.disconnect(); + void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 5)); + void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading(); + + // --- USB Serial Methods --- + Future> getAvailableSerialDevices() => _serialManager.getAvailableDevices(); + + Future requestUsbPermission(UsbDevice device) async { + try { + return await platform.invokeMethod('requestUsbPermission', {'vid': device.vid, 'pid': device.pid}) ?? false; + } on PlatformException catch (e) { + debugPrint("Failed to request USB permission: '${e.message}'."); + return false; + } + } + + Future connectToSerialDevice(UsbDevice device) async { + final bool permissionGranted = await requestUsbPermission(device); + if (permissionGranted) { + await _serialManager.connect(device); + } else { + throw Exception("USB permission was not granted."); + } + } + + void disconnectFromSerial() => _serialManager.disconnect(); + void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 5)); + void stopSerialAutoReading() => _serialManager.stopAutoReading(); + + void dispose() { + _bluetoothManager.dispose(); + _serialManager.dispose(); + } + + // --- Data Submission --- + Future> submitInSituSample({ + required InSituSamplingData data, + required List>? appSettings, + }) async { + const String moduleName = 'marine_in_situ'; + final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; + + final imageFilesWithNulls = data.toApiImageFiles(); + imageFilesWithNulls.removeWhere((key, value) => value == null); + final Map finalImageFiles = imageFilesWithNulls.cast(); + + // START CHANGE: Implement the correct two-step submission process. + // Step 1A: Submit form data as JSON. + debugPrint("Step 1A: Submitting In-Situ form data..."); + final apiDataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'marine/manual/sample', + body: data.toApiFormData(), + ); + + // If the initial data submission fails, log and exit early. + if (apiDataResult['success'] != true) { + data.submissionStatus = 'L1'; + data.submissionMessage = apiDataResult['message'] ?? 'Failed to submit form data.'; + await _logAndSave(data: data, apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles); + return {'success': false, 'message': data.submissionMessage}; + } + + final reportId = apiDataResult['data']?['man_id']?.toString(); + if (reportId == null) { + data.submissionStatus = 'L1'; + data.submissionMessage = 'API Error: Missing man_id in response.'; + await _logAndSave(data: data, apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles); + return {'success': false, 'message': data.submissionMessage}; + } + data.reportId = reportId; + + // Step 1B: Submit images as multipart/form-data. + debugPrint("Step 1B: Submitting In-Situ images..."); + Map apiImageResult = {'success': true, 'message': 'No images to upload.'}; + if (finalImageFiles.isNotEmpty) { + apiImageResult = await _submissionApiService.submitMultipart( + moduleName: moduleName, + endpoint: 'marine/manual/images', // Assumed endpoint for uploadManualImages + fields: {'man_id': reportId}, + files: finalImageFiles, + ); + } + final bool apiSuccess = apiImageResult['success'] == true; + // END CHANGE + + // Step 2: FTP Submission + final stationCode = data.selectedStation?['man_station_code'] ?? 'NA'; + final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); + final baseFileName = '${stationCode}_$fileTimestamp'; + + final Directory? logDirectory = await _localStorageService.getLogDirectory( + serverName: serverName, + module: 'marine', + subModule: 'marine_in_situ_sampling', + ); + + final Directory? localSubmissionDir = logDirectory != null ? Directory(path.join(logDirectory.path, data.reportId ?? baseFileName)) : null; + if (localSubmissionDir != null && !await localSubmissionDir.exists()) { + await localSubmissionDir.create(recursive: true); + } + + final dataZip = await _zippingService.createDataZip( + jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, + baseFileName: baseFileName, + destinationDir: localSubmissionDir); + Map ftpDataResult = {'success': true, 'statuses': []}; + if (dataZip != null) { + ftpDataResult = await _submissionFtpService.submit( + moduleName: moduleName, + fileToUpload: dataZip, + remotePath: '/${path.basename(dataZip.path)}'); + } + + final imageZip = await _zippingService.createImageZip( + imageFiles: finalImageFiles.values.toList(), + baseFileName: baseFileName, + destinationDir: localSubmissionDir); + Map ftpImageResult = {'success': true, 'statuses': []}; + if (imageZip != null) { + ftpImageResult = await _submissionFtpService.submit( + moduleName: moduleName, + fileToUpload: imageZip, + remotePath: '/${path.basename(imageZip.path)}'); + } + final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); + + // Step 3: Finalize and Log + String finalStatus; + String finalMessage; + if (apiSuccess) { + finalStatus = ftpSuccess ? 'S4' : 'S3'; + finalMessage = ftpSuccess ? 'Data submitted successfully.' : 'Data sent to API. FTP upload failed/queued.'; + } else { + finalStatus = ftpSuccess ? 'L4' : 'L1'; + finalMessage = ftpSuccess ? 'API failed, but files sent to FTP.' : 'All submission attempts failed.'; + } + + data.submissionStatus = finalStatus; + data.submissionMessage = finalMessage; + + await _logAndSave( + data: data, + apiResults: [apiDataResult, apiImageResult], // Log both API steps + ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], + serverName: serverName, + finalImageFiles: finalImageFiles, + ); + + if (apiSuccess || ftpSuccess) { + _handleInSituSuccessAlert(data, appSettings, isDataOnly: !apiSuccess); + } + + return {'success': apiSuccess || ftpSuccess, 'message': finalMessage}; + } + + // Helper function to centralize logging and local saving. + Future _logAndSave({ + required InSituSamplingData data, + required List> apiResults, + required List> ftpStatuses, + required String serverName, + required Map finalImageFiles, + }) async { + final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); + + await _localStorageService.saveInSituSamplingData(data, serverName: serverName); + + final logData = { + 'submission_id': data.reportId ?? fileTimestamp, + 'module': 'marine', + 'type': 'In-Situ', + 'status': data.submissionStatus, + 'message': data.submissionMessage, + 'report_id': data.reportId, + 'created_at': DateTime.now().toIso8601String(), + 'form_data': jsonEncode(data.toDbJson()), + 'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()), + 'server_name': serverName, + 'api_status': jsonEncode(apiResults), + 'ftp_status': jsonEncode(ftpStatuses), + }; + await _dbHelper.saveSubmissionLog(logData); + } + + Future _handleInSituSuccessAlert(InSituSamplingData data, List>? appSettings, {required bool isDataOnly}) async { + try { + final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); + final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings); + if (!wasSent) { + await _telegramService.queueMessage('marine_in_situ', message, appSettings); + } + } catch (e) { + debugPrint("Failed to handle In-Situ Telegram alert: $e"); + } + } +} \ No newline at end of file diff --git a/lib/services/marine_tarball_sampling_service.dart b/lib/services/marine_tarball_sampling_service.dart new file mode 100644 index 0000000..095a78e --- /dev/null +++ b/lib/services/marine_tarball_sampling_service.dart @@ -0,0 +1,194 @@ +// lib/services/marine_tarball_sampling_service.dart + +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +import 'package:environment_monitoring_app/models/tarball_data.dart'; +import 'package:environment_monitoring_app/services/local_storage_service.dart'; +import 'package:environment_monitoring_app/services/server_config_service.dart'; +import 'package:environment_monitoring_app/services/zipping_service.dart'; +import 'package:environment_monitoring_app/services/api_service.dart'; +import 'package:environment_monitoring_app/services/submission_api_service.dart'; +import 'package:environment_monitoring_app/services/submission_ftp_service.dart'; +import 'package:environment_monitoring_app/services/telegram_service.dart'; + +/// A dedicated service to handle all business logic for the Marine Tarball Sampling feature. +class MarineTarballSamplingService { + final SubmissionApiService _submissionApiService = SubmissionApiService(); + final SubmissionFtpService _submissionFtpService = SubmissionFtpService(); + final ZippingService _zippingService = ZippingService(); + final LocalStorageService _localStorageService = LocalStorageService(); + final ServerConfigService _serverConfigService = ServerConfigService(); + final DatabaseHelper _dbHelper = DatabaseHelper(); + // MODIFIED: Declare the service, but do not initialize it here. + final TelegramService _telegramService; + + // ADDED: A constructor to accept the global TelegramService instance. + MarineTarballSamplingService(this._telegramService); + + Future> submitTarballSample({ + required TarballSamplingData data, + required List>? appSettings, + }) async { + const String moduleName = 'marine_tarball'; + final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; + + final imageFilesWithNulls = data.toImageFiles(); + imageFilesWithNulls.removeWhere((key, value) => value == null); + final Map finalImageFiles = imageFilesWithNulls.cast(); + + // START CHANGE: Revert to the correct two-step API submission process + // --- Step 1A: API Data Submission --- + debugPrint("Step 1A: Submitting Tarball form data..."); + final apiDataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'marine/tarball/sample', + body: data.toFormData(), + ); + + if (apiDataResult['success'] != true) { + // If the initial data submission fails, log and exit early. + await _logAndSave(data: data, status: 'L1', message: apiDataResult['message']!, apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles); + return {'success': false, 'message': apiDataResult['message']}; + } + + final recordId = apiDataResult['data']?['autoid']?.toString(); + if (recordId == null) { + await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles); + return {'success': false, 'message': 'API Error: Missing record ID.'}; + } + data.reportId = recordId; + + // --- Step 1B: API Image Submission --- + debugPrint("Step 1B: Submitting Tarball images..."); + final apiImageResult = await _submissionApiService.submitMultipart( + moduleName: moduleName, + endpoint: 'marine/tarball/images', + fields: {'autoid': recordId}, + files: finalImageFiles, + ); + final bool apiSuccess = apiImageResult['success'] == true; + // END CHANGE + + // --- Step 2: FTP Submission --- + final stationCode = data.selectedStation?['tbl_station_code'] ?? 'NA'; + final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); + final baseFileName = '${stationCode}_$fileTimestamp'; + + final Directory? logDirectory = await _localStorageService.getLogDirectory( + serverName: serverName, + module: 'marine', + subModule: 'marine_tarball_sampling', + ); + + final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null; + if (localSubmissionDir != null && !await localSubmissionDir.exists()) { + await localSubmissionDir.create(recursive: true); + } + + final dataZip = await _zippingService.createDataZip( + jsonDataMap: {'data.json': jsonEncode(data.toDbJson())}, + baseFileName: baseFileName, + destinationDir: localSubmissionDir, + ); + + Map ftpDataResult = {'success': true, 'statuses': []}; + if (dataZip != null) { + ftpDataResult = await _submissionFtpService.submit( + moduleName: moduleName, + fileToUpload: dataZip, + remotePath: '/${p.basename(dataZip.path)}', + ); + } + + final imageZip = await _zippingService.createImageZip( + imageFiles: finalImageFiles.values.toList(), + baseFileName: baseFileName, + destinationDir: localSubmissionDir, + ); + + Map ftpImageResult = {'success': true, 'statuses': []}; + if (imageZip != null) { + ftpImageResult = await _submissionFtpService.submit( + moduleName: moduleName, + fileToUpload: imageZip, + remotePath: '/${p.basename(imageZip.path)}', + ); + } + final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); + + // --- Step 3: Finalize and Log --- + String finalStatus; + String finalMessage; + if (apiSuccess) { + finalStatus = ftpSuccess ? 'S4' : 'S3'; + finalMessage = ftpSuccess ? 'Data submitted successfully.' : 'Data sent to API. FTP upload failed/queued.'; + } else { + finalStatus = ftpSuccess ? 'L4' : 'L1'; + finalMessage = ftpSuccess ? 'API failed, but files sent to FTP.' : 'All submission attempts failed.'; + } + + await _logAndSave( + data: data, + status: finalStatus, + message: finalMessage, + apiResults: [apiDataResult, apiImageResult], // Log results from both API steps + ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], + serverName: serverName, + finalImageFiles: finalImageFiles + ); + + if (apiSuccess || ftpSuccess) { + _handleTarballSuccessAlert(data, appSettings, isDataOnly: !apiSuccess); + } + + return {'success': apiSuccess || ftpSuccess, 'message': finalMessage, 'reportId': data.reportId}; + } + + // Added a helper to reduce code duplication in the main submit method + Future _logAndSave({ + required TarballSamplingData data, + required String status, + required String message, + required List> apiResults, + required List> ftpStatuses, + required String serverName, + required Map finalImageFiles, + }) async { + data.submissionStatus = status; + data.submissionMessage = message; + final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); + + await _localStorageService.saveTarballSamplingData(data, serverName: serverName); + + final logData = { + 'submission_id': data.reportId ?? fileTimestamp, + 'module': 'marine', + 'type': 'Tarball', + 'status': status, + 'message': message, + 'report_id': data.reportId, + 'created_at': DateTime.now().toIso8601String(), + 'form_data': jsonEncode(data.toDbJson()), + 'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()), + 'server_name': serverName, + 'api_status': jsonEncode(apiResults), + 'ftp_status': jsonEncode(ftpStatuses), + }; + await _dbHelper.saveSubmissionLog(logData); + } + + Future _handleTarballSuccessAlert(TarballSamplingData data, List>? appSettings, {required bool isDataOnly}) async { + try { + final message = data.generateTelegramAlertMessage(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"); + } + } +} \ No newline at end of file diff --git a/lib/services/retry_service.dart b/lib/services/retry_service.dart index f8896f7..05c2c79 100644 --- a/lib/services/retry_service.dart +++ b/lib/services/retry_service.dart @@ -6,6 +6,9 @@ import 'package:flutter/foundation.dart'; import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/ftp_service.dart'; +// START CHANGE: Added imports to get server configurations +import 'package:environment_monitoring_app/services/server_config_service.dart'; +// END CHANGE /// A dedicated service to manage the queue of failed API and FTP requests /// for manual resubmission. @@ -14,6 +17,9 @@ class RetryService { final DatabaseHelper _dbHelper = DatabaseHelper(); final BaseApiService _baseApiService = BaseApiService(); final FtpService _ftpService = FtpService(); + // START CHANGE: Add instance of ServerConfigService + final ServerConfigService _serverConfigService = ServerConfigService(); + // END CHANGE /// Adds a failed API request to the local database queue. Future addApiToQueue({ @@ -81,20 +87,22 @@ class RetryService { final endpoint = task['endpoint_or_path'] as String; final method = payload['method'] as String; - debugPrint("Retrying API task $taskId: $method to $endpoint"); + // START CHANGE: Fetch the current active base URL to perform the retry + final baseUrl = await _serverConfigService.getActiveApiUrl(); + debugPrint("Retrying API task $taskId: $method to $baseUrl/$endpoint"); Map result; if (method == 'POST_MULTIPART') { - // Reconstruct fields and files from the stored payload final Map fields = Map.from(payload['fields'] ?? {}); final Map files = (payload['files'] as Map?) ?.map((key, value) => MapEntry(key, File(value as String))) ?? {}; - result = await _baseApiService.postMultipart(endpoint: endpoint, fields: fields, files: files); + result = await _baseApiService.postMultipart(baseUrl: baseUrl, endpoint: endpoint, fields: fields, files: files); } else { // Assume 'POST' final Map body = Map.from(payload['body'] ?? {}); - result = await _baseApiService.post(endpoint, body); + result = await _baseApiService.post(baseUrl, endpoint, body); } + // END CHANGE success = result['success']; @@ -104,11 +112,26 @@ class RetryService { debugPrint("Retrying FTP task $taskId: Uploading ${localFile.path} to $remotePath"); - // Ensure the file still exists before attempting to re-upload if (await localFile.exists()) { - final result = await _ftpService.uploadFile(localFile, remotePath); - // The FTP service already queues on failure, so we only care about success here. - success = result['success']; + // START CHANGE: On retry, attempt to upload to ALL available FTP servers. + final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? []; + if (ftpConfigs.isEmpty) { + debugPrint("Retry failed for FTP task $taskId: No FTP configurations found."); + return false; + } + + for (final config in ftpConfigs) { + final result = await _ftpService.uploadFile( + config: config, + fileToUpload: localFile, + remotePath: remotePath + ); + if (result['success']) { + success = true; + break; // Stop on the first successful upload + } + } + // END CHANGE } else { debugPrint("Retry failed for FTP task $taskId: Source file no longer exists at ${localFile.path}"); success = false; diff --git a/lib/services/river_api_service.dart b/lib/services/river_api_service.dart index a5039aa..bb8e7c7 100644 --- a/lib/services/river_api_service.dart +++ b/lib/services/river_api_service.dart @@ -1,151 +1,25 @@ // lib/services/river_api_service.dart -import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:intl/intl.dart'; - +import 'package:flutter/foundation.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart'; -// NOTE: RiverApiService still needs RiverInSituSamplingData import for its handle alert method, -// but since the model file wasn't provided directly, we assume it's correctly handled by the caller/context. +import 'package:environment_monitoring_app/services/server_config_service.dart'; class RiverApiService { final BaseApiService _baseService; final TelegramService _telegramService; - RiverApiService(this._baseService, this._telegramService); + final ServerConfigService _serverConfigService; - Future> getManualStations() { - return _baseService.get('river/manual-stations'); + RiverApiService(this._baseService, this._telegramService, this._serverConfigService); + + Future> getManualStations() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'river/manual-stations'); } - Future> getTriennialStations() { - return _baseService.get('river/triennial-stations'); - } - - // MODIFIED: Method now returns granular status tracking for API and Images. - Future> submitInSituSample({ - required Map formData, - required Map imageFiles, - required List>? appSettings, - }) async { - Map finalResult = { - 'success': false, - 'status': 'L1', // Default: Local Failure (Data failed) - 'api_status': 'NOT_ATTEMPTED', - 'image_upload_status': 'NOT_ATTEMPTED', - 'message': 'Submission failed.', - 'reportId': null, - }; - - // --- Step 1: Submit Form Data as JSON --- - debugPrint("Step 1: Submitting River In-Situ form data..."); - final dataResult = await _baseService.post('river/manual/sample', formData); - - finalResult['api_status'] = dataResult['success'] == true ? 'SUCCESS' : 'FAILED'; - - if (dataResult['success'] != true) { - finalResult['message'] = dataResult['message'] ?? 'Failed to submit river in-situ data (API failed).'; - return finalResult; - } - - // Update status and reportId upon successful data submission - final recordId = dataResult['data']?['r_man_id']; - finalResult['reportId'] = recordId?.toString(); - - if (recordId == null) { - finalResult['api_status'] = 'FAILED'; - finalResult['message'] = 'Data submitted, but server did not return a record ID.'; - return finalResult; - } - - final filesToUpload = {}; - imageFiles.forEach((key, value) { - if (value != null) filesToUpload[key] = value; - }); - - // --- Step 2: Upload Image Files --- - if (filesToUpload.isEmpty) { - debugPrint("No images to upload. Finalizing submission."); - finalResult['image_upload_status'] = 'NOT_REQUIRED'; - - // Final Status: S3 (Success, Data Only) - finalResult['success'] = true; - finalResult['status'] = 'S3'; - finalResult['message'] = 'Data submitted successfully. No images were attached.'; - - _handleInSituSuccessAlert(formData, appSettings, isDataOnly: true); - return finalResult; - } - - debugPrint("Step 2: Uploading ${filesToUpload.length} images..."); - final imageResult = await _baseService.postMultipart( - endpoint: 'river/manual/images', // Separate endpoint for images - fields: {'r_man_id': recordId.toString()}, // Link images to the submitted record ID - files: filesToUpload, - ); - - finalResult['image_upload_status'] = imageResult['success'] == true ? 'SUCCESS' : 'FAILED'; - - if (imageResult['success'] != true) { - // Data submitted successfully, but images failed (L2/L4 equivalent) - finalResult['success'] = true; // API data transfer was still successful - finalResult['status'] = 'L2_PENDING_IMAGES'; - finalResult['message'] = 'Data submitted, but image upload failed: ${imageResult['message']}'; - - _handleInSituSuccessAlert(formData, appSettings, isDataOnly: true); // Alert for data only - return finalResult; - } - - // --- Step 3: Full Success --- - finalResult['success'] = true; - finalResult['status'] = 'S2'; // S2 means Data+Images submitted - finalResult['message'] = 'Data and images submitted successfully.'; - - _handleInSituSuccessAlert(formData, appSettings, isDataOnly: false); - return finalResult; - } - - // MODIFIED: Method now requires appSettings and calls the updated TelegramService. - Future _handleInSituSuccessAlert(Map formData, List>? appSettings, {required bool isDataOnly}) async { - try { - final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; - final stationName = formData['r_man_station_name'] ?? 'N/A'; - final stationCode = formData['r_man_station_code'] ?? 'N/A'; - final submissionDate = formData['r_man_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); - final submitter = formData['first_sampler_name'] ?? 'N/A'; - final sondeID = formData['r_man_sondeID'] ?? 'N/A'; - final distanceKm = double.tryParse(formData['r_man_distance_difference'] ?? '0') ?? 0; - final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); - final distanceRemarks = formData['r_man_distance_difference_remarks'] ?? 'N/A'; - - final buffer = StringBuffer() - ..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*') - ..writeln() - ..writeln('*Station Name & Code:* $stationName ($stationCode)') - ..writeln('*Date of Submitted:* $submissionDate') - ..writeln('*Submitted by User:* $submitter') - ..writeln('*Sonde ID:* $sondeID') - ..writeln('*Status of Submission:* Successful'); - - if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { - buffer - ..writeln() - ..writeln('🔔 *Alert:*') - ..writeln('*Distance from station:* $distanceMeters meters'); - if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { - buffer.writeln('*Remarks for distance:* $distanceRemarks'); - } - } - - final String message = buffer.toString(); - - // MODIFIED: Pass the appSettings list to the TelegramService methods. - final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings); - if (!wasSent) { - await _telegramService.queueMessage('river_in_situ', message, appSettings); - } - } catch (e) { - debugPrint("Failed to handle River Telegram alert: $e"); - } + Future> getTriennialStations() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'river/triennial-stations'); } } \ No newline at end of file diff --git a/lib/services/river_in_situ_sampling_service.dart b/lib/services/river_in_situ_sampling_service.dart index 1e96a8b..11f2728 100644 --- a/lib/services/river_in_situ_sampling_service.dart +++ b/lib/services/river_in_situ_sampling_service.dart @@ -13,87 +13,87 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:usb_serial/usb_serial.dart'; import 'dart:convert'; +import 'package:intl/intl.dart'; -// CHANGED: Import river-specific services and models import 'location_service.dart'; -// REMOVED: import 'river_api_service.dart'; // Conflict: RiverApiService is defined here and in api_service.dart import '../models/river_in_situ_sampling_data.dart'; import '../bluetooth/bluetooth_manager.dart'; import '../serial/serial_manager.dart'; -// ADDED: Services needed for logging and configuration import 'api_service.dart'; import 'local_storage_service.dart'; import 'server_config_service.dart'; -// ADDED: DatabaseHelper import for local instantiation (as in your original code) -import 'api_service.dart'; // DatabaseHelper lives here, redundant but ensures access +import 'zipping_service.dart'; +import 'submission_api_service.dart'; +import 'submission_ftp_service.dart'; +import 'telegram_service.dart'; + -/// A dedicated service to handle all business logic for the River In-Situ Sampling feature. -// CHANGED: Renamed class for the River In-Situ Sampling Service class RiverInSituSamplingService { final LocationService _locationService = LocationService(); - // NOTE: RiverApiService type is defined in api_service.dart and used for DI - final RiverApiService _riverApiService; final BluetoothManager _bluetoothManager = BluetoothManager(); final SerialManager _serialManager = SerialManager(); - // ADDED: Instances for logging/configuration + final SubmissionApiService _submissionApiService = SubmissionApiService(); + final SubmissionFtpService _submissionFtpService = SubmissionFtpService(); final DatabaseHelper _dbHelper = DatabaseHelper(); final LocalStorageService _localStorageService = LocalStorageService(); final ServerConfigService _serverConfigService = ServerConfigService(); + final ZippingService _zippingService = ZippingService(); + final TelegramService _telegramService; + final ImagePicker _picker = ImagePicker(); - - // This channel name MUST match the one defined in MainActivity.kt static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); - // FIX: Constructor requires RiverApiService for dependency injection - RiverInSituSamplingService(this._riverApiService); + RiverInSituSamplingService(this._telegramService); - - // --- Location Services --- Future getCurrentLocation() => _locationService.getCurrentLocation(); double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2); - // --- Image Processing --- - Future pickAndProcessImage(ImageSource source, { - // CHANGED: Use the river-specific data model - required RiverInSituSamplingData data, - required String imageInfo, - bool isRequired = false, - String? stationCode, // Accept station code for naming - }) async { - final picker = ImagePicker(); - final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024); - if (photo == null) return null; + Future pickAndProcessImage(ImageSource source, { required RiverInSituSamplingData data, required String imageInfo, bool isRequired = false, String? stationCode}) async { + try { + final XFile? pickedFile = await _picker.pickImage( + source: source, + imageQuality: 85, + maxWidth: 1024, + ); - final bytes = await photo.readAsBytes(); - img.Image? originalImage = img.decodeImage(bytes); - if (originalImage == null) return null; + if (pickedFile == null) { + return null; + } - if (isRequired && originalImage.height > originalImage.width) { - debugPrint("Image rejected: Must be in landscape orientation."); + final bytes = await pickedFile.readAsBytes(); + img.Image? originalImage = img.decodeImage(bytes); + if (originalImage == null) { + return null; + } + + if (isRequired && originalImage.height > originalImage.width) { + debugPrint("Image rejected: Must be in landscape orientation."); + return null; + } + + final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}"; + final font = img.arial24; + final textWidth = watermarkTimestamp.length * 12; + img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255),); + img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0)); + + final tempDir = await getTemporaryDirectory(); + final finalStationCode = stationCode ?? 'NA'; + final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-'); + final newFileName = "${finalStationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg"; + final filePath = path.join(tempDir.path, newFileName); + + return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); + + } catch (e) { + debugPrint('Error in pickAndProcessImage: $e'); return null; } - - final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}"; - final font = img.arial24; - final textWidth = watermarkTimestamp.length * 12; - img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255)); - img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0)); - - final tempDir = await getTemporaryDirectory(); - final finalStationCode = stationCode ?? 'NA'; - final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-'); - final newFileName = "${finalStationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg"; - final filePath = path.join(tempDir.path, newFileName); - - return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); } - // --- Device Connection (Delegated to Managers) --- ValueNotifier get bluetoothConnectionState => _bluetoothManager.connectionState; ValueNotifier get serialConnectionState => _serialManager.connectionState; - // This getter now dynamically returns the correct Sonde ID notifier - // based on the active connection, which is essential for the UI. ValueNotifier get sondeId { if (_bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected) { return _bluetoothManager.sondeId; @@ -103,11 +103,9 @@ class RiverInSituSamplingService { Stream> get bluetoothDataStream => _bluetoothManager.dataStream; Stream> get serialDataStream => _serialManager.dataStream; - String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value; String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value; - // --- Permissions --- Future requestDevicePermissions() async { Map statuses = await [ Permission.bluetoothScan, @@ -115,7 +113,6 @@ class RiverInSituSamplingService { Permission.locationWhenInUse, ].request(); - // Return true only if the essential permissions are granted. if (statuses[Permission.bluetoothScan] == PermissionStatus.granted && statuses[Permission.bluetoothConnect] == PermissionStatus.granted) { return true; @@ -124,15 +121,11 @@ class RiverInSituSamplingService { } } - // --- Bluetooth Methods --- Future> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices(); Future connectToBluetoothDevice(BluetoothDevice device) => _bluetoothManager.connect(device); void disconnectFromBluetooth() => _bluetoothManager.disconnect(); void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 5)); void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading(); - - - // --- USB Serial Methods --- Future> getAvailableSerialDevices() => _serialManager.getAvailableDevices(); Future requestUsbPermission(UsbDevice device) async { @@ -156,130 +149,173 @@ class RiverInSituSamplingService { void disconnectFromSerial() => _serialManager.disconnect(); void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 5)); void stopSerialAutoReading() => _serialManager.stopAutoReading(); - - void dispose() { _bluetoothManager.dispose(); _serialManager.dispose(); } - // --- Data Submission --- - // MODIFIED: This method orchestrates submission, local saving, and logging. Future> submitData(RiverInSituSamplingData data, List>? appSettings) async { - final formData = data.toApiFormData(); - final imageFiles = data.toApiImageFiles(); + const String moduleName = 'river_in_situ'; + final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; - // Get server name for logging - final activeConfig = await _serverConfigService.getActiveApiConfig(); - final serverName = activeConfig?['config_name'] as String? ?? 'Default'; + final imageFilesWithNulls = data.toApiImageFiles(); + imageFilesWithNulls.removeWhere((key, value) => value == null); + final Map finalImageFiles = imageFilesWithNulls.cast(); - // Get API/FTP configs for granular logging (assuming max 2 servers for each) - final apiConfigs = (await _dbHelper.loadApiConfigs() ?? []).take(2).toList(); - final ftpConfigs = (await _dbHelper.loadFtpConfigs() ?? []).take(2).toList(); - - // 1. Attempt API Submission (Data + Images) - final apiResult = await _riverApiService.submitInSituSample( - formData: formData, - imageFiles: imageFiles, - appSettings: appSettings, + final dataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'river/manual/sample', + body: data.toApiFormData(), ); - final apiSuccess = apiResult['success'] == true; - final serverReportId = apiResult['reportId']; - - // Determine granular API statuses (Simulation based on BaseApiService trying 2 servers) - List> apiStatuses = []; - for (int i = 0; i < apiConfigs.length; i++) { - final config = apiConfigs[i]; - String status; - String message; - - if (apiSuccess && i == 0) { - status = "SUCCESS"; - message = "Data posted successfully to primary API."; - } else if (apiSuccess && i > 0) { - status = "SUCCESS (Fallback)"; - message = "Data posted successfully to fallback API."; - } else { - status = "FAILED"; - message = apiResult['message'] ?? "Connection or server error."; - } - - apiStatuses.add({ - "server_name": config['config_name'], - "status": status, - "message": message, - }); + if (dataResult['success'] != true) { + await _logAndSave(data: data, status: 'L1', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName); + return {'success': false, 'message': dataResult['message']}; } - // 2. Determine FTP Status (Simulated based on configuration existence) - List> ftpStatuses = []; - bool ftpQueueSuccess = false; + final recordId = dataResult['data']?['r_man_id']?.toString(); + if (recordId == null) { + await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [dataResult], ftpStatuses: [], serverName: serverName); + return {'success': false, 'message': 'API Error: Missing record ID.'}; + } + data.reportId = recordId; - if (ftpConfigs.isNotEmpty) { - // Assume zipping and queuing is successful here as separate service handles transfer - for (var config in ftpConfigs) { - ftpStatuses.add({ - "server_name": config['config_name'], - "status": "QUEUED", - "message": "Files queued for transfer.", - }); - } - ftpQueueSuccess = true; - } else { - ftpStatuses.add({ - "server_name": "N/A", - "status": "NOT_CONFIGURED", - "message": "No FTP servers configured.", - }); + Map imageResult = {'success': true, 'message': 'No images to upload.'}; + if (finalImageFiles.isNotEmpty) { + imageResult = await _submissionApiService.submitMultipart( + moduleName: moduleName, + endpoint: 'river/manual/images', + fields: {'r_man_id': recordId}, + files: finalImageFiles, + ); + } + final bool apiSuccess = imageResult['success'] == true; + + final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN'; + final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); + final baseFileName = "${stationCode}_$fileTimestamp"; + + final Directory? logDirectory = await _localStorageService.getLogDirectory( + serverName: serverName, + module: 'river', + subModule: 'river_in_situ_sampling', + ); + + final Directory? localSubmissionDir = logDirectory != null ? Directory(path.join(logDirectory.path, data.reportId ?? baseFileName)) : null; + if (localSubmissionDir != null && !await localSubmissionDir.exists()) { + await localSubmissionDir.create(recursive: true); } - // --- Step 3: Determine Final Status and Log to DB --- + final dataZip = await _zippingService.createDataZip( + jsonDataMap: {'db.json': data.toDbJson()}, + baseFileName: baseFileName, + destinationDir: localSubmissionDir, + ); + Map ftpDataResult = {'success': true, 'statuses': []}; + if (dataZip != null) { + ftpDataResult = await _submissionFtpService.submit( + moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${path.basename(dataZip.path)}'); + } + + final imageZip = await _zippingService.createImageZip( + imageFiles: finalImageFiles.values.toList(), + baseFileName: baseFileName, + destinationDir: localSubmissionDir, + ); + Map ftpImageResult = {'success': true, 'statuses': []}; + if (imageZip != null) { + ftpImageResult = await _submissionFtpService.submit( + moduleName: moduleName, fileToUpload: imageZip, remotePath: '/${path.basename(imageZip.path)}'); + } + final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); + String finalStatus; String finalMessage; - - if (apiSuccess && ftpQueueSuccess) { - finalStatus = 'S4'; // Submitted API, Queued FTP - finalMessage = 'Data submitted to API and files queued for FTP upload.'; - } else if (apiSuccess) { - finalStatus = 'S3'; // Submitted API Only - finalMessage = 'Data submitted successfully to API. FTP queueing failed or not configured.'; - } else if (ftpQueueSuccess) { - finalStatus = 'L4'; // Failed API, Queued FTP - finalMessage = 'API submission failed but files were successfully queued for FTP.'; + if (apiSuccess) { + finalStatus = ftpSuccess ? 'S4' : 'S3'; + finalMessage = ftpSuccess ? 'Data submitted successfully.' : 'Data sent to API. FTP upload failed/queued.'; } else { - finalStatus = 'L1'; // All submissions failed - finalMessage = 'All submission attempts failed. Data saved locally for retry.'; + finalStatus = ftpSuccess ? 'L4' : 'L1'; + finalMessage = ftpSuccess ? 'API failed, but files sent to FTP.' : 'All submission attempts failed.'; } - // FIX: Ensure submissionId is initialized, or get it from data - final String submissionId = data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString(); + await _logAndSave( + data: data, + status: finalStatus, + message: finalMessage, + apiResults: [dataResult, imageResult], + ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], + serverName: serverName + ); - // 4. Update data model and save to local storage - data.submissionStatus = finalStatus; - data.submissionMessage = finalMessage; - data.reportId = serverReportId; + if (apiSuccess || ftpSuccess) { + _handleSuccessAlert(data, appSettings, isDataOnly: !apiSuccess); + } + return {'success': apiSuccess || ftpSuccess, 'message': finalMessage}; + } + + Future _logAndSave({ + required RiverInSituSamplingData data, + required String status, + required String message, + required List> apiResults, + required List> ftpStatuses, + required String serverName, + }) async { + data.submissionStatus = status; + data.submissionMessage = message; await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName); - // 5. Save submission status to Central DB Log + final imagePaths = data.toApiImageFiles().values.whereType().map((f) => f.path).toList(); final logData = { - 'submission_id': submissionId, - 'module': 'river', - 'type': data.samplingType ?? 'Others', - 'status': finalStatus, // High-level status - 'message': finalMessage, - 'report_id': serverReportId, - 'created_at': DateTime.now().toIso8601String(), - 'form_data': jsonEncode(data.toMap()), - 'image_data': jsonEncode(imageFiles.keys.map((key) => imageFiles[key]?.path).where((p) => p != null).toList()), - 'server_name': serverName, - 'api_status': jsonEncode(apiStatuses), // GRANULAR API STATUSES - 'ftp_status': jsonEncode(ftpStatuses), // GRANULAR FTP STATUSES + 'submission_id': data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString(), + 'module': 'river', 'type': data.samplingType ?? 'In-Situ', 'status': status, + 'message': message, 'report_id': data.reportId, 'created_at': DateTime.now().toIso8601String(), + 'form_data': jsonEncode(data.toMap()), 'image_data': jsonEncode(imagePaths), + 'server_name': serverName, 'api_status': jsonEncode(apiResults), 'ftp_status': jsonEncode(ftpStatuses), }; await _dbHelper.saveSubmissionLog(logData); + } - // 6. Return the final API result (which contains the granular statuses) - return apiResult; + Future _handleSuccessAlert(RiverInSituSamplingData data, List>? appSettings, {required bool isDataOnly}) async { + debugPrint("[DEBUG] appSettings passed to _handleSuccessAlert: ${jsonEncode(appSettings)}"); + try { + final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; + final stationName = data.selectedStation?['sampling_river'] ?? 'N/A'; + final stationCode = data.selectedStation?['sampling_station_code'] ?? 'N/A'; + final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); + final submitter = data.firstSamplerName ?? 'N/A'; + final sondeID = data.sondeId ?? 'N/A'; + final distanceKm = data.distanceDifferenceInKm ?? 0; + final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); + final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A'; + + final buffer = StringBuffer() + ..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*') + ..writeln() + ..writeln('*Station Name & Code:* $stationName ($stationCode)') + ..writeln('*Date of Submitted:* $submissionDate') + ..writeln('*Submitted by User:* $submitter') + ..writeln('*Sonde ID:* $sondeID') + ..writeln('*Status of Submission:* Successful'); + + if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { + buffer + ..writeln() + ..writeln('🔔 *Alert:*') + ..writeln('*Distance from station:* $distanceMeters meters'); + if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { + buffer.writeln('*Remarks for distance:* $distanceRemarks'); + } + } + final String message = buffer.toString(); + final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings); + if (!wasSent) { + await _telegramService.queueMessage('river_in_situ', message, appSettings); + } + } catch (e) { + debugPrint("Failed to handle River Telegram alert: $e"); + } } } \ No newline at end of file diff --git a/lib/services/submission_api_service.dart b/lib/services/submission_api_service.dart new file mode 100644 index 0000000..b71b76e --- /dev/null +++ b/lib/services/submission_api_service.dart @@ -0,0 +1,109 @@ +// lib/services/submission_api_service.dart + +import 'dart:io'; +import 'package:flutter/foundation.dart'; + +import 'package:environment_monitoring_app/services/user_preferences_service.dart'; +import 'package:environment_monitoring_app/services/base_api_service.dart'; +import 'package:environment_monitoring_app/services/retry_service.dart'; + +/// A generic, reusable service for handling the entire API submission process. +/// It respects user preferences for enabled destinations for any given module. +class SubmissionApiService { + final UserPreferencesService _userPreferencesService = UserPreferencesService(); + final BaseApiService _baseApiService = BaseApiService(); + final RetryService _retryService = RetryService(); + + /// Submits a standard JSON POST request to all enabled destinations for a module. + /// + /// Returns a success result if AT LEAST ONE destination succeeds. + /// If all destinations fail, it queues the request for manual retry and returns a failure result. + Future> submitPost({ + required String moduleName, + required String endpoint, + required Map body, + }) async { + final destinations = await _userPreferencesService.getEnabledApiConfigsForModule(moduleName); + + if (destinations.isEmpty) { + debugPrint("SubmissionApiService: No enabled API destinations for module '$moduleName'. Skipping."); + return {'success': true, 'message': 'No API destinations enabled for this module.'}; + } + + for (final dest in destinations) { + final baseUrl = dest['api_url'] as String?; + if (baseUrl == null) { + debugPrint("SubmissionApiService: Skipping destination '${dest['config_name']}' due to missing URL."); + continue; + } + + debugPrint("SubmissionApiService: Attempting to POST to '${dest['config_name']}' ($baseUrl)"); + final result = await _baseApiService.post(baseUrl, endpoint, body); + + if (result['success'] == true) { + debugPrint("SubmissionApiService: Successfully submitted to '${dest['config_name']}'."); + return result; // Return immediately on the first success + } else { + debugPrint("SubmissionApiService: Failed to submit to '${dest['config_name']}'. Trying next destination."); + } + } + + // If the loop completes, it means all attempts failed. + debugPrint("SubmissionApiService: All API submission attempts for module '$moduleName' failed. Queuing for retry."); + await _retryService.addApiToQueue( + endpoint: endpoint, + method: 'POST', + body: body, + ); + + return {'success': false, 'message': 'All API attempts failed. Request has been queued for manual retry.'}; + } + + /// Submits a multipart (form data with files) request to all enabled destinations. + /// + /// Follows the same logic as `submitPost`: succeeds on the first successful upload, + /// and queues for retry if all attempts fail. + Future> submitMultipart({ + required String moduleName, + required String endpoint, + required Map fields, + required Map files, + }) async { + final destinations = await _userPreferencesService.getEnabledApiConfigsForModule(moduleName); + + if (destinations.isEmpty) { + debugPrint("SubmissionApiService: No enabled API destinations for module '$moduleName'. Skipping multipart upload."); + return {'success': true, 'message': 'No API destinations enabled for this module.'}; + } + + for (final dest in destinations) { + final baseUrl = dest['api_url'] as String?; + if (baseUrl == null) continue; + + debugPrint("SubmissionApiService: Attempting multipart upload to '${dest['config_name']}' ($baseUrl)"); + final result = await _baseApiService.postMultipart( + baseUrl: baseUrl, + endpoint: endpoint, + fields: fields, + files: files, + ); + + if (result['success'] == true) { + debugPrint("SubmissionApiService: Successfully uploaded to '${dest['config_name']}'."); + return result; + } else { + debugPrint("SubmissionApiService: Failed to upload to '${dest['config_name']}'. Trying next destination."); + } + } + + debugPrint("SubmissionApiService: All multipart upload attempts for module '$moduleName' failed. Queuing for retry."); + 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.'}; + } +} \ No newline at end of file diff --git a/lib/services/submission_ftp_service.dart b/lib/services/submission_ftp_service.dart new file mode 100644 index 0000000..3f32ea5 --- /dev/null +++ b/lib/services/submission_ftp_service.dart @@ -0,0 +1,80 @@ +// lib/services/submission_ftp_service.dart + +import 'dart:io'; +import 'package:flutter/foundation.dart'; + +import 'package:environment_monitoring_app/services/user_preferences_service.dart'; +import 'package:environment_monitoring_app/services/ftp_service.dart'; +import 'package:environment_monitoring_app/services/retry_service.dart'; + +/// A generic, reusable service for handling the FTP submission process. +/// It respects user preferences for enabled destinations for any given module. +class SubmissionFtpService { + final UserPreferencesService _userPreferencesService = UserPreferencesService(); + final FtpService _ftpService = FtpService(); + final RetryService _retryService = RetryService(); + + /// Submits a file to all enabled FTP destinations for a given module. + /// + /// This method works differently from the API service. It attempts to upload + /// to ALL enabled destinations. It returns a summary of success/failure for each. + /// If any upload fails, it is queued for individual retry. + /// The overall result is considered successful if there are no hard errors + /// during the process, even if some uploads are queued. + Future> submit({ + required String moduleName, + required File fileToUpload, + required String remotePath, + }) async { + final destinations = await _userPreferencesService.getEnabledFtpConfigsForModule(moduleName); + + if (destinations.isEmpty) { + debugPrint("SubmissionFtpService: No enabled FTP destinations for module '$moduleName'. Skipping."); + return {'success': true, 'message': 'No FTP destinations enabled for this module.'}; + } + + final List> statuses = []; + bool allSucceeded = true; + + for (final dest in destinations) { + final configName = dest['config_name'] as String? ?? 'Unknown FTP'; + debugPrint("SubmissionFtpService: Attempting to upload to '$configName'"); + + final result = await _ftpService.uploadFile( + config: dest, + fileToUpload: fileToUpload, + remotePath: remotePath, + ); + + statuses.add({ + 'config_name': configName, + 'success': result['success'], + 'message': result['message'], + }); + + if (result['success'] != true) { + allSucceeded = false; + // If an individual upload fails, queue it for manual retry. + debugPrint("SubmissionFtpService: Upload to '$configName' failed. Queuing for retry."); + await _retryService.addFtpToQueue( + localFilePath: fileToUpload.path, + remotePath: remotePath, + ); + } + } + + if (allSucceeded) { + return { + 'success': true, + 'message': 'File successfully uploaded to all enabled FTP destinations.', + 'statuses': statuses, + }; + } else { + return { + 'success': true, // The process itself succeeded, even if some uploads were queued. + 'message': 'One or more FTP uploads failed and have been queued for retry.', + 'statuses': statuses, + }; + } + } +} \ No newline at end of file diff --git a/lib/services/telegram_service.dart b/lib/services/telegram_service.dart index 95ff4ee..5bf6f74 100644 --- a/lib/services/telegram_service.dart +++ b/lib/services/telegram_service.dart @@ -6,38 +6,40 @@ import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/settings_service.dart'; class TelegramService { - // FIX: Change to a nullable, externally injected dependency. ApiService? _apiService; final DatabaseHelper _dbHelper = DatabaseHelper(); - final SettingsService _settingsService = SettingsService(); + // REMOVED: The SettingsService is no longer needed here as we will perform a direct lookup. + // final SettingsService _settingsService = SettingsService(); bool _isProcessing = false; - // FIX: Accept ApiService in the constructor to break the circular dependency at runtime. TelegramService({ApiService? apiService}) : _apiService = apiService; - // FIX: Re-introduce the setter for circular injection (used in main.dart) void setApiService(ApiService apiService) { _apiService = apiService; } - // MODIFIED: This method is now synchronous and requires the appSettings list. + // FIX: Replaced the brittle switch statement with a robust, generic lookup function. + // This function can now find the Chat ID for ANY module, including 'river_in_situ'. String _getChatIdForModule(String module, List>? appSettings) { - switch (module) { - case 'marine_in_situ': - return _settingsService.getInSituChatId(appSettings); - case 'marine_tarball': - return _settingsService.getTarballChatId(appSettings); - case 'air_manual': // ADDED THIS CASE - return _settingsService.getAirManualChatId(appSettings); - default: - return ''; + if (appSettings == null) { + return ''; + } + try { + final setting = appSettings.firstWhere( + (settingMap) => + settingMap['module_name'] == module && + settingMap['setting_key'] == 'telegram_chat_id' + ); + return setting['setting_value'] as String? ?? ''; + } catch (e) { + // This catch block handles cases where no matching setting is found in the list. + return ''; } } /// Tries to send an alert immediately over the network. /// Returns `true` on success, `false` on failure. - // MODIFIED: This method now requires the appSettings list to be passed in. Future sendAlertImmediately(String module, String message, List>? appSettings) async { debugPrint("[TelegramService] Attempting to send alert immediately for module: $module"); String chatId = _getChatIdForModule(module, appSettings); @@ -47,7 +49,6 @@ class TelegramService { return false; } - // FIX: Check for the injected ApiService if (_apiService == null) { debugPrint("[TelegramService] ❌ ApiService is not available."); return false; @@ -68,7 +69,6 @@ class TelegramService { } /// Saves an alert to the local database queue. (This is now the fallback) - // MODIFIED: This method now requires the appSettings list to be passed in. Future queueMessage(String module, String message, List>? appSettings) async { String chatId = _getChatIdForModule(module, appSettings); @@ -91,7 +91,6 @@ class TelegramService { } /// Processes all pending alerts in the queue. - /// This method does NOT need changes because the chatId is already stored in the queue. Future processAlertQueue() async { if (_isProcessing) { debugPrint("[TelegramService] ⏳ Queue is already being processed. Skipping."); @@ -110,7 +109,6 @@ class TelegramService { return; } - // FIX: Check for ApiService before starting the loop if (_apiService == null) { debugPrint("[TelegramService] ❌ ApiService is not available for processing queue."); _isProcessing = false; diff --git a/lib/services/user_preferences_service.dart b/lib/services/user_preferences_service.dart new file mode 100644 index 0000000..2854c1f --- /dev/null +++ b/lib/services/user_preferences_service.dart @@ -0,0 +1,152 @@ +// lib/services/user_preferences_service.dart + +import 'package:flutter/foundation.dart'; +import 'package:environment_monitoring_app/services/api_service.dart'; // Contains DatabaseHelper + +/// A dedicated service to manage the user's local preferences for +/// module-specific submission destinations. +class UserPreferencesService { + final DatabaseHelper _dbHelper = DatabaseHelper(); + + /// Retrieves a module's master submission preferences. + /// If no preference has been saved for this module, it returns a default + /// where both API and FTP are enabled. + Future> getModulePreference(String moduleName) async { + final preference = await _dbHelper.getModulePreference(moduleName); + if (preference != null) { + return preference; + } + // Return a default value if no preference is found in the database. + return { + 'module_name': moduleName, + 'is_api_enabled': true, + 'is_ftp_enabled': true, + }; + } + + /// Saves or updates a module's master on/off switches for API and FTP submissions. + Future saveModulePreference({ + required String moduleName, + required bool isApiEnabled, + required bool isFtpEnabled, + }) async { + await _dbHelper.saveModulePreference( + moduleName: moduleName, + isApiEnabled: isApiEnabled, + isFtpEnabled: isFtpEnabled, + ); + } + + /// Retrieves all available API configurations and merges them with the user's + /// saved preferences for a specific module. + /// + /// This is primarily for the Settings UI to display all possible destinations + /// with their current enabled/disabled state (e.g., checkboxes). + Future>> getAllApiConfigsWithModulePreferences(String moduleName) async { + // 1. Get all possible API destinations that have been synced to the device. + final allApiConfigs = await _dbHelper.loadApiConfigs() ?? []; + if (allApiConfigs.isEmpty) return []; + + // 2. Get the specific links the user has previously saved for this module. + final savedLinks = await _dbHelper.getAllApiLinksForModule(moduleName); + + // 3. Merge the two lists. + return allApiConfigs.map((config) { + final configId = config['api_config_id']; + bool isEnabled = false; // Default to disabled + + try { + // Find if a link exists for this config ID in the user's saved preferences. + final matchingLink = savedLinks.firstWhere( + (link) => link['api_config_id'] == configId, + // If no link is found, 'orElse' is not triggered, it throws. + ); + isEnabled = matchingLink['is_enabled'] as bool? ?? false; + } catch (e) { + // A 'firstWhere' with no match throws an error. We catch it here. + // This means no link was saved for this config, so it remains disabled. + isEnabled = false; + } + + // Return a new map containing the original config details plus the 'is_enabled' flag. + return { + ...config, + 'is_enabled': isEnabled, + }; + }).toList(); + } + + /// Retrieves all available FTP configurations and merges them with the user's + /// saved preferences for a specific module. (For the Settings UI). + Future>> getAllFtpConfigsWithModulePreferences(String moduleName) async { + final allFtpConfigs = await _dbHelper.loadFtpConfigs() ?? []; + if (allFtpConfigs.isEmpty) return []; + + final savedLinks = await _dbHelper.getAllFtpLinksForModule(moduleName); + + return allFtpConfigs.map((config) { + final configId = config['ftp_config_id']; + bool isEnabled = false; + try { + final matchingLink = savedLinks.firstWhere( + (link) => link['ftp_config_id'] == configId, + ); + isEnabled = matchingLink['is_enabled'] as bool? ?? false; + } catch (e) { + isEnabled = false; + } + return { + ...config, + 'is_enabled': isEnabled, + }; + }).toList(); + } + + /// Saves the complete set of enabled/disabled API links for a specific module. + /// This will replace all previous links for that module. + Future saveApiLinksForModule(String moduleName, List> links) async { + await _dbHelper.saveApiLinksForModule(moduleName, links); + } + + /// Saves the complete set of enabled/disabled FTP links for a specific module. + Future saveFtpLinksForModule(String moduleName, List> links) async { + await _dbHelper.saveFtpLinksForModule(moduleName, links); + } + + /// Retrieves only the API configurations that are actively enabled for a given module. + /// + /// This is primarily for the submission services to know exactly which + /// destinations to send data to. + Future>> getEnabledApiConfigsForModule(String moduleName) async { + // 1. Check the master switch for the module. + final pref = await getModulePreference(moduleName); + if (!(pref['is_api_enabled'] as bool)) { + debugPrint("API submissions are disabled for module '$moduleName' via master switch."); + return []; // Return empty list if API is globally disabled for this module. + } + + // 2. Get all configs with their preference flags. + final allConfigsWithPrefs = await getAllApiConfigsWithModulePreferences(moduleName); + + // 3. Filter for only those that are enabled. + final enabledConfigs = allConfigsWithPrefs.where((config) => config['is_enabled'] == true).toList(); + + debugPrint("Found ${enabledConfigs.length} enabled API destinations for module '$moduleName'."); + return enabledConfigs; + } + + /// Retrieves only the FTP configurations that are actively enabled for a given module. + Future>> getEnabledFtpConfigsForModule(String moduleName) async { + final pref = await getModulePreference(moduleName); + if (!(pref['is_ftp_enabled'] as bool)) { + debugPrint("FTP submissions are disabled for module '$moduleName' via master switch."); + return []; + } + + final allConfigsWithPrefs = await getAllFtpConfigsWithModulePreferences(moduleName); + final enabledConfigs = allConfigsWithPrefs.where((config) => config['is_enabled'] == true).toList(); + + debugPrint("Found ${enabledConfigs.length} enabled FTP destinations for module '$moduleName'."); + return enabledConfigs; + } +} \ No newline at end of file diff --git a/lib/services/zipping_service.dart b/lib/services/zipping_service.dart index a4d851e..66008ea 100644 --- a/lib/services/zipping_service.dart +++ b/lib/services/zipping_service.dart @@ -12,10 +12,15 @@ class ZippingService { Future createDataZip({ required Map jsonDataMap, required String baseFileName, + Directory? destinationDir, }) async { try { - final tempDir = await getTemporaryDirectory(); - final zipFilePath = p.join(tempDir.path, '$baseFileName.zip'); + final targetDir = destinationDir ?? await getTemporaryDirectory(); + // Ensure the target directory exists before creating the file + if (!await targetDir.exists()) { + await targetDir.create(recursive: true); + } + final zipFilePath = p.join(targetDir.path, '$baseFileName.zip'); final encoder = ZipFileEncoder(); encoder.create(zipFilePath); @@ -45,6 +50,7 @@ class ZippingService { Future createImageZip({ required List imageFiles, required String baseFileName, + Directory? destinationDir, // ADDED: New optional parameter }) async { if (imageFiles.isEmpty) { debugPrint("No images provided to create an image ZIP."); @@ -52,8 +58,12 @@ class ZippingService { } try { - final tempDir = await getTemporaryDirectory(); - final zipFilePath = p.join(tempDir.path, '${baseFileName}_img.zip'); + final targetDir = destinationDir ?? await getTemporaryDirectory(); + // Ensure the target directory exists before creating the file + if (!await targetDir.exists()) { + await targetDir.create(recursive: true); + } + final zipFilePath = p.join(targetDir.path, '${baseFileName}_img.zip'); final encoder = ZipFileEncoder(); encoder.create(zipFilePath);