diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 0f1fe84..5495af9 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index aa922d4..cc2aa21 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 6cf811a..d434ab5 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 8181856..fdaf4fe 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 0423878..b465d7b 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/icon_4_512x512.png b/assets/icon_4_512x512.png new file mode 100644 index 0000000..eca92e4 Binary files /dev/null and b/assets/icon_4_512x512.png differ diff --git a/assets/icon_5_512x512.png b/assets/icon_5_512x512.png new file mode 100644 index 0000000..317fb19 Binary files /dev/null and b/assets/icon_5_512x512.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index e185c12..471a4b4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index cf13a54..36c9210 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 19f228c..79516d7 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index b632482..1e3651f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 9292c5c..84fd4b1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index cab4c5f..c14f9c8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 8dce1f7..9014526 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 19f228c..79516d7 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 7d2c607..9bd2665 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 208fb5d..dbdbde9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 9c17b30..f22e956 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index b218d7c..d12b87c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 1f7d092..7d09855 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 624a336..bc50829 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 208fb5d..dbdbde9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index 614f058..9551f80 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index 0f1fe84..5495af9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index 8181856..fdaf4fe 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 0a29b2f..2d0d2fd 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index cf6e259..35516a0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 6dcf8ef..650d4dc 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/home_page.dart b/lib/home_page.dart index 420a5d4..ee18501 100644 --- a/lib/home_page.dart +++ b/lib/home_page.dart @@ -101,7 +101,7 @@ class _HomePageState extends State { ); }, ), - title: const Text("MMS Version 3.12.03"), + title: const Text("MMS Version 3.12.06"), actions: [ IconButton( icon: const Icon(Icons.person), diff --git a/lib/models/in_situ_sampling_data.dart b/lib/models/in_situ_sampling_data.dart index cb2bcff..50499da 100644 --- a/lib/models/in_situ_sampling_data.dart +++ b/lib/models/in_situ_sampling_data.dart @@ -32,7 +32,6 @@ class InSituSamplingData { String? weather; String? tideLevel; String? seaCondition; - // String? tarball; // <-- REMOVED THIS PROPERTY String? eventRemarks; String? labRemarks; @@ -72,10 +71,6 @@ class InSituSamplingData { String? reportId; // --- START: NPE Report Compatibility Fields --- - /// Fields to hold data that can be transferred to an NPE Report. - /// This makes the model compatible for auto-generating NPE reports in the future. - - // Corresponds to the checkboxes in the NPE form Map npeFieldObservations = { 'Oil slick on the water surface/ Oil spill': false, 'Discoloration of the sea water': false, @@ -88,12 +83,9 @@ class InSituSamplingData { 'Foul smell': false, 'Others': false, }; - // Corresponds to the "Others" text field in NPE observations String? npeOthersObservationRemark; - // Corresponds to the "Possible Source" field in the NPE form String? npePossibleSource; - // Holds the images to be attached to the NPE report File? npeImage1; File? npeImage2; File? npeImage3; @@ -109,48 +101,28 @@ class InSituSamplingData { /// Creates a pre-populated NPE Report object from the current In-Situ data. MarineManualNpeReportData toNpeReportData() { final npeData = MarineManualNpeReportData(); - - // Transfer Reporter & Event Info npeData.firstSamplerName = firstSamplerName; npeData.firstSamplerUserId = firstSamplerUserId; npeData.eventDate = samplingDate; npeData.eventTime = samplingTime; - - // Transfer Location Info npeData.selectedStation = selectedStation; npeData.latitude = currentLatitude; npeData.longitude = currentLongitude; - - // Transfer In-Situ Measurements relevant to NPE npeData.oxygenSaturation = oxygenSaturation; npeData.electricalConductivity = electricalConductivity; npeData.oxygenConcentration = oxygenConcentration; npeData.turbidity = turbidity; npeData.ph = ph; npeData.temperature = temperature; - - // Pre-populate possible source with event remarks as a starting point for the user npeData.possibleSource = eventRemarks; - // Pre-populate some common observations based on data - if ((turbidity ?? 0) > 50) { // Example threshold, adjust as needed - npeData.fieldObservations['Silt plume'] = true; - } - if ((oxygenConcentration ?? 999) < 4) { // Example threshold for low oxygen - npeData.fieldObservations['Foul smell'] = true; - } + if ((turbidity ?? 0) > 50) npeData.fieldObservations['Silt plume'] = true; + if ((oxygenConcentration ?? 999) < 4) npeData.fieldObservations['Foul smell'] = true; - // Transfer up to 4 available images final availableImages = [ - leftLandViewImage, - rightLandViewImage, - waterFillingImage, - seawaterColorImage, - phPaperImage, - optionalImage1, - optionalImage2, - optionalImage3, - optionalImage4, + leftLandViewImage, rightLandViewImage, waterFillingImage, + seawaterColorImage, phPaperImage, optionalImage1, + optionalImage2, optionalImage3, optionalImage4, ].where((img) => img != null).cast().toList(); if (availableImages.isNotEmpty) npeData.image1 = availableImages[0]; @@ -161,34 +133,26 @@ class InSituSamplingData { return npeData; } - /// Creates an InSituSamplingData object from a JSON map. factory InSituSamplingData.fromJson(Map json) { double? doubleFromJson(dynamic value) { if (value is num) return value.toDouble(); if (value is String) return double.tryParse(value); return null; } - int? intFromJson(dynamic value) { if (value is int) return value; if (value is String) return int.tryParse(value); return null; } - - File? fileFromPath(dynamic path) { - return (path is String && path.isNotEmpty) ? File(path) : null; - } + File? fileFromPath(dynamic path) => (path is String && path.isNotEmpty) ? File(path) : null; final data = InSituSamplingData(); - - // Standard In-Situ Fields data.firstSamplerName = json['first_sampler_name']; data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']); data.secondSampler = json['secondSampler'] ?? json['second_sampler']; data.samplingDate = json['sampling_date'] ?? json['man_date']; data.samplingTime = json['sampling_time'] ?? json['man_time']; data.samplingType = json['sampling_type']; - // ... (all other existing fields) data.sampleIdCode = json['sample_id_code']; data.selectedStateName = json['selected_state_name']; data.selectedCategoryName = json['selected_category_name']; @@ -202,7 +166,6 @@ class InSituSamplingData { data.weather = json['weather']; data.tideLevel = json['tide_level']; data.seaCondition = json['sea_condition']; - // data.tarball = json['tarball']; // <-- REMOVED DESERIALIZATION data.eventRemarks = json['event_remarks']; data.labRemarks = json['lab_remarks']; data.optionalRemark1 = json['man_optional_photo_01_remarks']; @@ -226,11 +189,8 @@ class InSituSamplingData { data.submissionMessage = json['submission_message']; data.reportId = json['report_id']?.toString(); - - // Image paths (handled by LocalStorageService) data.leftLandViewImage = fileFromPath(json['man_left_side_land_view']); data.rightLandViewImage = fileFromPath(json['man_right_side_land_view']); - // ... (all other existing images) data.waterFillingImage = fileFromPath(json['man_filling_water_into_sample_bottle']); data.seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle']); data.phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper']); @@ -239,15 +199,11 @@ class InSituSamplingData { data.optionalImage3 = fileFromPath(json['man_optional_photo_03']); data.optionalImage4 = fileFromPath(json['man_optional_photo_04']); - - // --- Deserialization for NPE Fields --- if (json['npe_field_observations'] is Map) { data.npeFieldObservations = Map.from(json['npe_field_observations']); } data.npeOthersObservationRemark = json['npe_others_observation_remark']; data.npePossibleSource = json['npe_possible_source']; - - // NPE image paths data.npeImage1 = fileFromPath(json['npe_image_1']); data.npeImage2 = fileFromPath(json['npe_image_2']); data.npeImage3 = fileFromPath(json['npe_image_3']); @@ -256,7 +212,6 @@ class InSituSamplingData { return data; } - /// Creates a Map object with all submission data for local logging. Map toMap() { return { 'first_sampler_name': firstSamplerName, @@ -278,7 +233,6 @@ class InSituSamplingData { 'weather': weather, 'tide_level': tideLevel, 'sea_condition': seaCondition, - // 'tarball': tarball, // <-- REMOVED 'event_remarks': eventRemarks, 'lab_remarks': labRemarks, 'man_optional_photo_01_remarks': optionalRemark1, @@ -304,143 +258,102 @@ class InSituSamplingData { 'npe_field_observations': npeFieldObservations, 'npe_others_observation_remark': npeOthersObservationRemark, 'npe_possible_source': npePossibleSource, - // Image paths will be added/updated by LocalStorageService during saving/updating }; } - /// Creates a single JSON object with all submission data, mimicking 'db.json' - /// Creates a single JSON object with all submission data, mimicking 'db.json' + /// Creates a single JSON object for 'db.json'. FORCING ALL VALUES TO STRING. String toDbJson() { final data = { - // --- Sorted exactly according to your Marine db.json sample --- - 'battery_cap': batteryVoltage ?? -999.0, - 'device_name': sondeId ?? "", - 'sampling_type': samplingType ?? "", - 'report_id': reportId ?? "", - 'sampler_2ndname': secondSampler?['first_name'] ?? "", - 'sample_state': selectedStateName ?? "", - 'station_id': selectedStation?['man_station_code'] ?? "", - 'tech_id': firstSamplerUserId ?? -999, // Default to -999 for int - 'tech_phonenum': "", // Not in model, default to empty string - 'tech_name': firstSamplerName ?? "", - 'latitude': stationLatitude ?? "", - 'longitude': stationLongitude ?? "", - 'record_dt': (samplingDate != null && samplingTime != null) - ? '$samplingDate $samplingTime' - : "", - - // --- Sensor Readings --- - 'do_mgl': oxygenConcentration ?? -999.0, - 'do_sat': oxygenSaturation ?? -999.0, - 'ph': ph ?? -999.0, - 'salinity': salinity ?? -999.0, - 'tss': tss ?? -999.0, - 'temperature': temperature ?? -999.0, - 'turbidity': turbidity ?? -999.0, - 'tds': tds ?? -999.0, - 'electric_conductivity': electricalConductivity ?? -999.0, - - // --- Manual/Observations --- - 'sample_id': sampleIdCode ?? "", - 'tarball': "No", // Field removed from model logic, default to empty - 'weather': weather ?? "", - 'tide_lvl': tideLevel ?? "", - 'sea_cond': seaCondition ?? "", - 'remarks_event': eventRemarks ?? "", - 'remarks_lab': labRemarks ?? "", + 'battery_cap': (batteryVoltage ?? "NULL").toString(), + 'device_name': (sondeId ?? "").toString(), + 'sampling_type': (samplingType ?? "").toString(), + 'report_id': (reportId ?? "").toString(), + 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(), + 'sample_state': (selectedStateName ?? "").toString(), + 'station_id': (selectedStation?['man_station_code'] ?? "").toString(), + 'tech_id': (firstSamplerUserId ?? "NULL").toString(), + 'tech_phonenum': "", + 'tech_name': (firstSamplerName ?? "").toString(), + 'latitude': (stationLatitude ?? "").toString(), + 'longitude': (stationLongitude ?? "").toString(), + 'record_dt': (samplingDate != null && samplingTime != null) ? '$samplingDate $samplingTime' : "", + 'do_mgl': (oxygenConcentration ?? "NULL").toString(), + 'do_sat': (oxygenSaturation ?? "NULL").toString(), + 'ph': (ph ?? "NULL").toString(), + 'salinity': (salinity ?? "NULL").toString(), + 'tss': (tss ?? "NULL").toString(), + 'temperature': (temperature ?? "NULL").toString(), + 'turbidity': (turbidity ?? "NULL").toString(), + 'tds': (tds ?? "NULL").toString(), + 'electric_conductivity': (electricalConductivity ?? "NULL").toString(), + 'sample_id': (sampleIdCode ?? "").toString(), + 'tarball': "No", + 'weather': (weather ?? "").toString(), + 'tide_lvl': (tideLevel ?? "").toString(), + 'sea_cond': (seaCondition ?? "").toString(), + 'remarks_event': (eventRemarks ?? "").toString(), + 'remarks_lab': (labRemarks ?? "").toString(), }; - - // ❌ DO NOT UNCOMMENT. Keeps all keys even if values are null/default. - // data.removeWhere((key, value) => value == null); - return jsonEncode(data); } - /// Creates a JSON object for basic form info, mimicking 'marine_insitu_basic_form.json'. + /// Creates a JSON object for basic form info. FORCING ALL VALUES TO STRING. String toBasicFormJson() { final data = { - // --- Sorted exactly according to your Marine sample --- - 'tech_name': firstSamplerName ?? "", - 'sampler_2ndname': secondSampler?['first_name'] ?? "", - 'sample_date': samplingDate ?? "", - 'sample_time': samplingTime ?? "", - 'sampling_type': samplingType ?? "", - 'sample_state': selectedStateName ?? "", - 'sample_category': selectedCategoryName ?? "", // Added to match sample - 'station_id': selectedStation?['man_station_code'] ?? "", - 'station_latitude': stationLatitude ?? "", - 'station_longitude': stationLongitude ?? "", - 'latitude': currentLatitude ?? "", - 'longitude': currentLongitude ?? "", - 'sample_id': sampleIdCode ?? "", + 'tech_name': (firstSamplerName ?? "").toString(), + 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(), + 'sample_date': (samplingDate ?? "").toString(), + 'sample_time': (samplingTime ?? "").toString(), + 'sampling_type': (samplingType ?? "").toString(), + 'sample_state': (selectedStateName ?? "").toString(), + 'sample_category': (selectedCategoryName ?? "").toString(), + 'station_id': (selectedStation?['man_station_code'] ?? "").toString(), + 'station_latitude': (stationLatitude ?? "").toString(), + 'station_longitude': (stationLongitude ?? "").toString(), + 'latitude': (currentLatitude ?? "").toString(), + 'longitude': (currentLongitude ?? "").toString(), + 'sample_id': (sampleIdCode ?? "").toString(), }; - - // ❌ DO NOT UNCOMMENT - // data.removeWhere((key, value) => value == null); - return jsonEncode(data); } - /// Creates a JSON object for sensor readings, mimicking 'marine_sampling_reading.json'. + /// Creates a JSON object for sensor readings. FORCING ALL VALUES TO STRING. String toReadingJson() { final data = { - // --- Sorted exactly according to your Marine sample --- - 'do_mgl': oxygenConcentration ?? -999.0, - 'do_sat': oxygenSaturation ?? -999.0, - 'ph': ph ?? -999.0, - 'salinity': salinity ?? -999.0, - 'tds': tds ?? -999.0, - 'tss': tss ?? -999.0, - 'temperature': temperature ?? -999.0, - 'turbidity': turbidity ?? -999.0, - 'electric_conductivity': electricalConductivity ?? -999.0, - 'date_sampling_reading': dataCaptureDate ?? "", - 'time_sampling_reading': dataCaptureTime ?? "", + 'do_mgl': (oxygenConcentration ?? "NULL").toString(), + 'do_sat': (oxygenSaturation ?? "NULL").toString(), + 'ph': (ph ?? "NULL").toString(), + 'salinity': (salinity ?? "NULL").toString(), + 'tds': (tds ?? "NULL").toString(), + 'tss': (tss ?? "NULL").toString(), + 'temperature': (temperature ?? "NULL").toString(), + 'turbidity': (turbidity ?? "NULL").toString(), + 'electric_conductivity': (electricalConductivity ?? "NULL").toString(), + 'date_sampling_reading': (dataCaptureDate ?? "").toString(), + 'time_sampling_reading': (dataCaptureTime ?? "").toString(), }; - - // ❌ DO NOT UNCOMMENT - // data.removeWhere((key, value) => value == null); - return jsonEncode(data); } - /// Creates a JSON object for manual info, mimicking 'marine_manual_info.json'. + /// Creates a JSON object for manual info. FORCING ALL VALUES TO STRING. String toManualInfoJson() { final data = { - // --- Sorted exactly according to your Marine sample --- - 'tarball': "", // Field removed from model logic, default to empty - 'weather': weather ?? "", - 'tide_lvl': tideLevel ?? "", - 'sea_cond': seaCondition ?? "", - 'remarks_event': eventRemarks ?? "", - 'remarks_lab': labRemarks ?? "", + 'tarball': "", + 'weather': (weather ?? "").toString(), + 'tide_lvl': (tideLevel ?? "").toString(), + 'sea_cond': (seaCondition ?? "").toString(), + 'remarks_event': (eventRemarks ?? "").toString(), + 'remarks_lab': (labRemarks ?? "").toString(), }; - - // ❌ DO NOT UNCOMMENT - // data.removeWhere((key, value) => value == null); - return jsonEncode(data); } Map toApiFormData() { final Map map = {}; - void add(String key, dynamic value) { if (value != null) { - String stringValue; - if (value is double) { - if (value == -999.0) { - stringValue = '-999'; - } else { - stringValue = value.toStringAsFixed(5); - } - } else { - stringValue = value.toString(); - } - - if (stringValue.isNotEmpty) { - map[key] = stringValue; - } + String stringValue = (value is double) ? ((value == -999.0) ? 'NULL' : value.toStringAsFixed(5)) : value.toString(); + if (stringValue.isNotEmpty) map[key] = stringValue; } } @@ -480,7 +393,6 @@ class InSituSamplingData { add('first_sampler_name', firstSamplerName); add('man_station_code', selectedStation?['man_station_code']); add('man_station_name', selectedStation?['man_station_name']); - return map; } diff --git a/lib/models/marine_inves_manual_sampling_data.dart b/lib/models/marine_inves_manual_sampling_data.dart index 6f2f9b0..2f01433 100644 --- a/lib/models/marine_inves_manual_sampling_data.dart +++ b/lib/models/marine_inves_manual_sampling_data.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'dart:convert'; // Added for jsonEncode -// REMOVED: import 'package:environment_monitoring_app/models/marine_manual_npe_report_data.dart'; // No longer needed /// A data model class to hold all information for the multi-step /// Marine Investigative Manual Sampling form. @@ -80,10 +79,7 @@ class MarineInvesManualSamplingData { // --- Post-Submission Status --- String? submissionStatus; String? submissionMessage; - String? reportId; // This will be 'man_inves_id' from the DB - - // REMOVED: All NPE Report Compatibility Fields (npeFieldObservations, npeOthersObservationRemark, etc.) - + String? reportId; // This will be 'man_inves_id' from the DB OR Timestamp ID MarineInvesManualSamplingData({ this.samplingDate, @@ -91,9 +87,6 @@ class MarineInvesManualSamplingData { this.stationTypeSelection = 'Existing Manual Station', // Default value }); - // REMOVED: toNpeReportData() method - - /// Creates a single JSON object with all submission data for offline storage. Map toDbJson() { return { @@ -147,7 +140,6 @@ class MarineInvesManualSamplingData { 'submission_status': submissionStatus, 'submission_message': submissionMessage, 'report_id': reportId, - // REMOVED: NPE fields from JSON // Image paths will be added by LocalStorageService during save 'inves_left_side_land_view': leftLandViewImage?.path, @@ -177,7 +169,6 @@ class MarineInvesManualSamplingData { } File? fileFromPath(dynamic path) { - // Ensure path is not null and not empty before creating File object return (path is String && path.isNotEmpty) ? File(path) : null; } @@ -186,7 +177,7 @@ class MarineInvesManualSamplingData { // Step 1 data.firstSamplerName = json['first_sampler_name']; data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']); - data.secondSampler = json['secondSampler']; // Assumes it's stored correctly as JSON Map + data.secondSampler = json['secondSampler']; data.samplingDate = json['sampling_date']; data.samplingTime = json['sampling_time']; data.samplingType = json['sampling_type']; @@ -194,15 +185,15 @@ class MarineInvesManualSamplingData { data.stationTypeSelection = json['stationTypeSelection']; data.selectedManualStateName = json['selectedManualStateName']; data.selectedManualCategoryName = json['selectedManualCategoryName']; - data.selectedStation = json['selectedStation']; // Assumes it's stored correctly as JSON Map + data.selectedStation = json['selectedStation']; data.selectedTarballStateName = json['selectedTarballStateName']; - data.selectedTarballStation = json['selectedTarballStation']; // Assumes it's stored correctly as JSON Map + data.selectedTarballStation = json['selectedTarballStation']; data.newStationName = json['newStationName']; data.newStationCode = json['newStationCode']; - data.stationLatitude = json['station_latitude']?.toString(); // Ensure conversion to String - data.stationLongitude = json['station_longitude']?.toString(); // Ensure conversion to String - data.currentLatitude = json['current_latitude']?.toString(); // Ensure conversion to String - data.currentLongitude = json['current_longitude']?.toString(); // Ensure conversion to String + data.stationLatitude = json['station_latitude']?.toString(); + data.stationLongitude = json['station_longitude']?.toString(); + data.currentLatitude = json['current_latitude']?.toString(); + data.currentLongitude = json['current_longitude']?.toString(); data.distanceDifferenceInKm = doubleFromJson(json['distance_difference_in_km']); data.distanceDifferenceRemarks = json['distance_difference_remarks']; @@ -217,7 +208,7 @@ class MarineInvesManualSamplingData { data.optionalRemark3 = json['inves_optional_photo_03_remarks']; data.optionalRemark4 = json['inves_optional_photo_04_remarks']; - // Step 2 Images (Paths stored in JSON) + // Step 2 Images data.leftLandViewImage = fileFromPath(json['inves_left_side_land_view']); data.rightLandViewImage = fileFromPath(json['inves_right_side_land_view']); data.waterFillingImage = fileFromPath(json['inves_filling_water_into_sample_bottle']); @@ -246,9 +237,7 @@ class MarineInvesManualSamplingData { // Status data.submissionStatus = json['submission_status']; data.submissionMessage = json['submission_message']; - data.reportId = json['report_id']?.toString(); // Ensure conversion to String - - // REMOVED: NPE fields from deserialization + data.reportId = json['report_id']?.toString(); return data; } @@ -262,26 +251,21 @@ class MarineInvesManualSamplingData { if (value != null) { String stringValue; if (value is double) { - // Handle special -999.0 value if (value == -999.0) { stringValue = '-999'; } else { - // Format other doubles to 5 decimal places stringValue = value.toStringAsFixed(5); } } else { - // Convert other types directly to string stringValue = value.toString(); } - // Only add if the resulting string is not empty if (stringValue.isNotEmpty) { map[key] = stringValue; } } } - // Add prefix 'inves_' to all keys to match new backend endpoints add('inves_date', samplingDate); add('inves_time', samplingTime); add('first_sampler_user_id', firstSamplerUserId); @@ -293,23 +277,22 @@ class MarineInvesManualSamplingData { add('inves_distance_difference', distanceDifferenceInKm); add('inves_distance_difference_remarks', distanceDifferenceRemarks); - // --- NEW: Add station selection logic --- add('inves_station_type', stationTypeSelection); if (stationTypeSelection == 'Existing Manual Station') { - add('station_id', selectedStation?['station_id']); // Foreign key to manual stations + // ✅ FIX: Ensure 'station_id' is added correctly for limit validation + add('station_id', selectedStation?['station_id'] ?? selectedStation?['man_station_id']); add('inves_station_code', selectedStation?['man_station_code']); add('inves_station_name', selectedStation?['man_station_name']); } else if (stationTypeSelection == 'Existing Tarball Station') { - add('tbl_station_id', selectedTarballStation?['station_id']); // Foreign key to tarball stations + add('tbl_station_id', selectedTarballStation?['station_id'] ?? selectedTarballStation?['tbl_station_id']); add('inves_station_code', selectedTarballStation?['tbl_station_code']); add('inves_station_name', selectedTarballStation?['tbl_station_name']); } else if (stationTypeSelection == 'New Location') { add('inves_new_station_name', newStationName); add('inves_new_station_code', newStationCode); - add('inves_station_latitude', stationLatitude); // Manually entered lat - add('inves_station_longitude', stationLongitude); // Manually entered lon + add('inves_station_latitude', stationLatitude); + add('inves_station_longitude', stationLongitude); } - // --- END NEW --- add('inves_weather', weather); add('inves_tide_level', tideLevel); @@ -321,8 +304,8 @@ class MarineInvesManualSamplingData { add('inves_optional_photo_03_remarks', optionalRemark3); add('inves_optional_photo_04_remarks', optionalRemark4); add('inves_sondeID', sondeId); - add('data_capture_date', dataCaptureDate); // Note: No 'inves_' prefix assumed based on original model - add('data_capture_time', dataCaptureTime); // Note: No 'inves_' prefix assumed based on original model + add('data_capture_date', dataCaptureDate); + add('data_capture_time', dataCaptureTime); add('inves_oxygen_conc', oxygenConcentration); add('inves_oxygen_sat', oxygenSaturation); add('inves_ph', ph); @@ -334,7 +317,7 @@ class MarineInvesManualSamplingData { add('inves_tss', tss); add('inves_battery_volt', batteryVoltage); - add('first_sampler_name', firstSamplerName); // For logging/display purposes on backend if needed + add('first_sampler_name', firstSamplerName); return map; } @@ -342,7 +325,6 @@ class MarineInvesManualSamplingData { /// Maps image files to keys for the API submission. Map toApiImageFiles() { return { - // Add prefix 'inves_' to match backend expectations 'inves_left_side_land_view': leftLandViewImage, 'inves_right_side_land_view': rightLandViewImage, 'inves_filling_water_into_sample_bottle': waterFillingImage, @@ -354,8 +336,4 @@ class MarineInvesManualSamplingData { 'inves_optional_photo_04': optionalImage4, }; } - -// --- START: REMOVED generateInvestigativeTelegramAlertMessage --- -// This logic is now handled in MarineInvestigativeSamplingService -// --- END: REMOVED --- } \ No newline at end of file diff --git a/lib/models/river_in_situ_sampling_data.dart b/lib/models/river_in_situ_sampling_data.dart index e9941d0..d0ab728 100644 --- a/lib/models/river_in_situ_sampling_data.dart +++ b/lib/models/river_in_situ_sampling_data.dart @@ -99,7 +99,6 @@ class RiverInSituSamplingData { } // --- START: MODIFIED FOR CONSISTENT SERIALIZATION --- - // Keys now match toMap() for reliability, with fallback to old API keys for backward compatibility. return RiverInSituSamplingData() ..firstSamplerName = json['firstSamplerName'] ?? json['first_sampler_name'] ..firstSamplerUserId = intFromJson(json['firstSamplerUserId'] ?? json['first_sampler_user_id']) @@ -154,7 +153,6 @@ class RiverInSituSamplingData { ..submissionStatus = json['submissionStatus'] ..submissionMessage = json['submissionMessage'] ..reportId = json['reportId']?.toString(); - // --- END: MODIFIED FOR CONSISTENT SERIALIZATION --- } @@ -165,7 +163,6 @@ class RiverInSituSamplingData { void add(String key, dynamic value) { if (value != null) { String stringValue; - // --- START FIX: Handle -999.0 correctly --- if (value is double) { if (value == -999.0) { stringValue = '-999'; @@ -175,9 +172,7 @@ class RiverInSituSamplingData { } else { stringValue = value.toString(); } - // --- END FIX --- - // Only add non-empty values if (stringValue.isNotEmpty) { map[key] = stringValue; } @@ -191,9 +186,7 @@ class RiverInSituSamplingData { add('r_man_time', samplingTime); add('r_man_type', samplingType); add('r_man_sample_id_code', sampleIdCode); - // --- START FIX: Use correct key 'station_id' --- add('station_id', selectedStation?['station_id']); - // --- END FIX --- add('r_man_current_latitude', currentLatitude); add('r_man_current_longitude', currentLongitude); add('r_man_distance_difference', distanceDifferenceInKm); @@ -222,10 +215,10 @@ class RiverInSituSamplingData { add('r_man_temperature', temperature); add('r_man_tds', tds); add('r_man_turbidity', turbidity); - add('r_man_ammonia', ammonia); // MODIFIED: Replaced tss with ammonia + add('r_man_ammonia', ammonia); add('r_man_battery_volt', batteryVoltage); - // ADDED: Flowrate fields to API form data + // ADDED: Flowrate fields add('r_man_flowrate_method', flowrateMethod); add('r_man_flowrate_sd_height', flowrateSurfaceDrifterHeight); add('r_man_flowrate_sd_distance', flowrateSurfaceDrifterDistance); @@ -233,13 +226,11 @@ class RiverInSituSamplingData { add('r_man_flowrate_sd_time_last', flowrateSurfaceDrifterTimeLast); add('r_man_flowrate_value', flowrateValue); - // Additional data for display or logging add('first_sampler_name', firstSamplerName); add('r_man_station_code', selectedStation?['sampling_station_code']); add('r_man_station_name', selectedStation?['sampling_river']); - return map; } @@ -257,7 +248,7 @@ class RiverInSituSamplingData { }; } - // ADDED: A new method to support the centralized submission logging + // ADDED: support for centralized submission logging Map toMap() { return { 'firstSamplerName': firstSamplerName, @@ -302,7 +293,7 @@ class RiverInSituSamplingData { 'temperature': temperature, 'tds': tds, 'turbidity': turbidity, - 'ammonia': ammonia, // MODIFIED: Replaced tss with ammonia + 'ammonia': ammonia, 'batteryVoltage': batteryVoltage, 'flowrateMethod': flowrateMethod, 'flowrateSurfaceDrifterHeight': flowrateSurfaceDrifterHeight, @@ -317,112 +308,87 @@ class RiverInSituSamplingData { } /// Creates a single JSON object with all submission data, mimicking 'db.json' + /// FORCING ALL VALUES TO STRING. String toDbJson() { final data = { - // --- Sorted exactly according to your sample --- - 'battery_cap': batteryVoltage ?? -999.0, - 'device_name': sondeId ?? "", - 'sampling_type': samplingType ?? "", - 'report_id': reportId ?? "", - 'sampler_2ndname': secondSampler?['first_name'] ?? "", - 'sample_state': selectedStateName ?? "", - 'station_id': selectedStation?['sampling_station_code'] ?? "", - 'tech_id': firstSamplerUserId ?? -999, // Default to -999 if no ID - 'tech_phonenum': "", // Field present in sample but not in model, defaults to empty - 'tech_name': firstSamplerName ?? "", - 'latitude': stationLatitude ?? "", - 'longitude': stationLongitude ?? "", + 'battery_cap': (batteryVoltage ?? "NULL").toString(), + 'device_name': (sondeId ?? "").toString(), + 'sampling_type': (samplingType ?? "").toString(), + 'report_id': (reportId ?? "").toString(), + 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(), + 'sample_state': (selectedStateName ?? "").toString(), + 'station_id': (selectedStation?['sampling_station_code'] ?? "").toString(), + 'tech_id': (firstSamplerUserId ?? "NULL").toString(), + 'tech_phonenum': "", + 'tech_name': (firstSamplerName ?? "").toString(), + 'latitude': (stationLatitude ?? "").toString(), + 'longitude': (stationLongitude ?? "").toString(), 'record_dt': (samplingDate != null && samplingTime != null) ? '$samplingDate $samplingTime' : "", - - // --- Sensor Readings --- - 'do_mgl': oxygenConcentration ?? -999.0, - 'do_sat': oxygenSaturation ?? -999.0, - 'ph': ph ?? -999.0, - 'salinity': salinity ?? -999.0, - 'temperature': temperature ?? -999.0, - 'turbidity': turbidity ?? -999.0, - 'tds': tds ?? -999.0, - 'electric_conductivity': electricalConductivity ?? -999.0, - 'flowrate': flowrateValue ?? -999.0, - - // --- Manual/Observations --- - 'odour': "", // Default empty - 'floatable': "", // Default empty - 'sample_id': sampleIdCode ?? "", - 'weather': weather ?? "", - 'remarks_event': eventRemarks ?? "", - 'remarks_lab': labRemarks ?? "", + 'do_mgl': (oxygenConcentration ?? "NULL").toString(), + 'do_sat': (oxygenSaturation ?? "NULL").toString(), + 'ph': (ph ?? "NULL").toString(), + 'salinity': (salinity ?? "NULL").toString(), + 'temperature': (temperature ?? "NULL").toString(), + 'turbidity': (turbidity ?? "NULL").toString(), + 'tds': (tds ?? "NULL").toString(), + 'electric_conductivity': (electricalConductivity ?? "NULL").toString(), + 'flowrate': (flowrateValue ?? "NULL").toString(), + 'odour': "", + 'floatable': "", + 'sample_id': (sampleIdCode ?? "").toString(), + 'weather': (weather ?? "").toString(), + 'remarks_event': (eventRemarks ?? "").toString(), + 'remarks_lab': (labRemarks ?? "").toString(), }; - - // ❌ DO NOT UNCOMMENT. We want to keep all keys even if values are default. - // data.removeWhere((key, value) => value == null); - return jsonEncode(data); } - /// Creates a JSON object for basic form info, mimicking 'river_insitu_basic_form.json'. + /// Creates a JSON object for basic form info. FORCING ALL VALUES TO STRING. String toBasicFormJson() { final data = { - // --- Sorted sequence: tech_name -> sampler_2ndname -> date/time -> type -> state -> station info -> location -> sample_id - 'tech_name': firstSamplerName ?? "", - 'sampler_2ndname': secondSampler?['first_name'] ?? "", - 'sample_date': samplingDate ?? "", - 'sample_time': samplingTime ?? "", - 'sampling_type': samplingType ?? "", - 'sample_state': selectedStateName ?? "", - 'station_id': selectedStation?['sampling_station_code'] ?? "", - 'station_latitude': stationLatitude ?? "", - 'station_longitude': stationLongitude ?? "", - 'latitude': currentLatitude ?? "", // Current user location lat - 'longitude': currentLongitude ?? "", // Current user location lon - 'sample_id': sampleIdCode ?? "", + 'tech_name': (firstSamplerName ?? "").toString(), + 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(), + 'sample_date': (samplingDate ?? "").toString(), + 'sample_time': (samplingTime ?? "").toString(), + 'sampling_type': (samplingType ?? "").toString(), + 'sample_state': (selectedStateName ?? "").toString(), + 'station_id': (selectedStation?['sampling_station_code'] ?? "").toString(), + 'station_latitude': (stationLatitude ?? "").toString(), + 'station_longitude': (stationLongitude ?? "").toString(), + 'latitude': (currentLatitude ?? "").toString(), + 'longitude': (currentLongitude ?? "").toString(), + 'sample_id': (sampleIdCode ?? "").toString(), }; - - // ❌ REMOVE or COMMENT OUT this line so no keys are deleted - // data.removeWhere((key, value) => value == null); - return jsonEncode(data); } - /// Creates a JSON object for sensor readings, mimicking 'river_sampling_reading.json'. - /// Creates a JSON object for sensor readings, mimicking 'river_sampling_reading.json'. + /// Creates a JSON object for sensor readings. FORCING ALL VALUES TO STRING. String toReadingJson() { final data = { - // --- Sorted exactly according to your sample --- - 'do_mgl': oxygenConcentration ?? -999.0, - 'do_sat': oxygenSaturation ?? -999.0, - 'ph': ph ?? -999.0, - 'salinity': salinity ?? -999.0, - 'temperature': temperature ?? -999.0, - 'turbidity': turbidity ?? -999.0, - 'tds': tds ?? -999.0, - 'electric_conductivity': electricalConductivity ?? -999.0, - 'flowrate': flowrateValue ?? -999.0, - - // --- Date and Time --- - 'date_sampling_reading': dataCaptureDate ?? "", - 'time_sampling_reading': dataCaptureTime ?? "", + 'do_mgl': (oxygenConcentration ?? "NULL").toString(), + 'do_sat': (oxygenSaturation ?? "NULL").toString(), + 'ph': (ph ?? "NULL").toString(), + 'salinity': (salinity ?? "NULL").toString(), + 'temperature': (temperature ?? "NULL").toString(), + 'turbidity': (turbidity ?? "NULL").toString(), + 'tds': (tds ?? "NULL").toString(), + 'electric_conductivity': (electricalConductivity ?? "NULL").toString(), + 'flowrate': (flowrateValue ?? "NULL").toString(), + 'date_sampling_reading': (dataCaptureDate ?? "").toString(), + 'time_sampling_reading': (dataCaptureTime ?? "").toString(), }; - - // ❌ REMOVE or COMMENT OUT this line to ensure no keys are skipped/removed - // data.removeWhere((key, value) => value == null); - return jsonEncode(data); } - /// Creates a JSON object for manual info, mimicking 'river_manual_info.json'. + /// Creates a JSON object for manual info. FORCING ALL VALUES TO STRING. String toManualInfoJson() { final data = { - // --- START FIX: Map model properties to correct manual info keys --- - 'weather': weather, - 'remarks_event': eventRemarks, - 'remarks_lab': labRemarks, - // --- END FIX --- + 'weather': (weather ?? "").toString(), + 'remarks_event': (eventRemarks ?? "").toString(), + 'remarks_lab': (labRemarks ?? "").toString(), }; - // Remove null values before encoding - data.removeWhere((key, value) => value == null); return jsonEncode(data); } } \ No newline at end of file diff --git a/lib/models/river_inves_manual_sampling_data.dart b/lib/models/river_inves_manual_sampling_data.dart index ac24502..dd2850e 100644 --- a/lib/models/river_inves_manual_sampling_data.dart +++ b/lib/models/river_inves_manual_sampling_data.dart @@ -422,101 +422,98 @@ class RiverInvesManualSamplingData { }; } - /// Creates a single JSON object for FTP 'db.json', mimicking River In-Situ structure. + /// Creates a single JSON object for FTP 'db.json', forcing every value to String. String toDbJson() { final data = { - 'battery_cap': batteryVoltage == -999.0 ? null : batteryVoltage, - 'device_name': sondeId, - 'sampling_type': samplingType, // 'Investigative' - 'report_id': reportId, - 'sampler_2ndname': secondSampler?['first_name'], - 'sample_state': getDeterminedStateName(), // Use determined state - 'station_id': getDeterminedStationCode(), // Use determined code - 'tech_id': firstSamplerUserId, - 'tech_name': firstSamplerName, - 'latitude': stationLatitude, // Use captured/selected station lat - 'longitude': stationLongitude, // Use captured/selected station lon + 'battery_cap': (batteryVoltage ?? "").toString(), + 'device_name': (sondeId ?? "").toString(), + 'sampling_type': (samplingType ?? "Investigative").toString(), + 'report_id': (reportId ?? "").toString(), + 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(), + 'sample_state': (getDeterminedStateName() ?? "").toString(), + 'station_id': (getDeterminedStationCode() ?? "").toString(), + 'tech_id': (firstSamplerUserId ?? "NULL").toString(), + 'tech_phonenum': "NULL", + 'tech_name': (firstSamplerName ?? "").toString(), + 'latitude': (stationLatitude ?? "").toString(), + 'longitude': (stationLongitude ?? "").toString(), 'record_dt': '$samplingDate $samplingTime', - 'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration, - 'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation, - 'ph': ph == -999.0 ? null : ph, - 'salinity': salinity == -999.0 ? null : salinity, - 'temperature': temperature == -999.0 ? null : temperature, - 'turbidity': turbidity == -999.0 ? null : turbidity, - 'tds': tds == -999.0 ? null : tds, - 'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity, - 'ammonia': ammonia == -999.0 ? null : ammonia, - 'flowrate': flowrateValue ?? -999.0, - 'odour': '', // Not collected - 'floatable': '', // Not collected - 'sample_id': sampleIdCode, - 'weather': weather, - 'remarks_event': eventRemarks, - 'remarks_lab': labRemarks, - // --- Add Investigative Specific fields if needed by FTP structure --- - 'station_type': stationTypeSelection, // e.g., 'New Location' - 'new_basin': stationTypeSelection == 'New Location' ? newBasinName : null, - 'new_river': stationTypeSelection == 'New Location' ? newRiverName : null, - 'new_station_name': stationTypeSelection == 'New Location' ? newStationName : null, // Include newStationName + 'do_mgl': (oxygenConcentration ?? -999.0).toString(), + 'do_sat': (oxygenSaturation ?? -999.0).toString(), + 'ph': (ph ?? -999.0).toString(), + 'salinity': (salinity ?? -999.0).toString(), + 'temperature': (temperature ?? -999.0).toString(), + 'turbidity': (turbidity ?? -999.0).toString(), + 'tds': (tds ?? -999.0).toString(), + 'electric_conductivity': (electricalConductivity ?? -999.0).toString(), + 'tss': (ammonia ?? 0.0).toString(), // Mapped ammonia to 'tss' key for FTP consistency + 'flowrate': (flowrateValue ?? -999.0).toString(), + 'odour': '', + 'floatable': '', + 'sample_id': (sampleIdCode ?? "").toString(), + 'weather': (weather ?? "").toString(), + 'remarks_event': (eventRemarks ?? "").toString(), + 'remarks_lab': (labRemarks ?? "").toString(), + 'station_type': (stationTypeSelection ?? "").toString(), + 'new_basin': (newBasinName ?? "").toString(), + 'new_river': (newRiverName ?? "").toString(), + 'new_station_name': (newStationName ?? "").toString(), + 'tarball': "No", + 'tide_lvl': "", + 'sea_cond': "", }; - data.removeWhere((key, value) => value == null); return jsonEncode(data); } - /// Creates JSON for FTP 'river_inves_basic_form.json' (mimicking In-Situ). + /// Creates JSON for FTP 'river_inves_basic_form.json', forcing every value to String. String toBasicFormJson() { final data = { - 'tech_name': firstSamplerName, - 'sampler_2ndname': secondSampler?['first_name'], - 'sample_date': samplingDate, - 'sample_time': samplingTime, - 'sampling_type': samplingType, // 'Investigative' - 'sample_state': getDeterminedStateName(), - 'station_id': getDeterminedStationCode(), - 'station_latitude': stationLatitude, - 'station_longitude': stationLongitude, - 'latitude': currentLatitude, // Current location lat - 'longitude': currentLongitude, // Current location lon - 'sample_id': sampleIdCode, - // --- Add Investigative Specific fields if needed --- - 'station_type': stationTypeSelection, - 'new_basin': stationTypeSelection == 'New Location' ? newBasinName : null, - 'new_river': stationTypeSelection == 'New Location' ? newRiverName : null, - 'new_station_name': stationTypeSelection == 'New Location' ? newStationName : null, // Include newStationName + 'tech_name': (firstSamplerName ?? "").toString(), + 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(), + 'sample_date': (samplingDate ?? "").toString(), + 'sample_time': (samplingTime ?? "").toString(), + 'sampling_type': (samplingType ?? "Investigative").toString(), + 'sample_state': (getDeterminedStateName() ?? "").toString(), + 'station_id': (getDeterminedStationCode() ?? "").toString(), + 'station_latitude': (stationLatitude ?? "").toString(), + 'station_longitude': (stationLongitude ?? "").toString(), + 'latitude': (currentLatitude ?? "").toString(), + 'longitude': (currentLongitude ?? "").toString(), + 'sample_id': (sampleIdCode ?? "").toString(), + 'station_type': (stationTypeSelection ?? "").toString(), + 'new_basin': (newBasinName ?? "").toString(), + 'new_river': (newRiverName ?? "").toString(), + 'new_station_name': (newStationName ?? "").toString(), }; - //data.removeWhere((key, value) => value == null); return jsonEncode(data); } - /// Creates JSON for FTP 'river_inves_reading.json' (mimicking In-Situ). + /// Creates JSON for FTP 'river_inves_reading.json', forcing every value to String. String toReadingJson() { final data = { - 'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration, - 'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation, - 'ph': ph == -999.0 ? null : ph, - 'salinity': salinity == -999.0 ? null : salinity, - 'temperature': temperature == -999.0 ? null : temperature, - 'turbidity': turbidity == -999.0 ? null : turbidity, - 'tds': tds == -999.0 ? null : tds, - 'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity, - 'ammonia': ammonia == -999.0 ? null : ammonia, - 'flowrate': flowrateValue, - 'date_sampling_reading': dataCaptureDate, - 'time_sampling_reading': dataCaptureTime, + 'do_mgl': (oxygenConcentration ?? -999.0).toString(), + 'do_sat': (oxygenSaturation ?? -999.0).toString(), + 'ph': (ph ?? -999.0).toString(), + 'salinity': (salinity ?? -999.0).toString(), + 'temperature': (temperature ?? -999.0).toString(), + 'turbidity': (turbidity ?? -999.0).toString(), + 'tds': (tds ?? -999.0).toString(), + 'electric_conductivity': (electricalConductivity ?? -999.0).toString(), + 'tss': (ammonia ?? -999.0).toString(), + 'flowrate': (flowrateValue ?? -999.0).toString(), + 'date_sampling_reading': (dataCaptureDate ?? "").toString(), + 'time_sampling_reading': (dataCaptureTime ?? "").toString(), }; - data.removeWhere((key, value) => value == null); return jsonEncode(data); } - /// Creates JSON for FTP 'river_inves_manual_info.json' (mimicking In-Situ). + /// Creates JSON for FTP 'river_inves_manual_info.json', forcing every value to String. String toManualInfoJson() { final data = { - 'weather': weather, - 'remarks_event': eventRemarks, - 'remarks_lab': labRemarks, + 'weather': (weather ?? "").toString(), + 'remarks_event': (eventRemarks ?? "").toString(), + 'remarks_lab': (labRemarks ?? "").toString(), }; - data.removeWhere((key, value) => value == null); return jsonEncode(data); } - } \ No newline at end of file diff --git a/lib/models/river_manual_triennial_sampling_data.dart b/lib/models/river_manual_triennial_sampling_data.dart index 7e3b602..38b44f0 100644 --- a/lib/models/river_manual_triennial_sampling_data.dart +++ b/lib/models/river_manual_triennial_sampling_data.dart @@ -1,7 +1,7 @@ // lib/models/river_manual_triennial_sampling_data.dart import 'dart:io'; -import 'dart:convert'; // Added for jsonEncode +import 'dart:convert'; /// Data model for the River Manual Triennial Sampling form. class RiverManualTriennialSamplingData { @@ -160,7 +160,7 @@ class RiverManualTriennialSamplingData { String stringValue; if (value is double) { if (value == -999.0) { - stringValue = '-999'; + stringValue = 'NULL'; } else { stringValue = value.toStringAsFixed(5); } @@ -299,106 +299,96 @@ class RiverManualTriennialSamplingData { }; } - /// Creates a single JSON object with all submission data, mimicking 'db.json' + /// Creates a single JSON object with all submission data. + /// Every value is explicitly converted to a String for the API. String toDbJson() { final data = { - 'battery_cap': batteryVoltage == -999.0 ? null : batteryVoltage, - 'device_name': sondeId, - 'sampling_type': samplingType, - 'report_id': reportId, - 'sampler_2ndname': secondSampler?['first_name'], - 'sample_state': selectedStateName, - 'station_id': selectedStation?['sampling_station_code'], - 'tech_id': firstSamplerUserId, - 'tech_name': firstSamplerName, - 'latitude': stationLatitude, - 'longitude': stationLongitude, + 'battery_cap': (batteryVoltage ?? "NULL").toString(), + 'device_name': (sondeId ?? "").toString(), + 'sampling_type': (samplingType ?? "").toString(), + 'report_id': (reportId ?? "").toString(), + 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(), + 'sample_state': (selectedStateName ?? "").toString(), + 'station_id': (selectedStation?['sampling_station_code'] ?? "").toString(), + 'tech_id': (firstSamplerUserId ?? "NULL").toString(), + 'tech_phonenum': "NULL", + 'tech_name': (firstSamplerName ?? "").toString(), + 'latitude': (stationLatitude ?? "").toString(), + 'longitude': (stationLongitude ?? "").toString(), 'record_dt': '$samplingDate $samplingTime', - 'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration, - 'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation, - 'ph': ph == -999.0 ? null : ph, - 'salinity': salinity == -999.0 ? null : salinity, - 'temperature': temperature == -999.0 ? null : temperature, - 'turbidity': turbidity == -999.0 ? null : turbidity, - 'tds': tds == -999.0 ? null : tds, - 'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity, - //'ammonia': ammonia == -999.0 ? null : ammonia, - 'flowrate': flowrateValue, - 'odour': '', // Not collected - 'floatable': '', // Not collected - 'sample_id': sampleIdCode, - 'weather': weather, - 'remarks_event': eventRemarks, - 'remarks_lab': labRemarks, + 'do_mgl': (oxygenConcentration ?? "NULL").toString(), + 'do_sat': (oxygenSaturation ?? "NULL").toString(), + 'ph': (ph ?? "NULL").toString(), + 'salinity': (salinity ?? "NULL").toString(), + 'temperature': (temperature ?? "NULL").toString(), + 'turbidity': (turbidity ?? "NULL").toString(), + 'tds': (tds ?? "NULL").toString(), + 'electric_conductivity': (electricalConductivity ?? "NULL").toString(), + 'tss': (ammonia ?? "NULL").toString(), + 'flowrate': (flowrateValue ?? "NULL").toString(), + 'odour': '', + 'floatable': '', + 'sample_id': (sampleIdCode ?? "").toString(), + 'weather': (weather ?? "").toString(), + 'remarks_event': (eventRemarks ?? "").toString(), + 'remarks_lab': (labRemarks ?? "").toString(), + 'tarball': "No", + 'tide_lvl': "", + 'sea_cond': "", }; - //data.removeWhere((key, value) => value == null); return jsonEncode(data); } - /// Creates a JSON object for basic form info, mimicking 'river_insitu_basic_form.json'. + /// Creates a JSON object for basic form info. + /// Every value is explicitly converted to a String. String toBasicFormJson() { final data = { - // Keys sorted exactly according to the provided sample - 'tech_name': firstSamplerName ?? "", - 'sampler_2ndname': secondSampler?['first_name'] ?? "", - 'sample_date': samplingDate ?? "", - 'sample_time': samplingTime ?? "", - 'sampling_type': samplingType ?? "", - 'sample_state': selectedStateName ?? "", - 'station_id': selectedStation?['sampling_station_code'] ?? "", - 'station_latitude': stationLatitude ?? "", - 'station_longitude': stationLongitude ?? "", - 'latitude': currentLatitude ?? "", // Current user location lat - 'longitude': currentLongitude ?? "", // Current user location lon - 'sample_id': sampleIdCode ?? "", + 'tech_name': (firstSamplerName ?? "").toString(), + 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(), + 'sample_date': (samplingDate ?? "").toString(), + 'sample_time': (samplingTime ?? "").toString(), + 'sampling_type': (samplingType ?? "").toString(), + 'sample_state': (selectedStateName ?? "").toString(), + 'station_id': (selectedStation?['sampling_station_code'] ?? "").toString(), + 'station_latitude': (stationLatitude ?? "").toString(), + 'station_longitude': (stationLongitude ?? "").toString(), + 'latitude': (currentLatitude ?? "").toString(), + 'longitude': (currentLongitude ?? "").toString(), + 'sample_id': (sampleIdCode ?? "").toString(), }; - // ❌ REMOVE or COMMENT OUT this line so no keys are deleted - // data.removeWhere((key, value) => value == null); - return jsonEncode(data); } - /// Creates a JSON object for sensor readings, mimicking 'river_sampling_reading.json'. + /// Creates a JSON object for sensor readings. + /// Every value is explicitly converted to a String. String toReadingJson() { final data = { - // Use ?? operator to default to -999.0 if null - 'do_mgl': oxygenConcentration ?? -999.0, - 'do_sat': oxygenSaturation ?? -999.0, - 'ph': ph ?? -999.0, - 'salinity': salinity ?? -999.0, - 'temperature': temperature ?? -999.0, - 'turbidity': turbidity ?? -999.0, - 'tds': tds ?? -999.0, - 'electric_conductivity': electricalConductivity ?? -999.0, - - // Keep 'ammonia' commented out if it's not used in Triennial, - // otherwise uncomment and use: 'ammonia': ammonia ?? -999.0, - - // Flowrate defaults to -999.0 (as requested in previous turn) - 'flowrate': flowrateValue ?? -999.0, - - // Date and Time default to empty string "" if null - 'date_sampling_reading': dataCaptureDate ?? "", - 'time_sampling_reading': dataCaptureTime ?? "", + 'do_mgl': (oxygenConcentration ?? "NULL").toString(), + 'do_sat': (oxygenSaturation ?? "NULL").toString(), + 'ph': (ph ?? "NULL").toString(), + 'salinity': (salinity ?? "NULL").toString(), + 'temperature': (temperature ?? "NULL").toString(), + 'turbidity': (turbidity ?? "NULL").toString(), + 'tds': (tds ?? "NULL").toString(), + 'electric_conductivity': (electricalConductivity ?? "NULL").toString(), + 'tss': (ammonia ?? "NULL").toString(), + 'flowrate': (flowrateValue ?? "NULL").toString(), + 'date_sampling_reading': (dataCaptureDate ?? "").toString(), + 'time_sampling_reading': (dataCaptureTime ?? "").toString(), }; - // ❌ REMOVE or COMMENT OUT this line so keys are NEVER deleted - // data.removeWhere((key, value) => value == null); - return jsonEncode(data); } - /// Creates a JSON object for manual info, mimicking 'river_manual_info.json'. + /// Creates a JSON object for manual info. + /// Every value is explicitly converted to a String. String toManualInfoJson() { final data = { - // --- START FIX: Map model properties to correct manual info keys --- - 'weather': weather, - 'remarks_event': eventRemarks, - 'remarks_lab': labRemarks, - // --- END FIX --- + 'weather': (weather ?? "").toString(), + 'remarks_event': (eventRemarks ?? "").toString(), + 'remarks_lab': (labRemarks ?? "").toString(), }; - data.removeWhere((key, value) => value == null); return jsonEncode(data); } } \ No newline at end of file diff --git a/lib/screens/login.dart b/lib/screens/login.dart index ac2d900..dc70584 100644 --- a/lib/screens/login.dart +++ b/lib/screens/login.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; // Keep for potential future use, though not strictly necessary for the new logic import 'package:environment_monitoring_app/services/api_service.dart'; +import 'package:environment_monitoring_app/services/user_preferences_service.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/home_page.dart'; @@ -23,6 +24,7 @@ class _LoginScreenState extends State { final TextEditingController _passwordController = TextEditingController(); bool _isLoading = false; String _errorMessage = ''; + String _loadingMessage = ''; // To show dynamic status updates @override void dispose() { @@ -39,6 +41,7 @@ class _LoginScreenState extends State { setState(() { _isLoading = true; _errorMessage = ''; + _loadingMessage = 'Authenticating...'; }); final auth = Provider.of(context, listen: false); @@ -58,10 +61,42 @@ class _LoginScreenState extends State { // --- Online Success --- final String token = result['data']['token']; final Map profile = result['data']['profile']; + await auth.login(token, profile, password); + // --- FIRST TIME VS SUBSEQUENT LOGIN LOGIC --- if (auth.isFirstLogin) { - await auth.setIsFirstLogin(false); + // >> FIRST TIME: BLOCKING WAIT REQUIRED << + // We must ensure configs are present before the user reaches the dashboard + // so that the default routing (API/FTP) is set up correctly. + setState(() { + _loadingMessage = 'Setting up environment...\nFetching server configurations...'; + }); + + try { + debugPrint("First time login: Fetching initial configurations (Blocking)..."); + + // 1. Fetch raw config data from server (API & FTP tables) + await apiService.fetchInitialConfigurations(); + + // 2. Apply default logic to "tick" the active servers + // We await this so the DB is ready before navigation. + await UserPreferencesService().applyAndSaveDefaultPreferencesIfNeeded(); + + // 3. Mark first login as done + await auth.setIsFirstLogin(false); + + debugPrint("First time setup complete."); + } catch (e) { + debugPrint("Warning: Initial setup encountered an error: $e"); + // Proceed anyway; user can sync manually later if needed. + } + } else { + // >> SUBSEQUENT LOGIN: NO BLOCKING << + // User wants fast access. We skip the 'await' for configs. + // The app will rely on local data immediately. + // The HomePage will handle background syncing later. + debugPrint("Subsequent login: Skipping blocking config fetch. Using local data."); } if (!mounted) return; @@ -72,6 +107,7 @@ class _LoginScreenState extends State { // --- Online Failure (API Error) --- setState(() { _errorMessage = result['message'] ?? 'Invalid email or password.'; + _isLoading = false; }); _showSnackBar(_errorMessage, isError: true); } @@ -86,21 +122,22 @@ class _LoginScreenState extends State { _showSnackBar("Connection failed. Trying offline login...", isError: true); // FIX: Removed the unreliable connectivity check. Treat all exceptions here as a reason to try offline. await _attemptOfflineLogin(auth, email, password); - } finally { - if (mounted) { - setState(() { - _isLoading = false; - }); - } } - // --- END: MODIFIED Internet-First Strategy --- } /// Helper function to perform offline validation and update UI. Future _attemptOfflineLogin(AuthProvider auth, String email, String password) async { + setState(() { + _loadingMessage = 'Verifying offline credentials...'; + }); + final bool offlineSuccess = await auth.loginOffline(email, password); if (mounted) { + setState(() { + _isLoading = false; + }); + if (offlineSuccess) { Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (context) => const HomePage()), @@ -141,7 +178,7 @@ class _LoginScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - "PSTW MMS V4", + "PSTW MMS", style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.bold, ), @@ -166,7 +203,7 @@ class _LoginScreenState extends State { ), ], image: const DecorationImage( - image: AssetImage('assets/icon_3_512x512.png'), + image: AssetImage('assets/icon_4_512x512.png'), fit: BoxFit.cover, // Ensures the image fills the circle ), ), @@ -189,7 +226,17 @@ class _LoginScreenState extends State { ), const SizedBox(height: 24), _isLoading - ? const CircularProgressIndicator() + ? Column( + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + _loadingMessage, + style: const TextStyle(fontSize: 12, color: Colors.grey), + textAlign: TextAlign.center, + ), + ], + ) : ElevatedButton( onPressed: _login, style: ElevatedButton.styleFrom( diff --git a/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_1_sampling_info.dart b/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_1_sampling_info.dart index a3c290a..f908018 100644 --- a/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_1_sampling_info.dart +++ b/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_1_sampling_info.dart @@ -37,15 +37,15 @@ class _MarineInvesManualStep1SamplingInfoState extends State _stationTypeOptions = ['Existing Manual Station', 'Existing Tarball Station', 'New Location']; - // --- Lists for Dropdowns --- + // Lists for Dropdowns List _manualStatesList = []; List _categoriesForManualState = []; List> _stationsForManualCategory = []; @@ -107,7 +107,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State s['state_name'] as String?).whereType().toSet().toList()..sort(); @@ -125,7 +125,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State s['state_name'] as String?).whereType().toSet().toList()..sort(); @@ -137,14 +137,12 @@ class _MarineInvesManualStep1SamplingInfoState extends State(context, listen: false); try { final position = await service.getCurrentLocation(); - if (mounted) { - setState(() { - widget.data.currentLatitude = position.latitude.toString(); - widget.data.currentLongitude = position.longitude.toString(); - _currentLatController.text = widget.data.currentLatitude!; - _currentLonController.text = widget.data.currentLongitude!; + // FIX: Check if mounted after async location call to prevent framework crash + if (!mounted) return; - // If 'New Location' is selected, also populate the station lat/lon - if (_stationType == 'New Location') { - widget.data.stationLatitude = widget.data.currentLatitude; - widget.data.stationLongitude = widget.data.currentLongitude; - _stationLatController.text = widget.data.stationLatitude!; - _stationLonController.text = widget.data.stationLongitude!; - } - _calculateDistance(); - }); - } + setState(() { + widget.data.currentLatitude = position.latitude.toString(); + widget.data.currentLongitude = position.longitude.toString(); + _currentLatController.text = widget.data.currentLatitude!; + _currentLonController.text = widget.data.currentLongitude!; + + if (_stationType == 'New Location') { + widget.data.stationLatitude = widget.data.currentLatitude; + widget.data.stationLongitude = widget.data.currentLongitude; + _stationLatController.text = widget.data.stationLatitude!; + _stationLonController.text = widget.data.stationLongitude!; + } + _calculateDistance(); + }); } catch (e) { if(mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e'))); @@ -219,12 +217,12 @@ class _MarineInvesManualStep1SamplingInfoState extends State const SimpleBarcodeScannerPage()), ); + // FIX: Ensure widget is still mounted before updating state after returning from navigation if (result is String && result != '-1' && mounted) { setState(() { widget.data.sampleIdCode = result; @@ -242,10 +241,10 @@ class _MarineInvesManualStep1SamplingInfoState extends State _findAndShowNearbyStations() async { if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) { await _getCurrentLocation(); + // FIX: Standard async mount check if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) { return; } @@ -256,26 +255,24 @@ class _MarineInvesManualStep1SamplingInfoState extends State> nearbyStations = []; for (var station in allStations) { final stationLat = station['man_latitude']; final stationLon = station['man_longitude']; - // Ensure coordinates are numbers before calculating distance if (stationLat is num && stationLon is num) { final distance = service.calculateDistance(currentLat, currentLon, stationLat.toDouble(), stationLon.toDouble()); - if (distance <= 5.0) { // 5km radius + if (distance <= 5.0) { nearbyStations.add({'station': station, 'distance': distance}); } - } else { - debugPrint("Skipping station ${station['man_station_code']} due to invalid coordinates: Lat=$stationLat, Lon=$stationLon"); } } nearbyStations.sort((a, b) => a['distance'].compareTo(b['distance'])); + // FIX: Verify mount before showing dialog if (!mounted) return; final selectedStation = await showDialog>( @@ -283,19 +280,17 @@ class _MarineInvesManualStep1SamplingInfoState extends State _NearbyStationsDialog(nearbyStations: nearbyStations), ); - if (selectedStation != null) { + // FIX: Verify mount after dialog closes + if (selectedStation != null && mounted) { _updateFormWithSelectedStation(selectedStation); } } - // --- Re-used from original for manual stations --- void _updateFormWithSelectedStation(Map station) { final allStations = Provider.of(context, listen: false).manualStations ?? []; setState(() { - // Update State widget.data.selectedManualStateName = station['state_name']; - // Update Category List based on new State final categories = allStations .where((s) => s['state_name'] == widget.data.selectedManualStateName) .map((s) => s['category_name'] as String?) @@ -305,10 +300,8 @@ class _MarineInvesManualStep1SamplingInfoState extends State s['state_name'] == widget.data.selectedManualStateName && @@ -316,14 +309,12 @@ class _MarineInvesManualStep1SamplingInfoState extends State (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? '')); - // Update Selected Station and its coordinates widget.data.selectedStation = station; widget.data.stationLatitude = station['man_latitude']?.toString(); widget.data.stationLongitude = station['man_longitude']?.toString(); _stationLatController.text = widget.data.stationLatitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? ''; - // Recalculate distance _calculateDistance(); }); } @@ -332,16 +323,12 @@ class _MarineInvesManualStep1SamplingInfoState extends State 50m AND station lat/lon are actually set - // (prevents dialog for 'New Location' before coords are entered/fetched) if (distanceInMeters > 50 && widget.data.stationLatitude != null && widget.data.stationLongitude != null) { _showDistanceRemarkDialog(); } else { - widget.data.distanceDifferenceRemarks = null; // Clear remarks if within limit + widget.data.distanceDifferenceRemarks = null; widget.onNext(); } } @@ -364,7 +351,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State[ TextButton( child: const Text('Cancel'), - onPressed: () { - Navigator.of(context).pop(); - }, + onPressed: () => Navigator.of(context).pop(), ), FilledButton( child: const Text('Confirm'), onPressed: () { - if (dialogFormKey.currentState!.validate()) { + // FIX: Guard setState and navigation within dialog actions + if (dialogFormKey.currentState!.validate() && mounted) { setState(() { widget.data.distanceDifferenceRemarks = remarkController.text; }); Navigator.of(context).pop(); - widget.onNext(); // Proceed after confirming remark + widget.onNext(); } }, ), @@ -452,7 +438,6 @@ class _MarineInvesManualStep1SamplingInfoState extends State( @@ -460,11 +445,10 @@ class _MarineInvesManualStep1SamplingInfoState extends State DropdownMenuItem(value: type, child: Text(type))).toList(), onChanged: _handleStationTypeChange, decoration: const InputDecoration(labelText: 'Station Source *'), - validator: (value) => value == null ? 'Please select a station source' : null, // Added validator + validator: (value) => value == null ? 'Please select a station source' : null, ), const SizedBox(height: 16), - // --- NEW: Conditional Station Widgets --- if (_stationType == 'Existing Manual Station') _buildManualStationSelectors(auth.manualStations ?? []), @@ -474,7 +458,6 @@ class _MarineInvesManualStep1SamplingInfoState extends State val == null || val.isEmpty ? "Sample ID is required" : null, onSaved: (val) => widget.data.sampleIdCode = val, - onChanged: (val) => widget.data.sampleIdCode = val, // Update data model on change + onChanged: (val) => widget.data.sampleIdCode = val, ), const SizedBox(height: 32), ElevatedButton( @@ -540,13 +520,12 @@ class _MarineInvesManualStep1SamplingInfoState extends State> allStations) { return Column( children: [ @@ -563,22 +542,16 @@ class _MarineInvesManualStep1SamplingInfoState extends State s['state_name'] == state) .map((s) => s['category_name'] as String?) .whereType() - .toSet() - .toList(); - _categoriesForManualState.sort(); // Sort after creating the list + .toSet().toList()..sort(); } else { _categoriesForManualState = []; } - // --- END CORRECTION --- - - _stationsForManualCategory = []; // Clear stations list + _stationsForManualCategory = []; }); }, validator: (val) => val == null ? "State is required" : null, @@ -597,17 +570,13 @@ class _MarineInvesManualStep1SamplingInfoState extends State s['state_name'] == widget.data.selectedManualStateName && s['category_name'] == category) - .toList(); - _stationsForManualCategory.sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? '')); // Sort after creating + .toList()..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? '')); } else { _stationsForManualCategory = []; } - // --- END CORRECTION --- }); }, validator: (val) => widget.data.selectedManualStateName != null && val == null ? "Category is required" : null, @@ -626,7 +595,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State widget.data.selectedManualCategoryName != null && val == null ? "Station is required" : null, ), @@ -645,7 +614,6 @@ class _MarineInvesManualStep1SamplingInfoState extends State> allStations) { return Column( children: [ @@ -661,17 +629,13 @@ class _MarineInvesManualStep1SamplingInfoState extends State s['state_name'] == state) - .toList(); - _stationsForTarballState.sort((a, b) => (a['tbl_station_code'] ?? '').compareTo(b['tbl_station_code'] ?? '')); // Sort after creating + .toList()..sort((a, b) => (a['tbl_station_code'] ?? '').compareTo(b['tbl_station_code'] ?? '')); } else { _stationsForTarballState = >[]; } - // --- END CORRECTION --- }); }, validator: (val) => val == null ? "State is required" : null, @@ -690,7 +654,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State widget.data.selectedTarballStateName != null && val == null ? "Station is required" : null, ), @@ -702,7 +666,6 @@ class _MarineInvesManualStep1SamplingInfoState extends State val == null || val.isEmpty ? "Station Name is required" : null, onSaved: (val) => widget.data.newStationName = val, - onChanged: (val) => widget.data.newStationName = val, // Update data model on change + onChanged: (val) => widget.data.newStationName = val, ), const SizedBox(height: 16), TextFormField( @@ -719,7 +682,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State val == null || val.isEmpty ? "Station Code is required" : null, onSaved: (val) => widget.data.newStationCode = val, - onChanged: (val) => widget.data.newStationCode = val, // Update data model on change + onChanged: (val) => widget.data.newStationCode = val, ), const SizedBox(height: 16), TextFormField( @@ -734,8 +697,8 @@ class _MarineInvesManualStep1SamplingInfoState extends State widget.data.stationLatitude = val, onChanged: (val) { - widget.data.stationLatitude = val; // Update data model on change - _calculateDistance(); // Recalculate distance when manually changed + widget.data.stationLatitude = val; + _calculateDistance(); }, ), const SizedBox(height: 16), @@ -751,8 +714,8 @@ class _MarineInvesManualStep1SamplingInfoState extends State widget.data.stationLongitude = val, onChanged: (val) { - widget.data.stationLongitude = val; // Update data model on change - _calculateDistance(); // Recalculate distance when manually changed + widget.data.stationLongitude = val; + _calculateDistance(); }, ), ], @@ -760,7 +723,6 @@ class _MarineInvesManualStep1SamplingInfoState extends State> nearbyStations; @@ -773,7 +735,7 @@ class _NearbyStationsDialog extends StatelessWidget { content: SizedBox( width: double.maxFinite, child: nearbyStations.isEmpty - ? const Center(child: Text('No stations found within 5km of your current location.')) // More informative text + ? const Center(child: Text('No stations found within 5km of your current location.')) : ListView.builder( shrinkWrap: true, itemCount: nearbyStations.length, @@ -783,13 +745,13 @@ class _NearbyStationsDialog extends StatelessWidget { final distanceInMeters = (item['distance'] as double) * 1000; return Card( - margin: const EdgeInsets.symmetric(vertical: 4.0), // Add vertical margin + margin: const EdgeInsets.symmetric(vertical: 4.0), child: ListTile( title: Text("${station['man_station_code'] ?? 'N/A'}"), subtitle: Text("${station['man_station_name'] ?? 'N/A'}"), trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"), onTap: () { - Navigator.of(context).pop(station); // Return selected station + Navigator.of(context).pop(station); }, ), ); @@ -798,7 +760,7 @@ class _NearbyStationsDialog extends StatelessWidget { ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), // Return null on cancel + onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), ], diff --git a/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart b/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart index cca70f7..f3d1a06 100644 --- a/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart +++ b/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart @@ -1,7 +1,7 @@ // lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart import 'dart:io'; -import 'dart:typed_data'; // <-- ADDED: Required for Uint8List +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; @@ -27,7 +27,7 @@ class MarineInvesManualStep2SiteInfo extends StatefulWidget { class _MarineInvesManualStep2SiteInfoState extends State { final _formKey = GlobalKey(); - bool _isPickingImage = false; // <-- ADDED: State variable from in-situ + bool _isPickingImage = false; late final TextEditingController _eventRemarksController; late final TextEditingController _labRemarksController; @@ -50,7 +50,6 @@ class _MarineInvesManualStep2SiteInfoState extends State setImageCallback(file)); - } else if (mounted) { - // Corrected snackbar message + } else { _showSnackBar('Image selection failed. Please ensure all photos are taken in landscape (horizontal) mode.', isError: true); } - if (mounted) { - setState(() => _isPickingImage = false); - } + setState(() => _isPickingImage = false); } - // --- END: MODIFIED _setImage function --- /// Validates the form and all required images before proceeding. void _goToNextStep() { @@ -86,7 +83,6 @@ class _MarineInvesManualStep2SiteInfoState extends State( @@ -146,9 +140,7 @@ class _MarineInvesManualStep2SiteInfoState extends State widget.data.seawaterColorImage = file, isRequired: true), const SizedBox(height: 24), - // --- Section: Additional photos and conditional remarks --- Text("Additional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), _buildImagePicker('Examine Preservative (pH paper)', 'PH_PAPER', widget.data.phPaperImage, (file) => widget.data.phPaperImage = file, isRequired: false), @@ -172,7 +163,6 @@ class _MarineInvesManualStep2SiteInfoState extends State( - // Use ValueKey to ensure FutureBuilder refetches when the file path changes key: ValueKey(imageFile.path), future: imageFile.readAsBytes(), builder: (context, snapshot) { @@ -243,7 +230,6 @@ class _MarineInvesManualStep2SiteInfoState extends State _handleConnectionAttempt(String type) async { final service = context.read(); final hasPermissions = await service.requestDevicePermissions(); + if (!mounted) return; + if (!hasPermissions && mounted) { _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); return; } _disconnectFromAll(); await Future.delayed(const Duration(milliseconds: 250)); + if (!mounted) return; + final bool connectionSuccess = await _connectToDevice(type); if (connectionSuccess && mounted) { _dataSubscription?.cancel(); @@ -200,6 +204,7 @@ class _MarineInvesManualStep3DataCaptureState extends State> outOfBoundsParams = []; - // --- NEW CONDITIONAL LOGIC --- - // Only check limits if it's a Manual Station if (widget.data.stationTypeSelection == 'Existing Manual Station') { final authProvider = Provider.of(context, listen: false); final marineLimits = authProvider.marineParameterLimits ?? []; @@ -367,12 +370,10 @@ class _MarineInvesManualStep3DataCaptureState extends State _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet(); }); } else { - // If not a manual station, ensure any old highlights are cleared setState(() { _outOfBoundsKeys.clear(); }); } - // --- END NEW CONDITIONAL LOGIC --- if (outOfBoundsParams.isNotEmpty) { _showParameterLimitDialog(outOfBoundsParams, currentReadings); @@ -380,7 +381,6 @@ class _MarineInvesManualStep3DataCaptureState extends State _captureReadingsToMap() { final Map readings = {}; @@ -394,15 +394,7 @@ class _MarineInvesManualStep3DataCaptureState extends State> _validateParameters(Map readings, List> limits) { final List> invalidParams = []; - - int? stationId; - // This check is now redundant due to _validateAndProceed, but safe to keep - if (widget.data.stationTypeSelection == 'Existing Manual Station') { - stationId = widget.data.selectedStation?['station_id']; - } - - debugPrint("--- Parameter Validation Start (Investigative) ---"); - debugPrint("Selected Station ID: $stationId (from 'man_station_id')"); + final dynamic stationId = widget.data.selectedStation?['station_id'] ?? widget.data.selectedStation?['man_station_id']; double? _parseLimitValue(dynamic value) { if (value == null) return null; @@ -417,23 +409,17 @@ class _MarineInvesManualStep3DataCaptureState extends State limitData = {}; if (stationId != null) { limitData = limits.firstWhere( - (l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(), + (l) => l['param_parameter_list'] == limitName && + (l['station_id']?.toString() == stationId.toString() || + l['man_station_id']?.toString() == stationId.toString()), orElse: () => {}, ); } - if (limitData.isNotEmpty) { - debugPrint(" > Found station-specific limit for Station ID $stationId: $limitData"); - } else { - debugPrint(" > No station-specific limit found for Station ID $stationId. Skipping check for this parameter."); - } - if (limitData.isNotEmpty) { final lowerLimit = _parseLimitValue(limitData['param_lower_limit']); final upperLimit = _parseLimitValue(limitData['param_upper_limit']); @@ -450,8 +436,6 @@ class _MarineInvesManualStep3DataCaptureState extends State _toggleAutoReading(type), style: ElevatedButton.styleFrom( diff --git a/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_4_summary.dart b/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_4_summary.dart index a7f2828..d06c2b8 100644 --- a/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_4_summary.dart +++ b/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_4_summary.dart @@ -178,14 +178,14 @@ class _MarineInvesManualStep4SummaryState extends State { try { final position = await service.getCurrentLocation(); - if (mounted) { - setState(() { - widget.data.currentLatitude = position.latitude.toString(); - widget.data.currentLongitude = position.longitude.toString(); - _currentLatController.text = widget.data.currentLatitude!; - _currentLonController.text = widget.data.currentLongitude!; - _calculateDistance(); - }); - } + // --- FIX: Check if mounted after async call --- + if (!mounted) return; + + setState(() { + widget.data.currentLatitude = position.latitude.toString(); + widget.data.currentLongitude = position.longitude.toString(); + _currentLatController.text = widget.data.currentLatitude!; + _currentLonController.text = widget.data.currentLongitude!; + _calculateDistance(); + }); } catch (e) { if(mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e'))); @@ -175,6 +176,7 @@ class _InSituStep1SamplingInfoState extends State { context, MaterialPageRoute(builder: (context) => const SimpleBarcodeScannerPage()), ); + // --- FIX: Check if mounted after returning from a new route --- if (result is String && result != '-1' && mounted) { setState(() { widget.data.sampleIdCode = result; @@ -187,6 +189,7 @@ class _InSituStep1SamplingInfoState extends State { Future _findAndShowNearbyStations() async { if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) { await _getCurrentLocation(); + // --- FIX: Ensure we are still active after location fetch --- if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) { return; } @@ -221,7 +224,8 @@ class _InSituStep1SamplingInfoState extends State { builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations), ); - if (selectedStation != null) { + // --- FIX: Verify mounted state after dialog closure --- + if (selectedStation != null && mounted) { _updateFormWithSelectedStation(selectedStation); } } @@ -338,11 +342,14 @@ class _InSituStep1SamplingInfoState extends State { child: const Text('Confirm'), onPressed: () { if (dialogFormKey.currentState!.validate()) { - setState(() { - widget.data.distanceDifferenceRemarks = remarkController.text; - }); - Navigator.of(context).pop(); - widget.onNext(); + // --- FIX: Ensure mounted check inside dialog action --- + if (mounted) { + setState(() { + widget.data.distanceDifferenceRemarks = remarkController.text; + }); + Navigator.of(context).pop(); + widget.onNext(); + } } }, ), @@ -609,5 +616,4 @@ class _NearbyStationsDialog extends StatelessWidget { ], ); } -} -// --- END: New Dialog Widget --- \ No newline at end of file +} \ 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 32fca9e..b9f0a30 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 @@ -111,6 +111,7 @@ class _InSituStep3DataCaptureState extends State with Wi @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { + // --- FIX: Check if mounted before performing logic or calling setState --- if (mounted) { // Use the member variable, not context final service = _samplingService; @@ -196,12 +197,16 @@ class _InSituStep3DataCaptureState extends State with Wi Future _handleConnectionAttempt(String type) async { final service = context.read(); final hasPermissions = await service.requestDevicePermissions(); + if (!mounted) return; // --- FIX: Prevent context usage if unmounted --- + if (!hasPermissions && mounted) { _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); return; } _disconnectFromAll(); await Future.delayed(const Duration(milliseconds: 250)); + if (!mounted) return; // --- FIX --- + final bool connectionSuccess = await _connectToDevice(type); if (connectionSuccess && mounted) { _dataSubscription?.cancel(); @@ -219,6 +224,8 @@ class _InSituStep3DataCaptureState extends State with Wi try { if (type == 'bluetooth') { final devices = await service.getPairedBluetoothDevices(); + if (!mounted) return false; // --- FIX --- + if (devices.isEmpty && mounted) { _showSnackBar('No paired Bluetooth devices found.', isError: true); return false; @@ -230,6 +237,8 @@ class _InSituStep3DataCaptureState extends State with Wi } } else if (type == 'serial') { final devices = await service.getAvailableSerialDevices(); + if (!mounted) return false; // --- FIX --- + if (devices.isEmpty && mounted) { _showSnackBar('No USB Serial devices found.', isError: true); return false; @@ -409,20 +418,13 @@ class _InSituStep3DataCaptureState extends State with Wi List> _validateParameters(Map readings, List> limits) { final List> invalidParams = []; - final int? stationId = widget.data.selectedStation?['station_id']; + // --- FIX: Try both station_id and man_station_id from the data object --- + final dynamic stationId = widget.data.selectedStation?['station_id'] ?? widget.data.selectedStation?['man_station_id']; debugPrint("--- Parameter Validation Start ---"); debugPrint("Selected Station ID: $stationId"); debugPrint("Total Marine Limits Loaded: ${limits.length}"); - // --- START DEBUG: Add type inspection --- - if (limits.isNotEmpty && stationId != null) { - debugPrint("Inspecting the first loaded limit record: ${limits.first}"); - debugPrint("Type of Selected Station ID ($stationId): ${stationId.runtimeType}"); - debugPrint("Type of man_station_id in first limit record: ${limits.first['man_station_id']?.runtimeType}"); - } - // --- END DEBUG --- - double? _parseLimitValue(dynamic value) { if (value == null) return null; if (value is num) return value.toDouble(); @@ -441,21 +443,17 @@ class _InSituStep3DataCaptureState extends State with Wi Map limitData = {}; if (stationId != null) { - // --- START FIX: Use type-safe comparison --- + // --- START FIX: Use robust string comparison to handle data type differences --- limitData = limits.firstWhere( - (l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(), + (l) => l['param_parameter_list'] == limitName && + (l['station_id']?.toString() == stationId.toString() || l['man_station_id']?.toString() == stationId.toString()), orElse: () => {}, ); // --- END FIX --- } if (limitData.isNotEmpty) { - debugPrint(" > Found station-specific limit for Station ID $stationId: $limitData"); - } else { - debugPrint(" > No station-specific limit found for Station ID $stationId. Skipping check for this parameter."); - } - - if (limitData.isNotEmpty) { + debugPrint(" > Found station-specific limit: $limitData"); final lowerLimit = _parseLimitValue(limitData['param_lower_limit']); final upperLimit = _parseLimitValue(limitData['param_upper_limit']); @@ -468,6 +466,8 @@ class _InSituStep3DataCaptureState extends State with Wi 'upper_limit': upperLimit, }); } + } else { + debugPrint(" > No station-specific limit found for $limitName."); } }); 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 968d3dd..ecfc747 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 @@ -83,11 +83,13 @@ class _InSituStep4SummaryState extends State { final limitName = _parameterKeyToLimitName[key]; if (limitName == null) return; - // Find limits for this specific station + // ✅ FIX: Use robust string comparison to handle data type differences (int vs String) + // Checks both 'station_id' and 'man_station_id' keys in the limits list final limitData = marineLimits.firstWhere( (l) => l['param_parameter_list'] == limitName && - l['station_id']?.toString() == stationId.toString(), + (l['station_id']?.toString() == stationId.toString() || + l['man_station_id']?.toString() == stationId.toString()), orElse: () => {}, ); @@ -179,14 +181,14 @@ class _InSituStep4SummaryState extends State { barrierDismissible: false, builder: (BuildContext dialogContext) { return AlertDialog( - title: const Text("NPE Parameter Limit Detected"), + title: const Text("Notification Pollution Event (NPE 1) Limit Detected"), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - 'The following parameters have fallen under the Non-Permissible Event (NPE) limit:'), + 'The following parameters have fallen under the Notification Pollution Event (NPE) limit:'), const SizedBox(height: 16), Table( columnWidths: const { diff --git a/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_1_sampling_info.dart b/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_1_sampling_info.dart index 2d1a881..230fa95 100644 --- a/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_1_sampling_info.dart +++ b/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_1_sampling_info.dart @@ -7,8 +7,8 @@ import 'package:intl/intl.dart'; import 'package:simple_barcode_scanner/simple_barcode_scanner.dart'; import '../../../../auth_provider.dart'; -import '../../../../models/river_inves_manual_sampling_data.dart'; // Updated model -import '../../../../services/river_investigative_sampling_service.dart'; // Updated service +import '../../../../models/river_inves_manual_sampling_data.dart'; +import '../../../../services/river_investigative_sampling_service.dart'; class RiverInvesStep1SamplingInfo extends StatefulWidget { final RiverInvesManualSamplingData data; @@ -29,6 +29,9 @@ class _RiverInvesStep1SamplingInfoState extends State(); bool _isLoadingLocation = false; + // New flag to track if user manually typed the location + bool _isManualLocationEntry = false; + late final TextEditingController _firstSamplerController; late final TextEditingController _dateController; late final TextEditingController _timeController; @@ -52,7 +55,6 @@ class _RiverInvesStep1SamplingInfoState extends State s['state_name'] as String?) // Assuming Triennial has state_name + .map((s) => s['state_name'] as String?) .whereType() .toSet() .toList(); states.sort(); setState(() { _statesList = states; }); } else { - // Further fallback final generalStates = auth.states ?? []; final states = generalStates .map((s) => s['state_name'] as String?) @@ -147,10 +145,8 @@ class _RiverInvesStep1SamplingInfoState extends State s['state_name'] == widget.data.selectedStateName) // Assuming Triennial has state_name + .where((s) => s['state_name'] == widget.data.selectedStateName) .toList() ..sort((a, b) => (a['triennial_station_code'] ?? '') .compareTo(b['triennial_station_code'] ?? '')); @@ -183,21 +179,23 @@ class _RiverInvesStep1SamplingInfoState extends State _findAndShowNearbyStations() async { - // Only works for Manual Stations currently if (widget.data.stationTypeSelection != 'Existing Manual Station') { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Nearby station search only available for Manual Stations.'))); return; @@ -269,9 +264,12 @@ class _RiverInvesStep1SamplingInfoState extends State(context, listen: false); final auth = Provider.of(context, listen: false); - final currentLat = double.parse(widget.data.currentLatitude!); - final currentLon = double.parse(widget.data.currentLongitude!); - final allStations = auth.riverManualStations ?? []; // Only search Manual + final currentLat = double.tryParse(widget.data.currentLatitude ?? ''); + final currentLon = double.tryParse(widget.data.currentLongitude ?? ''); + + if (currentLat == null || currentLon == null) return; + + final allStations = auth.riverManualStations ?? []; final List> nearbyStations = []; for (var station in allStations) { @@ -280,7 +278,7 @@ class _RiverInvesStep1SamplingInfoState extends State>( context: context, - builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations), // Use the same dialog + builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations), ); if (selectedStation != null) { @@ -301,22 +299,20 @@ class _RiverInvesStep1SamplingInfoState extends State station) { - // This specifically handles selecting a MANUAL station from nearby search or dropdown final auth = Provider.of(context, listen: false); final allManualStations = auth.riverManualStations ?? []; setState(() { - widget.data.stationTypeSelection = 'Existing Manual Station'; // Ensure type is correct + widget.data.stationTypeSelection = 'Existing Manual Station'; widget.data.selectedStateName = station['state_name']; - widget.data.selectedStation = station; // Set manual station - widget.data.selectedTriennialStation = null; // Clear triennial - _clearNewLocationFields(); // Clear new location fields + widget.data.selectedStation = station; + widget.data.selectedTriennialStation = null; + _clearNewLocationFields(); widget.data.stationLatitude = station['sampling_lat']?.toString(); widget.data.stationLongitude = station['sampling_long']?.toString(); _stationLatController.text = widget.data.stationLatitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? ''; - // Reload stations for the selected state if needed (mainly for UI consistency) _manualStationsForState = allManualStations .where((s) => s['state_name'] == widget.data.selectedStateName) .toList() @@ -327,22 +323,20 @@ class _RiverInvesStep1SamplingInfoState extends State station) { - // This specifically handles selecting a TRIENNIAL station from dropdown final auth = Provider.of(context, listen: false); final allTriennialStations = auth.riverTriennialStations ?? []; setState(() { widget.data.stationTypeSelection = 'Existing Triennial Station'; - widget.data.selectedStateName = station['state_name']; // Use state from Triennial data - widget.data.selectedTriennialStation = station; // Set triennial station - widget.data.selectedStation = null; // Clear manual + widget.data.selectedStateName = station['state_name']; + widget.data.selectedTriennialStation = station; + widget.data.selectedStation = null; _clearNewLocationFields(); - widget.data.stationLatitude = station['triennial_lat']?.toString(); // Use triennial keys - widget.data.stationLongitude = station['triennial_long']?.toString(); // Use triennial keys + widget.data.stationLatitude = station['triennial_lat']?.toString(); + widget.data.stationLongitude = station['triennial_long']?.toString(); _stationLatController.text = widget.data.stationLatitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? ''; - // Reload stations for state (UI consistency) _triennialStationsForState = allTriennialStations .where((s) => s['state_name'] == widget.data.selectedStateName) .toList() @@ -371,15 +365,13 @@ class _RiverInvesStep1SamplingInfoState extends State _goToNextStep() async { if (_formKey.currentState!.validate()) { - _formKey.currentState!.save(); // Save form fields to widget.data + _formKey.currentState!.save(); - // --- Additional Validation for New Location --- if (widget.data.stationTypeSelection == 'New Location') { if (widget.data.stationLatitude == null || widget.data.stationLatitude!.isEmpty || widget.data.stationLongitude == null || widget.data.stationLongitude!.isEmpty ) { @@ -389,20 +381,58 @@ class _RiverInvesStep1SamplingInfoState extends State 50m if (widget.data.stationTypeSelection != 'New Location' && distanceInMeters > 50) { _showDistanceRemarkDialog(); } else { - widget.data.distanceDifferenceRemarks = null; // Clear remark if not needed + widget.data.distanceDifferenceRemarks = null; widget.onNext(); } } } + // NEW: Dialog for manual location verification + Future _showLocationConfirmationDialog() { + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Verify Coordinates'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('You have manually entered the location. Please confirm these coordinates are correct:'), + const SizedBox(height: 12), + Text('Latitude: ${widget.data.currentLatitude}', style: const TextStyle(fontWeight: FontWeight.bold)), + Text('Longitude: ${widget.data.currentLongitude}', style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Edit'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Confirm'), + ), + ], + ); + }, + ); + } + Future _showDistanceRemarkDialog() async { final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks); final dialogFormKey = GlobalKey(); @@ -456,7 +486,7 @@ class _RiverInvesStep1SamplingInfoState extends State(context, listen: false); - // Note: Station lists (_manualStationsForState, _triennialStationsForState) are updated in callbacks final allUsers = auth.allUsers ?? []; final secondSamplersList = allUsers @@ -526,7 +555,6 @@ class _RiverInvesStep1SamplingInfoState extends State val == null || val.isEmpty ? "Sample ID is required" : null, onSaved: (val) => widget.data.sampleIdCode = val, - onChanged: (val) => widget.data.sampleIdCode = val, // Update model immediately + onChanged: (val) => widget.data.sampleIdCode = val, ), const SizedBox(height: 24), - // --- NEW: Station Type Selection --- + // --- Station Type Selection --- DropdownButtonFormField( value: widget.data.stationTypeSelection, items: _stationTypes @@ -556,14 +584,13 @@ class _RiverInvesStep1SamplingInfoState extends State( // State selection might be needed if not pre-selected + DropdownSearch( items: _statesList, selectedItem: widget.data.selectedStateName, popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))), @@ -641,7 +668,7 @@ class _RiverInvesStep1SamplingInfoState extends State - "${station['triennial_station_code']} | ${station['triennial_river']} | ${station['triennial_basin']}", // Use triennial keys + "${station['triennial_station_code']} | ${station['triennial_river']} | ${station['triennial_basin']}", popupProps: const PopupProps.menu( showSearchBox: true, searchFieldProps: TextFieldProps( @@ -675,7 +702,7 @@ class _RiverInvesStep1SamplingInfoState extends State( // Use Dropdown for State consistency + DropdownSearch( items: _statesList, selectedItem: widget.data.newStateName, popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))), @@ -683,7 +710,7 @@ class _RiverInvesStep1SamplingInfoState extends State val == null ? "State is required" : null, @@ -708,7 +735,7 @@ class _RiverInvesStep1SamplingInfoState extends State widget.data.newRiverName = val, ), const SizedBox(height: 16), - TextFormField( // Optional Station Code for New Location + TextFormField( controller: _newStationCodeController, decoration: const InputDecoration(labelText: 'Station Code (Optional)'), onSaved: (val) => widget.data.newStationCode = val, @@ -717,20 +744,20 @@ class _RiverInvesStep1SamplingInfoState extends State isNewLocation && (val == null || val.isEmpty) ? "Latitude is required for new location" : null, - onChanged: (val) { // Allow manual edit for New Location + onChanged: (val) { if (isNewLocation) { widget.data.stationLatitude = val; - _calculateDistance(); // Recalculate if manually changed + _calculateDistance(); } }, onSaved: (val) => widget.data.stationLatitude = val, @@ -738,17 +765,17 @@ class _RiverInvesStep1SamplingInfoState extends State isNewLocation && (val == null || val.isEmpty) ? "Longitude is required for new location" : null, - onChanged: (val) { // Allow manual edit for New Location + onChanged: (val) { if (isNewLocation) { widget.data.stationLongitude = val; - _calculateDistance(); // Recalculate if manually changed + _calculateDistance(); } }, onSaved: (val) => widget.data.stationLongitude = val, @@ -760,16 +787,41 @@ class _RiverInvesStep1SamplingInfoState extends State val == null || val.isEmpty ? "Latitude is required" : null, + onChanged: (val) { + _isManualLocationEntry = true; + widget.data.currentLatitude = val; + _calculateDistance(); + }, + ), const SizedBox(height: 16), + + // MODIFIED: Enabled manual entry for Current Longitude TextFormField( - controller: _currentLonController, - readOnly: true, - decoration: const InputDecoration(labelText: 'Current Longitude')), - if (widget.data.distanceDifferenceInKm != null && widget.data.stationTypeSelection != 'New Location') // Only show distance if NOT new location + controller: _currentLonController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Current Longitude *', + hintText: 'e.g. 101.68685', + ), + validator: (val) => val == null || val.isEmpty ? "Longitude is required" : null, + onChanged: (val) { + _isManualLocationEntry = true; + widget.data.currentLongitude = val; + _calculateDistance(); + }, + ), + + if (widget.data.distanceDifferenceInKm != null && widget.data.stationTypeSelection != 'New Location') Padding( padding: const EdgeInsets.only(top: 16.0), child: Container( @@ -813,7 +865,7 @@ class _RiverInvesStep1SamplingInfoState extends State> nearbyStations; @@ -859,7 +909,7 @@ class _NearbyStationsDialog extends StatelessWidget { subtitle: Text("${station['sampling_river'] ?? 'N/A'}"), trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"), onTap: () { - Navigator.of(context).pop(station); // Return the selected station map + Navigator.of(context).pop(station); }, ), ); diff --git a/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_3_data_capture.dart b/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_3_data_capture.dart index a30b8c4..0407458 100644 --- a/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_3_data_capture.dart +++ b/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_3_data_capture.dart @@ -8,10 +8,9 @@ import 'package:usb_serial/usb_serial.dart'; import 'package:intl/intl.dart'; import '../../../../auth_provider.dart'; -import '../../../../models/river_inves_manual_sampling_data.dart'; // Updated model -//import '../../../../services/api_service.dart'; +import '../../../../models/river_inves_manual_sampling_data.dart'; import 'package:environment_monitoring_app/services/database_helper.dart'; -import '../../../../services/river_investigative_sampling_service.dart'; // Updated service +import '../../../../services/river_investigative_sampling_service.dart'; import '../../../../bluetooth/bluetooth_manager.dart'; import '../../../../serial/serial_manager.dart'; import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart'; @@ -42,13 +41,12 @@ class _RiverInvesStep3DataCaptureState extends State int _lockoutSecondsRemaining = 30; bool _isLockedOut = false; - late final RiverInvestigativeSamplingService _samplingService; // Updated service type + late final RiverInvestigativeSamplingService _samplingService; final DatabaseHelper _dbHelper = DatabaseHelper(); Map? _previousReadingsForComparison; Set _outOfBoundsKeys = {}; - // Parameter mappings and definitions remain the same as River In-Situ final Map _parameterKeyToLimitName = const { 'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', @@ -63,7 +61,6 @@ class _RiverInvesStep3DataCaptureState extends State }; final List> _parameters = []; - // Controllers remain the same final _sondeIdController = TextEditingController(); final _dateController = TextEditingController(); final _timeController = TextEditingController(); @@ -77,17 +74,21 @@ class _RiverInvesStep3DataCaptureState extends State final _turbidityController = TextEditingController(); final _ammoniaController = TextEditingController(); final _batteryController = TextEditingController(); + String? _selectedFlowrateMethod; final _flowrateValueController = TextEditingController(); final _sdHeightController = TextEditingController(); final _sdDistanceController = TextEditingController(); - final _sdTimeFirstController = TextEditingController(); - final _sdTimeLastController = TextEditingController(); + + // --- MODIFICATION: Duration controllers instead of TimeFirst/Last --- + final _sdDurationHourController = TextEditingController(); + final _sdDurationMinuteController = TextEditingController(); + final _sdDurationSecondController = TextEditingController(); + // --- END MODIFICATION --- @override void initState() { super.initState(); - // Use the Investigative service _samplingService = Provider.of(context, listen: false); _initializeControllers(); _initializeFlowrateControllers(); @@ -96,15 +97,10 @@ class _RiverInvesStep3DataCaptureState extends State @override void dispose() { - _dataSubscription?.cancel(); - _lockoutTimer?.cancel(); - // Ensure disconnect calls use the correct service instance - if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { - _samplingService.disconnectFromBluetooth(); - } - if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) { - _samplingService.disconnectFromSerial(); - } + // --- MODIFICATION: Robust cleanup on dispose --- + _disconnectFromAll(); + // --- END MODIFICATION --- + _disposeControllers(); _disposeFlowrateControllers(); WidgetsBinding.instance.removeObserver(this); @@ -115,37 +111,39 @@ class _RiverInvesStep3DataCaptureState extends State void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { if (mounted) { - // --- START MODIFICATION --- final service = _samplingService; final btConnecting = service.bluetoothConnectionState.value == BluetoothConnectionState.connecting; final serialConnecting = service.serialConnectionState.value == SerialConnectionState.connecting; - // If the widget's local state is loading OR the service's state is stuck connecting if (_isLoading || btConnecting || serialConnecting) { - // Force-call disconnect to reset both the service's state - // and the local _isLoading flag (inside _disconnect). _disconnectFromAll(); } else { - // If not stuck, just a normal refresh setState(() {}); } - // --- END MODIFICATION --- } } } - // --- All helper methods (_initializeControllers, _disposeControllers, _initializeFlowrateControllers, - // _disposeFlowrateControllers, _onFlowrateMethodChanged, _calculateFlowrate, _selectTime, - // _handleConnectionAttempt, _connectToDevice, _startLockoutTimer, _toggleAutoReading, - // _disconnect, _disconnectFromAll, _updateTextFields, _validateAndProceed, _captureReadingsToMap, - // _validateParameters, _saveDataAndMoveOn, _showSnackBar, _showStopReadingDialog, - // _getActiveConnectionDetails, _showParameterLimitDialog, _buildFlowrateSection, etc.) - // are copied directly from river_in_situ_step_3_data_capture.dart - // but ensure they use the _samplingService instance of type RiverInvestigativeSamplingService. - // --- + // --- MODIFICATION: Added helper to clear data fields --- + void _clearDataFields() { + const defaultValue = '-999.0'; + _oxyConcController.text = defaultValue; + _oxySatController.text = defaultValue; + _phController.text = defaultValue; + _salinityController.text = defaultValue; + _ecController.text = defaultValue; + _tempController.text = defaultValue; + _tdsController.text = defaultValue; + _turbidityController.text = defaultValue; + _ammoniaController.text = defaultValue; + _batteryController.text = defaultValue; + + // Also clear Sonde ID + _sondeIdController.clear(); + } + // --- END MODIFICATION --- void _initializeControllers() { - // Logic copied from RiverInSituStep3DataCaptureState._initializeControllers widget.data.dataCaptureDate = widget.data.samplingDate; widget.data.dataCaptureTime = widget.data.samplingTime; @@ -181,7 +179,6 @@ class _RiverInvesStep3DataCaptureState extends State } void _disposeControllers() { - // Logic copied from RiverInSituStep3DataCaptureState._disposeControllers _sondeIdController.dispose(); _dateController.dispose(); _timeController.dispose(); @@ -198,89 +195,88 @@ class _RiverInvesStep3DataCaptureState extends State } void _initializeFlowrateControllers() { - // Logic copied from RiverInSituStep3DataCaptureState._initializeFlowrateControllers _selectedFlowrateMethod = widget.data.flowrateMethod; _flowrateValueController.text = widget.data.flowrateValue?.toString() ?? ''; _sdHeightController.text = widget.data.flowrateSurfaceDrifterHeight?.toString() ?? ''; _sdDistanceController.text = widget.data.flowrateSurfaceDrifterDistance?.toString() ?? ''; - _sdTimeFirstController.text = widget.data.flowrateSurfaceDrifterTimeFirst ?? ''; - _sdTimeLastController.text = widget.data.flowrateSurfaceDrifterTimeLast ?? ''; + + // --- MODIFICATION: Parse existing duration if available --- + if (widget.data.flowrateSurfaceDrifterTimeLast != null && + widget.data.flowrateSurfaceDrifterTimeLast!.contains(':')) { + final parts = widget.data.flowrateSurfaceDrifterTimeLast!.split(':'); + if (parts.length == 3) { + _sdDurationHourController.text = parts[0]; + _sdDurationMinuteController.text = parts[1]; + _sdDurationSecondController.text = parts[2]; + } + } + // --- END MODIFICATION --- } void _disposeFlowrateControllers() { - // Logic copied from RiverInSituStep3DataCaptureState._disposeFlowrateControllers _flowrateValueController.dispose(); _sdHeightController.dispose(); _sdDistanceController.dispose(); - _sdTimeFirstController.dispose(); - _sdTimeLastController.dispose(); + // --- MODIFICATION: Dispose duration controllers --- + _sdDurationHourController.dispose(); + _sdDurationMinuteController.dispose(); + _sdDurationSecondController.dispose(); + // --- END MODIFICATION --- } void _onFlowrateMethodChanged(String? value) { - // Logic copied from RiverInSituStep3DataCaptureState._onFlowrateMethodChanged setState(() { _selectedFlowrateMethod = value; - widget.data.flowrateMethod = value; // Update model immediately + widget.data.flowrateMethod = value; if (value == 'NA') { _flowrateValueController.text = 'NA'; } else if (value == 'Flowmeter') { - // --- MODIFICATION: Clear flowrate value for Flowmeter --- _flowrateValueController.clear(); - // --- END MODIFICATION --- _sdHeightController.clear(); _sdDistanceController.clear(); - _sdTimeFirstController.clear(); - _sdTimeLastController.clear(); + // --- MODIFICATION: Clear duration fields --- + _sdDurationHourController.clear(); + _sdDurationMinuteController.clear(); + _sdDurationSecondController.clear(); + // --- END MODIFICATION --- - } else { // Surface Drifter - // _flowrateValueController.clear(); // Will be calculated + } else { + // Surface Drifter } }); } void _calculateFlowrate() { - // Logic copied from RiverInSituStep3DataCaptureState._calculateFlowrate final distance = double.tryParse(_sdDistanceController.text); - final timeFirstStr = _sdTimeFirstController.text; - final timeLastStr = _sdTimeLastController.text; + // --- MODIFICATION: Calculate using duration --- + final hours = int.tryParse(_sdDurationHourController.text) ?? 0; + final minutes = int.tryParse(_sdDurationMinuteController.text) ?? 0; + final seconds = int.tryParse(_sdDurationSecondController.text) ?? 0; - if (distance == null || timeFirstStr.isEmpty || timeLastStr.isEmpty) { - _showSnackBar("Please fill in Distance, Time First, and Time Last.", isError: true); + if (distance == null) { + _showSnackBar("Please enter the Distance.", isError: true); + return; + } + + final totalSeconds = (hours * 3600) + (minutes * 60) + seconds; + + if (totalSeconds <= 0) { + _showSnackBar("Total duration must be greater than zero.", isError: true); return; } try { - final timeFormat = DateFormat("HH:mm:ss"); - // Use a common date (like today) to allow time difference calculation across midnight - final now = DateTime.now(); - final timeFirst = timeFormat.parse(timeFirstStr); - final dateTimeFirst = DateTime(now.year, now.month, now.day, timeFirst.hour, timeFirst.minute, timeFirst.second); - - final timeLast = timeFormat.parse(timeLastStr); - var dateTimeLast = DateTime(now.year, now.month, now.day, timeLast.hour, timeLast.minute, timeLast.second); - - // Handle crossing midnight - if (dateTimeLast.isBefore(dateTimeFirst)) { - dateTimeLast = dateTimeLast.add(const Duration(days: 1)); - } - - final differenceInSeconds = dateTimeLast.difference(dateTimeFirst).inSeconds; - - if (differenceInSeconds <= 0) { - _showSnackBar("Time Last Deploy must be after Time First Deploy.", isError: true); - return; - } - final flowrate = distance / differenceInSeconds; + final flowrate = distance / totalSeconds; setState(() { _flowrateValueController.text = flowrate.toStringAsFixed(4); }); } catch (e) { - _showSnackBar("Invalid time format. Please use HH:mm:ss.", isError: true); + _showSnackBar("Error calculating flowrate.", isError: true); } + // --- END MODIFICATION --- } Future _selectTime(BuildContext context, TextEditingController controller) async { - // Logic copied from RiverInSituStep3DataCaptureState._selectTime final TimeOfDay? picked = await showTimePicker( context: context, initialTime: TimeOfDay.now(), @@ -295,19 +291,24 @@ class _RiverInvesStep3DataCaptureState extends State } Future _handleConnectionAttempt(String type) async { - // Logic copied from RiverInSituStep3DataCaptureState._handleConnectionAttempt - // Uses the correct _samplingService instance final bool hasPermissions = await _samplingService.requestDevicePermissions(); if (!hasPermissions && mounted) { _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); return; } - _disconnectFromAll(); - await Future.delayed(const Duration(milliseconds: 250)); // Short delay after disconnect + + // --- MODIFICATION: Aggressive disconnect sequence --- + _disconnectFromAll(); // 1. Stop streams and disconnect services + _clearDataFields(); // 2. Clear all UI fields to remove old data + + // 3. Wait to ensure streams are fully closed + await Future.delayed(const Duration(milliseconds: 500)); + // --- END MODIFICATION --- + final bool connectionSuccess = await _connectToDevice(type); if (connectionSuccess && mounted) { - _dataSubscription?.cancel(); // Cancel previous subscription if any + _dataSubscription?.cancel(); final stream = type == 'bluetooth' ? _samplingService.bluetoothDataStream : _samplingService.serialDataStream; _dataSubscription = stream.listen((readings) { if (mounted) { @@ -316,23 +317,21 @@ class _RiverInvesStep3DataCaptureState extends State }, onError: (error) { debugPrint("Error on data stream: $error"); if (mounted) _showSnackBar("Data stream error: $error", isError: true); - _disconnect(type); // Disconnect on stream error + _disconnect(type); }, onDone: () { debugPrint("Data stream done."); - if (mounted) _disconnect(type); // Disconnect when stream closes + if (mounted) _disconnect(type); }); } } Future _connectToDevice(String type) async { - // Logic copied from RiverInSituStep3DataCaptureState._connectToDevice - // Uses the correct _samplingService instance setState(() => _isLoading = true); bool success = false; try { if (type == 'bluetooth') { final devices = await _samplingService.getPairedBluetoothDevices(); - if (!mounted) return false; // Check mounted after async gap + if (!mounted) return false; if (devices.isEmpty) { _showSnackBar('No paired Bluetooth devices found.', isError: true); return false; @@ -365,23 +364,22 @@ class _RiverInvesStep3DataCaptureState extends State } void _startLockoutTimer() { - // Logic copied from RiverInSituStep3DataCaptureState._startLockoutTimer - _lockoutTimer?.cancel(); // Cancel any existing timer + _lockoutTimer?.cancel(); setState(() { _isLockedOut = true; - _lockoutSecondsRemaining = 30; // Reset countdown + _lockoutSecondsRemaining = 30; }); _lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (_lockoutSecondsRemaining > 0) { - if (mounted) { // Check if widget is still in the tree + if (mounted) { setState(() { _lockoutSecondsRemaining--; }); } else { - timer.cancel(); // Stop timer if widget is disposed + timer.cancel(); } } else { timer.cancel(); - if (mounted) { // Check before final setState + if (mounted) { setState(() { _isLockedOut = false; }); } } @@ -389,61 +387,72 @@ class _RiverInvesStep3DataCaptureState extends State } void _toggleAutoReading(String activeType) { - // Logic copied from RiverInSituStep3DataCaptureState._toggleAutoReading - // Uses the correct _samplingService instance setState(() { _isAutoReading = !_isAutoReading; if (_isAutoReading) { if (activeType == 'bluetooth') _samplingService.startBluetoothAutoReading(); else _samplingService.startSerialAutoReading(); - _startLockoutTimer(); // Start countdown when reading starts + _startLockoutTimer(); } else { if (activeType == 'bluetooth') _samplingService.stopBluetoothAutoReading(); else _samplingService.stopSerialAutoReading(); - _lockoutTimer?.cancel(); // Stop countdown if reading is stopped manually - _isLockedOut = false; // Ensure unlocked if stopped manually + // Lockout timer is NOT cancelled manually to ensure waiting period completes } }); } void _disconnect(String type) { - // Logic copied from RiverInSituStep3DataCaptureState._disconnect - // Uses the correct _samplingService instance - // --- START MODIFICATION --- - final service = _samplingService; // NEW: Use the member variable - // --- END MODIFICATION --- + final service = _samplingService; if (type == 'bluetooth') { service.disconnectFromBluetooth(); } else { service.disconnectFromSerial(); } + + // --- MODIFICATION: Unconditional cleanup --- _dataSubscription?.cancel(); _dataSubscription = null; - _lockoutTimer?.cancel(); // Cancel timer on disconnect + _lockoutTimer?.cancel(); + _clearDataFields(); // Clear UI + // --- END MODIFICATION --- + if (mounted) { setState(() { _isAutoReading = false; - _isLockedOut = false; // Reset lockout state - _isLoading = false; // --- NEW: Also reset the loading flag --- + _isLockedOut = false; + _isLoading = false; }); } } void _disconnectFromAll() { - // Logic copied from RiverInSituStep3DataCaptureState._disconnectFromAll - // --- START MODIFICATION --- - final service = _samplingService; // NEW: Use the member variable - // --- END MODIFICATION --- + // --- MODIFICATION: Unconditional cleanup --- + // 1. Cancel local listeners first + _dataSubscription?.cancel(); + _dataSubscription = null; + _lockoutTimer?.cancel(); + + // 2. Disconnect services if active + final service = _samplingService; if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { - _disconnect('bluetooth'); + service.disconnectFromBluetooth(); } if (service.serialConnectionState.value != SerialConnectionState.disconnected) { - _disconnect('serial'); + service.disconnectFromSerial(); } + + // 3. Reset local state + if (mounted) { + setState(() { + _isAutoReading = false; + _isLockedOut = false; + _isLoading = false; + }); + } + // --- END MODIFICATION --- } void _updateTextFields(Map readings) { - // Logic copied from RiverInSituStep3DataCaptureState._updateTextFields const defaultValue = -999.0; setState(() { _oxyConcController.text = (readings['Optical Dissolved Oxygen: Compensated mg/L'] ?? defaultValue).toStringAsFixed(5); @@ -455,36 +464,28 @@ class _RiverInvesStep3DataCaptureState extends State _tdsController.text = (readings['Conductivity:TDS mg/L'] ?? defaultValue).toStringAsFixed(5); _turbidityController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5); _batteryController.text = (readings['Sonde: Battery Voltage'] ?? defaultValue).toStringAsFixed(5); - // Handle Ammonia if the key exists in the readings map _ammoniaController.text = (readings['Ammonium (NH4+) mg/L'] ?? defaultValue).toStringAsFixed(5); }); } void _validateAndProceed() async { - // Logic copied from RiverInSituStep3DataCaptureState._validateAndProceed if (_isLockedOut) { _showSnackBar("Please wait for the initial reading period to complete.", isError: true); return; } - // --- START MODIFICATION: Disable Next if Connected --- - // --- MODIFICATION: Changed to allow proceeding if reading is STOPPED, even if connected --- if (_isAutoReading) { _showStopReadingDialog(); return; } - // --- END MODIFICATION --- if (!_formKey.currentState!.validate()) { return; } - _formKey.currentState!.save(); // Save manual inputs like Sonde ID + _formKey.currentState!.save(); final currentReadings = _captureReadingsToMap(); - - // Load the generic river parameter limits (same as In-Situ) final List> riverLimits = await _dbHelper.loadRiverParameterLimits() ?? []; - final outOfBoundsParams = _validateParameters(currentReadings, riverLimits); setState(() { @@ -501,19 +502,16 @@ class _RiverInvesStep3DataCaptureState extends State } Map _captureReadingsToMap() { - // Logic copied from RiverInSituStep3DataCaptureState._captureReadingsToMap final Map readings = {}; for (var param in _parameters) { final key = param['key'] as String; final controller = param['controller'] as TextEditingController; - // Use -999.0 as the default if parsing fails or text is empty/invalid readings[key] = double.tryParse(controller.text) ?? -999.0; } return readings; } List> _validateParameters(Map readings, List> limits) { - // Logic copied from RiverInSituStep3DataCaptureState._validateParameters final List> invalidParams = []; double? parseLimitValue(dynamic value) { @@ -524,22 +522,20 @@ class _RiverInvesStep3DataCaptureState extends State } readings.forEach((key, value) { - if (value == -999.0) return; // Skip validation for missing/default values + if (value == -999.0) return; final limitName = _parameterKeyToLimitName[key]; - if (limitName == null) return; // Skip if no mapping exists + if (limitName == null) return; - // Find the limits for this parameter final limitData = limits.firstWhere( (l) => l['param_parameter_list'] == limitName, - orElse: () => {}, // Return empty map if not found + orElse: () => {}, ); if (limitData.isNotEmpty) { final lowerLimit = parseLimitValue(limitData['param_lower_limit']); final upperLimit = parseLimitValue(limitData['param_upper_limit']); - // Check if the value is outside the defined range (inclusive check is usually fine) bool isOutOfBounds = false; if (lowerLimit != null && value < lowerLimit) { isOutOfBounds = true; @@ -563,11 +559,9 @@ class _RiverInvesStep3DataCaptureState extends State } void _saveDataAndMoveOn(Map readings) { - // Logic copied from RiverInSituStep3DataCaptureState._saveDataAndMoveOn - // Saves data to the RiverInvesManualSamplingData model try { const defaultValue = -999.0; - widget.data.sondeId = _sondeIdController.text; // Make sure sonde ID is saved + widget.data.sondeId = _sondeIdController.text; widget.data.temperature = readings['temperature'] ?? defaultValue; widget.data.ph = readings['ph'] ?? defaultValue; widget.data.salinity = readings['salinity'] ?? defaultValue; @@ -579,13 +573,22 @@ class _RiverInvesStep3DataCaptureState extends State widget.data.ammonia = readings['ammonia'] ?? defaultValue; widget.data.batteryVoltage = readings['batteryVoltage'] ?? defaultValue; - // Save flowrate data widget.data.flowrateMethod = _selectedFlowrateMethod; if (_selectedFlowrateMethod == 'Surface Drifter') { widget.data.flowrateSurfaceDrifterHeight = double.tryParse(_sdHeightController.text); widget.data.flowrateSurfaceDrifterDistance = double.tryParse(_sdDistanceController.text); - widget.data.flowrateSurfaceDrifterTimeFirst = _sdTimeFirstController.text; - widget.data.flowrateSurfaceDrifterTimeLast = _sdTimeLastController.text; + + // --- MODIFICATION: Save formatted duration --- + String twoDigits(int n) => n.toString().padLeft(2, "0"); + String formattedDuration = + "${twoDigits(int.tryParse(_sdDurationHourController.text) ?? 0)}:" + "${twoDigits(int.tryParse(_sdDurationMinuteController.text) ?? 0)}:" + "${twoDigits(int.tryParse(_sdDurationSecondController.text) ?? 0)}"; + + widget.data.flowrateSurfaceDrifterTimeFirst = "00:00:00"; + widget.data.flowrateSurfaceDrifterTimeLast = formattedDuration; + // --- END MODIFICATION --- + widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); } else if (_selectedFlowrateMethod == 'Flowmeter') { widget.data.flowrateSurfaceDrifterHeight = null; @@ -598,24 +601,21 @@ class _RiverInvesStep3DataCaptureState extends State widget.data.flowrateSurfaceDrifterDistance = null; widget.data.flowrateSurfaceDrifterTimeFirst = null; widget.data.flowrateSurfaceDrifterTimeLast = null; - widget.data.flowrateValue = null; // Store null for NA - _flowrateValueController.text = 'NA'; // Display NA + widget.data.flowrateValue = null; + _flowrateValueController.text = 'NA'; } - // Set data capture date/time right before moving on final now = DateTime.now(); widget.data.dataCaptureDate = DateFormat('yyyy-MM-dd').format(now); widget.data.dataCaptureTime = DateFormat('HH:mm:ss').format(now); _dateController.text = widget.data.dataCaptureDate!; _timeController.text = widget.data.dataCaptureTime!; - } catch (e) { _showSnackBar("Could not save parameters due to a data format error: $e", isError: true); return; } - // Clear comparison state if moving on setState(() { _outOfBoundsKeys.clear(); if (_previousReadingsForComparison != null) { @@ -623,11 +623,10 @@ class _RiverInvesStep3DataCaptureState extends State } }); - widget.onNext(); // Proceed to the next step + widget.onNext(); } void _showSnackBar(String message, {bool isError = false}) { - // Logic copied from RiverInSituStep3DataCaptureState._showSnackBar if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(message), @@ -637,7 +636,6 @@ class _RiverInvesStep3DataCaptureState extends State } void _showStopReadingDialog() { - // Logic copied from RiverInSituStep3DataCaptureState._showStopReadingDialog showDialog( context: context, builder: (BuildContext context) { @@ -653,9 +651,7 @@ class _RiverInvesStep3DataCaptureState extends State } Map? _getActiveConnectionDetails() { - // --- START FIX: Use read() instead of watch() --- final service = context.read(); - // --- END FIX --- if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { return {'type': 'bluetooth', 'state': service.bluetoothConnectionState.value, 'name': service.connectedBluetoothDeviceName}; @@ -666,47 +662,30 @@ class _RiverInvesStep3DataCaptureState extends State return null; } - // --- BUILD METHOD and child build methods --- - // All build methods (_buildParameterListItem, _buildConnectionCard, _buildComparisonView, - // _showParameterLimitDialog, _buildFlowrateSection, _buildFlowrateRadioButton, - // _buildSurfaceDrifterFields, _buildFlowmeterField, _buildNAField) - // are copied directly from river_in_situ_step_3_data_capture.dart. - // Ensure context.watch() is used in build. - @override Widget build(BuildContext context) { - // Watch the Investigative service for state changes final service = context.watch(); final activeConnection = _getActiveConnectionDetails(); final String? activeType = activeConnection?['type'] as String?; - // Check if ANY device is currently connected - final bool isDeviceConnected = activeConnection != null; - - // --- START MODIFICATION: Logic for disabling inputs --- - // Disable interaction if auto-reading is active OR if locked out. - // If reading is stopped (even if connected), we allow interaction. final bool shouldDisableInput = _isAutoReading || _isLockedOut; - // --- END MODIFICATION --- return WillPopScope( onWillPop: () async { if (_isLockedOut) { _showSnackBar("Please wait for the initial reading period to complete.", isError: true); - return false; // Prevent back navigation + return false; } - // Disconnect if navigating back while connected _disconnectFromAll(); - return true; // Allow back navigation + return true; }, child: Form( key: _formKey, child: ListView( padding: const EdgeInsets.all(24.0), children: [ - Text("Investigative Data Capture", style: Theme.of(context).textTheme.headlineSmall), // Updated Title + Text("Investigative Data Capture", style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 16), - // Connection Buttons (Bluetooth/Serial) Row( children: [ Expanded( @@ -724,39 +703,33 @@ class _RiverInvesStep3DataCaptureState extends State ), const SizedBox(height: 16), - // Connection Status Card if (activeConnection != null) _buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']), const SizedBox(height: 24), - // Sonde ID - Updates based on service ValueNotifier ValueListenableBuilder( - valueListenable: service.sondeId, // Listen to the correct service instance + valueListenable: service.sondeId, builder: (context, sondeId, child) { - // --- START FIX: Only update if non-null to prevent clearing on disconnect --- if (sondeId != null && sondeId.isNotEmpty) { final newSondeId = sondeId; - // Use addPostFrameCallback to avoid setting state during build WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _sondeIdController.text != newSondeId) { _sondeIdController.text = newSondeId; - widget.data.sondeId = newSondeId; // Update model + widget.data.sondeId = newSondeId; } }); } - // --- END FIX --- return TextFormField( controller: _sondeIdController, decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'), validator: (v) => v == null || v.isEmpty ? 'Sonde ID is required' : null, - onChanged: (value) { widget.data.sondeId = value; }, // Update model on change + onChanged: (value) { widget.data.sondeId = value; }, onSaved: (v) => widget.data.sondeId = v, ); }, ), const SizedBox(height: 16), - // Date & Time (Read-only, set during save) Row( children: [ Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Capture Date'))), @@ -765,13 +738,11 @@ class _RiverInvesStep3DataCaptureState extends State ], ), - // Resample Comparison View (Conditional) if (_previousReadingsForComparison != null) _buildComparisonView(), const Divider(height: 32), - // Parameter List Column( children: _parameters.map((param) { return _buildParameterListItem( @@ -785,20 +756,16 @@ class _RiverInvesStep3DataCaptureState extends State ), const Divider(height: 32), - // Flowrate Section - // --- MODIFIED: Pass connection state to Flowrate Section --- _buildFlowrateSection(isInputDisabled: shouldDisableInput), const SizedBox(height: 32), - // Next Button with Lockout Timer ElevatedButton( - // Disable if Locked Out OR Auto Reading is active onPressed: (_isLockedOut || _isAutoReading) ? null : _validateAndProceed, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text( _isLockedOut ? 'Next ($_lockoutSecondsRemaining\s)' - : (_isAutoReading ? 'Stop Reading to Proceed' : 'Next') // Helper text + : (_isAutoReading ? 'Stop Reading to Proceed' : 'Next') ), ), ], @@ -807,20 +774,14 @@ class _RiverInvesStep3DataCaptureState extends State ); } - // --- All helper build methods (_buildParameterListItem, _buildConnectionCard, etc.) --- - // --- are copied directly from RiverInSituStep3DataCaptureState --- - Widget _buildParameterListItem({ required IconData icon, required String label, required String unit, required TextEditingController controller, bool isOutOfBounds = false}) { - // Copied from RiverInSituStep3DataCaptureState._buildParameterListItem final bool isMissing = controller.text.isEmpty || controller.text.contains('-999'); - // Display value formatted nicely, use '-.--' for missing/default final String displayValue = isMissing ? '-.--' : (double.tryParse(controller.text) ?? -999.0).toStringAsFixed(5); final String displayLabel = unit.isEmpty ? label : '$label ($unit)'; - // Determine color based on limits and missing status final Color valueColor = isOutOfBounds - ? Colors.red // Highlight out of bounds in red - : (isMissing ? Colors.grey : Theme.of(context).textTheme.bodyLarge?.color ?? Colors.black); // Grey for missing, default otherwise + ? Colors.red + : (isMissing ? Colors.grey : Theme.of(context).textTheme.bodyLarge?.color ?? Colors.black); return Card( margin: const EdgeInsets.symmetric(vertical: 4.0), @@ -839,7 +800,6 @@ class _RiverInvesStep3DataCaptureState extends State } Widget _buildConnectionCard({required String type, required dynamic connectionState, String? deviceName}) { - // Copied from RiverInSituStep3DataCaptureState._buildConnectionCard, modified to use Wrap final bool isConnected = connectionState == BluetoothConnectionState.connected || connectionState == SerialConnectionState.connected; final bool isConnecting = connectionState == BluetoothConnectionState.connecting || connectionState == SerialConnectionState.connecting; @@ -868,22 +828,21 @@ class _RiverInvesStep3DataCaptureState extends State if (isConnecting || _isLoading) const CircularProgressIndicator() else if (isConnected) - // --- START FIX: Replaced Row with Wrap to fix horizontal overflow with countdown timer --- Wrap( alignment: WrapAlignment.spaceEvenly, crossAxisAlignment: WrapCrossAlignment.center, - spacing: 8.0, // Horizontal space between buttons - runSpacing: 4.0, // Vertical space if it wraps + spacing: 8.0, + runSpacing: 4.0, children: [ ElevatedButton.icon( icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), label: Text(_isAutoReading ? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading') : 'Start Reading'), - onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type), // Pass active type + onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type), style: ElevatedButton.styleFrom( backgroundColor: _isAutoReading - ? (_isLockedOut ? Colors.grey.shade600 : Colors.orange) // Grey out if locked + ? (_isLockedOut ? Colors.grey.shade600 : Colors.orange) : Colors.green, foregroundColor: Colors.white, ), @@ -891,13 +850,13 @@ class _RiverInvesStep3DataCaptureState extends State TextButton.icon( icon: const Icon(Icons.link_off), label: const Text('Disconnect'), - onPressed: () => _disconnect(type), // Pass active type + // --- MODIFICATION: Disabled during lockout --- + onPressed: _isLockedOut ? null : () => _disconnect(type), + // --- END MODIFICATION --- style: TextButton.styleFrom(foregroundColor: Colors.red), ) ], ) - // --- END FIX --- - // Optionally add a button to reconnect if disconnected else ElevatedButton.icon( icon: Icon(type == 'bluetooth' ? Icons.bluetooth_searching : Icons.usb), @@ -911,7 +870,6 @@ class _RiverInvesStep3DataCaptureState extends State } Widget _buildComparisonView() { - // Copied from RiverInSituStep3DataCaptureState._buildComparisonView final previousReadings = _previousReadingsForComparison!; final isDarkTheme = Theme.of(context).brightness == Brightness.dark; @@ -967,7 +925,7 @@ class _RiverInvesStep3DataCaptureState extends State padding: const EdgeInsets.all(8.0), child: Text( previousValue == -999.0 ? '-.--' : previousValue!.toStringAsFixed(5), - style: TextStyle(color: isDarkTheme ? Colors.orange.shade200 : Colors.orange.shade700), // Previous always orange-ish + style: TextStyle(color: isDarkTheme ? Colors.orange.shade200 : Colors.orange.shade700), ), ), Padding( @@ -976,8 +934,8 @@ class _RiverInvesStep3DataCaptureState extends State currentValue == -999.0 ? '-.--' : currentValue.toStringAsFixed(5), style: TextStyle( color: isCurrentValueOutOfBounds - ? Colors.red // Current out of bounds = red - : (isDarkTheme ? Colors.green.shade200 : Colors.green.shade700), // Current in bounds = green + ? Colors.red + : (isDarkTheme ? Colors.green.shade200 : Colors.green.shade700), fontWeight: FontWeight.bold ), ), @@ -995,10 +953,9 @@ class _RiverInvesStep3DataCaptureState extends State } Future _showParameterLimitDialog(List> invalidParams, Map readings) async { - // Copied from RiverInSituStep3DataCaptureState._showParameterLimitDialog return showDialog( context: context, - barrierDismissible: false, // User must choose an action + barrierDismissible: false, builder: (BuildContext context) { final isDarkTheme = Theme.of(context).brightness == Brightness.dark; return AlertDialog( @@ -1014,9 +971,9 @@ class _RiverInvesStep3DataCaptureState extends State const SizedBox(height: 16), Table( columnWidths: const { - 0: FlexColumnWidth(2), // Parameter name - 1: FlexColumnWidth(2.5), // Limit range - 2: FlexColumnWidth(1.5), // Current value + 0: FlexColumnWidth(2), + 1: FlexColumnWidth(2.5), + 2: FlexColumnWidth(1.5), }, border: TableBorder( horizontalInside: BorderSide(width: 0.5, color: isDarkTheme ? Colors.grey.shade700 : Colors.grey.shade300), @@ -1036,7 +993,6 @@ class _RiverInvesStep3DataCaptureState extends State ...invalidParams.map((p) => TableRow( children: [ Padding(padding: const EdgeInsets.all(6.0), child: Text(p['label'])), - // Display limits nicely, handling nulls Padding(padding: const EdgeInsets.all(6.0), child: Text('${p['lower_limit']?.toStringAsFixed(5) ?? 'N/A'} - ${p['upper_limit']?.toStringAsFixed(5) ?? 'N/A'}')), Padding( padding: const EdgeInsets.all(6.0), @@ -1060,17 +1016,16 @@ class _RiverInvesStep3DataCaptureState extends State child: const Text('Resample'), onPressed: () { setState(() { - // Store the current (out of bounds) readings for comparison view _previousReadingsForComparison = readings; }); - Navigator.of(context).pop(); // Close the dialog, user will retake readings + Navigator.of(context).pop(); }, ), FilledButton( child: const Text('Proceed Anyway'), onPressed: () { - Navigator.of(context).pop(); // Close the dialog - _saveDataAndMoveOn(readings); // Save current readings and move to next step + Navigator.of(context).pop(); + _saveDataAndMoveOn(readings); }, ), ], @@ -1079,7 +1034,6 @@ class _RiverInvesStep3DataCaptureState extends State ); } - // Updated to include disable logic Widget _buildFlowrateSection({bool isInputDisabled = false}) { return Card( margin: const EdgeInsets.symmetric(vertical: 4.0), @@ -1106,14 +1060,12 @@ class _RiverInvesStep3DataCaptureState extends State ), ), const SizedBox(height: 8), - // Wrap content in AbsorbPointer and Opacity if connected AbsorbPointer( absorbing: isInputDisabled, child: Opacity( opacity: isInputDisabled ? 0.5 : 1.0, child: Column( children: [ - // Replaced Row with Wrap to fix horizontal overflow for radio buttons Wrap( alignment: WrapAlignment.spaceAround, spacing: 8.0, @@ -1121,10 +1073,9 @@ class _RiverInvesStep3DataCaptureState extends State children: [ _buildFlowrateRadioButton("Surface Drifter"), _buildFlowrateRadioButton("Flowmeter"), - _buildFlowrateRadioButton("NA"), // Not Applicable + _buildFlowrateRadioButton("NA"), ], ), - // Conditional fields based on selected method if (_selectedFlowrateMethod == 'Surface Drifter') _buildSurfaceDrifterFields(), if (_selectedFlowrateMethod == 'Flowmeter') @@ -1152,7 +1103,7 @@ class _RiverInvesStep3DataCaptureState extends State Text( title, textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, // Add ellipsis handling for safety + overflow: TextOverflow.ellipsis, ), ], ); @@ -1162,12 +1113,12 @@ class _RiverInvesStep3DataCaptureState extends State return Padding( padding: const EdgeInsets.only(top: 16.0), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ TextFormField( controller: _sdHeightController, decoration: const InputDecoration(labelText: 'Height (m)'), keyboardType: const TextInputType.numberWithOptions(decimal: true), - // Add validation if needed ), const SizedBox(height: 16), TextFormField( @@ -1177,21 +1128,51 @@ class _RiverInvesStep3DataCaptureState extends State validator: (v) => v == null || v.isEmpty ? 'Distance is required' : null, ), const SizedBox(height: 16), - TextFormField( - controller: _sdTimeFirstController, - decoration: const InputDecoration(labelText: 'Time First Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)), - readOnly: true, - onTap: () => _selectTime(context, _sdTimeFirstController), - validator: (v) => v == null || v.isEmpty ? 'Start time is required' : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _sdTimeLastController, - decoration: const InputDecoration(labelText: 'Time Last Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)), - readOnly: true, - onTap: () => _selectTime(context, _sdTimeLastController), - validator: (v) => v == null || v.isEmpty ? 'End time is required' : null, + + // --- MODIFICATION: Duration Input Fields --- + const Text("Duration of Travel", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _sdDurationHourController, + decoration: const InputDecoration( + labelText: 'Hours', + counterText: "", + ), + keyboardType: TextInputType.number, + maxLength: 2, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _sdDurationMinuteController, + decoration: const InputDecoration( + labelText: 'Minutes', + counterText: "", + ), + keyboardType: TextInputType.number, + maxLength: 2, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _sdDurationSecondController, + decoration: const InputDecoration( + labelText: 'Seconds', + counterText: "", + ), + keyboardType: TextInputType.number, + maxLength: 2, + ), + ), + ], ), + // --- END MODIFICATION --- + const SizedBox(height: 16), ElevatedButton( onPressed: _calculateFlowrate, @@ -1202,7 +1183,6 @@ class _RiverInvesStep3DataCaptureState extends State controller: _flowrateValueController, decoration: const InputDecoration(labelText: 'Calculated Flowrate (m/s)'), readOnly: true, - // Add validator if calculation must be done? ), ], ), @@ -1210,7 +1190,6 @@ class _RiverInvesStep3DataCaptureState extends State } Widget _buildFlowmeterField() { - // Copied from RiverInSituStep3DataCaptureState._buildFlowmeterField return Padding( padding: const EdgeInsets.only(top: 16.0), child: TextFormField( @@ -1223,7 +1202,6 @@ class _RiverInvesStep3DataCaptureState extends State } Widget _buildNAField() { - // Fix: Use controller to set value instead of initialValue to avoid conflict crash if (_flowrateValueController.text != 'NA') { _flowrateValueController.text = 'NA'; } @@ -1233,10 +1211,8 @@ class _RiverInvesStep3DataCaptureState extends State child: TextFormField( controller: _flowrateValueController, decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), - // initialValue: 'NA', // Removed to fix AssertionError: initialValue == null || controller == null - readOnly: true, // Make it read-only + readOnly: true, ), ); } - -} // End of State class \ No newline at end of file +} \ No newline at end of file diff --git a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_1_sampling_info.dart b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_1_sampling_info.dart index 39a3509..8244a6e 100644 --- a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_1_sampling_info.dart +++ b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_1_sampling_info.dart @@ -28,6 +28,9 @@ class _RiverManualTriennialStep1SamplingInfoState extends State(); bool _isLoadingLocation = false; + // New flag to track if user manually typed the location + bool _isManualLocationEntry = false; + late final TextEditingController _firstSamplerController; late final TextEditingController _dateController; late final TextEditingController _timeController; @@ -117,6 +120,9 @@ class _RiverManualTriennialStep1SamplingInfoState extends State(context, listen: false); final auth = Provider.of(context, listen: false); - final currentLat = double.parse(widget.data.currentLatitude!); - final currentLon = double.parse(widget.data.currentLongitude!); + final currentLat = double.tryParse(widget.data.currentLatitude ?? ''); + final currentLon = double.tryParse(widget.data.currentLongitude ?? ''); + + if (currentLat == null || currentLon == null) return; + final allStations = auth.riverManualStations ?? []; final List> nearbyStations = []; @@ -232,10 +246,18 @@ class _RiverManualTriennialStep1SamplingInfoState extends State _goToNextStep() async { if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); + // NEW: Check if manually entered, ask for confirmation + if (_isManualLocationEntry) { + final confirmed = await _showLocationConfirmationDialog(); + if (confirmed != true) { + return; // Stop if user cancels + } + } + final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000; if (distanceInMeters > 50) { @@ -247,6 +269,38 @@ class _RiverManualTriennialStep1SamplingInfoState extends State _showLocationConfirmationDialog() { + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Verify Coordinates'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('You have manually entered the location. Please confirm these coordinates are correct:'), + const SizedBox(height: 12), + Text('Latitude: ${widget.data.currentLatitude}', style: const TextStyle(fontWeight: FontWeight.bold)), + Text('Longitude: ${widget.data.currentLongitude}', style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Edit'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Confirm'), + ), + ], + ); + }, + ); + } + Future _showDistanceRemarkDialog() async { final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks); final dialogFormKey = GlobalKey(); @@ -436,9 +490,42 @@ class _RiverManualTriennialStep1SamplingInfoState extends State val == null || val.isEmpty ? "Latitude is required" : null, + onChanged: (val) { + // Set manual flag to true and recalculate + _isManualLocationEntry = true; + widget.data.currentLatitude = val; + _calculateDistance(); + }, + ), const SizedBox(height: 16), - TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')), + + // MODIFIED: Enabled manual entry for Current Longitude + TextFormField( + controller: _currentLonController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Current Longitude *', + hintText: 'e.g. 101.68685', + ), + validator: (val) => val == null || val.isEmpty ? "Longitude is required" : null, + onChanged: (val) { + // Set manual flag to true and recalculate + _isManualLocationEntry = true; + widget.data.currentLongitude = val; + _calculateDistance(); + }, + ), + if (widget.data.distanceDifferenceInKm != null) Padding( padding: const EdgeInsets.only(top: 16.0), @@ -471,7 +558,7 @@ class _RiverManualTriennialStep1SamplingInfoState extends State _selectTime(BuildContext context, TextEditingController controller) async { @@ -290,8 +311,15 @@ class _RiverManualTriennialStep3DataCaptureState extends State(); @@ -400,9 +427,14 @@ class _RiverManualTriennialStep3DataCaptureState extends State readings) { @@ -440,7 +487,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State _captureReadingsToMap() { final Map readings = {}; @@ -532,6 +576,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State readings) { try { const defaultValue = -999.0; + widget.data.sondeId = _sondeIdController.text; // Ensure ID is saved widget.data.temperature = readings['temperature'] ?? defaultValue; widget.data.ph = readings['ph'] ?? defaultValue; widget.data.salinity = readings['salinity'] ?? defaultValue; @@ -547,8 +592,18 @@ class _RiverManualTriennialStep3DataCaptureState extends State n.toString().padLeft(2, "0"); + String formattedDuration = + "${twoDigits(int.tryParse(_sdDurationHourController.text) ?? 0)}:" + "${twoDigits(int.tryParse(_sdDurationMinuteController.text) ?? 0)}:" + "${twoDigits(int.tryParse(_sdDurationSecondController.text) ?? 0)}"; + + widget.data.flowrateSurfaceDrifterTimeFirst = "00:00:00"; + widget.data.flowrateSurfaceDrifterTimeLast = formattedDuration; + // --- END MODIFICATION --- + widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); } else if (_selectedFlowrateMethod == 'Flowmeter') { widget.data.flowrateSurfaceDrifterHeight = null; @@ -809,7 +864,9 @@ class _RiverManualTriennialStep3DataCaptureState extends State _disconnect(type), + // --- MODIFICATION: Disabled during lockout --- + onPressed: _isLockedOut ? null : () => _disconnect(type), + // --- END MODIFICATION --- style: TextButton.styleFrom(foregroundColor: Colors.red), ) ], @@ -985,7 +1042,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State v == null || v.isEmpty ? 'Distance is required' : null, ), const SizedBox(height: 16), - TextFormField( - controller: _sdTimeFirstController, - decoration: const InputDecoration(labelText: 'Time First Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)), - readOnly: true, - onTap: () => _selectTime(context, _sdTimeFirstController), - validator: (v) => v == null || v.isEmpty ? 'Start time is required' : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _sdTimeLastController, - decoration: const InputDecoration(labelText: 'Time Last Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)), - readOnly: true, - onTap: () => _selectTime(context, _sdTimeLastController), - validator: (v) => v == null || v.isEmpty ? 'End time is required' : null, + + // --- MODIFICATION: Duration Input Fields --- + const Text("Duration of Travel", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _sdDurationHourController, + decoration: const InputDecoration( + labelText: 'Hours', + counterText: "", + ), + keyboardType: TextInputType.number, + maxLength: 2, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _sdDurationMinuteController, + decoration: const InputDecoration( + labelText: 'Minutes', + counterText: "", + ), + keyboardType: TextInputType.number, + maxLength: 2, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _sdDurationSecondController, + decoration: const InputDecoration( + labelText: 'Seconds', + counterText: "", + ), + keyboardType: TextInputType.number, + maxLength: 2, + ), + ), + ], ), + // --- END MODIFICATION --- + const SizedBox(height: 16), ElevatedButton( onPressed: _calculateFlowrate, @@ -1108,7 +1191,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State(); bool _isLoadingLocation = false; + // New flag to track if user manually typed the location + bool _isManualLocationEntry = false; + late final TextEditingController _firstSamplerController; late final TextEditingController _dateController; late final TextEditingController _timeController; @@ -100,9 +103,7 @@ class _RiverInSituStep1SamplingInfoState extends State s['state_name'] == widget.data.selectedStateName) .toList() - // --- START MODIFICATION: Sort stations on initial load --- ..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? '')); - // --- END MODIFICATION --- } setState(() { @@ -117,15 +118,20 @@ class _RiverInSituStep1SamplingInfoState extends State(context, listen: false); final auth = Provider.of(context, listen: false); - final currentLat = double.parse(widget.data.currentLatitude!); - final currentLon = double.parse(widget.data.currentLongitude!); + final currentLat = double.tryParse(widget.data.currentLatitude ?? ''); + final currentLon = double.tryParse(widget.data.currentLongitude ?? ''); + + if (currentLat == null || currentLon == null) return; + final allStations = auth.riverManualStations ?? []; final List> nearbyStations = []; @@ -209,35 +227,45 @@ class _RiverInSituStep1SamplingInfoState extends State _NearbyStationsDialog(nearbyStations: nearbyStations), ); - if (selectedStation != null) { + if (selectedStation != null && mounted) { _updateFormWithSelectedStation(selectedStation); } } void _updateFormWithSelectedStation(Map station) { final allStations = Provider.of(context, listen: false).riverManualStations ?? []; - setState(() { - widget.data.selectedStateName = station['state_name']; - _stationsForState = allStations - .where((s) => s['state_name'] == widget.data.selectedStateName) - .toList() - ..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? '')); + if (mounted) { + setState(() { + widget.data.selectedStateName = station['state_name']; + _stationsForState = allStations + .where((s) => s['state_name'] == widget.data.selectedStateName) + .toList() + ..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? '')); - widget.data.selectedStation = station; - widget.data.stationLatitude = station['sampling_lat']?.toString(); - widget.data.stationLongitude = station['sampling_long']?.toString(); - _stationLatController.text = widget.data.stationLatitude ?? ''; - _stationLonController.text = widget.data.stationLongitude ?? ''; + widget.data.selectedStation = station; + widget.data.stationLatitude = station['sampling_lat']?.toString(); + widget.data.stationLongitude = station['sampling_long']?.toString(); + _stationLatController.text = widget.data.stationLatitude ?? ''; + _stationLonController.text = widget.data.stationLongitude ?? ''; - _calculateDistance(); - }); + _calculateDistance(); + }); + } } - void _goToNextStep() { + Future _goToNextStep() async { if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); + // NEW: Check if manually entered, ask for confirmation + if (_isManualLocationEntry) { + final confirmed = await _showLocationConfirmationDialog(); + if (confirmed != true) { + return; // Stop if user cancels + } + } + final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000; if (distanceInMeters > 50) { @@ -249,6 +277,38 @@ class _RiverInSituStep1SamplingInfoState extends State _showLocationConfirmationDialog() { + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Verify Coordinates'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('You have manually entered the location. Please confirm these coordinates are correct:'), + const SizedBox(height: 12), + Text('Latitude: ${widget.data.currentLatitude}', style: const TextStyle(fontWeight: FontWeight.bold)), + Text('Longitude: ${widget.data.currentLongitude}', style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Edit'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Confirm'), + ), + ], + ); + }, + ); + } + Future _showDistanceRemarkDialog() async { final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks); final dialogFormKey = GlobalKey(); @@ -298,9 +358,11 @@ class _RiverInSituStep1SamplingInfoState extends State user['user_id'] != auth.profileData?['user_id']).toList() ..sort((a, b) => (a['first_name'] ?? '').compareTo(b['first_name'] ?? '')); - // --- END MODIFICATION --- return Form( key: _formKey, @@ -390,9 +450,7 @@ class _RiverInSituStep1SamplingInfoState extends State s['state_name'] == state) .toList() - // --- START MODIFICATION: Sort stations when state changes --- ..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? ''))) - // --- END MODIFICATION --- : []; }); }, @@ -442,9 +500,42 @@ class _RiverInSituStep1SamplingInfoState extends State val == null || val.isEmpty ? "Latitude is required" : null, + onChanged: (val) { + // Set manual flag to true and recalculate + _isManualLocationEntry = true; + widget.data.currentLatitude = val; + _calculateDistance(); + }, + ), const SizedBox(height: 16), - TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')), + + // MODIFIED: Enabled manual entry for Current Longitude + TextFormField( + controller: _currentLonController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Current Longitude *', + hintText: 'e.g. 101.68685', + ), + validator: (val) => val == null || val.isEmpty ? "Longitude is required" : null, + onChanged: (val) { + // Set manual flag to true and recalculate + _isManualLocationEntry = true; + widget.data.currentLongitude = val; + _calculateDistance(); + }, + ), + if (widget.data.distanceDifferenceInKm != null) Padding( padding: const EdgeInsets.only(top: 16.0), @@ -477,7 +568,7 @@ class _RiverInSituStep1SamplingInfoState extends State { void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async { if (_isPickingImage) return; + + // Safety check before starting + if (!mounted) return; setState(() => _isPickingImage = true); final service = Provider.of(context, listen: false); @@ -62,6 +65,10 @@ class _RiverInSituStep2SiteInfoState extends State { stationCode: stationCode, // Pass the station code here ); + // --- CRITICAL FIX: Check if widget is still mounted after the await call --- + // This prevents "Looking up a deactivated widget's ancestor" if user closed camera/app. + if (!mounted) return; + if (file != null) { setState(() => setImageCallback(file)); } else if (mounted) { @@ -201,4 +208,4 @@ class _RiverInSituStep2SiteInfoState extends State { ), ); } -} +} \ No newline at end of file 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 faead53..ea571af 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 @@ -9,7 +9,6 @@ import 'package:intl/intl.dart'; import '../../../../../auth_provider.dart'; import '../../../../../models/river_in_situ_sampling_data.dart'; -//import '../../../../../services/api_service.dart'; // Import to access DatabaseHelper import 'package:environment_monitoring_app/services/database_helper.dart'; import '../../../../../services/river_in_situ_sampling_service.dart'; import '../../../../../bluetooth/bluetooth_manager.dart'; @@ -37,17 +36,12 @@ class _RiverInSituStep3DataCaptureState extends State? _previousReadingsForComparison; Set _outOfBoundsKeys = {}; @@ -67,7 +61,6 @@ class _RiverInSituStep3DataCaptureState extends State> _parameters = []; - // Sonde parameter controllers final _sondeIdController = TextEditingController(); final _dateController = TextEditingController(); final _timeController = TextEditingController(); @@ -82,13 +75,14 @@ class _RiverInSituStep3DataCaptureState extends State _handleConnectionAttempt(String type) async { - // Uses the correct _samplingService instance final bool hasPermissions = await _samplingService.requestDevicePermissions(); if (!hasPermissions && mounted) { _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); return; } + _disconnectFromAll(); - await Future.delayed(const Duration(milliseconds: 250)); // Short delay after disconnect + _clearDataFields(); + + await Future.delayed(const Duration(milliseconds: 500)); + final bool connectionSuccess = await _connectToDevice(type); if (connectionSuccess && mounted) { - _dataSubscription?.cancel(); // Cancel previous subscription if any + _dataSubscription?.cancel(); final stream = type == 'bluetooth' ? _samplingService.bluetoothDataStream : _samplingService.serialDataStream; _dataSubscription = stream.listen((readings) { if (mounted) { @@ -303,22 +303,21 @@ class _RiverInSituStep3DataCaptureState extends State _connectToDevice(String type) async { - // Uses the correct _samplingService instance - setState(() => _isLoading = true); + if (mounted) setState(() => _isLoading = true); bool success = false; try { if (type == 'bluetooth') { final devices = await _samplingService.getPairedBluetoothDevices(); - if (!mounted) return false; // Check mounted after async gap + if (!mounted) return false; if (devices.isEmpty) { _showSnackBar('No paired Bluetooth devices found.', isError: true); return false; @@ -350,13 +349,14 @@ class _RiverInSituStep3DataCaptureState extends State 0) { @@ -375,26 +375,25 @@ class _RiverInSituStep3DataCaptureState extends State(); - setState(() { - _isAutoReading = !_isAutoReading; - if (_isAutoReading) { - if (activeType == 'bluetooth') service.startBluetoothAutoReading(); else service.startSerialAutoReading(); - _startLockoutTimer(); // --- MODIFICATION: Start countdown - } else { - if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading(); - // NOTE: _lockoutTimer is intentionally NOT cancelled here so the lockout persists for the remaining duration - } - }); + if (mounted) { + setState(() { + _isAutoReading = !_isAutoReading; + if (_isAutoReading) { + if (activeType == 'bluetooth') service.startBluetoothAutoReading(); else service.startSerialAutoReading(); + _startLockoutTimer(); + } else { + if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading(); + } + }); + } } - void _disconnect(String type) { - // --- START MODIFICATION --- - final service = _samplingService; // NEW: Use the member variable - // --- END MODIFICATION --- + // MODIFIED: Added isDisposing flag to guard setState + void _disconnect(String type, {bool isDisposing = false}) { + final service = _samplingService; if (type == 'bluetooth') { service.disconnectFromBluetooth(); } else { @@ -402,63 +401,59 @@ class _RiverInSituStep3DataCaptureState extends State readings) { const defaultValue = -999.0; - setState(() { - _oxyConcController.text = (readings['Optical Dissolved Oxygen: Compensated mg/L'] ?? defaultValue).toStringAsFixed(5); - _oxySatController.text = (readings['Optical Dissolved Oxygen: Compensated % Saturation'] ?? defaultValue).toStringAsFixed(5); - _phController.text = (readings['PH: PH units'] ?? defaultValue).toStringAsFixed(5); - _tempController.text = (readings['External Temp: Degrees Celcius'] ?? defaultValue).toStringAsFixed(5); - _ecController.text = (readings['Conductivity: us/cm'] ?? defaultValue).toStringAsFixed(5); - _salinityController.text = (readings['Conductivity: Salinity'] ?? defaultValue).toStringAsFixed(5); - _tdsController.text = (readings['Conductivity:TDS mg/L'] ?? defaultValue).toStringAsFixed(5); - _turbidityController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5); - _batteryController.text = (readings['Sonde: Battery Voltage'] ?? defaultValue).toStringAsFixed(5); - _ammoniaController.text = (readings['Ammonium (NH4+) mg/L'] ?? defaultValue).toStringAsFixed(5); - }); + if (mounted) { + setState(() { + _oxyConcController.text = (readings['Optical Dissolved Oxygen: Compensated mg/L'] ?? defaultValue).toStringAsFixed(5); + _oxySatController.text = (readings['Optical Dissolved Oxygen: Compensated % Saturation'] ?? defaultValue).toStringAsFixed(5); + _phController.text = (readings['PH: PH units'] ?? defaultValue).toStringAsFixed(5); + _tempController.text = (readings['External Temp: Degrees Celcius'] ?? defaultValue).toStringAsFixed(5); + _ecController.text = (readings['Conductivity: us/cm'] ?? defaultValue).toStringAsFixed(5); + _salinityController.text = (readings['Conductivity: Salinity'] ?? defaultValue).toStringAsFixed(5); + _tdsController.text = (readings['Conductivity:TDS mg/L'] ?? defaultValue).toStringAsFixed(5); + _turbidityController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5); + _batteryController.text = (readings['Sonde: Battery Voltage'] ?? defaultValue).toStringAsFixed(5); + _ammoniaController.text = (readings['Ammonium (NH4+) mg/L'] ?? defaultValue).toStringAsFixed(5); + }); + } } - // --- START: MODIFIED VALIDATION FLOW --- void _validateAndProceed() async { - // --- START MODIFICATION: Add lockout check --- if (_isLockedOut) { _showSnackBar("Please wait for the initial reading period to complete.", isError: true); return; } - // --- END MODIFICATION --- - - // --- START MODIFICATION: Disable Next if Connected and Auto Reading is active --- - // Check if reading is active or if device is connected but not reading (user must disconnect or stop reading first) - // Wait, request was: "either user click stop reading or disconnect button then the next button and flowrate can be used again" - // So if _isAutoReading is true, block. If device connected but _isAutoReading is false, ALLOW. if (_isAutoReading) { - _showStopReadingDialog(); // Still show dialog if reading is actively running + _showStopReadingDialog(); return; } - // Remove the forced disconnect check here because user can proceed if they stopped reading manually if (!_formKey.currentState!.validate()) { return; @@ -466,15 +461,14 @@ class _RiverInSituStep3DataCaptureState extends State> riverLimits = await _dbHelper.loadRiverParameterLimits() ?? []; - final outOfBoundsParams = _validateParameters(currentReadings, riverLimits); - setState(() { - _outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet(); - }); + if (mounted) { + setState(() { + _outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet(); + }); + } if (outOfBoundsParams.isNotEmpty) { _showParameterLimitDialog(outOfBoundsParams, currentReadings); @@ -482,7 +476,6 @@ class _RiverInSituStep3DataCaptureState extends State _captureReadingsToMap() { final Map readings = {}; @@ -551,8 +544,16 @@ class _RiverInSituStep3DataCaptureState extends State n.toString().padLeft(2, "0"); + String formattedDuration = + "${twoDigits(int.tryParse(_sdDurationHourController.text) ?? 0)}:" + "${twoDigits(int.tryParse(_sdDurationMinuteController.text) ?? 0)}:" + "${twoDigits(int.tryParse(_sdDurationSecondController.text) ?? 0)}"; + + widget.data.flowrateSurfaceDrifterTimeFirst = "00:00:00"; + widget.data.flowrateSurfaceDrifterTimeLast = formattedDuration; + widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); } else if (_selectedFlowrateMethod == 'Flowmeter') { widget.data.flowrateSurfaceDrifterHeight = null; @@ -573,12 +574,14 @@ class _RiverInSituStep3DataCaptureState extends State? _getActiveConnectionDetails() { - // --- START FIX: Use read() instead of watch() --- final service = context.read(); - // --- END FIX --- if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { return {'type': 'bluetooth', 'state': service.bluetoothConnectionState.value, 'name': service.connectedBluetoothDeviceName}; @@ -627,23 +628,16 @@ class _RiverInSituStep3DataCaptureState extends State( valueListenable: service.sondeId, builder: (context, sondeId, child) { - // --- START FIX: Only update if non-null to prevent clearing on disconnect --- if (sondeId != null && sondeId.isNotEmpty) { final newSondeId = sondeId; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -684,7 +677,6 @@ class _RiverInSituStep3DataCaptureState extends State _disconnect(type), + // --- MODIFICATION: Disable button if locked out --- + onPressed: _isLockedOut ? null : () => _disconnect(type), + // --- END MODIFICATION --- style: TextButton.styleFrom(foregroundColor: Colors.red), ) ], @@ -966,9 +954,11 @@ class _RiverInSituStep3DataCaptureState extends State v == null || v.isEmpty ? 'Distance is required' : null, ), const SizedBox(height: 16), - TextFormField( - controller: _sdTimeFirstController, - decoration: const InputDecoration(labelText: 'Time First Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)), - readOnly: true, - onTap: () => _selectTime(context, _sdTimeFirstController), - validator: (v) => v == null || v.isEmpty ? 'Start time is required' : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _sdTimeLastController, - decoration: const InputDecoration(labelText: 'Time Last Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)), - readOnly: true, - onTap: () => _selectTime(context, _sdTimeLastController), - validator: (v) => v == null || v.isEmpty ? 'End time is required' : null, + const Text("Duration of Travel", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _sdDurationHourController, + decoration: const InputDecoration( + labelText: 'Hours', + counterText: "", + ), + keyboardType: TextInputType.number, + maxLength: 2, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _sdDurationMinuteController, + decoration: const InputDecoration( + labelText: 'Minutes', + counterText: "", + ), + keyboardType: TextInputType.number, + maxLength: 2, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _sdDurationSecondController, + decoration: const InputDecoration( + labelText: 'Seconds', + counterText: "", + ), + keyboardType: TextInputType.number, + maxLength: 2, + ), + ), + ], ), const SizedBox(height: 16), ElevatedButton( @@ -1108,7 +1120,6 @@ class _RiverInSituStep3DataCaptureState extends State setImageCallback(file)); } else if (mounted) { diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index b9cf900..f486ac0 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -221,7 +221,7 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.info_outline), title: const Text('App Version'), - subtitle: const Text('MMS Version 3.12.03'), + subtitle: const Text('MMS Version 3.12.06'), dense: true, ), ListTile( diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 130085b..7fe29b4 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -179,6 +179,36 @@ class ApiService { await _baseService.get(baseUrl, 'profile'); } + // --- NEW METHOD FOR FIRST TIME LOGIN --- + /// Fetches critical configuration data (API and FTP settings) needed for + /// immediate operation. This should be called during the login/splash sequence. + Future fetchInitialConfigurations() async { + debugPrint("ApiService: Fetching initial API and FTP configurations..."); + final baseUrl = await _serverConfigService.getActiveApiUrl(); + + try { + // 1. Fetch API Configs + final apiResult = await _baseService.get(baseUrl, 'api-configs'); + if (apiResult['success'] == true && apiResult['data'] != null) { + final apiData = List>.from(apiResult['data']['updated'] ?? []); + await dbHelper.upsertApiConfigs(apiData); + debugPrint("Initial API Configs synced: ${apiData.length} items."); + } + + // 2. Fetch FTP Configs + final ftpResult = await _baseService.get(baseUrl, 'ftp-configs'); + if (ftpResult['success'] == true && ftpResult['data'] != null) { + final ftpData = List>.from(ftpResult['data']['updated'] ?? []); + await dbHelper.upsertFtpConfigs(ftpData); + debugPrint("Initial FTP Configs synced: ${ftpData.length} items."); + } + } catch (e) { + debugPrint("ApiService: Error fetching initial configurations: $e"); + // We don't rethrow here to allow login to proceed, but UserPreferencesService + // will handle the missing data gracefully (by not saving defaults yet). + } + } + Future> _fetchDelta(String endpoint, String? lastSyncTimestamp) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); String url = endpoint; diff --git a/lib/services/marine_in_situ_sampling_service.dart b/lib/services/marine_in_situ_sampling_service.dart index e057650..aa20a58 100644 --- a/lib/services/marine_in_situ_sampling_service.dart +++ b/lib/services/marine_in_situ_sampling_service.dart @@ -53,6 +53,10 @@ class MarineInSituSamplingService { final TelegramService _telegramService; final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED + // --- START FIX: Activity Tracker --- + bool _isDisposed = false; + // --- END FIX --- + MarineInSituSamplingService(this._telegramService); static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); @@ -69,7 +73,7 @@ class MarineInSituSamplingService { }) async { final picker = ImagePicker(); final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024); - if (photo == null) return null; + if (photo == null || _isDisposed) return null; final bytes = await photo.readAsBytes(); img.Image? originalImage = img.decodeImage(bytes); @@ -156,11 +160,18 @@ class MarineInSituSamplingService { } } - void disconnectFromSerial() => _serialManager.disconnect(); + // --- START FIX: Handle Thread Interruption during disconnect --- + void disconnectFromSerial() { + stopSerialAutoReading(); + _serialManager.disconnect(); + } + // --- END FIX --- + void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 2)); void stopSerialAutoReading() => _serialManager.stopAutoReading(); void dispose() { + _isDisposed = true; _bluetoothManager.dispose(); _serialManager.dispose(); } @@ -333,14 +344,14 @@ class MarineInSituSamplingService { bool anyFtpSuccess = false; // --- START FIX: Check if FTP is enabled AND if it was already successful --- - bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4'; + bool previousFtpSuccess = previousStatus == 'L4' || previousStatus == 'S4'; if (!isFtpEnabled) { debugPrint("FTP submission disabled for $moduleName by user preference. Skipping FTP."); ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user preference.', 'success': true}]}; anyFtpSuccess = true; } else if (previousFtpSuccess) { - debugPrint("FTP submission skipped because it was already successful (Status: ${data.submissionStatus})."); + debugPrint("FTP submission skipped because it was already successful (Status: $previousStatus)."); ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful in previous attempt.', 'success': true}]}; anyFtpSuccess = true; } else { @@ -478,10 +489,7 @@ class MarineInSituSamplingService { // Save/Update local log first if (savedLogPath != null && savedLogPath.isNotEmpty) { // Need to reconstruct the map with file paths for updating - // --- START: MODIFICATION (FIXED ERROR) --- - // Changed data.toDbJson() to data.toMap() to get a Map, not a String. Map logUpdateData = data.toMap(); - // --- END: MODIFICATION (FIXED ERROR) --- final imageFiles = data.toApiImageFiles(); imageFiles.forEach((key, file) { logUpdateData[key] = file?.path; // Add paths back @@ -515,8 +523,6 @@ class MarineInSituSamplingService { ); const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored."; - // Log final queued state to central DB - // await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, apiRecordId: null, logDirectory: savedLogPath); return {'success': true, 'message': successMessage, 'reportId': data.reportId}; // Return timestamp ID } @@ -622,10 +628,7 @@ class MarineInSituSamplingService { final baseFileName = _generateBaseFileName(data); // Use helper // Prepare log data map including file paths - // --- START: MODIFICATION (FIXED ERROR) --- - // Changed data.toDbJson() to data.toMap() to get a Map, not a String. Map logMapData = data.toMap(); - // --- END: MODIFICATION (FIXED ERROR) --- final imageFileMap = data.toApiImageFiles(); imageFileMap.forEach((key, file) { logMapData[key] = file?.path; // Store path or null @@ -795,7 +798,8 @@ class MarineInSituSamplingService { // Find the limit data for this parameter AND this specific station final limitData = allLimits.firstWhere( - (l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(), + (l) => l['param_parameter_list'] == limitName && + (l['station_id']?.toString() == stationId.toString() || l['man_station_id']?.toString() == stationId.toString()), orElse: () => {}, // Use explicit type ); @@ -888,7 +892,7 @@ class MarineInSituSamplingService { if (isHit) { final valueStr = value.toStringAsFixed(5); final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A'; - final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/á'; + final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A'; String limitStr; if (lowerStr != 'N/A' && upperStr != 'N/A') { limitStr = '$lowerStr - $upperStr'; diff --git a/lib/services/marine_investigative_sampling_service.dart b/lib/services/marine_investigative_sampling_service.dart index 3803bb8..b632d2b 100644 --- a/lib/services/marine_investigative_sampling_service.dart +++ b/lib/services/marine_investigative_sampling_service.dart @@ -52,6 +52,10 @@ class MarineInvestigativeSamplingService { final TelegramService _telegramService; final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED + // --- START FIX: Activity Tracker --- + bool _isDisposed = false; + // --- END FIX --- + MarineInvestigativeSamplingService(this._telegramService); static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); @@ -68,7 +72,9 @@ class MarineInvestigativeSamplingService { }) async { final picker = ImagePicker(); final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024); - if (photo == null) return null; + + // --- FIX: Check if photo is null or service is disposed --- + if (photo == null || _isDisposed) return null; final bytes = await photo.readAsBytes(); img.Image? originalImage = img.decodeImage(bytes); @@ -164,11 +170,18 @@ class MarineInvestigativeSamplingService { } } - void disconnectFromSerial() => _serialManager.disconnect(); + // --- START FIX: Handle Thread Interruption during disconnect --- + void disconnectFromSerial() { + stopSerialAutoReading(); + _serialManager.disconnect(); + } + // --- END FIX --- + void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 2)); void stopSerialAutoReading() => _serialManager.stopAutoReading(); void dispose() { + _isDisposed = true; // --- FIX: Track disposal --- _bluetoothManager.dispose(); _serialManager.dispose(); } @@ -459,7 +472,7 @@ class MarineInvestigativeSamplingService { Future> _performOfflineQueuing({ required MarineInvesManualSamplingData data, required String moduleName, - String? logDirectory, // Added for potential update + String? logDirectory, // Pass for potential update }) async { final serverConfig = await _serverConfigService.getActiveApiConfig(); final serverName = serverConfig?['config_name'] as String? ?? 'Default'; @@ -789,8 +802,11 @@ class MarineInvestigativeSamplingService { final limitName = _parameterKeyToLimitName[key]; if (limitName == null) return; + // --- FIX: Ensure robust string-based ID comparison --- final limitData = allLimits.firstWhere( - (l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(), + (l) => l['param_parameter_list'] == limitName && + (l['station_id']?.toString() == stationId.toString() || + l['man_station_id']?.toString() == stationId.toString()), orElse: () => {}, ); @@ -902,5 +918,4 @@ class MarineInvestigativeSamplingService { return buffer.toString(); } -// --- END: NEW METHOD --- } \ No newline at end of file diff --git a/lib/services/user_preferences_service.dart b/lib/services/user_preferences_service.dart index 8d59a94..d8957ba 100644 --- a/lib/services/user_preferences_service.dart +++ b/lib/services/user_preferences_service.dart @@ -35,17 +35,29 @@ class UserPreferencesService { return; } - debugPrint("Applying and auto-saving default submission preferences for the first time."); + debugPrint("Checking availability of configs for default preference application..."); try { // Get all possible configs from the database just once final allApiConfigs = await _dbHelper.loadApiConfigs() ?? []; final allFtpConfigs = await _dbHelper.loadFtpConfigs() ?? []; + // --- CRITICAL CHECK --- + // If we haven't synced data from the server yet, do NOT attempt to apply defaults. + // If we proceed now, we would save "empty" preferences and the flag would be set to true, + // preventing the defaults from ever being applied correctly later. + if (allApiConfigs.isEmpty && allFtpConfigs.isEmpty) { + debugPrint("No API or FTP configs found in local DB. Skipping default application. Will retry later."); + return; + } + + debugPrint("Configs found. Applying and auto-saving default submission preferences."); + for (var module in _configurableModules) { final moduleKey = module['key']!; // 1. Save master switches to enable API and FTP for the module. + // FORCE them to be TRUE by default. await saveModulePreference( moduleName: moduleKey, isApiEnabled: true, @@ -54,8 +66,10 @@ class UserPreferencesService { // 2. Determine default API links final defaultApiLinks = allApiConfigs.map((config) { - bool isActive = (config['is_active'] == 1 || config['is_active'] == true); - bool isPstwHq = (config['config_name'] == 'PSTW_HQ'); + // Robust check for active status (handles int 1, string '1', bool true) + final activeVal = config['is_active']; + final bool isActive = activeVal == 1 || activeVal == true || activeVal.toString() == '1'; + final bool isPstwHq = (config['config_name'] == 'PSTW_HQ'); bool isEnabled; @@ -82,7 +96,10 @@ class UserPreferencesService { final defaultFtpLinks = allFtpConfigs.map((config) { final String configModule = config['ftp_module'] ?? ''; - final bool isActive = (config['is_active'] == 1 || config['is_active'] == true); + + // Robust check for active status (Handles your SQL data where is_active is 1) + final activeVal = config['is_active']; + final bool isActive = activeVal == 1 || activeVal == true || activeVal.toString() == '1'; // Enable if the config's module matches the current moduleKey AND it's active bool isEnabled = (configModule == expectedFtpModuleKey) && isActive; @@ -107,13 +124,14 @@ class UserPreferencesService { /// Retrieves a module's master submission preferences. - /// This method now returns null if no preference is found. + /// This method now returns a default TRUE object if no preference is found. Future?> getModulePreference(String moduleName) async { final preference = await _dbHelper.getModulePreference(moduleName); if (preference != null) { return preference; } - return null; + // Return default enabled state (TRUE) if not found in DB yet + return {'is_api_enabled': true, 'is_ftp_enabled': true}; } /// Saves or updates a module's master on/off switches for API and FTP submissions. @@ -162,8 +180,10 @@ class UserPreferencesService { isEnabled = matchingLink['is_enabled'] as bool? ?? false; } else { // No preference saved for this config. Apply default logic. - bool isActive = (config['is_active'] == 1 || config['is_active'] == true); - bool isPstwHq = (config['config_name'] == 'PSTW_HQ'); + // Robust check for active status + final activeVal = config['is_active']; + final bool isActive = activeVal == 1 || activeVal == true || activeVal.toString() == '1'; + final bool isPstwHq = (config['config_name'] == 'PSTW_HQ'); // --- MODIFIED: Special logic for Marine Report --- if (moduleName == 'marine_report') { @@ -218,7 +238,10 @@ class UserPreferencesService { } else { // No preference saved for this config. Apply default logic. final String configModule = config['ftp_module'] ?? ''; - final bool isActive = (config['is_active'] == 1 || config['is_active'] == true); + // Robust check for active status + final activeVal = config['is_active']; + final bool isActive = activeVal == 1 || activeVal == true || activeVal.toString() == '1'; + // Use the mapped key for comparison isEnabled = (configModule == expectedFtpModuleKey) && isActive; } @@ -248,7 +271,7 @@ class UserPreferencesService { /// destinations to send data to. Future>> getEnabledApiConfigsForModule(String moduleName) async { // 1. Check the master switch for the module. - final pref = await _dbHelper.getModulePreference(moduleName); // Use direct DB call + final pref = await getModulePreference(moduleName); // Use method that has default if (pref == null || !(pref['is_api_enabled'] as bool)) { debugPrint("API submissions are disabled for module '$moduleName'."); return []; // Return empty list if API is disabled or not set. @@ -266,7 +289,7 @@ class UserPreferencesService { /// Retrieves only the FTP configurations that are actively enabled for a given module. Future>> getEnabledFtpConfigsForModule(String moduleName) async { - final pref = await _dbHelper.getModulePreference(moduleName); // Use direct DB call + final pref = await getModulePreference(moduleName); // Use method that has default if (pref == null || !(pref['is_ftp_enabled'] as bool)) { debugPrint("FTP submissions are disabled for module '$moduleName'."); return []; diff --git a/pubspec.yaml b/pubspec.yaml index c0cd41e..b09be1f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,4 +75,4 @@ flutter: flutter_launcher_icons: android: true ios: true - image_path: "assets/icon_3_512x512.png" \ No newline at end of file + image_path: "assets/icon_4_512x512.png" \ No newline at end of file