updated on mms to edc data

This commit is contained in:
ALim Aidrus 2026-03-03 13:52:51 +08:00
parent b97aa7ddf9
commit 9f9c7ff1cd
57 changed files with 1525 additions and 1322 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 32 KiB

BIN
assets/icon_4_512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
assets/icon_5_512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -101,7 +101,7 @@ class _HomePageState extends State<HomePage> {
); );
}, },
), ),
title: const Text("MMS Version 3.12.03"), title: const Text("MMS Version 3.12.06"),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.person), icon: const Icon(Icons.person),

View File

@ -32,7 +32,6 @@ class InSituSamplingData {
String? weather; String? weather;
String? tideLevel; String? tideLevel;
String? seaCondition; String? seaCondition;
// String? tarball; // <-- REMOVED THIS PROPERTY
String? eventRemarks; String? eventRemarks;
String? labRemarks; String? labRemarks;
@ -72,10 +71,6 @@ class InSituSamplingData {
String? reportId; String? reportId;
// --- START: NPE Report Compatibility Fields --- // --- 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<String, bool> npeFieldObservations = { Map<String, bool> npeFieldObservations = {
'Oil slick on the water surface/ Oil spill': false, 'Oil slick on the water surface/ Oil spill': false,
'Discoloration of the sea water': false, 'Discoloration of the sea water': false,
@ -88,12 +83,9 @@ class InSituSamplingData {
'Foul smell': false, 'Foul smell': false,
'Others': false, 'Others': false,
}; };
// Corresponds to the "Others" text field in NPE observations
String? npeOthersObservationRemark; String? npeOthersObservationRemark;
// Corresponds to the "Possible Source" field in the NPE form
String? npePossibleSource; String? npePossibleSource;
// Holds the images to be attached to the NPE report
File? npeImage1; File? npeImage1;
File? npeImage2; File? npeImage2;
File? npeImage3; File? npeImage3;
@ -109,48 +101,28 @@ class InSituSamplingData {
/// Creates a pre-populated NPE Report object from the current In-Situ data. /// Creates a pre-populated NPE Report object from the current In-Situ data.
MarineManualNpeReportData toNpeReportData() { MarineManualNpeReportData toNpeReportData() {
final npeData = MarineManualNpeReportData(); final npeData = MarineManualNpeReportData();
// Transfer Reporter & Event Info
npeData.firstSamplerName = firstSamplerName; npeData.firstSamplerName = firstSamplerName;
npeData.firstSamplerUserId = firstSamplerUserId; npeData.firstSamplerUserId = firstSamplerUserId;
npeData.eventDate = samplingDate; npeData.eventDate = samplingDate;
npeData.eventTime = samplingTime; npeData.eventTime = samplingTime;
// Transfer Location Info
npeData.selectedStation = selectedStation; npeData.selectedStation = selectedStation;
npeData.latitude = currentLatitude; npeData.latitude = currentLatitude;
npeData.longitude = currentLongitude; npeData.longitude = currentLongitude;
// Transfer In-Situ Measurements relevant to NPE
npeData.oxygenSaturation = oxygenSaturation; npeData.oxygenSaturation = oxygenSaturation;
npeData.electricalConductivity = electricalConductivity; npeData.electricalConductivity = electricalConductivity;
npeData.oxygenConcentration = oxygenConcentration; npeData.oxygenConcentration = oxygenConcentration;
npeData.turbidity = turbidity; npeData.turbidity = turbidity;
npeData.ph = ph; npeData.ph = ph;
npeData.temperature = temperature; npeData.temperature = temperature;
// Pre-populate possible source with event remarks as a starting point for the user
npeData.possibleSource = eventRemarks; npeData.possibleSource = eventRemarks;
// Pre-populate some common observations based on data if ((turbidity ?? 0) > 50) npeData.fieldObservations['Silt plume'] = true;
if ((turbidity ?? 0) > 50) { // Example threshold, adjust as needed if ((oxygenConcentration ?? 999) < 4) npeData.fieldObservations['Foul smell'] = true;
npeData.fieldObservations['Silt plume'] = true;
}
if ((oxygenConcentration ?? 999) < 4) { // Example threshold for low oxygen
npeData.fieldObservations['Foul smell'] = true;
}
// Transfer up to 4 available images
final availableImages = [ final availableImages = [
leftLandViewImage, leftLandViewImage, rightLandViewImage, waterFillingImage,
rightLandViewImage, seawaterColorImage, phPaperImage, optionalImage1,
waterFillingImage, optionalImage2, optionalImage3, optionalImage4,
seawaterColorImage,
phPaperImage,
optionalImage1,
optionalImage2,
optionalImage3,
optionalImage4,
].where((img) => img != null).cast<File>().toList(); ].where((img) => img != null).cast<File>().toList();
if (availableImages.isNotEmpty) npeData.image1 = availableImages[0]; if (availableImages.isNotEmpty) npeData.image1 = availableImages[0];
@ -161,34 +133,26 @@ class InSituSamplingData {
return npeData; return npeData;
} }
/// Creates an InSituSamplingData object from a JSON map.
factory InSituSamplingData.fromJson(Map<String, dynamic> json) { factory InSituSamplingData.fromJson(Map<String, dynamic> json) {
double? doubleFromJson(dynamic value) { double? doubleFromJson(dynamic value) {
if (value is num) return value.toDouble(); if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value); if (value is String) return double.tryParse(value);
return null; return null;
} }
int? intFromJson(dynamic value) { int? intFromJson(dynamic value) {
if (value is int) return value; if (value is int) return value;
if (value is String) return int.tryParse(value); if (value is String) return int.tryParse(value);
return null; return null;
} }
File? fileFromPath(dynamic path) => (path is String && path.isNotEmpty) ? File(path) : null;
File? fileFromPath(dynamic path) {
return (path is String && path.isNotEmpty) ? File(path) : null;
}
final data = InSituSamplingData(); final data = InSituSamplingData();
// Standard In-Situ Fields
data.firstSamplerName = json['first_sampler_name']; data.firstSamplerName = json['first_sampler_name'];
data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']); data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']);
data.secondSampler = json['secondSampler'] ?? json['second_sampler']; data.secondSampler = json['secondSampler'] ?? json['second_sampler'];
data.samplingDate = json['sampling_date'] ?? json['man_date']; data.samplingDate = json['sampling_date'] ?? json['man_date'];
data.samplingTime = json['sampling_time'] ?? json['man_time']; data.samplingTime = json['sampling_time'] ?? json['man_time'];
data.samplingType = json['sampling_type']; data.samplingType = json['sampling_type'];
// ... (all other existing fields)
data.sampleIdCode = json['sample_id_code']; data.sampleIdCode = json['sample_id_code'];
data.selectedStateName = json['selected_state_name']; data.selectedStateName = json['selected_state_name'];
data.selectedCategoryName = json['selected_category_name']; data.selectedCategoryName = json['selected_category_name'];
@ -202,7 +166,6 @@ class InSituSamplingData {
data.weather = json['weather']; data.weather = json['weather'];
data.tideLevel = json['tide_level']; data.tideLevel = json['tide_level'];
data.seaCondition = json['sea_condition']; data.seaCondition = json['sea_condition'];
// data.tarball = json['tarball']; // <-- REMOVED DESERIALIZATION
data.eventRemarks = json['event_remarks']; data.eventRemarks = json['event_remarks'];
data.labRemarks = json['lab_remarks']; data.labRemarks = json['lab_remarks'];
data.optionalRemark1 = json['man_optional_photo_01_remarks']; data.optionalRemark1 = json['man_optional_photo_01_remarks'];
@ -226,11 +189,8 @@ class InSituSamplingData {
data.submissionMessage = json['submission_message']; data.submissionMessage = json['submission_message'];
data.reportId = json['report_id']?.toString(); data.reportId = json['report_id']?.toString();
// Image paths (handled by LocalStorageService)
data.leftLandViewImage = fileFromPath(json['man_left_side_land_view']); data.leftLandViewImage = fileFromPath(json['man_left_side_land_view']);
data.rightLandViewImage = fileFromPath(json['man_right_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.waterFillingImage = fileFromPath(json['man_filling_water_into_sample_bottle']);
data.seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle']); data.seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle']);
data.phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper']); data.phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper']);
@ -239,15 +199,11 @@ class InSituSamplingData {
data.optionalImage3 = fileFromPath(json['man_optional_photo_03']); data.optionalImage3 = fileFromPath(json['man_optional_photo_03']);
data.optionalImage4 = fileFromPath(json['man_optional_photo_04']); data.optionalImage4 = fileFromPath(json['man_optional_photo_04']);
// --- Deserialization for NPE Fields ---
if (json['npe_field_observations'] is Map) { if (json['npe_field_observations'] is Map) {
data.npeFieldObservations = Map<String, bool>.from(json['npe_field_observations']); data.npeFieldObservations = Map<String, bool>.from(json['npe_field_observations']);
} }
data.npeOthersObservationRemark = json['npe_others_observation_remark']; data.npeOthersObservationRemark = json['npe_others_observation_remark'];
data.npePossibleSource = json['npe_possible_source']; data.npePossibleSource = json['npe_possible_source'];
// NPE image paths
data.npeImage1 = fileFromPath(json['npe_image_1']); data.npeImage1 = fileFromPath(json['npe_image_1']);
data.npeImage2 = fileFromPath(json['npe_image_2']); data.npeImage2 = fileFromPath(json['npe_image_2']);
data.npeImage3 = fileFromPath(json['npe_image_3']); data.npeImage3 = fileFromPath(json['npe_image_3']);
@ -256,7 +212,6 @@ class InSituSamplingData {
return data; return data;
} }
/// Creates a Map object with all submission data for local logging.
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'first_sampler_name': firstSamplerName, 'first_sampler_name': firstSamplerName,
@ -278,7 +233,6 @@ class InSituSamplingData {
'weather': weather, 'weather': weather,
'tide_level': tideLevel, 'tide_level': tideLevel,
'sea_condition': seaCondition, 'sea_condition': seaCondition,
// 'tarball': tarball, // <-- REMOVED
'event_remarks': eventRemarks, 'event_remarks': eventRemarks,
'lab_remarks': labRemarks, 'lab_remarks': labRemarks,
'man_optional_photo_01_remarks': optionalRemark1, 'man_optional_photo_01_remarks': optionalRemark1,
@ -304,143 +258,102 @@ class InSituSamplingData {
'npe_field_observations': npeFieldObservations, 'npe_field_observations': npeFieldObservations,
'npe_others_observation_remark': npeOthersObservationRemark, 'npe_others_observation_remark': npeOthersObservationRemark,
'npe_possible_source': npePossibleSource, '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 for 'db.json'. FORCING ALL VALUES TO STRING.
/// Creates a single JSON object with all submission data, mimicking 'db.json'
String toDbJson() { String toDbJson() {
final data = { final data = {
// --- Sorted exactly according to your Marine db.json sample --- 'battery_cap': (batteryVoltage ?? "NULL").toString(),
'battery_cap': batteryVoltage ?? -999.0, 'device_name': (sondeId ?? "").toString(),
'device_name': sondeId ?? "", 'sampling_type': (samplingType ?? "").toString(),
'sampling_type': samplingType ?? "", 'report_id': (reportId ?? "").toString(),
'report_id': reportId ?? "", 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(),
'sampler_2ndname': secondSampler?['first_name'] ?? "", 'sample_state': (selectedStateName ?? "").toString(),
'sample_state': selectedStateName ?? "", 'station_id': (selectedStation?['man_station_code'] ?? "").toString(),
'station_id': selectedStation?['man_station_code'] ?? "", 'tech_id': (firstSamplerUserId ?? "NULL").toString(),
'tech_id': firstSamplerUserId ?? -999, // Default to -999 for int 'tech_phonenum': "",
'tech_phonenum': "", // Not in model, default to empty string 'tech_name': (firstSamplerName ?? "").toString(),
'tech_name': firstSamplerName ?? "", 'latitude': (stationLatitude ?? "").toString(),
'latitude': stationLatitude ?? "", 'longitude': (stationLongitude ?? "").toString(),
'longitude': stationLongitude ?? "", 'record_dt': (samplingDate != null && samplingTime != null) ? '$samplingDate $samplingTime' : "",
'record_dt': (samplingDate != null && samplingTime != null) 'do_mgl': (oxygenConcentration ?? "NULL").toString(),
? '$samplingDate $samplingTime' 'do_sat': (oxygenSaturation ?? "NULL").toString(),
: "", 'ph': (ph ?? "NULL").toString(),
'salinity': (salinity ?? "NULL").toString(),
// --- Sensor Readings --- 'tss': (tss ?? "NULL").toString(),
'do_mgl': oxygenConcentration ?? -999.0, 'temperature': (temperature ?? "NULL").toString(),
'do_sat': oxygenSaturation ?? -999.0, 'turbidity': (turbidity ?? "NULL").toString(),
'ph': ph ?? -999.0, 'tds': (tds ?? "NULL").toString(),
'salinity': salinity ?? -999.0, 'electric_conductivity': (electricalConductivity ?? "NULL").toString(),
'tss': tss ?? -999.0, 'sample_id': (sampleIdCode ?? "").toString(),
'temperature': temperature ?? -999.0, 'tarball': "No",
'turbidity': turbidity ?? -999.0, 'weather': (weather ?? "").toString(),
'tds': tds ?? -999.0, 'tide_lvl': (tideLevel ?? "").toString(),
'electric_conductivity': electricalConductivity ?? -999.0, 'sea_cond': (seaCondition ?? "").toString(),
'remarks_event': (eventRemarks ?? "").toString(),
// --- Manual/Observations --- 'remarks_lab': (labRemarks ?? "").toString(),
'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 ?? "",
}; };
// DO NOT UNCOMMENT. Keeps all keys even if values are null/default.
// data.removeWhere((key, value) => value == null);
return jsonEncode(data); 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() { String toBasicFormJson() {
final data = { final data = {
// --- Sorted exactly according to your Marine sample --- 'tech_name': (firstSamplerName ?? "").toString(),
'tech_name': firstSamplerName ?? "", 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(),
'sampler_2ndname': secondSampler?['first_name'] ?? "", 'sample_date': (samplingDate ?? "").toString(),
'sample_date': samplingDate ?? "", 'sample_time': (samplingTime ?? "").toString(),
'sample_time': samplingTime ?? "", 'sampling_type': (samplingType ?? "").toString(),
'sampling_type': samplingType ?? "", 'sample_state': (selectedStateName ?? "").toString(),
'sample_state': selectedStateName ?? "", 'sample_category': (selectedCategoryName ?? "").toString(),
'sample_category': selectedCategoryName ?? "", // Added to match sample 'station_id': (selectedStation?['man_station_code'] ?? "").toString(),
'station_id': selectedStation?['man_station_code'] ?? "", 'station_latitude': (stationLatitude ?? "").toString(),
'station_latitude': stationLatitude ?? "", 'station_longitude': (stationLongitude ?? "").toString(),
'station_longitude': stationLongitude ?? "", 'latitude': (currentLatitude ?? "").toString(),
'latitude': currentLatitude ?? "", 'longitude': (currentLongitude ?? "").toString(),
'longitude': currentLongitude ?? "", 'sample_id': (sampleIdCode ?? "").toString(),
'sample_id': sampleIdCode ?? "",
}; };
// DO NOT UNCOMMENT
// data.removeWhere((key, value) => value == null);
return jsonEncode(data); 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() { String toReadingJson() {
final data = { final data = {
// --- Sorted exactly according to your Marine sample --- 'do_mgl': (oxygenConcentration ?? "NULL").toString(),
'do_mgl': oxygenConcentration ?? -999.0, 'do_sat': (oxygenSaturation ?? "NULL").toString(),
'do_sat': oxygenSaturation ?? -999.0, 'ph': (ph ?? "NULL").toString(),
'ph': ph ?? -999.0, 'salinity': (salinity ?? "NULL").toString(),
'salinity': salinity ?? -999.0, 'tds': (tds ?? "NULL").toString(),
'tds': tds ?? -999.0, 'tss': (tss ?? "NULL").toString(),
'tss': tss ?? -999.0, 'temperature': (temperature ?? "NULL").toString(),
'temperature': temperature ?? -999.0, 'turbidity': (turbidity ?? "NULL").toString(),
'turbidity': turbidity ?? -999.0, 'electric_conductivity': (electricalConductivity ?? "NULL").toString(),
'electric_conductivity': electricalConductivity ?? -999.0, 'date_sampling_reading': (dataCaptureDate ?? "").toString(),
'date_sampling_reading': dataCaptureDate ?? "", 'time_sampling_reading': (dataCaptureTime ?? "").toString(),
'time_sampling_reading': dataCaptureTime ?? "",
}; };
// DO NOT UNCOMMENT
// data.removeWhere((key, value) => value == null);
return jsonEncode(data); 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() { String toManualInfoJson() {
final data = { final data = {
// --- Sorted exactly according to your Marine sample --- 'tarball': "",
'tarball': "", // Field removed from model logic, default to empty 'weather': (weather ?? "").toString(),
'weather': weather ?? "", 'tide_lvl': (tideLevel ?? "").toString(),
'tide_lvl': tideLevel ?? "", 'sea_cond': (seaCondition ?? "").toString(),
'sea_cond': seaCondition ?? "", 'remarks_event': (eventRemarks ?? "").toString(),
'remarks_event': eventRemarks ?? "", 'remarks_lab': (labRemarks ?? "").toString(),
'remarks_lab': labRemarks ?? "",
}; };
// DO NOT UNCOMMENT
// data.removeWhere((key, value) => value == null);
return jsonEncode(data); return jsonEncode(data);
} }
Map<String, String> toApiFormData() { Map<String, String> toApiFormData() {
final Map<String, String> map = {}; final Map<String, String> map = {};
void add(String key, dynamic value) { void add(String key, dynamic value) {
if (value != null) { if (value != null) {
String stringValue; String stringValue = (value is double) ? ((value == -999.0) ? 'NULL' : value.toStringAsFixed(5)) : value.toString();
if (value is double) { if (stringValue.isNotEmpty) map[key] = stringValue;
if (value == -999.0) {
stringValue = '-999';
} else {
stringValue = value.toStringAsFixed(5);
}
} else {
stringValue = value.toString();
}
if (stringValue.isNotEmpty) {
map[key] = stringValue;
}
} }
} }
@ -480,7 +393,6 @@ class InSituSamplingData {
add('first_sampler_name', firstSamplerName); add('first_sampler_name', firstSamplerName);
add('man_station_code', selectedStation?['man_station_code']); add('man_station_code', selectedStation?['man_station_code']);
add('man_station_name', selectedStation?['man_station_name']); add('man_station_name', selectedStation?['man_station_name']);
return map; return map;
} }

View File

@ -2,7 +2,6 @@
import 'dart:io'; import 'dart:io';
import 'dart:convert'; // Added for jsonEncode 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 /// A data model class to hold all information for the multi-step
/// Marine Investigative Manual Sampling form. /// Marine Investigative Manual Sampling form.
@ -80,10 +79,7 @@ class MarineInvesManualSamplingData {
// --- Post-Submission Status --- // --- Post-Submission Status ---
String? submissionStatus; String? submissionStatus;
String? submissionMessage; String? submissionMessage;
String? reportId; // This will be 'man_inves_id' from the DB String? reportId; // This will be 'man_inves_id' from the DB OR Timestamp ID
// REMOVED: All NPE Report Compatibility Fields (npeFieldObservations, npeOthersObservationRemark, etc.)
MarineInvesManualSamplingData({ MarineInvesManualSamplingData({
this.samplingDate, this.samplingDate,
@ -91,9 +87,6 @@ class MarineInvesManualSamplingData {
this.stationTypeSelection = 'Existing Manual Station', // Default value this.stationTypeSelection = 'Existing Manual Station', // Default value
}); });
// REMOVED: toNpeReportData() method
/// Creates a single JSON object with all submission data for offline storage. /// Creates a single JSON object with all submission data for offline storage.
Map<String, dynamic> toDbJson() { Map<String, dynamic> toDbJson() {
return { return {
@ -147,7 +140,6 @@ class MarineInvesManualSamplingData {
'submission_status': submissionStatus, 'submission_status': submissionStatus,
'submission_message': submissionMessage, 'submission_message': submissionMessage,
'report_id': reportId, 'report_id': reportId,
// REMOVED: NPE fields from JSON
// Image paths will be added by LocalStorageService during save // Image paths will be added by LocalStorageService during save
'inves_left_side_land_view': leftLandViewImage?.path, 'inves_left_side_land_view': leftLandViewImage?.path,
@ -177,7 +169,6 @@ class MarineInvesManualSamplingData {
} }
File? fileFromPath(dynamic path) { 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; return (path is String && path.isNotEmpty) ? File(path) : null;
} }
@ -186,7 +177,7 @@ class MarineInvesManualSamplingData {
// Step 1 // Step 1
data.firstSamplerName = json['first_sampler_name']; data.firstSamplerName = json['first_sampler_name'];
data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']); 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.samplingDate = json['sampling_date'];
data.samplingTime = json['sampling_time']; data.samplingTime = json['sampling_time'];
data.samplingType = json['sampling_type']; data.samplingType = json['sampling_type'];
@ -194,15 +185,15 @@ class MarineInvesManualSamplingData {
data.stationTypeSelection = json['stationTypeSelection']; data.stationTypeSelection = json['stationTypeSelection'];
data.selectedManualStateName = json['selectedManualStateName']; data.selectedManualStateName = json['selectedManualStateName'];
data.selectedManualCategoryName = json['selectedManualCategoryName']; 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.selectedTarballStateName = json['selectedTarballStateName'];
data.selectedTarballStation = json['selectedTarballStation']; // Assumes it's stored correctly as JSON Map data.selectedTarballStation = json['selectedTarballStation'];
data.newStationName = json['newStationName']; data.newStationName = json['newStationName'];
data.newStationCode = json['newStationCode']; data.newStationCode = json['newStationCode'];
data.stationLatitude = json['station_latitude']?.toString(); // Ensure conversion to String data.stationLatitude = json['station_latitude']?.toString();
data.stationLongitude = json['station_longitude']?.toString(); // Ensure conversion to String data.stationLongitude = json['station_longitude']?.toString();
data.currentLatitude = json['current_latitude']?.toString(); // Ensure conversion to String data.currentLatitude = json['current_latitude']?.toString();
data.currentLongitude = json['current_longitude']?.toString(); // Ensure conversion to String data.currentLongitude = json['current_longitude']?.toString();
data.distanceDifferenceInKm = doubleFromJson(json['distance_difference_in_km']); data.distanceDifferenceInKm = doubleFromJson(json['distance_difference_in_km']);
data.distanceDifferenceRemarks = json['distance_difference_remarks']; data.distanceDifferenceRemarks = json['distance_difference_remarks'];
@ -217,7 +208,7 @@ class MarineInvesManualSamplingData {
data.optionalRemark3 = json['inves_optional_photo_03_remarks']; data.optionalRemark3 = json['inves_optional_photo_03_remarks'];
data.optionalRemark4 = json['inves_optional_photo_04_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.leftLandViewImage = fileFromPath(json['inves_left_side_land_view']);
data.rightLandViewImage = fileFromPath(json['inves_right_side_land_view']); data.rightLandViewImage = fileFromPath(json['inves_right_side_land_view']);
data.waterFillingImage = fileFromPath(json['inves_filling_water_into_sample_bottle']); data.waterFillingImage = fileFromPath(json['inves_filling_water_into_sample_bottle']);
@ -246,9 +237,7 @@ class MarineInvesManualSamplingData {
// Status // Status
data.submissionStatus = json['submission_status']; data.submissionStatus = json['submission_status'];
data.submissionMessage = json['submission_message']; data.submissionMessage = json['submission_message'];
data.reportId = json['report_id']?.toString(); // Ensure conversion to String data.reportId = json['report_id']?.toString();
// REMOVED: NPE fields from deserialization
return data; return data;
} }
@ -262,26 +251,21 @@ class MarineInvesManualSamplingData {
if (value != null) { if (value != null) {
String stringValue; String stringValue;
if (value is double) { if (value is double) {
// Handle special -999.0 value
if (value == -999.0) { if (value == -999.0) {
stringValue = '-999'; stringValue = '-999';
} else { } else {
// Format other doubles to 5 decimal places
stringValue = value.toStringAsFixed(5); stringValue = value.toStringAsFixed(5);
} }
} else { } else {
// Convert other types directly to string
stringValue = value.toString(); stringValue = value.toString();
} }
// Only add if the resulting string is not empty
if (stringValue.isNotEmpty) { if (stringValue.isNotEmpty) {
map[key] = stringValue; map[key] = stringValue;
} }
} }
} }
// Add prefix 'inves_' to all keys to match new backend endpoints
add('inves_date', samplingDate); add('inves_date', samplingDate);
add('inves_time', samplingTime); add('inves_time', samplingTime);
add('first_sampler_user_id', firstSamplerUserId); add('first_sampler_user_id', firstSamplerUserId);
@ -293,23 +277,22 @@ class MarineInvesManualSamplingData {
add('inves_distance_difference', distanceDifferenceInKm); add('inves_distance_difference', distanceDifferenceInKm);
add('inves_distance_difference_remarks', distanceDifferenceRemarks); add('inves_distance_difference_remarks', distanceDifferenceRemarks);
// --- NEW: Add station selection logic ---
add('inves_station_type', stationTypeSelection); add('inves_station_type', stationTypeSelection);
if (stationTypeSelection == 'Existing Manual Station') { 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_code', selectedStation?['man_station_code']);
add('inves_station_name', selectedStation?['man_station_name']); add('inves_station_name', selectedStation?['man_station_name']);
} else if (stationTypeSelection == 'Existing Tarball Station') { } 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_code', selectedTarballStation?['tbl_station_code']);
add('inves_station_name', selectedTarballStation?['tbl_station_name']); add('inves_station_name', selectedTarballStation?['tbl_station_name']);
} else if (stationTypeSelection == 'New Location') { } else if (stationTypeSelection == 'New Location') {
add('inves_new_station_name', newStationName); add('inves_new_station_name', newStationName);
add('inves_new_station_code', newStationCode); add('inves_new_station_code', newStationCode);
add('inves_station_latitude', stationLatitude); // Manually entered lat add('inves_station_latitude', stationLatitude);
add('inves_station_longitude', stationLongitude); // Manually entered lon add('inves_station_longitude', stationLongitude);
} }
// --- END NEW ---
add('inves_weather', weather); add('inves_weather', weather);
add('inves_tide_level', tideLevel); add('inves_tide_level', tideLevel);
@ -321,8 +304,8 @@ class MarineInvesManualSamplingData {
add('inves_optional_photo_03_remarks', optionalRemark3); add('inves_optional_photo_03_remarks', optionalRemark3);
add('inves_optional_photo_04_remarks', optionalRemark4); add('inves_optional_photo_04_remarks', optionalRemark4);
add('inves_sondeID', sondeId); add('inves_sondeID', sondeId);
add('data_capture_date', dataCaptureDate); // Note: No 'inves_' prefix assumed based on original model add('data_capture_date', dataCaptureDate);
add('data_capture_time', dataCaptureTime); // Note: No 'inves_' prefix assumed based on original model add('data_capture_time', dataCaptureTime);
add('inves_oxygen_conc', oxygenConcentration); add('inves_oxygen_conc', oxygenConcentration);
add('inves_oxygen_sat', oxygenSaturation); add('inves_oxygen_sat', oxygenSaturation);
add('inves_ph', ph); add('inves_ph', ph);
@ -334,7 +317,7 @@ class MarineInvesManualSamplingData {
add('inves_tss', tss); add('inves_tss', tss);
add('inves_battery_volt', batteryVoltage); 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; return map;
} }
@ -342,7 +325,6 @@ class MarineInvesManualSamplingData {
/// Maps image files to keys for the API submission. /// Maps image files to keys for the API submission.
Map<String, File?> toApiImageFiles() { Map<String, File?> toApiImageFiles() {
return { return {
// Add prefix 'inves_' to match backend expectations
'inves_left_side_land_view': leftLandViewImage, 'inves_left_side_land_view': leftLandViewImage,
'inves_right_side_land_view': rightLandViewImage, 'inves_right_side_land_view': rightLandViewImage,
'inves_filling_water_into_sample_bottle': waterFillingImage, 'inves_filling_water_into_sample_bottle': waterFillingImage,
@ -354,8 +336,4 @@ class MarineInvesManualSamplingData {
'inves_optional_photo_04': optionalImage4, 'inves_optional_photo_04': optionalImage4,
}; };
} }
// --- START: REMOVED generateInvestigativeTelegramAlertMessage ---
// This logic is now handled in MarineInvestigativeSamplingService
// --- END: REMOVED ---
} }

View File

@ -99,7 +99,6 @@ class RiverInSituSamplingData {
} }
// --- START: MODIFIED FOR CONSISTENT SERIALIZATION --- // --- START: MODIFIED FOR CONSISTENT SERIALIZATION ---
// Keys now match toMap() for reliability, with fallback to old API keys for backward compatibility.
return RiverInSituSamplingData() return RiverInSituSamplingData()
..firstSamplerName = json['firstSamplerName'] ?? json['first_sampler_name'] ..firstSamplerName = json['firstSamplerName'] ?? json['first_sampler_name']
..firstSamplerUserId = intFromJson(json['firstSamplerUserId'] ?? json['first_sampler_user_id']) ..firstSamplerUserId = intFromJson(json['firstSamplerUserId'] ?? json['first_sampler_user_id'])
@ -154,7 +153,6 @@ class RiverInSituSamplingData {
..submissionStatus = json['submissionStatus'] ..submissionStatus = json['submissionStatus']
..submissionMessage = json['submissionMessage'] ..submissionMessage = json['submissionMessage']
..reportId = json['reportId']?.toString(); ..reportId = json['reportId']?.toString();
// --- END: MODIFIED FOR CONSISTENT SERIALIZATION ---
} }
@ -165,7 +163,6 @@ class RiverInSituSamplingData {
void add(String key, dynamic value) { void add(String key, dynamic value) {
if (value != null) { if (value != null) {
String stringValue; String stringValue;
// --- START FIX: Handle -999.0 correctly ---
if (value is double) { if (value is double) {
if (value == -999.0) { if (value == -999.0) {
stringValue = '-999'; stringValue = '-999';
@ -175,9 +172,7 @@ class RiverInSituSamplingData {
} else { } else {
stringValue = value.toString(); stringValue = value.toString();
} }
// --- END FIX ---
// Only add non-empty values
if (stringValue.isNotEmpty) { if (stringValue.isNotEmpty) {
map[key] = stringValue; map[key] = stringValue;
} }
@ -191,9 +186,7 @@ class RiverInSituSamplingData {
add('r_man_time', samplingTime); add('r_man_time', samplingTime);
add('r_man_type', samplingType); add('r_man_type', samplingType);
add('r_man_sample_id_code', sampleIdCode); add('r_man_sample_id_code', sampleIdCode);
// --- START FIX: Use correct key 'station_id' ---
add('station_id', selectedStation?['station_id']); add('station_id', selectedStation?['station_id']);
// --- END FIX ---
add('r_man_current_latitude', currentLatitude); add('r_man_current_latitude', currentLatitude);
add('r_man_current_longitude', currentLongitude); add('r_man_current_longitude', currentLongitude);
add('r_man_distance_difference', distanceDifferenceInKm); add('r_man_distance_difference', distanceDifferenceInKm);
@ -222,10 +215,10 @@ class RiverInSituSamplingData {
add('r_man_temperature', temperature); add('r_man_temperature', temperature);
add('r_man_tds', tds); add('r_man_tds', tds);
add('r_man_turbidity', turbidity); 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); 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_method', flowrateMethod);
add('r_man_flowrate_sd_height', flowrateSurfaceDrifterHeight); add('r_man_flowrate_sd_height', flowrateSurfaceDrifterHeight);
add('r_man_flowrate_sd_distance', flowrateSurfaceDrifterDistance); 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_sd_time_last', flowrateSurfaceDrifterTimeLast);
add('r_man_flowrate_value', flowrateValue); add('r_man_flowrate_value', flowrateValue);
// Additional data for display or logging // Additional data for display or logging
add('first_sampler_name', firstSamplerName); add('first_sampler_name', firstSamplerName);
add('r_man_station_code', selectedStation?['sampling_station_code']); add('r_man_station_code', selectedStation?['sampling_station_code']);
add('r_man_station_name', selectedStation?['sampling_river']); add('r_man_station_name', selectedStation?['sampling_river']);
return map; 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<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'firstSamplerName': firstSamplerName, 'firstSamplerName': firstSamplerName,
@ -302,7 +293,7 @@ class RiverInSituSamplingData {
'temperature': temperature, 'temperature': temperature,
'tds': tds, 'tds': tds,
'turbidity': turbidity, 'turbidity': turbidity,
'ammonia': ammonia, // MODIFIED: Replaced tss with ammonia 'ammonia': ammonia,
'batteryVoltage': batteryVoltage, 'batteryVoltage': batteryVoltage,
'flowrateMethod': flowrateMethod, 'flowrateMethod': flowrateMethod,
'flowrateSurfaceDrifterHeight': flowrateSurfaceDrifterHeight, 'flowrateSurfaceDrifterHeight': flowrateSurfaceDrifterHeight,
@ -317,112 +308,87 @@ class RiverInSituSamplingData {
} }
/// Creates a single JSON object with all submission data, mimicking 'db.json' /// Creates a single JSON object with all submission data, mimicking 'db.json'
/// FORCING ALL VALUES TO STRING.
String toDbJson() { String toDbJson() {
final data = { final data = {
// --- Sorted exactly according to your sample --- 'battery_cap': (batteryVoltage ?? "NULL").toString(),
'battery_cap': batteryVoltage ?? -999.0, 'device_name': (sondeId ?? "").toString(),
'device_name': sondeId ?? "", 'sampling_type': (samplingType ?? "").toString(),
'sampling_type': samplingType ?? "", 'report_id': (reportId ?? "").toString(),
'report_id': reportId ?? "", 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(),
'sampler_2ndname': secondSampler?['first_name'] ?? "", 'sample_state': (selectedStateName ?? "").toString(),
'sample_state': selectedStateName ?? "", 'station_id': (selectedStation?['sampling_station_code'] ?? "").toString(),
'station_id': selectedStation?['sampling_station_code'] ?? "", 'tech_id': (firstSamplerUserId ?? "NULL").toString(),
'tech_id': firstSamplerUserId ?? -999, // Default to -999 if no ID 'tech_phonenum': "",
'tech_phonenum': "", // Field present in sample but not in model, defaults to empty 'tech_name': (firstSamplerName ?? "").toString(),
'tech_name': firstSamplerName ?? "", 'latitude': (stationLatitude ?? "").toString(),
'latitude': stationLatitude ?? "", 'longitude': (stationLongitude ?? "").toString(),
'longitude': stationLongitude ?? "",
'record_dt': (samplingDate != null && samplingTime != null) 'record_dt': (samplingDate != null && samplingTime != null)
? '$samplingDate $samplingTime' ? '$samplingDate $samplingTime'
: "", : "",
'do_mgl': (oxygenConcentration ?? "NULL").toString(),
// --- Sensor Readings --- 'do_sat': (oxygenSaturation ?? "NULL").toString(),
'do_mgl': oxygenConcentration ?? -999.0, 'ph': (ph ?? "NULL").toString(),
'do_sat': oxygenSaturation ?? -999.0, 'salinity': (salinity ?? "NULL").toString(),
'ph': ph ?? -999.0, 'temperature': (temperature ?? "NULL").toString(),
'salinity': salinity ?? -999.0, 'turbidity': (turbidity ?? "NULL").toString(),
'temperature': temperature ?? -999.0, 'tds': (tds ?? "NULL").toString(),
'turbidity': turbidity ?? -999.0, 'electric_conductivity': (electricalConductivity ?? "NULL").toString(),
'tds': tds ?? -999.0, 'flowrate': (flowrateValue ?? "NULL").toString(),
'electric_conductivity': electricalConductivity ?? -999.0, 'odour': "",
'flowrate': flowrateValue ?? -999.0, 'floatable': "",
'sample_id': (sampleIdCode ?? "").toString(),
// --- Manual/Observations --- 'weather': (weather ?? "").toString(),
'odour': "", // Default empty 'remarks_event': (eventRemarks ?? "").toString(),
'floatable': "", // Default empty 'remarks_lab': (labRemarks ?? "").toString(),
'sample_id': sampleIdCode ?? "",
'weather': weather ?? "",
'remarks_event': eventRemarks ?? "",
'remarks_lab': labRemarks ?? "",
}; };
// DO NOT UNCOMMENT. We want to keep all keys even if values are default.
// data.removeWhere((key, value) => value == null);
return jsonEncode(data); 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() { String toBasicFormJson() {
final data = { final data = {
// --- Sorted sequence: tech_name -> sampler_2ndname -> date/time -> type -> state -> station info -> location -> sample_id 'tech_name': (firstSamplerName ?? "").toString(),
'tech_name': firstSamplerName ?? "", 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(),
'sampler_2ndname': secondSampler?['first_name'] ?? "", 'sample_date': (samplingDate ?? "").toString(),
'sample_date': samplingDate ?? "", 'sample_time': (samplingTime ?? "").toString(),
'sample_time': samplingTime ?? "", 'sampling_type': (samplingType ?? "").toString(),
'sampling_type': samplingType ?? "", 'sample_state': (selectedStateName ?? "").toString(),
'sample_state': selectedStateName ?? "", 'station_id': (selectedStation?['sampling_station_code'] ?? "").toString(),
'station_id': selectedStation?['sampling_station_code'] ?? "", 'station_latitude': (stationLatitude ?? "").toString(),
'station_latitude': stationLatitude ?? "", 'station_longitude': (stationLongitude ?? "").toString(),
'station_longitude': stationLongitude ?? "", 'latitude': (currentLatitude ?? "").toString(),
'latitude': currentLatitude ?? "", // Current user location lat 'longitude': (currentLongitude ?? "").toString(),
'longitude': currentLongitude ?? "", // Current user location lon 'sample_id': (sampleIdCode ?? "").toString(),
'sample_id': sampleIdCode ?? "",
}; };
// REMOVE or COMMENT OUT this line so no keys are deleted
// data.removeWhere((key, value) => value == null);
return jsonEncode(data); return jsonEncode(data);
} }
/// Creates a JSON object for sensor readings, mimicking 'river_sampling_reading.json'. /// Creates a JSON object for sensor readings. FORCING ALL VALUES TO STRING.
/// Creates a JSON object for sensor readings, mimicking 'river_sampling_reading.json'.
String toReadingJson() { String toReadingJson() {
final data = { final data = {
// --- Sorted exactly according to your sample --- 'do_mgl': (oxygenConcentration ?? "NULL").toString(),
'do_mgl': oxygenConcentration ?? -999.0, 'do_sat': (oxygenSaturation ?? "NULL").toString(),
'do_sat': oxygenSaturation ?? -999.0, 'ph': (ph ?? "NULL").toString(),
'ph': ph ?? -999.0, 'salinity': (salinity ?? "NULL").toString(),
'salinity': salinity ?? -999.0, 'temperature': (temperature ?? "NULL").toString(),
'temperature': temperature ?? -999.0, 'turbidity': (turbidity ?? "NULL").toString(),
'turbidity': turbidity ?? -999.0, 'tds': (tds ?? "NULL").toString(),
'tds': tds ?? -999.0, 'electric_conductivity': (electricalConductivity ?? "NULL").toString(),
'electric_conductivity': electricalConductivity ?? -999.0, 'flowrate': (flowrateValue ?? "NULL").toString(),
'flowrate': flowrateValue ?? -999.0, 'date_sampling_reading': (dataCaptureDate ?? "").toString(),
'time_sampling_reading': (dataCaptureTime ?? "").toString(),
// --- Date and Time ---
'date_sampling_reading': dataCaptureDate ?? "",
'time_sampling_reading': dataCaptureTime ?? "",
}; };
// REMOVE or COMMENT OUT this line to ensure no keys are skipped/removed
// data.removeWhere((key, value) => value == null);
return jsonEncode(data); 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() { String toManualInfoJson() {
final data = { final data = {
// --- START FIX: Map model properties to correct manual info keys --- 'weather': (weather ?? "").toString(),
'weather': weather, 'remarks_event': (eventRemarks ?? "").toString(),
'remarks_event': eventRemarks, 'remarks_lab': (labRemarks ?? "").toString(),
'remarks_lab': labRemarks,
// --- END FIX ---
}; };
// Remove null values before encoding
data.removeWhere((key, value) => value == null);
return jsonEncode(data); return jsonEncode(data);
} }
} }

View File

@ -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() { String toDbJson() {
final data = { final data = {
'battery_cap': batteryVoltage == -999.0 ? null : batteryVoltage, 'battery_cap': (batteryVoltage ?? "").toString(),
'device_name': sondeId, 'device_name': (sondeId ?? "").toString(),
'sampling_type': samplingType, // 'Investigative' 'sampling_type': (samplingType ?? "Investigative").toString(),
'report_id': reportId, 'report_id': (reportId ?? "").toString(),
'sampler_2ndname': secondSampler?['first_name'], 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(),
'sample_state': getDeterminedStateName(), // Use determined state 'sample_state': (getDeterminedStateName() ?? "").toString(),
'station_id': getDeterminedStationCode(), // Use determined code 'station_id': (getDeterminedStationCode() ?? "").toString(),
'tech_id': firstSamplerUserId, 'tech_id': (firstSamplerUserId ?? "NULL").toString(),
'tech_name': firstSamplerName, 'tech_phonenum': "NULL",
'latitude': stationLatitude, // Use captured/selected station lat 'tech_name': (firstSamplerName ?? "").toString(),
'longitude': stationLongitude, // Use captured/selected station lon 'latitude': (stationLatitude ?? "").toString(),
'longitude': (stationLongitude ?? "").toString(),
'record_dt': '$samplingDate $samplingTime', 'record_dt': '$samplingDate $samplingTime',
'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration, 'do_mgl': (oxygenConcentration ?? -999.0).toString(),
'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation, 'do_sat': (oxygenSaturation ?? -999.0).toString(),
'ph': ph == -999.0 ? null : ph, 'ph': (ph ?? -999.0).toString(),
'salinity': salinity == -999.0 ? null : salinity, 'salinity': (salinity ?? -999.0).toString(),
'temperature': temperature == -999.0 ? null : temperature, 'temperature': (temperature ?? -999.0).toString(),
'turbidity': turbidity == -999.0 ? null : turbidity, 'turbidity': (turbidity ?? -999.0).toString(),
'tds': tds == -999.0 ? null : tds, 'tds': (tds ?? -999.0).toString(),
'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity, 'electric_conductivity': (electricalConductivity ?? -999.0).toString(),
'ammonia': ammonia == -999.0 ? null : ammonia, 'tss': (ammonia ?? 0.0).toString(), // Mapped ammonia to 'tss' key for FTP consistency
'flowrate': flowrateValue ?? -999.0, 'flowrate': (flowrateValue ?? -999.0).toString(),
'odour': '', // Not collected 'odour': '',
'floatable': '', // Not collected 'floatable': '',
'sample_id': sampleIdCode, 'sample_id': (sampleIdCode ?? "").toString(),
'weather': weather, 'weather': (weather ?? "").toString(),
'remarks_event': eventRemarks, 'remarks_event': (eventRemarks ?? "").toString(),
'remarks_lab': labRemarks, 'remarks_lab': (labRemarks ?? "").toString(),
// --- Add Investigative Specific fields if needed by FTP structure --- 'station_type': (stationTypeSelection ?? "").toString(),
'station_type': stationTypeSelection, // e.g., 'New Location' 'new_basin': (newBasinName ?? "").toString(),
'new_basin': stationTypeSelection == 'New Location' ? newBasinName : null, 'new_river': (newRiverName ?? "").toString(),
'new_river': stationTypeSelection == 'New Location' ? newRiverName : null, 'new_station_name': (newStationName ?? "").toString(),
'new_station_name': stationTypeSelection == 'New Location' ? newStationName : null, // Include newStationName 'tarball': "No",
'tide_lvl': "",
'sea_cond': "",
}; };
data.removeWhere((key, value) => value == null);
return jsonEncode(data); 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() { String toBasicFormJson() {
final data = { final data = {
'tech_name': firstSamplerName, 'tech_name': (firstSamplerName ?? "").toString(),
'sampler_2ndname': secondSampler?['first_name'], 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(),
'sample_date': samplingDate, 'sample_date': (samplingDate ?? "").toString(),
'sample_time': samplingTime, 'sample_time': (samplingTime ?? "").toString(),
'sampling_type': samplingType, // 'Investigative' 'sampling_type': (samplingType ?? "Investigative").toString(),
'sample_state': getDeterminedStateName(), 'sample_state': (getDeterminedStateName() ?? "").toString(),
'station_id': getDeterminedStationCode(), 'station_id': (getDeterminedStationCode() ?? "").toString(),
'station_latitude': stationLatitude, 'station_latitude': (stationLatitude ?? "").toString(),
'station_longitude': stationLongitude, 'station_longitude': (stationLongitude ?? "").toString(),
'latitude': currentLatitude, // Current location lat 'latitude': (currentLatitude ?? "").toString(),
'longitude': currentLongitude, // Current location lon 'longitude': (currentLongitude ?? "").toString(),
'sample_id': sampleIdCode, 'sample_id': (sampleIdCode ?? "").toString(),
// --- Add Investigative Specific fields if needed --- 'station_type': (stationTypeSelection ?? "").toString(),
'station_type': stationTypeSelection, 'new_basin': (newBasinName ?? "").toString(),
'new_basin': stationTypeSelection == 'New Location' ? newBasinName : null, 'new_river': (newRiverName ?? "").toString(),
'new_river': stationTypeSelection == 'New Location' ? newRiverName : null, 'new_station_name': (newStationName ?? "").toString(),
'new_station_name': stationTypeSelection == 'New Location' ? newStationName : null, // Include newStationName
}; };
//data.removeWhere((key, value) => value == null);
return jsonEncode(data); 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() { String toReadingJson() {
final data = { final data = {
'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration, 'do_mgl': (oxygenConcentration ?? -999.0).toString(),
'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation, 'do_sat': (oxygenSaturation ?? -999.0).toString(),
'ph': ph == -999.0 ? null : ph, 'ph': (ph ?? -999.0).toString(),
'salinity': salinity == -999.0 ? null : salinity, 'salinity': (salinity ?? -999.0).toString(),
'temperature': temperature == -999.0 ? null : temperature, 'temperature': (temperature ?? -999.0).toString(),
'turbidity': turbidity == -999.0 ? null : turbidity, 'turbidity': (turbidity ?? -999.0).toString(),
'tds': tds == -999.0 ? null : tds, 'tds': (tds ?? -999.0).toString(),
'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity, 'electric_conductivity': (electricalConductivity ?? -999.0).toString(),
'ammonia': ammonia == -999.0 ? null : ammonia, 'tss': (ammonia ?? -999.0).toString(),
'flowrate': flowrateValue, 'flowrate': (flowrateValue ?? -999.0).toString(),
'date_sampling_reading': dataCaptureDate, 'date_sampling_reading': (dataCaptureDate ?? "").toString(),
'time_sampling_reading': dataCaptureTime, 'time_sampling_reading': (dataCaptureTime ?? "").toString(),
}; };
data.removeWhere((key, value) => value == null);
return jsonEncode(data); 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() { String toManualInfoJson() {
final data = { final data = {
'weather': weather, 'weather': (weather ?? "").toString(),
'remarks_event': eventRemarks, 'remarks_event': (eventRemarks ?? "").toString(),
'remarks_lab': labRemarks, 'remarks_lab': (labRemarks ?? "").toString(),
}; };
data.removeWhere((key, value) => value == null);
return jsonEncode(data); return jsonEncode(data);
} }
} }

View File

@ -1,7 +1,7 @@
// lib/models/river_manual_triennial_sampling_data.dart // lib/models/river_manual_triennial_sampling_data.dart
import 'dart:io'; import 'dart:io';
import 'dart:convert'; // Added for jsonEncode import 'dart:convert';
/// Data model for the River Manual Triennial Sampling form. /// Data model for the River Manual Triennial Sampling form.
class RiverManualTriennialSamplingData { class RiverManualTriennialSamplingData {
@ -160,7 +160,7 @@ class RiverManualTriennialSamplingData {
String stringValue; String stringValue;
if (value is double) { if (value is double) {
if (value == -999.0) { if (value == -999.0) {
stringValue = '-999'; stringValue = 'NULL';
} else { } else {
stringValue = value.toStringAsFixed(5); 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() { String toDbJson() {
final data = { final data = {
'battery_cap': batteryVoltage == -999.0 ? null : batteryVoltage, 'battery_cap': (batteryVoltage ?? "NULL").toString(),
'device_name': sondeId, 'device_name': (sondeId ?? "").toString(),
'sampling_type': samplingType, 'sampling_type': (samplingType ?? "").toString(),
'report_id': reportId, 'report_id': (reportId ?? "").toString(),
'sampler_2ndname': secondSampler?['first_name'], 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(),
'sample_state': selectedStateName, 'sample_state': (selectedStateName ?? "").toString(),
'station_id': selectedStation?['sampling_station_code'], 'station_id': (selectedStation?['sampling_station_code'] ?? "").toString(),
'tech_id': firstSamplerUserId, 'tech_id': (firstSamplerUserId ?? "NULL").toString(),
'tech_name': firstSamplerName, 'tech_phonenum': "NULL",
'latitude': stationLatitude, 'tech_name': (firstSamplerName ?? "").toString(),
'longitude': stationLongitude, 'latitude': (stationLatitude ?? "").toString(),
'longitude': (stationLongitude ?? "").toString(),
'record_dt': '$samplingDate $samplingTime', 'record_dt': '$samplingDate $samplingTime',
'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration, 'do_mgl': (oxygenConcentration ?? "NULL").toString(),
'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation, 'do_sat': (oxygenSaturation ?? "NULL").toString(),
'ph': ph == -999.0 ? null : ph, 'ph': (ph ?? "NULL").toString(),
'salinity': salinity == -999.0 ? null : salinity, 'salinity': (salinity ?? "NULL").toString(),
'temperature': temperature == -999.0 ? null : temperature, 'temperature': (temperature ?? "NULL").toString(),
'turbidity': turbidity == -999.0 ? null : turbidity, 'turbidity': (turbidity ?? "NULL").toString(),
'tds': tds == -999.0 ? null : tds, 'tds': (tds ?? "NULL").toString(),
'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity, 'electric_conductivity': (electricalConductivity ?? "NULL").toString(),
//'ammonia': ammonia == -999.0 ? null : ammonia, 'tss': (ammonia ?? "NULL").toString(),
'flowrate': flowrateValue, 'flowrate': (flowrateValue ?? "NULL").toString(),
'odour': '', // Not collected 'odour': '',
'floatable': '', // Not collected 'floatable': '',
'sample_id': sampleIdCode, 'sample_id': (sampleIdCode ?? "").toString(),
'weather': weather, 'weather': (weather ?? "").toString(),
'remarks_event': eventRemarks, 'remarks_event': (eventRemarks ?? "").toString(),
'remarks_lab': labRemarks, 'remarks_lab': (labRemarks ?? "").toString(),
'tarball': "No",
'tide_lvl': "",
'sea_cond': "",
}; };
//data.removeWhere((key, value) => value == null);
return jsonEncode(data); 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() { String toBasicFormJson() {
final data = { final data = {
// Keys sorted exactly according to the provided sample 'tech_name': (firstSamplerName ?? "").toString(),
'tech_name': firstSamplerName ?? "", 'sampler_2ndname': (secondSampler?['first_name'] ?? "").toString(),
'sampler_2ndname': secondSampler?['first_name'] ?? "", 'sample_date': (samplingDate ?? "").toString(),
'sample_date': samplingDate ?? "", 'sample_time': (samplingTime ?? "").toString(),
'sample_time': samplingTime ?? "", 'sampling_type': (samplingType ?? "").toString(),
'sampling_type': samplingType ?? "", 'sample_state': (selectedStateName ?? "").toString(),
'sample_state': selectedStateName ?? "", 'station_id': (selectedStation?['sampling_station_code'] ?? "").toString(),
'station_id': selectedStation?['sampling_station_code'] ?? "", 'station_latitude': (stationLatitude ?? "").toString(),
'station_latitude': stationLatitude ?? "", 'station_longitude': (stationLongitude ?? "").toString(),
'station_longitude': stationLongitude ?? "", 'latitude': (currentLatitude ?? "").toString(),
'latitude': currentLatitude ?? "", // Current user location lat 'longitude': (currentLongitude ?? "").toString(),
'longitude': currentLongitude ?? "", // Current user location lon 'sample_id': (sampleIdCode ?? "").toString(),
'sample_id': sampleIdCode ?? "",
}; };
// REMOVE or COMMENT OUT this line so no keys are deleted
// data.removeWhere((key, value) => value == null);
return jsonEncode(data); 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() { String toReadingJson() {
final data = { final data = {
// Use ?? operator to default to -999.0 if null 'do_mgl': (oxygenConcentration ?? "NULL").toString(),
'do_mgl': oxygenConcentration ?? -999.0, 'do_sat': (oxygenSaturation ?? "NULL").toString(),
'do_sat': oxygenSaturation ?? -999.0, 'ph': (ph ?? "NULL").toString(),
'ph': ph ?? -999.0, 'salinity': (salinity ?? "NULL").toString(),
'salinity': salinity ?? -999.0, 'temperature': (temperature ?? "NULL").toString(),
'temperature': temperature ?? -999.0, 'turbidity': (turbidity ?? "NULL").toString(),
'turbidity': turbidity ?? -999.0, 'tds': (tds ?? "NULL").toString(),
'tds': tds ?? -999.0, 'electric_conductivity': (electricalConductivity ?? "NULL").toString(),
'electric_conductivity': electricalConductivity ?? -999.0, 'tss': (ammonia ?? "NULL").toString(),
'flowrate': (flowrateValue ?? "NULL").toString(),
// Keep 'ammonia' commented out if it's not used in Triennial, 'date_sampling_reading': (dataCaptureDate ?? "").toString(),
// otherwise uncomment and use: 'ammonia': ammonia ?? -999.0, 'time_sampling_reading': (dataCaptureTime ?? "").toString(),
// 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 ?? "",
}; };
// REMOVE or COMMENT OUT this line so keys are NEVER deleted
// data.removeWhere((key, value) => value == null);
return jsonEncode(data); 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() { String toManualInfoJson() {
final data = { final data = {
// --- START FIX: Map model properties to correct manual info keys --- 'weather': (weather ?? "").toString(),
'weather': weather, 'remarks_event': (eventRemarks ?? "").toString(),
'remarks_event': eventRemarks, 'remarks_lab': (labRemarks ?? "").toString(),
'remarks_lab': labRemarks,
// --- END FIX ---
}; };
data.removeWhere((key, value) => value == null);
return jsonEncode(data); return jsonEncode(data);
} }
} }

View File

@ -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: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/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/auth_provider.dart';
import 'package:environment_monitoring_app/home_page.dart'; import 'package:environment_monitoring_app/home_page.dart';
@ -23,6 +24,7 @@ class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
bool _isLoading = false; bool _isLoading = false;
String _errorMessage = ''; String _errorMessage = '';
String _loadingMessage = ''; // To show dynamic status updates
@override @override
void dispose() { void dispose() {
@ -39,6 +41,7 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_errorMessage = ''; _errorMessage = '';
_loadingMessage = 'Authenticating...';
}); });
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
@ -58,10 +61,42 @@ class _LoginScreenState extends State<LoginScreen> {
// --- Online Success --- // --- Online Success ---
final String token = result['data']['token']; final String token = result['data']['token'];
final Map<String, dynamic> profile = result['data']['profile']; final Map<String, dynamic> profile = result['data']['profile'];
await auth.login(token, profile, password); await auth.login(token, profile, password);
// --- FIRST TIME VS SUBSEQUENT LOGIN LOGIC ---
if (auth.isFirstLogin) { 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; if (!mounted) return;
@ -72,6 +107,7 @@ class _LoginScreenState extends State<LoginScreen> {
// --- Online Failure (API Error) --- // --- Online Failure (API Error) ---
setState(() { setState(() {
_errorMessage = result['message'] ?? 'Invalid email or password.'; _errorMessage = result['message'] ?? 'Invalid email or password.';
_isLoading = false;
}); });
_showSnackBar(_errorMessage, isError: true); _showSnackBar(_errorMessage, isError: true);
} }
@ -86,21 +122,22 @@ class _LoginScreenState extends State<LoginScreen> {
_showSnackBar("Connection failed. Trying offline login...", isError: true); _showSnackBar("Connection failed. Trying offline login...", isError: true);
// FIX: Removed the unreliable connectivity check. Treat all exceptions here as a reason to try offline. // FIX: Removed the unreliable connectivity check. Treat all exceptions here as a reason to try offline.
await _attemptOfflineLogin(auth, email, password); 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. /// Helper function to perform offline validation and update UI.
Future<void> _attemptOfflineLogin(AuthProvider auth, String email, String password) async { Future<void> _attemptOfflineLogin(AuthProvider auth, String email, String password) async {
setState(() {
_loadingMessage = 'Verifying offline credentials...';
});
final bool offlineSuccess = await auth.loginOffline(email, password); final bool offlineSuccess = await auth.loginOffline(email, password);
if (mounted) { if (mounted) {
setState(() {
_isLoading = false;
});
if (offlineSuccess) { if (offlineSuccess) {
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomePage()), MaterialPageRoute(builder: (context) => const HomePage()),
@ -141,7 +178,7 @@ class _LoginScreenState extends State<LoginScreen> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
Text( Text(
"PSTW MMS V4", "PSTW MMS",
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@ -166,7 +203,7 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
], ],
image: const DecorationImage( 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 fit: BoxFit.cover, // Ensures the image fills the circle
), ),
), ),
@ -189,7 +226,17 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
_isLoading _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( : ElevatedButton(
onPressed: _login, onPressed: _login,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(

View File

@ -37,15 +37,15 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
late final TextEditingController _currentLatController; late final TextEditingController _currentLatController;
late final TextEditingController _currentLonController; late final TextEditingController _currentLonController;
// --- NEW: Controllers for 'New Location' --- // Controllers for 'New Location'
late final TextEditingController _newStationNameController; late final TextEditingController _newStationNameController;
late final TextEditingController _newStationCodeController; late final TextEditingController _newStationCodeController;
// --- NEW: State for Station Selection --- // State for Station Selection
String _stationType = 'Existing Manual Station'; String _stationType = 'Existing Manual Station';
final List<String> _stationTypeOptions = ['Existing Manual Station', 'Existing Tarball Station', 'New Location']; final List<String> _stationTypeOptions = ['Existing Manual Station', 'Existing Tarball Station', 'New Location'];
// --- Lists for Dropdowns --- // Lists for Dropdowns
List<String> _manualStatesList = []; List<String> _manualStatesList = [];
List<String> _categoriesForManualState = []; List<String> _categoriesForManualState = [];
List<Map<String, dynamic>> _stationsForManualCategory = []; List<Map<String, dynamic>> _stationsForManualCategory = [];
@ -107,7 +107,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
_stationType = widget.data.stationTypeSelection ?? 'Existing Manual Station'; _stationType = widget.data.stationTypeSelection ?? 'Existing Manual Station';
// --- Load Manual Station Data --- // Load Manual Station Data
final allManualStations = auth.manualStations ?? []; final allManualStations = auth.manualStations ?? [];
if (allManualStations.isNotEmpty) { if (allManualStations.isNotEmpty) {
_manualStatesList = allManualStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList()..sort(); _manualStatesList = allManualStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList()..sort();
@ -125,7 +125,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
} }
} }
// --- Load Tarball Station Data --- // Load Tarball Station Data
final allTarballStations = auth.tarballStations ?? []; final allTarballStations = auth.tarballStations ?? [];
if (allTarballStations.isNotEmpty) { if (allTarballStations.isNotEmpty) {
_tarballStatesList = allTarballStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList()..sort(); _tarballStatesList = allTarballStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList()..sort();
@ -137,14 +137,12 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
} }
} }
/// --- NEW: Handle Station Type Change ---
void _handleStationTypeChange(String? value) { void _handleStationTypeChange(String? value) {
if (value == null) return; if (value == null) return;
setState(() { setState(() {
_stationType = value; _stationType = value;
widget.data.stationTypeSelection = value; widget.data.stationTypeSelection = value;
// Clear all station-related data to avoid conflicts
widget.data.selectedManualStateName = null; widget.data.selectedManualStateName = null;
widget.data.selectedManualCategoryName = null; widget.data.selectedManualCategoryName = null;
widget.data.selectedStation = null; widget.data.selectedStation = null;
@ -171,23 +169,23 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false); final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
try { try {
final position = await service.getCurrentLocation(); final position = await service.getCurrentLocation();
if (mounted) { // FIX: Check if mounted after async location call to prevent framework crash
setState(() { if (!mounted) return;
widget.data.currentLatitude = position.latitude.toString();
widget.data.currentLongitude = position.longitude.toString();
_currentLatController.text = widget.data.currentLatitude!;
_currentLonController.text = widget.data.currentLongitude!;
// If 'New Location' is selected, also populate the station lat/lon setState(() {
if (_stationType == 'New Location') { widget.data.currentLatitude = position.latitude.toString();
widget.data.stationLatitude = widget.data.currentLatitude; widget.data.currentLongitude = position.longitude.toString();
widget.data.stationLongitude = widget.data.currentLongitude; _currentLatController.text = widget.data.currentLatitude!;
_stationLatController.text = widget.data.stationLatitude!; _currentLonController.text = widget.data.currentLongitude!;
_stationLonController.text = widget.data.stationLongitude!;
} if (_stationType == 'New Location') {
_calculateDistance(); 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) { } catch (e) {
if(mounted) { if(mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e'))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e')));
@ -219,12 +217,12 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
}); });
} else { } else {
setState(() { setState(() {
widget.data.distanceDifferenceInKm = null; // Clear distance if coords invalid widget.data.distanceDifferenceInKm = null;
}); });
} }
} else { } else {
setState(() { setState(() {
widget.data.distanceDifferenceInKm = null; // Clear distance if coords missing widget.data.distanceDifferenceInKm = null;
}); });
} }
} }
@ -234,6 +232,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
context, context,
MaterialPageRoute(builder: (context) => const SimpleBarcodeScannerPage()), MaterialPageRoute(builder: (context) => const SimpleBarcodeScannerPage()),
); );
// FIX: Ensure widget is still mounted before updating state after returning from navigation
if (result is String && result != '-1' && mounted) { if (result is String && result != '-1' && mounted) {
setState(() { setState(() {
widget.data.sampleIdCode = result; widget.data.sampleIdCode = result;
@ -242,10 +241,10 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
} }
} }
// --- Re-used from original for manual stations ---
Future<void> _findAndShowNearbyStations() async { Future<void> _findAndShowNearbyStations() async {
if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) { if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
await _getCurrentLocation(); await _getCurrentLocation();
// FIX: Standard async mount check
if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) { if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
return; return;
} }
@ -256,26 +255,24 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
final currentLat = double.parse(widget.data.currentLatitude!); final currentLat = double.parse(widget.data.currentLatitude!);
final currentLon = double.parse(widget.data.currentLongitude!); final currentLon = double.parse(widget.data.currentLongitude!);
final allStations = auth.manualStations ?? []; // Only search manual stations final allStations = auth.manualStations ?? [];
final List<Map<String, dynamic>> nearbyStations = []; final List<Map<String, dynamic>> nearbyStations = [];
for (var station in allStations) { for (var station in allStations) {
final stationLat = station['man_latitude']; final stationLat = station['man_latitude'];
final stationLon = station['man_longitude']; final stationLon = station['man_longitude'];
// Ensure coordinates are numbers before calculating distance
if (stationLat is num && stationLon is num) { if (stationLat is num && stationLon is num) {
final distance = service.calculateDistance(currentLat, currentLon, stationLat.toDouble(), stationLon.toDouble()); 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}); 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'])); nearbyStations.sort((a, b) => a['distance'].compareTo(b['distance']));
// FIX: Verify mount before showing dialog
if (!mounted) return; if (!mounted) return;
final selectedStation = await showDialog<Map<String, dynamic>>( final selectedStation = await showDialog<Map<String, dynamic>>(
@ -283,19 +280,17 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations), builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations),
); );
if (selectedStation != null) { // FIX: Verify mount after dialog closes
if (selectedStation != null && mounted) {
_updateFormWithSelectedStation(selectedStation); _updateFormWithSelectedStation(selectedStation);
} }
} }
// --- Re-used from original for manual stations ---
void _updateFormWithSelectedStation(Map<String, dynamic> station) { void _updateFormWithSelectedStation(Map<String, dynamic> station) {
final allStations = Provider.of<AuthProvider>(context, listen: false).manualStations ?? []; final allStations = Provider.of<AuthProvider>(context, listen: false).manualStations ?? [];
setState(() { setState(() {
// Update State
widget.data.selectedManualStateName = station['state_name']; widget.data.selectedManualStateName = station['state_name'];
// Update Category List based on new State
final categories = allStations final categories = allStations
.where((s) => s['state_name'] == widget.data.selectedManualStateName) .where((s) => s['state_name'] == widget.data.selectedManualStateName)
.map((s) => s['category_name'] as String?) .map((s) => s['category_name'] as String?)
@ -305,10 +300,8 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
categories.sort(); categories.sort();
_categoriesForManualState = categories; _categoriesForManualState = categories;
// Update Category
widget.data.selectedManualCategoryName = station['category_name']; widget.data.selectedManualCategoryName = station['category_name'];
// Update Station List based on new State and Category
_stationsForManualCategory = allStations _stationsForManualCategory = allStations
.where((s) => .where((s) =>
s['state_name'] == widget.data.selectedManualStateName && s['state_name'] == widget.data.selectedManualStateName &&
@ -316,14 +309,12 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
.toList() .toList()
..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? '')); ..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? ''));
// Update Selected Station and its coordinates
widget.data.selectedStation = station; widget.data.selectedStation = station;
widget.data.stationLatitude = station['man_latitude']?.toString(); widget.data.stationLatitude = station['man_latitude']?.toString();
widget.data.stationLongitude = station['man_longitude']?.toString(); widget.data.stationLongitude = station['man_longitude']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? ''; _stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? '';
// Recalculate distance
_calculateDistance(); _calculateDistance();
}); });
} }
@ -332,16 +323,12 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
_formKey.currentState!.save(); _formKey.currentState!.save();
// The distance check applies to all 3 types.
// For "New Location", it compares manually-entered Lat/Lon vs. Current Lat/Lon.
final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000; final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000;
// Only show remark dialog if distance is > 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) { if (distanceInMeters > 50 && widget.data.stationLatitude != null && widget.data.stationLongitude != null) {
_showDistanceRemarkDialog(); _showDistanceRemarkDialog();
} else { } else {
widget.data.distanceDifferenceRemarks = null; // Clear remarks if within limit widget.data.distanceDifferenceRemarks = null;
widget.onNext(); widget.onNext();
} }
} }
@ -364,7 +351,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text('Your current location is more than 50m away from the selected/entered station location.'), const Text('Your current location is more than 50m away from the station.'),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: remarkController, controller: remarkController,
@ -388,19 +375,18 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
child: const Text('Cancel'), child: const Text('Cancel'),
onPressed: () { onPressed: () => Navigator.of(context).pop(),
Navigator.of(context).pop();
},
), ),
FilledButton( FilledButton(
child: const Text('Confirm'), child: const Text('Confirm'),
onPressed: () { onPressed: () {
if (dialogFormKey.currentState!.validate()) { // FIX: Guard setState and navigation within dialog actions
if (dialogFormKey.currentState!.validate() && mounted) {
setState(() { setState(() {
widget.data.distanceDifferenceRemarks = remarkController.text; widget.data.distanceDifferenceRemarks = remarkController.text;
}); });
Navigator.of(context).pop(); Navigator.of(context).pop();
widget.onNext(); // Proceed after confirming remark widget.onNext();
} }
}, },
), ),
@ -452,7 +438,6 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// --- NEW: Station Type Selection ---
Text("Station Selection", style: Theme.of(context).textTheme.titleLarge), Text("Station Selection", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
@ -460,11 +445,10 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
items: _stationTypeOptions.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(), items: _stationTypeOptions.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(),
onChanged: _handleStationTypeChange, onChanged: _handleStationTypeChange,
decoration: const InputDecoration(labelText: 'Station Source *'), 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), const SizedBox(height: 16),
// --- NEW: Conditional Station Widgets ---
if (_stationType == 'Existing Manual Station') if (_stationType == 'Existing Manual Station')
_buildManualStationSelectors(auth.manualStations ?? []), _buildManualStationSelectors(auth.manualStations ?? []),
@ -474,7 +458,6 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
if (_stationType == 'New Location') if (_stationType == 'New Location')
_buildNewLocationFields(), _buildNewLocationFields(),
// --- Location Verification (Common to all) ---
const SizedBox(height: 24), const SizedBox(height: 24),
Text("Location Verification", style: Theme.of(context).textTheme.titleLarge), Text("Location Verification", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -514,13 +497,10 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
onPressed: _isLoadingLocation ? null : _getCurrentLocation, onPressed: _isLoadingLocation ? null : _getCurrentLocation,
icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching), icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching),
label: Text(_stationType == 'New Location' ? "Get & Use Current Location" : "Get Current Location"), label: Text(_stationType == 'New Location' ? "Get & Use Current Location" : "Get Current Location"),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
padding: const EdgeInsets.symmetric(vertical: 12), // Consistent padding
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// --- Sample ID (Common to all) ---
TextFormField( TextFormField(
controller: _barcodeController, controller: _barcodeController,
decoration: InputDecoration( decoration: InputDecoration(
@ -532,7 +512,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
), ),
validator: (val) => val == null || val.isEmpty ? "Sample ID is required" : null, validator: (val) => val == null || val.isEmpty ? "Sample ID is required" : null,
onSaved: (val) => widget.data.sampleIdCode = val, 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), const SizedBox(height: 32),
ElevatedButton( ElevatedButton(
@ -540,13 +520,12 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Next'), child: const Text('Next'),
), ),
const SizedBox(height: 16), // Add padding at the bottom const SizedBox(height: 16),
], ],
), ),
); );
} }
/// --- Widget builder for Manual Station selection ---
Widget _buildManualStationSelectors(List<Map<String, dynamic>> allStations) { Widget _buildManualStationSelectors(List<Map<String, dynamic>> allStations) {
return Column( return Column(
children: [ children: [
@ -563,22 +542,16 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
_stationLatController.clear(); _stationLatController.clear();
_stationLonController.clear(); _stationLonController.clear();
widget.data.distanceDifferenceInKm = null; widget.data.distanceDifferenceInKm = null;
// --- CORRECTED LOGIC ---
if (state != null) { if (state != null) {
_categoriesForManualState = allStations _categoriesForManualState = allStations
.where((s) => s['state_name'] == state) .where((s) => s['state_name'] == state)
.map((s) => s['category_name'] as String?) .map((s) => s['category_name'] as String?)
.whereType<String>() .whereType<String>()
.toSet() .toSet().toList()..sort();
.toList();
_categoriesForManualState.sort(); // Sort after creating the list
} else { } else {
_categoriesForManualState = <String>[]; _categoriesForManualState = <String>[];
} }
// --- END CORRECTION --- _stationsForManualCategory = [];
_stationsForManualCategory = []; // Clear stations list
}); });
}, },
validator: (val) => val == null ? "State is required" : null, validator: (val) => val == null ? "State is required" : null,
@ -597,17 +570,13 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
_stationLatController.clear(); _stationLatController.clear();
_stationLonController.clear(); _stationLonController.clear();
widget.data.distanceDifferenceInKm = null; widget.data.distanceDifferenceInKm = null;
// --- CORRECTED LOGIC (Similar structure) ---
if (category != null) { if (category != null) {
_stationsForManualCategory = allStations _stationsForManualCategory = allStations
.where((s) => s['state_name'] == widget.data.selectedManualStateName && s['category_name'] == category) .where((s) => s['state_name'] == widget.data.selectedManualStateName && s['category_name'] == category)
.toList(); .toList()..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? ''));
_stationsForManualCategory.sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? '')); // Sort after creating
} else { } else {
_stationsForManualCategory = []; _stationsForManualCategory = [];
} }
// --- END CORRECTION ---
}); });
}, },
validator: (val) => widget.data.selectedManualStateName != null && val == null ? "Category is required" : null, validator: (val) => widget.data.selectedManualStateName != null && val == null ? "Category is required" : null,
@ -626,7 +595,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
widget.data.stationLongitude = station?['man_longitude']?.toString(); widget.data.stationLongitude = station?['man_longitude']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? ''; _stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? '';
_calculateDistance(); // Recalculate distance when station changes _calculateDistance();
}), }),
validator: (val) => widget.data.selectedManualCategoryName != null && val == null ? "Station is required" : null, validator: (val) => widget.data.selectedManualCategoryName != null && val == null ? "Station is required" : null,
), ),
@ -645,7 +614,6 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
); );
} }
/// --- Widget builder for Tarball Station selection ---
Widget _buildTarballStationSelectors(List<Map<String, dynamic>> allStations) { Widget _buildTarballStationSelectors(List<Map<String, dynamic>> allStations) {
return Column( return Column(
children: [ children: [
@ -661,17 +629,13 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
_stationLatController.clear(); _stationLatController.clear();
_stationLonController.clear(); _stationLonController.clear();
widget.data.distanceDifferenceInKm = null; widget.data.distanceDifferenceInKm = null;
// --- CORRECTED LOGIC ---
if (state != null) { if (state != null) {
_stationsForTarballState = allStations _stationsForTarballState = allStations
.where((s) => s['state_name'] == state) .where((s) => s['state_name'] == state)
.toList(); .toList()..sort((a, b) => (a['tbl_station_code'] ?? '').compareTo(b['tbl_station_code'] ?? ''));
_stationsForTarballState.sort((a, b) => (a['tbl_station_code'] ?? '').compareTo(b['tbl_station_code'] ?? '')); // Sort after creating
} else { } else {
_stationsForTarballState = <Map<String, dynamic>>[]; _stationsForTarballState = <Map<String, dynamic>>[];
} }
// --- END CORRECTION ---
}); });
}, },
validator: (val) => val == null ? "State is required" : null, validator: (val) => val == null ? "State is required" : null,
@ -690,7 +654,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
widget.data.stationLongitude = station?['tbl_longitude']?.toString(); widget.data.stationLongitude = station?['tbl_longitude']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? ''; _stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? '';
_calculateDistance(); // Recalculate distance when station changes _calculateDistance();
}), }),
validator: (val) => widget.data.selectedTarballStateName != null && val == null ? "Station is required" : null, validator: (val) => widget.data.selectedTarballStateName != null && val == null ? "Station is required" : null,
), ),
@ -702,7 +666,6 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
); );
} }
/// --- Widget builder for New Location fields ---
Widget _buildNewLocationFields() { Widget _buildNewLocationFields() {
return Column( return Column(
children: [ children: [
@ -711,7 +674,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
decoration: const InputDecoration(labelText: 'New Station Name *'), decoration: const InputDecoration(labelText: 'New Station Name *'),
validator: (val) => val == null || val.isEmpty ? "Station Name is required" : null, validator: (val) => val == null || val.isEmpty ? "Station Name is required" : null,
onSaved: (val) => widget.data.newStationName = val, 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), const SizedBox(height: 16),
TextFormField( TextFormField(
@ -719,7 +682,7 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
decoration: const InputDecoration(labelText: 'New Station Code *', hintText: "e.g., INV-001"), decoration: const InputDecoration(labelText: 'New Station Code *', hintText: "e.g., INV-001"),
validator: (val) => val == null || val.isEmpty ? "Station Code is required" : null, validator: (val) => val == null || val.isEmpty ? "Station Code is required" : null,
onSaved: (val) => widget.data.newStationCode = val, 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), const SizedBox(height: 16),
TextFormField( TextFormField(
@ -734,8 +697,8 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
}, },
onSaved: (val) => widget.data.stationLatitude = val, onSaved: (val) => widget.data.stationLatitude = val,
onChanged: (val) { onChanged: (val) {
widget.data.stationLatitude = val; // Update data model on change widget.data.stationLatitude = val;
_calculateDistance(); // Recalculate distance when manually changed _calculateDistance();
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -751,8 +714,8 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
}, },
onSaved: (val) => widget.data.stationLongitude = val, onSaved: (val) => widget.data.stationLongitude = val,
onChanged: (val) { onChanged: (val) {
widget.data.stationLongitude = val; // Update data model on change widget.data.stationLongitude = val;
_calculateDistance(); // Recalculate distance when manually changed _calculateDistance();
}, },
), ),
], ],
@ -760,7 +723,6 @@ class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualSt
} }
} }
// --- Re-used Dialog Widget for Nearby Stations ---
class _NearbyStationsDialog extends StatelessWidget { class _NearbyStationsDialog extends StatelessWidget {
final List<Map<String, dynamic>> nearbyStations; final List<Map<String, dynamic>> nearbyStations;
@ -773,7 +735,7 @@ class _NearbyStationsDialog extends StatelessWidget {
content: SizedBox( content: SizedBox(
width: double.maxFinite, width: double.maxFinite,
child: nearbyStations.isEmpty 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( : ListView.builder(
shrinkWrap: true, shrinkWrap: true,
itemCount: nearbyStations.length, itemCount: nearbyStations.length,
@ -783,13 +745,13 @@ class _NearbyStationsDialog extends StatelessWidget {
final distanceInMeters = (item['distance'] as double) * 1000; final distanceInMeters = (item['distance'] as double) * 1000;
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0), // Add vertical margin margin: const EdgeInsets.symmetric(vertical: 4.0),
child: ListTile( child: ListTile(
title: Text("${station['man_station_code'] ?? 'N/A'}"), title: Text("${station['man_station_code'] ?? 'N/A'}"),
subtitle: Text("${station['man_station_name'] ?? 'N/A'}"), subtitle: Text("${station['man_station_name'] ?? 'N/A'}"),
trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"), trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"),
onTap: () { onTap: () {
Navigator.of(context).pop(station); // Return selected station Navigator.of(context).pop(station);
}, },
), ),
); );
@ -798,7 +760,7 @@ class _NearbyStationsDialog extends StatelessWidget {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), // Return null on cancel onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
], ],

View File

@ -1,7 +1,7 @@
// lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart // lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; // <-- ADDED: Required for Uint8List import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -27,7 +27,7 @@ class MarineInvesManualStep2SiteInfo extends StatefulWidget {
class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2SiteInfo> { class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2SiteInfo> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false; // <-- ADDED: State variable from in-situ bool _isPickingImage = false;
late final TextEditingController _eventRemarksController; late final TextEditingController _eventRemarksController;
late final TextEditingController _labRemarksController; late final TextEditingController _labRemarksController;
@ -50,7 +50,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
super.dispose(); super.dispose();
} }
// --- START: MODIFIED _setImage function (from in-situ) ---
/// Handles picking and processing an image using the dedicated service. /// Handles picking and processing an image using the dedicated service.
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async { void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return; if (_isPickingImage) return;
@ -60,21 +59,19 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
// Always pass `isRequired: true` to the service to enforce landscape check // Always pass `isRequired: true` to the service to enforce landscape check
// and watermarking for ALL photos (required or optional). // and watermarking for ALL photos (required or optional).
// The 'isRequired' param is just for the UI text.
final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: true); final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: true);
// --- FIX: Check if mounted after async call to prevent framework crash ---
if (!mounted) return;
if (file != null) { if (file != null) {
setState(() => setImageCallback(file)); setState(() => setImageCallback(file));
} else if (mounted) { } else {
// Corrected snackbar message
_showSnackBar('Image selection failed. Please ensure all photos are taken in landscape (horizontal) mode.', isError: true); _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. /// Validates the form and all required images before proceeding.
void _goToNextStep() { void _goToNextStep() {
@ -86,7 +83,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
return; return;
} }
// Form validation handles the conditional requirement for Event Remarks
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) {
return; return;
} }
@ -106,7 +102,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Logic to determine if Event Remarks are required
final bool areAdditionalPhotosAttached = widget.data.phPaperImage != null || final bool areAdditionalPhotosAttached = widget.data.phPaperImage != null ||
widget.data.optionalImage1 != null || widget.data.optionalImage1 != null ||
widget.data.optionalImage2 != null || widget.data.optionalImage2 != null ||
@ -118,7 +113,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
child: ListView( child: ListView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
children: [ children: [
// --- Section: On-Site Information ---
Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall), Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24), const SizedBox(height: 24),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
@ -146,9 +140,7 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// --- Section: Required Photos ---
Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge), Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge),
// MODIFIED: Matched in-situ text
const Text( const Text(
"All photos must be in landscape (horizontal) orientation. A watermark will be applied automatically.", "All photos must be in landscape (horizontal) orientation. A watermark will be applied automatically.",
style: TextStyle(color: Colors.grey) style: TextStyle(color: Colors.grey)
@ -160,7 +152,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
_buildImagePicker('Seawater in Clear Glass Bottle', 'SEAWATER_COLOR', widget.data.seawaterColorImage, (file) => widget.data.seawaterColorImage = file, isRequired: true), _buildImagePicker('Seawater in Clear Glass Bottle', 'SEAWATER_COLOR', widget.data.seawaterColorImage, (file) => widget.data.seawaterColorImage = file, isRequired: true),
const SizedBox(height: 24), const SizedBox(height: 24),
// --- Section: Additional photos and conditional remarks ---
Text("Additional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge), Text("Additional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildImagePicker('Examine Preservative (pH paper)', 'PH_PAPER', widget.data.phPaperImage, (file) => widget.data.phPaperImage = file, isRequired: false), _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<MarineInvesManualStep2S
Text("Remarks", style: Theme.of(context).textTheme.titleLarge), Text("Remarks", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
// Event Remarks field is conditionally required
TextFormField( TextFormField(
controller: _eventRemarksController, controller: _eventRemarksController,
decoration: InputDecoration( decoration: InputDecoration(
@ -206,8 +196,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
); );
} }
// --- START: MODIFIED _buildImagePicker (from in-situ) ---
/// A reusable widget for picking and displaying an image.
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {bool isRequired = false}) { Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {bool isRequired = false}) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
@ -223,7 +211,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8.0), borderRadius: BorderRadius.circular(8.0),
child: FutureBuilder<Uint8List>( child: FutureBuilder<Uint8List>(
// Use ValueKey to ensure FutureBuilder refetches when the file path changes
key: ValueKey(imageFile.path), key: ValueKey(imageFile.path),
future: imageFile.readAsBytes(), future: imageFile.readAsBytes(),
builder: (context, snapshot) { builder: (context, snapshot) {
@ -243,7 +230,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
child: const Icon(Icons.error, color: Colors.red, size: 40), child: const Icon(Icons.error, color: Colors.red, size: 40),
); );
} }
// Display the image from memory
return Image.memory( return Image.memory(
snapshot.data!, snapshot.data!,
height: 150, height: 150,
@ -276,5 +262,4 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
), ),
); );
} }
// --- END: MODIFIED _buildImagePicker ---
} }

View File

@ -9,8 +9,8 @@ import 'package:usb_serial/usb_serial.dart';
import '../../../../auth_provider.dart'; import '../../../../auth_provider.dart';
import '../../../../models/marine_inves_manual_sampling_data.dart'; import '../../../../models/marine_inves_manual_sampling_data.dart';
import '../../../../services/marine_investigative_sampling_service.dart'; import '../../../../services/marine_investigative_sampling_service.dart';
import '../../../../bluetooth/bluetooth_manager.dart'; // For connection state enum import '../../../../bluetooth/bluetooth_manager.dart';
import '../../../../serial/serial_manager.dart'; // For connection state enum import '../../../../serial/serial_manager.dart';
import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart'; import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart';
import '../../../../serial/widget/serial_port_list_dialog.dart'; import '../../../../serial/widget/serial_port_list_dialog.dart';
@ -177,12 +177,16 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
Future<void> _handleConnectionAttempt(String type) async { Future<void> _handleConnectionAttempt(String type) async {
final service = context.read<MarineInvestigativeSamplingService>(); final service = context.read<MarineInvestigativeSamplingService>();
final hasPermissions = await service.requestDevicePermissions(); final hasPermissions = await service.requestDevicePermissions();
if (!mounted) return;
if (!hasPermissions && mounted) { if (!hasPermissions && mounted) {
_showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true);
return; return;
} }
_disconnectFromAll(); _disconnectFromAll();
await Future.delayed(const Duration(milliseconds: 250)); await Future.delayed(const Duration(milliseconds: 250));
if (!mounted) return;
final bool connectionSuccess = await _connectToDevice(type); final bool connectionSuccess = await _connectToDevice(type);
if (connectionSuccess && mounted) { if (connectionSuccess && mounted) {
_dataSubscription?.cancel(); _dataSubscription?.cancel();
@ -200,6 +204,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
try { try {
if (type == 'bluetooth') { if (type == 'bluetooth') {
final devices = await service.getPairedBluetoothDevices(); final devices = await service.getPairedBluetoothDevices();
if (!mounted) return false;
if (devices.isEmpty && mounted) { if (devices.isEmpty && mounted) {
_showSnackBar('No paired Bluetooth devices found.', isError: true); _showSnackBar('No paired Bluetooth devices found.', isError: true);
return false; return false;
@ -211,6 +216,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
} }
} else if (type == 'serial') { } else if (type == 'serial') {
final devices = await service.getAvailableSerialDevices(); final devices = await service.getAvailableSerialDevices();
if (!mounted) return false;
if (devices.isEmpty && mounted) { if (devices.isEmpty && mounted) {
_showSnackBar('No USB Serial devices found.', isError: true); _showSnackBar('No USB Serial devices found.', isError: true);
return false; return false;
@ -337,7 +343,6 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
}); });
} }
// --- START: MODIFIED _validateAndProceed ---
void _validateAndProceed() { void _validateAndProceed() {
if (_isLockedOut) { if (_isLockedOut) {
_showSnackBar("Please wait for the initial reading period to complete.", isError: true); _showSnackBar("Please wait for the initial reading period to complete.", isError: true);
@ -356,8 +361,6 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
final currentReadings = _captureReadingsToMap(); final currentReadings = _captureReadingsToMap();
List<Map<String, dynamic>> outOfBoundsParams = []; List<Map<String, dynamic>> outOfBoundsParams = [];
// --- NEW CONDITIONAL LOGIC ---
// Only check limits if it's a Manual Station
if (widget.data.stationTypeSelection == 'Existing Manual Station') { if (widget.data.stationTypeSelection == 'Existing Manual Station') {
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final marineLimits = authProvider.marineParameterLimits ?? []; final marineLimits = authProvider.marineParameterLimits ?? [];
@ -367,12 +370,10 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
_outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet(); _outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet();
}); });
} else { } else {
// If not a manual station, ensure any old highlights are cleared
setState(() { setState(() {
_outOfBoundsKeys.clear(); _outOfBoundsKeys.clear();
}); });
} }
// --- END NEW CONDITIONAL LOGIC ---
if (outOfBoundsParams.isNotEmpty) { if (outOfBoundsParams.isNotEmpty) {
_showParameterLimitDialog(outOfBoundsParams, currentReadings); _showParameterLimitDialog(outOfBoundsParams, currentReadings);
@ -380,7 +381,6 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
_saveDataAndMoveOn(currentReadings); _saveDataAndMoveOn(currentReadings);
} }
} }
// --- END: MODIFIED _validateAndProceed ---
Map<String, double> _captureReadingsToMap() { Map<String, double> _captureReadingsToMap() {
final Map<String, double> readings = {}; final Map<String, double> readings = {};
@ -394,15 +394,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) { List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) {
final List<Map<String, dynamic>> invalidParams = []; final List<Map<String, dynamic>> invalidParams = [];
final dynamic stationId = widget.data.selectedStation?['station_id'] ?? widget.data.selectedStation?['man_station_id'];
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')");
double? _parseLimitValue(dynamic value) { double? _parseLimitValue(dynamic value) {
if (value == null) return null; if (value == null) return null;
@ -417,23 +409,17 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
final limitName = _parameterKeyToLimitName[key]; final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return; if (limitName == null) return;
debugPrint("Checking parameter: '$limitName' (key: '$key')");
Map<String, dynamic> limitData = {}; Map<String, dynamic> limitData = {};
if (stationId != null) { if (stationId != null) {
limitData = limits.firstWhere( 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: () => {}, 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) { if (limitData.isNotEmpty) {
final lowerLimit = _parseLimitValue(limitData['param_lower_limit']); final lowerLimit = _parseLimitValue(limitData['param_lower_limit']);
final upperLimit = _parseLimitValue(limitData['param_upper_limit']); final upperLimit = _parseLimitValue(limitData['param_upper_limit']);
@ -450,8 +436,6 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
} }
}); });
debugPrint("--- Parameter Validation End ---");
return invalidParams; return invalidParams;
} }
@ -531,9 +515,9 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
onWillPop: () async { onWillPop: () async {
if (_isLockedOut) { if (_isLockedOut) {
_showSnackBar("Please wait for the initial reading period to complete.", isError: true); _showSnackBar("Please wait for the initial reading period to complete.", isError: true);
return false; // Prevent back navigation return false;
} }
return true; // Allow back navigation return true;
}, },
child: Form( child: Form(
key: _formKey, key: _formKey,
@ -832,7 +816,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
ElevatedButton.icon( ElevatedButton.icon(
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
label: Text(_isAutoReading label: Text(_isAutoReading
? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading') ? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\\s)' : 'Stop Reading')
: 'Start Reading'), : 'Start Reading'),
onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type), onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(

View File

@ -178,14 +178,14 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text("NPE Parameter Limit Detected"), title: const Text("Notification Pollution Event (NPE 1) Limit Detected"),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( 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), const SizedBox(height: 16),
Table( Table(
columnWidths: const { columnWidths: const {

View File

@ -77,7 +77,7 @@ class _MarineManualPreDepartureChecklistScreenState
// Iterate through the map structure to initialize data // Iterate through the map structure to initialize data
_checklistSections.forEach((section, items) { _checklistSections.forEach((section, items) {
for (var item in items) { for (var item in items) {
_data.checklistItems[item] = true; // MODIFIED: Default to 'Yes' _data.checklistItems[item] = false; // MODIFIED: Default to 'No' (false)
_data.remarks[item] = ''; _data.remarks[item] = '';
_remarksVisibility[item] = false; _remarksVisibility[item] = false;
} }

View File

@ -128,15 +128,16 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
try { try {
final position = await service.getCurrentLocation(); final position = await service.getCurrentLocation();
if (mounted) { // --- FIX: Check if mounted after async call ---
setState(() { if (!mounted) return;
widget.data.currentLatitude = position.latitude.toString();
widget.data.currentLongitude = position.longitude.toString(); setState(() {
_currentLatController.text = widget.data.currentLatitude!; widget.data.currentLatitude = position.latitude.toString();
_currentLonController.text = widget.data.currentLongitude!; widget.data.currentLongitude = position.longitude.toString();
_calculateDistance(); _currentLatController.text = widget.data.currentLatitude!;
}); _currentLonController.text = widget.data.currentLongitude!;
} _calculateDistance();
});
} catch (e) { } catch (e) {
if(mounted) { if(mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e'))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e')));
@ -175,6 +176,7 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
context, context,
MaterialPageRoute(builder: (context) => const SimpleBarcodeScannerPage()), MaterialPageRoute(builder: (context) => const SimpleBarcodeScannerPage()),
); );
// --- FIX: Check if mounted after returning from a new route ---
if (result is String && result != '-1' && mounted) { if (result is String && result != '-1' && mounted) {
setState(() { setState(() {
widget.data.sampleIdCode = result; widget.data.sampleIdCode = result;
@ -187,6 +189,7 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
Future<void> _findAndShowNearbyStations() async { Future<void> _findAndShowNearbyStations() async {
if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) { if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
await _getCurrentLocation(); await _getCurrentLocation();
// --- FIX: Ensure we are still active after location fetch ---
if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) { if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
return; return;
} }
@ -221,7 +224,8 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations), builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations),
); );
if (selectedStation != null) { // --- FIX: Verify mounted state after dialog closure ---
if (selectedStation != null && mounted) {
_updateFormWithSelectedStation(selectedStation); _updateFormWithSelectedStation(selectedStation);
} }
} }
@ -338,11 +342,14 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
child: const Text('Confirm'), child: const Text('Confirm'),
onPressed: () { onPressed: () {
if (dialogFormKey.currentState!.validate()) { if (dialogFormKey.currentState!.validate()) {
setState(() { // --- FIX: Ensure mounted check inside dialog action ---
widget.data.distanceDifferenceRemarks = remarkController.text; if (mounted) {
}); setState(() {
Navigator.of(context).pop(); widget.data.distanceDifferenceRemarks = remarkController.text;
widget.onNext(); });
Navigator.of(context).pop();
widget.onNext();
}
} }
}, },
), ),
@ -610,4 +617,3 @@ class _NearbyStationsDialog extends StatelessWidget {
); );
} }
} }
// --- END: New Dialog Widget ---

View File

@ -111,6 +111,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
// --- FIX: Check if mounted before performing logic or calling setState ---
if (mounted) { if (mounted) {
// Use the member variable, not context // Use the member variable, not context
final service = _samplingService; final service = _samplingService;
@ -196,12 +197,16 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
Future<void> _handleConnectionAttempt(String type) async { Future<void> _handleConnectionAttempt(String type) async {
final service = context.read<MarineInSituSamplingService>(); final service = context.read<MarineInSituSamplingService>();
final hasPermissions = await service.requestDevicePermissions(); final hasPermissions = await service.requestDevicePermissions();
if (!mounted) return; // --- FIX: Prevent context usage if unmounted ---
if (!hasPermissions && mounted) { if (!hasPermissions && mounted) {
_showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true);
return; return;
} }
_disconnectFromAll(); _disconnectFromAll();
await Future.delayed(const Duration(milliseconds: 250)); await Future.delayed(const Duration(milliseconds: 250));
if (!mounted) return; // --- FIX ---
final bool connectionSuccess = await _connectToDevice(type); final bool connectionSuccess = await _connectToDevice(type);
if (connectionSuccess && mounted) { if (connectionSuccess && mounted) {
_dataSubscription?.cancel(); _dataSubscription?.cancel();
@ -219,6 +224,8 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
try { try {
if (type == 'bluetooth') { if (type == 'bluetooth') {
final devices = await service.getPairedBluetoothDevices(); final devices = await service.getPairedBluetoothDevices();
if (!mounted) return false; // --- FIX ---
if (devices.isEmpty && mounted) { if (devices.isEmpty && mounted) {
_showSnackBar('No paired Bluetooth devices found.', isError: true); _showSnackBar('No paired Bluetooth devices found.', isError: true);
return false; return false;
@ -230,6 +237,8 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
} }
} else if (type == 'serial') { } else if (type == 'serial') {
final devices = await service.getAvailableSerialDevices(); final devices = await service.getAvailableSerialDevices();
if (!mounted) return false; // --- FIX ---
if (devices.isEmpty && mounted) { if (devices.isEmpty && mounted) {
_showSnackBar('No USB Serial devices found.', isError: true); _showSnackBar('No USB Serial devices found.', isError: true);
return false; return false;
@ -409,20 +418,13 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) { List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) {
final List<Map<String, dynamic>> invalidParams = []; final List<Map<String, dynamic>> 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("--- Parameter Validation Start ---");
debugPrint("Selected Station ID: $stationId"); debugPrint("Selected Station ID: $stationId");
debugPrint("Total Marine Limits Loaded: ${limits.length}"); 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) { double? _parseLimitValue(dynamic value) {
if (value == null) return null; if (value == null) return null;
if (value is num) return value.toDouble(); if (value is num) return value.toDouble();
@ -441,21 +443,17 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
Map<String, dynamic> limitData = {}; Map<String, dynamic> limitData = {};
if (stationId != null) { if (stationId != null) {
// --- START FIX: Use type-safe comparison --- // --- START FIX: Use robust string comparison to handle data type differences ---
limitData = limits.firstWhere( 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: () => {}, orElse: () => {},
); );
// --- END FIX --- // --- END FIX ---
} }
if (limitData.isNotEmpty) { if (limitData.isNotEmpty) {
debugPrint(" > Found station-specific limit for Station ID $stationId: $limitData"); debugPrint(" > Found station-specific limit: $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 lowerLimit = _parseLimitValue(limitData['param_lower_limit']);
final upperLimit = _parseLimitValue(limitData['param_upper_limit']); final upperLimit = _parseLimitValue(limitData['param_upper_limit']);
@ -468,6 +466,8 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
'upper_limit': upperLimit, 'upper_limit': upperLimit,
}); });
} }
} else {
debugPrint(" > No station-specific limit found for $limitName.");
} }
}); });

View File

@ -83,11 +83,13 @@ class _InSituStep4SummaryState extends State<InSituStep4Summary> {
final limitName = _parameterKeyToLimitName[key]; final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return; 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( final limitData = marineLimits.firstWhere(
(l) => (l) =>
l['param_parameter_list'] == limitName && 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: () => {}, orElse: () => {},
); );
@ -179,14 +181,14 @@ class _InSituStep4SummaryState extends State<InSituStep4Summary> {
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text("NPE Parameter Limit Detected"), title: const Text("Notification Pollution Event (NPE 1) Limit Detected"),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( 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), const SizedBox(height: 16),
Table( Table(
columnWidths: const { columnWidths: const {

View File

@ -7,8 +7,8 @@ import 'package:intl/intl.dart';
import 'package:simple_barcode_scanner/simple_barcode_scanner.dart'; import 'package:simple_barcode_scanner/simple_barcode_scanner.dart';
import '../../../../auth_provider.dart'; import '../../../../auth_provider.dart';
import '../../../../models/river_inves_manual_sampling_data.dart'; // Updated model import '../../../../models/river_inves_manual_sampling_data.dart';
import '../../../../services/river_investigative_sampling_service.dart'; // Updated service import '../../../../services/river_investigative_sampling_service.dart';
class RiverInvesStep1SamplingInfo extends StatefulWidget { class RiverInvesStep1SamplingInfo extends StatefulWidget {
final RiverInvesManualSamplingData data; final RiverInvesManualSamplingData data;
@ -29,6 +29,9 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isLoadingLocation = false; bool _isLoadingLocation = false;
// New flag to track if user manually typed the location
bool _isManualLocationEntry = false;
late final TextEditingController _firstSamplerController; late final TextEditingController _firstSamplerController;
late final TextEditingController _dateController; late final TextEditingController _dateController;
late final TextEditingController _timeController; late final TextEditingController _timeController;
@ -52,7 +55,6 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
'Existing Triennial Station', 'Existing Triennial Station',
'New Location' 'New Location'
]; ];
// Note: Investigative sampling type is fixed in the model
@override @override
void initState() { void initState() {
@ -109,9 +111,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
_dateController.text = widget.data.samplingDate!; _dateController.text = widget.data.samplingDate!;
_timeController.text = widget.data.samplingTime!; _timeController.text = widget.data.samplingTime!;
// Sampling type is fixed to Investigative in the model // Populate states list logic (same as before)
// Populate states list from Manual stations (assuming they cover all states)
final allManualStations = auth.riverManualStations ?? []; final allManualStations = auth.riverManualStations ?? [];
if (allManualStations.isNotEmpty) { if (allManualStations.isNotEmpty) {
final states = allManualStations final states = allManualStations
@ -124,18 +124,16 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
_statesList = states; _statesList = states;
}); });
} else { } else {
// Fallback: If no manual stations, try getting states from Triennial or general States list
final allTriennialStations = auth.riverTriennialStations ?? []; final allTriennialStations = auth.riverTriennialStations ?? [];
if (allTriennialStations.isNotEmpty) { if (allTriennialStations.isNotEmpty) {
final states = allTriennialStations final states = allTriennialStations
.map((s) => s['state_name'] as String?) // Assuming Triennial has state_name .map((s) => s['state_name'] as String?)
.whereType<String>() .whereType<String>()
.toSet() .toSet()
.toList(); .toList();
states.sort(); states.sort();
setState(() { _statesList = states; }); setState(() { _statesList = states; });
} else { } else {
// Further fallback
final generalStates = auth.states ?? []; final generalStates = auth.states ?? [];
final states = generalStates final states = generalStates
.map((s) => s['state_name'] as String?) .map((s) => s['state_name'] as String?)
@ -147,10 +145,8 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
} }
} }
// Pre-load stations if state and type are already selected (e.g., coming back to step)
_loadStationsForSelectedState(); _loadStationsForSelectedState();
_calculateDistance(); // Recalculate distance on init _calculateDistance();
} }
void _loadStationsForSelectedState() { void _loadStationsForSelectedState() {
@ -168,7 +164,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
.compareTo(b['sampling_station_code'] ?? '')); .compareTo(b['sampling_station_code'] ?? ''));
_triennialStationsForState = allTriennialStations _triennialStationsForState = allTriennialStations
.where((s) => s['state_name'] == widget.data.selectedStateName) // Assuming Triennial has state_name .where((s) => s['state_name'] == widget.data.selectedStateName)
.toList() .toList()
..sort((a, b) => (a['triennial_station_code'] ?? '') ..sort((a, b) => (a['triennial_station_code'] ?? '')
.compareTo(b['triennial_station_code'] ?? '')); .compareTo(b['triennial_station_code'] ?? ''));
@ -183,21 +179,23 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
final position = await service.getCurrentLocation(); final position = await service.getCurrentLocation();
if (mounted) { if (mounted) {
setState(() { setState(() {
// Reset manual entry flag because we successfully used GPS
_isManualLocationEntry = false;
widget.data.currentLatitude = position.latitude.toString(); widget.data.currentLatitude = position.latitude.toString();
widget.data.currentLongitude = position.longitude.toString(); widget.data.currentLongitude = position.longitude.toString();
_currentLatController.text = widget.data.currentLatitude!; _currentLatController.text = widget.data.currentLatitude!;
_currentLonController.text = widget.data.currentLongitude!; _currentLonController.text = widget.data.currentLongitude!;
// --- MODIFICATION: Update station lat/lon ONLY if 'New Location' --- // If 'New Location', update station lat/lon as well
if (widget.data.stationTypeSelection == 'New Location') { if (widget.data.stationTypeSelection == 'New Location') {
widget.data.stationLatitude = position.latitude.toString(); widget.data.stationLatitude = position.latitude.toString();
widget.data.stationLongitude = position.longitude.toString(); widget.data.stationLongitude = position.longitude.toString();
_stationLatController.text = widget.data.stationLatitude!; _stationLatController.text = widget.data.stationLatitude!;
_stationLonController.text = widget.data.stationLongitude!; _stationLonController.text = widget.data.stationLongitude!;
} }
// --- END MODIFICATION ---
_calculateDistance(); // Always calculate distance after getting current location _calculateDistance();
}); });
} }
} catch (e) { } catch (e) {
@ -211,7 +209,6 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
} }
} }
void _calculateDistance() { void _calculateDistance() {
final lat1Str = widget.data.stationLatitude; final lat1Str = widget.data.stationLatitude;
final lon1Str = widget.data.stationLongitude; final lon1Str = widget.data.stationLongitude;
@ -251,9 +248,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
} }
} }
// --- MODIFICATION: Disable Nearby Station for now, or adapt later ---
Future<void> _findAndShowNearbyStations() async { Future<void> _findAndShowNearbyStations() async {
// Only works for Manual Stations currently
if (widget.data.stationTypeSelection != 'Existing Manual Station') { if (widget.data.stationTypeSelection != 'Existing Manual Station') {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Nearby station search only available for Manual Stations.'))); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Nearby station search only available for Manual Stations.')));
return; return;
@ -269,9 +264,12 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
final service = Provider.of<RiverInvestigativeSamplingService>(context, listen: false); final service = Provider.of<RiverInvestigativeSamplingService>(context, listen: false);
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
final currentLat = double.parse(widget.data.currentLatitude!); final currentLat = double.tryParse(widget.data.currentLatitude ?? '');
final currentLon = double.parse(widget.data.currentLongitude!); final currentLon = double.tryParse(widget.data.currentLongitude ?? '');
final allStations = auth.riverManualStations ?? []; // Only search Manual
if (currentLat == null || currentLon == null) return;
final allStations = auth.riverManualStations ?? [];
final List<Map<String, dynamic>> nearbyStations = []; final List<Map<String, dynamic>> nearbyStations = [];
for (var station in allStations) { for (var station in allStations) {
@ -280,7 +278,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
if (stationLat is num && stationLon is num) { if (stationLat is num && stationLon is num) {
final distance = service.calculateDistance(currentLat, currentLon, stationLat.toDouble(), stationLon.toDouble()); final distance = service.calculateDistance(currentLat, currentLon, stationLat.toDouble(), stationLon.toDouble());
if (distance <= 3.0) { // 3km radius if (distance <= 3.0) {
nearbyStations.add({'station': station, 'distance': distance}); nearbyStations.add({'station': station, 'distance': distance});
} }
} }
@ -292,7 +290,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
final selectedStation = await showDialog<Map<String, dynamic>>( final selectedStation = await showDialog<Map<String, dynamic>>(
context: context, context: context,
builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations), // Use the same dialog builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations),
); );
if (selectedStation != null) { if (selectedStation != null) {
@ -301,22 +299,20 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
} }
void _updateFormWithSelectedManualStation(Map<String, dynamic> station) { void _updateFormWithSelectedManualStation(Map<String, dynamic> station) {
// This specifically handles selecting a MANUAL station from nearby search or dropdown
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
final allManualStations = auth.riverManualStations ?? []; final allManualStations = auth.riverManualStations ?? [];
setState(() { 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.selectedStateName = station['state_name'];
widget.data.selectedStation = station; // Set manual station widget.data.selectedStation = station;
widget.data.selectedTriennialStation = null; // Clear triennial widget.data.selectedTriennialStation = null;
_clearNewLocationFields(); // Clear new location fields _clearNewLocationFields();
widget.data.stationLatitude = station['sampling_lat']?.toString(); widget.data.stationLatitude = station['sampling_lat']?.toString();
widget.data.stationLongitude = station['sampling_long']?.toString(); widget.data.stationLongitude = station['sampling_long']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? ''; _stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? '';
// Reload stations for the selected state if needed (mainly for UI consistency)
_manualStationsForState = allManualStations _manualStationsForState = allManualStations
.where((s) => s['state_name'] == widget.data.selectedStateName) .where((s) => s['state_name'] == widget.data.selectedStateName)
.toList() .toList()
@ -327,22 +323,20 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
} }
void _updateFormWithSelectedTriennialStation(Map<String, dynamic> station) { void _updateFormWithSelectedTriennialStation(Map<String, dynamic> station) {
// This specifically handles selecting a TRIENNIAL station from dropdown
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
final allTriennialStations = auth.riverTriennialStations ?? []; final allTriennialStations = auth.riverTriennialStations ?? [];
setState(() { setState(() {
widget.data.stationTypeSelection = 'Existing Triennial Station'; widget.data.stationTypeSelection = 'Existing Triennial Station';
widget.data.selectedStateName = station['state_name']; // Use state from Triennial data widget.data.selectedStateName = station['state_name'];
widget.data.selectedTriennialStation = station; // Set triennial station widget.data.selectedTriennialStation = station;
widget.data.selectedStation = null; // Clear manual widget.data.selectedStation = null;
_clearNewLocationFields(); _clearNewLocationFields();
widget.data.stationLatitude = station['triennial_lat']?.toString(); // Use triennial keys widget.data.stationLatitude = station['triennial_lat']?.toString();
widget.data.stationLongitude = station['triennial_long']?.toString(); // Use triennial keys widget.data.stationLongitude = station['triennial_long']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? ''; _stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? '';
// Reload stations for state (UI consistency)
_triennialStationsForState = allTriennialStations _triennialStationsForState = allTriennialStations
.where((s) => s['state_name'] == widget.data.selectedStateName) .where((s) => s['state_name'] == widget.data.selectedStateName)
.toList() .toList()
@ -371,15 +365,13 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
_newBasinController.clear(); _newBasinController.clear();
_newRiverController.clear(); _newRiverController.clear();
_newStationCodeController.clear(); _newStationCodeController.clear();
// Don't clear station lat/lon here, as they might be set by GPS for new location
} }
void _goToNextStep() { Future<void> _goToNextStep() async {
if (_formKey.currentState!.validate()) { 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.stationTypeSelection == 'New Location') {
if (widget.data.stationLatitude == null || widget.data.stationLatitude!.isEmpty || if (widget.data.stationLatitude == null || widget.data.stationLatitude!.isEmpty ||
widget.data.stationLongitude == null || widget.data.stationLongitude!.isEmpty ) { widget.data.stationLongitude == null || widget.data.stationLongitude!.isEmpty ) {
@ -389,20 +381,58 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
return; return;
} }
} }
// --- End Additional Validation ---
// 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; final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000;
// Only show distance warning if NOT a new location and distance > 50m
if (widget.data.stationTypeSelection != 'New Location' && distanceInMeters > 50) { if (widget.data.stationTypeSelection != 'New Location' && distanceInMeters > 50) {
_showDistanceRemarkDialog(); _showDistanceRemarkDialog();
} else { } else {
widget.data.distanceDifferenceRemarks = null; // Clear remark if not needed widget.data.distanceDifferenceRemarks = null;
widget.onNext(); widget.onNext();
} }
} }
} }
// NEW: Dialog for manual location verification
Future<bool?> _showLocationConfirmationDialog() {
return showDialog<bool>(
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<void> _showDistanceRemarkDialog() async { Future<void> _showDistanceRemarkDialog() async {
final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks); final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks);
final dialogFormKey = GlobalKey<FormState>(); final dialogFormKey = GlobalKey<FormState>();
@ -456,7 +486,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
widget.data.distanceDifferenceRemarks = remarkController.text; widget.data.distanceDifferenceRemarks = remarkController.text;
}); });
Navigator.of(context).pop(); Navigator.of(context).pop();
widget.onNext(); // Proceed after confirming remark widget.onNext();
} }
}, },
), ),
@ -469,7 +499,6 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
// Note: Station lists (_manualStationsForState, _triennialStationsForState) are updated in callbacks
final allUsers = auth.allUsers ?? []; final allUsers = auth.allUsers ?? [];
final secondSamplersList = allUsers final secondSamplersList = allUsers
@ -526,7 +555,6 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Sampling Type is fixed for Investigative
// --- Sample ID --- // --- Sample ID ---
TextFormField( TextFormField(
@ -541,11 +569,11 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
validator: (val) => validator: (val) =>
val == null || val.isEmpty ? "Sample ID is required" : null, val == null || val.isEmpty ? "Sample ID is required" : null,
onSaved: (val) => widget.data.sampleIdCode = val, 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), const SizedBox(height: 24),
// --- NEW: Station Type Selection --- // --- Station Type Selection ---
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: widget.data.stationTypeSelection, value: widget.data.stationTypeSelection,
items: _stationTypes items: _stationTypes
@ -556,14 +584,13 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
widget.data.stationTypeSelection = value; widget.data.stationTypeSelection = value;
_clearStationSelections(); _clearStationSelections();
_clearNewLocationFields(); _clearNewLocationFields();
// If selecting New Location, prepopulate station coords with current if available
if (value == 'New Location' && widget.data.currentLatitude != null) { if (value == 'New Location' && widget.data.currentLatitude != null) {
widget.data.stationLatitude = widget.data.currentLatitude; widget.data.stationLatitude = widget.data.currentLatitude;
widget.data.stationLongitude = widget.data.currentLongitude; widget.data.stationLongitude = widget.data.currentLongitude;
_stationLatController.text = widget.data.stationLatitude!; _stationLatController.text = widget.data.stationLatitude!;
_stationLonController.text = widget.data.stationLongitude!; _stationLonController.text = widget.data.stationLongitude!;
} }
_calculateDistance(); // Recalculate distance _calculateDistance();
}); });
}, },
decoration: const InputDecoration(labelText: 'Station Type *'), decoration: const InputDecoration(labelText: 'Station Type *'),
@ -588,7 +615,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
onChanged: (state) { onChanged: (state) {
setState(() { setState(() {
widget.data.selectedStateName = state; widget.data.selectedStateName = state;
_clearStationSelections(); // Clear selections when state changes _clearStationSelections();
_loadStationsForSelectedState(); _loadStationsForSelectedState();
_calculateDistance(); _calculateDistance();
}); });
@ -632,7 +659,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
// == Existing Triennial Station == // == Existing Triennial Station ==
if (widget.data.stationTypeSelection == 'Existing Triennial Station') ...[ if (widget.data.stationTypeSelection == 'Existing Triennial Station') ...[
DropdownSearch<String>( // State selection might be needed if not pre-selected DropdownSearch<String>(
items: _statesList, items: _statesList,
selectedItem: widget.data.selectedStateName, selectedItem: widget.data.selectedStateName,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))), popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
@ -641,7 +668,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
setState(() { setState(() {
widget.data.selectedStateName = state; widget.data.selectedStateName = state;
_clearStationSelections(); _clearStationSelections();
_loadStationsForSelectedState(); // Reloads both manual and triennial lists _loadStationsForSelectedState();
_calculateDistance(); _calculateDistance();
}); });
}, },
@ -653,7 +680,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
selectedItem: widget.data.selectedTriennialStation, selectedItem: widget.data.selectedTriennialStation,
enabled: widget.data.selectedStateName != null, enabled: widget.data.selectedStateName != null,
itemAsString: (station) => itemAsString: (station) =>
"${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( popupProps: const PopupProps.menu(
showSearchBox: true, showSearchBox: true,
searchFieldProps: TextFieldProps( searchFieldProps: TextFieldProps(
@ -675,7 +702,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
// == New Location == // == New Location ==
if (widget.data.stationTypeSelection == 'New Location') ...[ if (widget.data.stationTypeSelection == 'New Location') ...[
DropdownSearch<String>( // Use Dropdown for State consistency DropdownSearch<String>(
items: _statesList, items: _statesList,
selectedItem: widget.data.newStateName, selectedItem: widget.data.newStateName,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))), popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
@ -683,7 +710,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
onChanged: (state) { onChanged: (state) {
setState(() { setState(() {
widget.data.newStateName = state; widget.data.newStateName = state;
widget.data.selectedStateName = state; // Keep consistent if needed elsewhere widget.data.selectedStateName = state;
}); });
}, },
validator: (val) => val == null ? "State is required" : null, validator: (val) => val == null ? "State is required" : null,
@ -708,7 +735,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
onChanged: (val) => widget.data.newRiverName = val, onChanged: (val) => widget.data.newRiverName = val,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( // Optional Station Code for New Location TextFormField(
controller: _newStationCodeController, controller: _newStationCodeController,
decoration: const InputDecoration(labelText: 'Station Code (Optional)'), decoration: const InputDecoration(labelText: 'Station Code (Optional)'),
onSaved: (val) => widget.data.newStationCode = val, onSaved: (val) => widget.data.newStationCode = val,
@ -717,20 +744,20 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
// --- Station Coordinates (Read-only for existing, editable/GPS-fed for new) --- // --- Station Coordinates ---
TextFormField( TextFormField(
controller: _stationLatController, controller: _stationLatController,
readOnly: !isNewLocation, // Editable only for New Location readOnly: !isNewLocation,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Station Latitude ${isNewLocation ? "*" : ""}', labelText: 'Station Latitude ${isNewLocation ? "*" : ""}',
hintText: isNewLocation ? 'Use GPS or enter manually' : null hintText: isNewLocation ? 'Use GPS or enter manually' : null
), ),
keyboardType: TextInputType.numberWithOptions(decimal: true), keyboardType: TextInputType.numberWithOptions(decimal: true),
validator: (val) => isNewLocation && (val == null || val.isEmpty) ? "Latitude is required for new location" : null, validator: (val) => isNewLocation && (val == null || val.isEmpty) ? "Latitude is required for new location" : null,
onChanged: (val) { // Allow manual edit for New Location onChanged: (val) {
if (isNewLocation) { if (isNewLocation) {
widget.data.stationLatitude = val; widget.data.stationLatitude = val;
_calculateDistance(); // Recalculate if manually changed _calculateDistance();
} }
}, },
onSaved: (val) => widget.data.stationLatitude = val, onSaved: (val) => widget.data.stationLatitude = val,
@ -738,17 +765,17 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _stationLonController, controller: _stationLonController,
readOnly: !isNewLocation, // Editable only for New Location readOnly: !isNewLocation,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Station Longitude ${isNewLocation ? "*" : ""}', labelText: 'Station Longitude ${isNewLocation ? "*" : ""}',
hintText: isNewLocation ? 'Use GPS or enter manually' : null hintText: isNewLocation ? 'Use GPS or enter manually' : null
), ),
keyboardType: TextInputType.numberWithOptions(decimal: true), keyboardType: TextInputType.numberWithOptions(decimal: true),
validator: (val) => isNewLocation && (val == null || val.isEmpty) ? "Longitude is required for new location" : null, validator: (val) => isNewLocation && (val == null || val.isEmpty) ? "Longitude is required for new location" : null,
onChanged: (val) { // Allow manual edit for New Location onChanged: (val) {
if (isNewLocation) { if (isNewLocation) {
widget.data.stationLongitude = val; widget.data.stationLongitude = val;
_calculateDistance(); // Recalculate if manually changed _calculateDistance();
} }
}, },
onSaved: (val) => widget.data.stationLongitude = val, onSaved: (val) => widget.data.stationLongitude = val,
@ -760,16 +787,41 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
Text("Location Verification", Text("Location Verification",
style: Theme.of(context).textTheme.titleLarge), style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
// MODIFIED: Enabled manual entry for Current Latitude
TextFormField( TextFormField(
controller: _currentLatController, controller: _currentLatController,
readOnly: true, keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Current Latitude')), decoration: const InputDecoration(
labelText: 'Current Latitude *',
hintText: 'e.g. 3.14159',
),
validator: (val) => val == null || val.isEmpty ? "Latitude is required" : null,
onChanged: (val) {
_isManualLocationEntry = true;
widget.data.currentLatitude = val;
_calculateDistance();
},
),
const SizedBox(height: 16), const SizedBox(height: 16),
// MODIFIED: Enabled manual entry for Current Longitude
TextFormField( TextFormField(
controller: _currentLonController, controller: _currentLonController,
readOnly: true, keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Current Longitude')), decoration: const InputDecoration(
if (widget.data.distanceDifferenceInKm != null && widget.data.stationTypeSelection != 'New Location') // Only show distance if NOT new location 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(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: Container( child: Container(
@ -813,7 +865,7 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2)) child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.location_searching), : const Icon(Icons.location_searching),
label: Text(isNewLocation ? "Get Current Location (for Station & Verification)" : "Get Current Location"), label: Text(isNewLocation ? "Get Current Location (GPS)" : "Get Current Location (GPS)"),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@ -830,8 +882,6 @@ class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInf
} }
} }
// Re-use the same dialog as River Manual In-Situ for nearby stations
class _NearbyStationsDialog extends StatelessWidget { class _NearbyStationsDialog extends StatelessWidget {
final List<Map<String, dynamic>> nearbyStations; final List<Map<String, dynamic>> nearbyStations;
@ -859,7 +909,7 @@ class _NearbyStationsDialog extends StatelessWidget {
subtitle: Text("${station['sampling_river'] ?? 'N/A'}"), subtitle: Text("${station['sampling_river'] ?? 'N/A'}"),
trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"), trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"),
onTap: () { onTap: () {
Navigator.of(context).pop(station); // Return the selected station map Navigator.of(context).pop(station);
}, },
), ),
); );

View File

@ -28,6 +28,9 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isLoadingLocation = false; bool _isLoadingLocation = false;
// New flag to track if user manually typed the location
bool _isManualLocationEntry = false;
late final TextEditingController _firstSamplerController; late final TextEditingController _firstSamplerController;
late final TextEditingController _dateController; late final TextEditingController _dateController;
late final TextEditingController _timeController; late final TextEditingController _timeController;
@ -117,6 +120,9 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
final position = await service.getCurrentLocation(); final position = await service.getCurrentLocation();
if (mounted) { if (mounted) {
setState(() { setState(() {
// Reset manual entry flag because we successfully used GPS
_isManualLocationEntry = false;
widget.data.currentLatitude = position.latitude.toString(); widget.data.currentLatitude = position.latitude.toString();
widget.data.currentLongitude = position.longitude.toString(); widget.data.currentLongitude = position.longitude.toString();
_currentLatController.text = widget.data.currentLatitude!; _currentLatController.text = widget.data.currentLatitude!;
@ -153,6 +159,11 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
setState(() { setState(() {
widget.data.distanceDifferenceInKm = distance; widget.data.distanceDifferenceInKm = distance;
}); });
} else {
// Clear distance if parsing failed (e.g. user is typing)
setState(() {
widget.data.distanceDifferenceInKm = null;
});
} }
} }
} }
@ -181,8 +192,11 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
final service = Provider.of<RiverInSituSamplingService>(context, listen: false); final service = Provider.of<RiverInSituSamplingService>(context, listen: false);
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
final currentLat = double.parse(widget.data.currentLatitude!); final currentLat = double.tryParse(widget.data.currentLatitude ?? '');
final currentLon = double.parse(widget.data.currentLongitude!); final currentLon = double.tryParse(widget.data.currentLongitude ?? '');
if (currentLat == null || currentLon == null) return;
final allStations = auth.riverManualStations ?? []; final allStations = auth.riverManualStations ?? [];
final List<Map<String, dynamic>> nearbyStations = []; final List<Map<String, dynamic>> nearbyStations = [];
@ -232,10 +246,18 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
} }
void _goToNextStep() { Future<void> _goToNextStep() async {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
_formKey.currentState!.save(); _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; final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000;
if (distanceInMeters > 50) { if (distanceInMeters > 50) {
@ -247,6 +269,38 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
} }
} }
// NEW: Dialog for manual location verification
Future<bool?> _showLocationConfirmationDialog() {
return showDialog<bool>(
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<void> _showDistanceRemarkDialog() async { Future<void> _showDistanceRemarkDialog() async {
final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks); final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks);
final dialogFormKey = GlobalKey<FormState>(); final dialogFormKey = GlobalKey<FormState>();
@ -436,9 +490,42 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
Text("Location Verification", style: Theme.of(context).textTheme.titleLarge), Text("Location Verification", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')),
// MODIFIED: Enabled manual entry for Current Latitude
TextFormField(
controller: _currentLatController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Current Latitude *',
hintText: 'e.g. 3.14159',
),
validator: (val) => 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), 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) if (widget.data.distanceDifferenceInKm != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
@ -471,7 +558,7 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _isLoadingLocation ? null : _getCurrentLocation, onPressed: _isLoadingLocation ? null : _getCurrentLocation,
icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching), icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching),
label: const Text("Get Current Location"), label: const Text("Get Current Location (GPS)"),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@ -87,8 +87,12 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
final _flowrateValueController = TextEditingController(); final _flowrateValueController = TextEditingController();
final _sdHeightController = TextEditingController(); final _sdHeightController = TextEditingController();
final _sdDistanceController = 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 @override
void initState() { void initState() {
@ -101,15 +105,9 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
@override @override
void dispose() { void dispose() {
_dataSubscription?.cancel(); // --- MODIFICATION: Robust cleanup on dispose ---
_lockoutTimer?.cancel(); // --- MODIFICATION: Cancel timer on dispose --- _disconnectFromAll();
// --- END MODIFICATION ---
if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_samplingService.disconnectFromBluetooth();
}
if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) {
_samplingService.disconnectFromSerial();
}
_disposeControllers(); _disposeControllers();
_disposeFlowrateControllers(); _disposeFlowrateControllers();
@ -140,6 +138,25 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
} }
} }
// --- 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() { void _initializeControllers() {
widget.data.dataCaptureDate = widget.data.samplingDate; widget.data.dataCaptureDate = widget.data.samplingDate;
widget.data.dataCaptureTime = widget.data.samplingTime; widget.data.dataCaptureTime = widget.data.samplingTime;
@ -196,16 +213,29 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
_flowrateValueController.text = widget.data.flowrateValue?.toString() ?? ''; _flowrateValueController.text = widget.data.flowrateValue?.toString() ?? '';
_sdHeightController.text = widget.data.flowrateSurfaceDrifterHeight?.toString() ?? ''; _sdHeightController.text = widget.data.flowrateSurfaceDrifterHeight?.toString() ?? '';
_sdDistanceController.text = widget.data.flowrateSurfaceDrifterDistance?.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() { void _disposeFlowrateControllers() {
_flowrateValueController.dispose(); _flowrateValueController.dispose();
_sdHeightController.dispose(); _sdHeightController.dispose();
_sdDistanceController.dispose(); _sdDistanceController.dispose();
_sdTimeFirstController.dispose(); // --- MODIFICATION: Dispose duration controllers ---
_sdTimeLastController.dispose(); _sdDurationHourController.dispose();
_sdDurationMinuteController.dispose();
_sdDurationSecondController.dispose();
// --- END MODIFICATION ---
} }
void _onFlowrateMethodChanged(String? value) { void _onFlowrateMethodChanged(String? value) {
@ -215,13 +245,14 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
if (value == 'NA') { if (value == 'NA') {
_flowrateValueController.text = 'NA'; _flowrateValueController.text = 'NA';
} else if (value == 'Flowmeter') { } else if (value == 'Flowmeter') {
// --- MODIFICATION: Clear flowrate value for Flowmeter ---
_flowrateValueController.clear(); _flowrateValueController.clear();
// --- END MODIFICATION ---
_sdHeightController.clear(); _sdHeightController.clear();
_sdDistanceController.clear(); _sdDistanceController.clear();
_sdTimeFirstController.clear(); // --- MODIFICATION: Clear duration fields ---
_sdTimeLastController.clear(); _sdDurationHourController.clear();
_sdDurationMinuteController.clear();
_sdDurationSecondController.clear();
// --- END MODIFICATION ---
} else { // Surface Drifter } else { // Surface Drifter
_flowrateValueController.clear(); // Will be calculated _flowrateValueController.clear(); // Will be calculated
@ -231,42 +262,32 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
void _calculateFlowrate() { void _calculateFlowrate() {
final distance = double.tryParse(_sdDistanceController.text); final distance = double.tryParse(_sdDistanceController.text);
final timeFirstStr = _sdTimeFirstController.text; // --- MODIFICATION: Calculate using duration ---
final timeLastStr = _sdTimeLastController.text; 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) { if (distance == null) {
_showSnackBar("Please fill in Distance, Time First, and Time Last.", isError: true); _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; return;
} }
try { try {
final timeFormat = DateFormat("HH:mm:ss"); final flowrate = distance / totalSeconds;
// 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;
setState(() { setState(() {
_flowrateValueController.text = flowrate.toStringAsFixed(4); _flowrateValueController.text = flowrate.toStringAsFixed(4);
}); });
} catch (e) { } catch (e) {
_showSnackBar("Invalid time format. Please use HH:mm:ss.", isError: true); _showSnackBar("Error calculating flowrate.", isError: true);
} }
// --- END MODIFICATION ---
} }
Future<void> _selectTime(BuildContext context, TextEditingController controller) async { Future<void> _selectTime(BuildContext context, TextEditingController controller) async {
@ -290,8 +311,15 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
_showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true);
return; return;
} }
_disconnectFromAll();
await Future.delayed(const Duration(milliseconds: 250)); // --- 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); final bool connectionSuccess = await _connectToDevice(type);
if (connectionSuccess && mounted) { if (connectionSuccess && mounted) {
_dataSubscription?.cancel(); _dataSubscription?.cancel();
@ -350,7 +378,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
return success; return success;
} }
// --- START MODIFICATION: Countdown Timer Logic --- // --- START: MODIFIED VALIDATION FLOW ---
void _startLockoutTimer() { void _startLockoutTimer() {
_lockoutTimer?.cancel(); _lockoutTimer?.cancel();
setState(() { setState(() {
@ -375,7 +403,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
} }
}); });
} }
// --- END MODIFICATION ---
void _toggleAutoReading(String activeType) { void _toggleAutoReading(String activeType) {
final service = context.read<RiverInSituSamplingService>(); final service = context.read<RiverInSituSamplingService>();
@ -400,9 +427,14 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
} else { } else {
service.disconnectFromSerial(); service.disconnectFromSerial();
} }
// --- MODIFICATION: Unconditional cleanup ---
_dataSubscription?.cancel(); _dataSubscription?.cancel();
_dataSubscription = null; _dataSubscription = null;
_lockoutTimer?.cancel(); // --- MODIFICATION: Cancel timer on disconnect --- _lockoutTimer?.cancel();
_clearDataFields(); // Clear UI
// --- END MODIFICATION ---
if (mounted) { if (mounted) {
setState(() { setState(() {
_isAutoReading = false; _isAutoReading = false;
@ -413,15 +445,30 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
} }
void _disconnectFromAll() { void _disconnectFromAll() {
// --- START MODIFICATION --- // --- MODIFICATION: Unconditional cleanup ---
final service = _samplingService; // NEW: Use the member variable // 1. Cancel local listeners first
// --- END MODIFICATION --- _dataSubscription?.cancel();
_dataSubscription = null;
_lockoutTimer?.cancel();
// 2. Disconnect services if active
final service = _samplingService;
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_disconnect('bluetooth'); service.disconnectFromBluetooth();
} }
if (service.serialConnectionState.value != SerialConnectionState.disconnected) { 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<String, double> readings) { void _updateTextFields(Map<String, double> readings) {
@ -440,7 +487,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
}); });
} }
// --- START: MODIFIED VALIDATION FLOW ---
void _validateAndProceed() async { void _validateAndProceed() async {
// --- START MODIFICATION: Add lockout check --- // --- START MODIFICATION: Add lockout check ---
if (_isLockedOut) { if (_isLockedOut) {
@ -450,7 +496,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
// --- END MODIFICATION --- // --- END MODIFICATION ---
// --- START MODIFICATION: Disable Next if Connected and Auto Reading is active --- // --- START MODIFICATION: Disable Next if Connected and Auto Reading is active ---
// Similar to River In-Situ, allow manual stop to re-enable 'Next'
if (_isAutoReading) { if (_isAutoReading) {
_showStopReadingDialog(); _showStopReadingDialog();
return; return;
@ -478,7 +523,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
_saveDataAndMoveOn(currentReadings); _saveDataAndMoveOn(currentReadings);
} }
} }
// --- END: MODIFIED VALIDATION FLOW ---
Map<String, double> _captureReadingsToMap() { Map<String, double> _captureReadingsToMap() {
final Map<String, double> readings = {}; final Map<String, double> readings = {};
@ -532,6 +576,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
void _saveDataAndMoveOn(Map<String, double> readings) { void _saveDataAndMoveOn(Map<String, double> readings) {
try { try {
const defaultValue = -999.0; const defaultValue = -999.0;
widget.data.sondeId = _sondeIdController.text; // Ensure ID is saved
widget.data.temperature = readings['temperature'] ?? defaultValue; widget.data.temperature = readings['temperature'] ?? defaultValue;
widget.data.ph = readings['ph'] ?? defaultValue; widget.data.ph = readings['ph'] ?? defaultValue;
widget.data.salinity = readings['salinity'] ?? defaultValue; widget.data.salinity = readings['salinity'] ?? defaultValue;
@ -547,8 +592,18 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
if (_selectedFlowrateMethod == 'Surface Drifter') { if (_selectedFlowrateMethod == 'Surface Drifter') {
widget.data.flowrateSurfaceDrifterHeight = double.tryParse(_sdHeightController.text); widget.data.flowrateSurfaceDrifterHeight = double.tryParse(_sdHeightController.text);
widget.data.flowrateSurfaceDrifterDistance = double.tryParse(_sdDistanceController.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); widget.data.flowrateValue = double.tryParse(_flowrateValueController.text);
} else if (_selectedFlowrateMethod == 'Flowmeter') { } else if (_selectedFlowrateMethod == 'Flowmeter') {
widget.data.flowrateSurfaceDrifterHeight = null; widget.data.flowrateSurfaceDrifterHeight = null;
@ -809,7 +864,9 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.link_off), icon: const Icon(Icons.link_off),
label: const Text('Disconnect'), label: const Text('Disconnect'),
onPressed: () => _disconnect(type), // --- MODIFICATION: Disabled during lockout ---
onPressed: _isLockedOut ? null : () => _disconnect(type),
// --- END MODIFICATION ---
style: TextButton.styleFrom(foregroundColor: Colors.red), style: TextButton.styleFrom(foregroundColor: Colors.red),
) )
], ],
@ -985,7 +1042,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
); );
} }
// Updated to include disable logic
Widget _buildFlowrateSection({bool isInputDisabled = false}) { Widget _buildFlowrateSection({bool isInputDisabled = false}) {
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0), margin: const EdgeInsets.symmetric(vertical: 4.0),
@ -1012,14 +1068,12 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Wrap content in AbsorbPointer and Opacity if connected
AbsorbPointer( AbsorbPointer(
absorbing: isInputDisabled, absorbing: isInputDisabled,
child: Opacity( child: Opacity(
opacity: isInputDisabled ? 0.5 : 1.0, opacity: isInputDisabled ? 0.5 : 1.0,
child: Column( child: Column(
children: [ children: [
// Replaced Row with Wrap to fix horizontal overflow for radio buttons
Wrap( Wrap(
alignment: WrapAlignment.spaceAround, alignment: WrapAlignment.spaceAround,
spacing: 8.0, spacing: 8.0,
@ -1027,10 +1081,9 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
children: [ children: [
_buildFlowrateRadioButton("Surface Drifter"), _buildFlowrateRadioButton("Surface Drifter"),
_buildFlowrateRadioButton("Flowmeter"), _buildFlowrateRadioButton("Flowmeter"),
_buildFlowrateRadioButton("NA"), // Not Applicable _buildFlowrateRadioButton("NA"),
], ],
), ),
// Conditional fields based on selected method
if (_selectedFlowrateMethod == 'Surface Drifter') if (_selectedFlowrateMethod == 'Surface Drifter')
_buildSurfaceDrifterFields(), _buildSurfaceDrifterFields(),
if (_selectedFlowrateMethod == 'Flowmeter') if (_selectedFlowrateMethod == 'Flowmeter')
@ -1058,7 +1111,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
Text( Text(
title, title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, // Add ellipsis handling for safety overflow: TextOverflow.ellipsis,
), ),
], ],
); );
@ -1068,12 +1121,12 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
return Padding( return Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TextFormField( TextFormField(
controller: _sdHeightController, controller: _sdHeightController,
decoration: const InputDecoration(labelText: 'Height (m)'), decoration: const InputDecoration(labelText: 'Height (m)'),
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
// Add validation if needed
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
@ -1083,21 +1136,51 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
validator: (v) => v == null || v.isEmpty ? 'Distance is required' : null, validator: (v) => v == null || v.isEmpty ? 'Distance is required' : null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField(
controller: _sdTimeFirstController, // --- MODIFICATION: Duration Input Fields ---
decoration: const InputDecoration(labelText: 'Time First Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)), const Text("Duration of Travel", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
readOnly: true, const SizedBox(height: 8),
onTap: () => _selectTime(context, _sdTimeFirstController), Row(
validator: (v) => v == null || v.isEmpty ? 'Start time is required' : null, children: [
), Expanded(
const SizedBox(height: 16), child: TextFormField(
TextFormField( controller: _sdDurationHourController,
controller: _sdTimeLastController, decoration: const InputDecoration(
decoration: const InputDecoration(labelText: 'Time Last Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)), labelText: 'Hours',
readOnly: true, counterText: "",
onTap: () => _selectTime(context, _sdTimeLastController), ),
validator: (v) => v == null || v.isEmpty ? 'End time is required' : null, 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), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _calculateFlowrate, onPressed: _calculateFlowrate,
@ -1108,7 +1191,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
controller: _flowrateValueController, controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Calculated Flowrate (m/s)'), decoration: const InputDecoration(labelText: 'Calculated Flowrate (m/s)'),
readOnly: true, readOnly: true,
// Add validator if calculation must be done?
), ),
], ],
), ),
@ -1128,7 +1210,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
} }
Widget _buildNAField() { Widget _buildNAField() {
// Fix: Use controller to set value instead of initialValue to avoid conflict crash
if (_flowrateValueController.text != 'NA') { if (_flowrateValueController.text != 'NA') {
_flowrateValueController.text = 'NA'; _flowrateValueController.text = 'NA';
} }
@ -1138,10 +1219,8 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
child: TextFormField( child: TextFormField(
controller: _flowrateValueController, controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), decoration: const InputDecoration(labelText: 'Flowrate (m/s)'),
// initialValue: 'NA', // Removed to fix AssertionError: initialValue == null || controller == null readOnly: true,
readOnly: true, // Make it read-only
), ),
); );
} }
}
} // End of State class

View File

@ -28,6 +28,9 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isLoadingLocation = false; bool _isLoadingLocation = false;
// New flag to track if user manually typed the location
bool _isManualLocationEntry = false;
late final TextEditingController _firstSamplerController; late final TextEditingController _firstSamplerController;
late final TextEditingController _dateController; late final TextEditingController _dateController;
late final TextEditingController _timeController; late final TextEditingController _timeController;
@ -100,9 +103,7 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
_stationsForState = allStations _stationsForState = allStations
.where((s) => s['state_name'] == widget.data.selectedStateName) .where((s) => s['state_name'] == widget.data.selectedStateName)
.toList() .toList()
// --- START MODIFICATION: Sort stations on initial load ---
..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? '')); ..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? ''));
// --- END MODIFICATION ---
} }
setState(() { setState(() {
@ -117,15 +118,20 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
try { try {
final position = await service.getCurrentLocation(); final position = await service.getCurrentLocation();
if (mounted) {
setState(() { // FIX: Check if widget is still mounted before calling setState to prevent defunct exception
widget.data.currentLatitude = position.latitude.toString(); if (!mounted) return;
widget.data.currentLongitude = position.longitude.toString();
_currentLatController.text = widget.data.currentLatitude!; setState(() {
_currentLonController.text = widget.data.currentLongitude!; // Reset manual entry flag because we successfully used GPS
_calculateDistance(); _isManualLocationEntry = false;
});
} 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) { } catch (e) {
if(mounted) { if(mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e'))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e')));
@ -152,9 +158,18 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) { if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) {
final distance = service.calculateDistance(lat1, lon1, lat2, lon2); final distance = service.calculateDistance(lat1, lon1, lat2, lon2);
setState(() { if (mounted) {
widget.data.distanceDifferenceInKm = distance; setState(() {
}); widget.data.distanceDifferenceInKm = distance;
});
}
} else {
// Clear distance if parsing failed
if (mounted) {
setState(() {
widget.data.distanceDifferenceInKm = null;
});
}
} }
} }
} }
@ -183,8 +198,11 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
final service = Provider.of<RiverInSituSamplingService>(context, listen: false); final service = Provider.of<RiverInSituSamplingService>(context, listen: false);
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
final currentLat = double.parse(widget.data.currentLatitude!); final currentLat = double.tryParse(widget.data.currentLatitude ?? '');
final currentLon = double.parse(widget.data.currentLongitude!); final currentLon = double.tryParse(widget.data.currentLongitude ?? '');
if (currentLat == null || currentLon == null) return;
final allStations = auth.riverManualStations ?? []; final allStations = auth.riverManualStations ?? [];
final List<Map<String, dynamic>> nearbyStations = []; final List<Map<String, dynamic>> nearbyStations = [];
@ -209,35 +227,45 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations), builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations),
); );
if (selectedStation != null) { if (selectedStation != null && mounted) {
_updateFormWithSelectedStation(selectedStation); _updateFormWithSelectedStation(selectedStation);
} }
} }
void _updateFormWithSelectedStation(Map<String, dynamic> station) { void _updateFormWithSelectedStation(Map<String, dynamic> station) {
final allStations = Provider.of<AuthProvider>(context, listen: false).riverManualStations ?? []; final allStations = Provider.of<AuthProvider>(context, listen: false).riverManualStations ?? [];
setState(() { if (mounted) {
widget.data.selectedStateName = station['state_name']; setState(() {
_stationsForState = allStations widget.data.selectedStateName = station['state_name'];
.where((s) => s['state_name'] == widget.data.selectedStateName) _stationsForState = allStations
.toList() .where((s) => s['state_name'] == widget.data.selectedStateName)
..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? '')); .toList()
..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? ''));
widget.data.selectedStation = station; widget.data.selectedStation = station;
widget.data.stationLatitude = station['sampling_lat']?.toString(); widget.data.stationLatitude = station['sampling_lat']?.toString();
widget.data.stationLongitude = station['sampling_long']?.toString(); widget.data.stationLongitude = station['sampling_long']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? ''; _stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? '';
_calculateDistance(); _calculateDistance();
}); });
}
} }
void _goToNextStep() { Future<void> _goToNextStep() async {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
_formKey.currentState!.save(); _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; final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000;
if (distanceInMeters > 50) { if (distanceInMeters > 50) {
@ -249,6 +277,38 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
} }
} }
// NEW: Dialog for manual location verification
Future<bool?> _showLocationConfirmationDialog() {
return showDialog<bool>(
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<void> _showDistanceRemarkDialog() async { Future<void> _showDistanceRemarkDialog() async {
final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks); final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks);
final dialogFormKey = GlobalKey<FormState>(); final dialogFormKey = GlobalKey<FormState>();
@ -298,9 +358,11 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
child: const Text('Confirm'), child: const Text('Confirm'),
onPressed: () { onPressed: () {
if (dialogFormKey.currentState!.validate()) { if (dialogFormKey.currentState!.validate()) {
setState(() { if (mounted) {
widget.data.distanceDifferenceRemarks = remarkController.text; setState(() {
}); widget.data.distanceDifferenceRemarks = remarkController.text;
});
}
Navigator.of(context).pop(); Navigator.of(context).pop();
widget.onNext(); widget.onNext();
} }
@ -318,10 +380,8 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
final allStations = auth.riverManualStations ?? []; final allStations = auth.riverManualStations ?? [];
final allUsers = auth.allUsers ?? []; final allUsers = auth.allUsers ?? [];
// --- START MODIFICATION: Sort 2nd sampler list alphabetically ---
final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList() final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList()
..sort((a, b) => (a['first_name'] ?? '').compareTo(b['first_name'] ?? '')); ..sort((a, b) => (a['first_name'] ?? '').compareTo(b['first_name'] ?? ''));
// --- END MODIFICATION ---
return Form( return Form(
key: _formKey, key: _formKey,
@ -390,9 +450,7 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
? (allStations ? (allStations
.where((s) => s['state_name'] == state) .where((s) => s['state_name'] == state)
.toList() .toList()
// --- START MODIFICATION: Sort stations when state changes ---
..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? ''))) ..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? '')))
// --- END MODIFICATION ---
: []; : [];
}); });
}, },
@ -442,9 +500,42 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
Text("Location Verification", style: Theme.of(context).textTheme.titleLarge), Text("Location Verification", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')),
// MODIFIED: Enabled manual entry for Current Latitude
TextFormField(
controller: _currentLatController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Current Latitude *',
hintText: 'e.g. 3.14159',
),
validator: (val) => 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), 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) if (widget.data.distanceDifferenceInKm != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
@ -477,7 +568,7 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _isLoadingLocation ? null : _getCurrentLocation, onPressed: _isLoadingLocation ? null : _getCurrentLocation,
icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching), icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching),
label: const Text("Get Current Location"), label: const Text("Get Current Location (GPS)"),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@ -46,6 +46,9 @@ class _RiverInSituStep2SiteInfoState extends State<RiverInSituStep2SiteInfo> {
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async { void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return; if (_isPickingImage) return;
// Safety check before starting
if (!mounted) return;
setState(() => _isPickingImage = true); setState(() => _isPickingImage = true);
final service = Provider.of<RiverInSituSamplingService>(context, listen: false); final service = Provider.of<RiverInSituSamplingService>(context, listen: false);
@ -62,6 +65,10 @@ class _RiverInSituStep2SiteInfoState extends State<RiverInSituStep2SiteInfo> {
stationCode: stationCode, // Pass the station code here 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) { if (file != null) {
setState(() => setImageCallback(file)); setState(() => setImageCallback(file));
} else if (mounted) { } else if (mounted) {

View File

@ -9,7 +9,6 @@ import 'package:intl/intl.dart';
import '../../../../../auth_provider.dart'; import '../../../../../auth_provider.dart';
import '../../../../../models/river_in_situ_sampling_data.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 'package:environment_monitoring_app/services/database_helper.dart';
import '../../../../../services/river_in_situ_sampling_service.dart'; import '../../../../../services/river_in_situ_sampling_service.dart';
import '../../../../../bluetooth/bluetooth_manager.dart'; import '../../../../../bluetooth/bluetooth_manager.dart';
@ -37,17 +36,12 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
bool _isAutoReading = false; bool _isAutoReading = false;
StreamSubscription? _dataSubscription; StreamSubscription? _dataSubscription;
// --- START: Added for lockout timer ---
Timer? _lockoutTimer; Timer? _lockoutTimer;
int _lockoutSecondsRemaining = 30; int _lockoutSecondsRemaining = 30;
bool _isLockedOut = false; bool _isLockedOut = false;
// --- END: Added for lockout timer ---
late final RiverInSituSamplingService _samplingService; late final RiverInSituSamplingService _samplingService;
// --- START: Added for direct database access ---
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
// --- END: Added for direct database access ---
Map<String, double>? _previousReadingsForComparison; Map<String, double>? _previousReadingsForComparison;
Set<String> _outOfBoundsKeys = {}; Set<String> _outOfBoundsKeys = {};
@ -67,7 +61,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final List<Map<String, dynamic>> _parameters = []; final List<Map<String, dynamic>> _parameters = [];
// Sonde parameter controllers
final _sondeIdController = TextEditingController(); final _sondeIdController = TextEditingController();
final _dateController = TextEditingController(); final _dateController = TextEditingController();
final _timeController = TextEditingController(); final _timeController = TextEditingController();
@ -82,13 +75,14 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final _ammoniaController = TextEditingController(); final _ammoniaController = TextEditingController();
final _batteryController = TextEditingController(); final _batteryController = TextEditingController();
// Flowrate controllers and state
String? _selectedFlowrateMethod; String? _selectedFlowrateMethod;
final _flowrateValueController = TextEditingController(); final _flowrateValueController = TextEditingController();
final _sdHeightController = TextEditingController(); final _sdHeightController = TextEditingController();
final _sdDistanceController = TextEditingController(); final _sdDistanceController = TextEditingController();
final _sdTimeFirstController = TextEditingController();
final _sdTimeLastController = TextEditingController(); final _sdDurationHourController = TextEditingController();
final _sdDurationMinuteController = TextEditingController();
final _sdDurationSecondController = TextEditingController();
@override @override
void initState() { void initState() {
@ -102,14 +96,11 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
@override @override
void dispose() { void dispose() {
_dataSubscription?.cancel(); _dataSubscription?.cancel();
_lockoutTimer?.cancel(); // --- MODIFICATION: Cancel timer on dispose --- _lockoutTimer?.cancel();
if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { // --- MODIFICATION: Ensure clean disconnect on dispose without setState ---
_samplingService.disconnectFromBluetooth(); _disconnectFromAll(isDisposing: true);
} // --- END MODIFICATION ---
if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) {
_samplingService.disconnectFromSerial();
}
_disposeControllers(); _disposeControllers();
_disposeFlowrateControllers(); _disposeFlowrateControllers();
@ -120,26 +111,35 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
if (mounted) { // FIX: Immediate mounted check to prevent defunct exception
// --- START MODIFICATION --- if (!mounted) return;
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 final service = _samplingService;
if (_isLoading || btConnecting || serialConnecting) { final btConnecting = service.bluetoothConnectionState.value == BluetoothConnectionState.connecting;
// Force-call disconnect to reset both the service's state final serialConnecting = service.serialConnectionState.value == SerialConnectionState.connecting;
// and the local _isLoading flag (inside _disconnect).
_disconnectFromAll(); if (_isLoading || btConnecting || serialConnecting) {
} else { _disconnectFromAll();
// If not stuck, just a normal refresh } else {
setState(() {}); setState(() {});
}
// --- END MODIFICATION ---
} }
} }
} }
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;
}
void _initializeControllers() { void _initializeControllers() {
widget.data.dataCaptureDate = widget.data.samplingDate; widget.data.dataCaptureDate = widget.data.samplingDate;
widget.data.dataCaptureTime = widget.data.samplingTime; widget.data.dataCaptureTime = widget.data.samplingTime;
@ -196,75 +196,72 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_flowrateValueController.text = widget.data.flowrateValue?.toString() ?? ''; _flowrateValueController.text = widget.data.flowrateValue?.toString() ?? '';
_sdHeightController.text = widget.data.flowrateSurfaceDrifterHeight?.toString() ?? ''; _sdHeightController.text = widget.data.flowrateSurfaceDrifterHeight?.toString() ?? '';
_sdDistanceController.text = widget.data.flowrateSurfaceDrifterDistance?.toString() ?? ''; _sdDistanceController.text = widget.data.flowrateSurfaceDrifterDistance?.toString() ?? '';
_sdTimeFirstController.text = widget.data.flowrateSurfaceDrifterTimeFirst ?? '';
_sdTimeLastController.text = widget.data.flowrateSurfaceDrifterTimeLast ?? ''; 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];
}
}
} }
void _disposeFlowrateControllers() { void _disposeFlowrateControllers() {
_flowrateValueController.dispose(); _flowrateValueController.dispose();
_sdHeightController.dispose(); _sdHeightController.dispose();
_sdDistanceController.dispose(); _sdDistanceController.dispose();
_sdTimeFirstController.dispose(); _sdDurationHourController.dispose();
_sdTimeLastController.dispose(); _sdDurationMinuteController.dispose();
_sdDurationSecondController.dispose();
} }
void _onFlowrateMethodChanged(String? value) { void _onFlowrateMethodChanged(String? value) {
setState(() { setState(() {
_selectedFlowrateMethod = value; _selectedFlowrateMethod = value;
widget.data.flowrateMethod = value; // Update model immediately widget.data.flowrateMethod = value;
if (value == 'NA') { if (value == 'NA') {
_flowrateValueController.text = 'NA'; _flowrateValueController.text = 'NA';
} else if (value == 'Flowmeter') { } else if (value == 'Flowmeter') {
// --- MODIFICATION: Clear flowrate value for Flowmeter ---
_flowrateValueController.clear(); _flowrateValueController.clear();
// --- END MODIFICATION ---
_sdHeightController.clear(); _sdHeightController.clear();
_sdDistanceController.clear(); _sdDistanceController.clear();
_sdTimeFirstController.clear(); _sdDurationHourController.clear();
_sdTimeLastController.clear(); _sdDurationMinuteController.clear();
_sdDurationSecondController.clear();
} else { // Surface Drifter } else { // Surface Drifter
_flowrateValueController.clear(); // Will be calculated _flowrateValueController.clear();
} }
}); });
} }
void _calculateFlowrate() { void _calculateFlowrate() {
final distance = double.tryParse(_sdDistanceController.text); final distance = double.tryParse(_sdDistanceController.text);
final timeFirstStr = _sdTimeFirstController.text; final hours = int.tryParse(_sdDurationHourController.text) ?? 0;
final timeLastStr = _sdTimeLastController.text; final minutes = int.tryParse(_sdDurationMinuteController.text) ?? 0;
final seconds = int.tryParse(_sdDurationSecondController.text) ?? 0;
if (distance == null || timeFirstStr.isEmpty || timeLastStr.isEmpty) { if (distance == null) {
_showSnackBar("Please fill in Distance, Time First, and Time Last.", isError: true); _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; return;
} }
try { try {
final timeFormat = DateFormat("HH:mm:ss"); final flowrate = distance / totalSeconds;
final timeFirst = timeFormat.parse(timeFirstStr);
final timeLast = timeFormat.parse(timeLastStr);
// Use a common date (like today) to allow time difference calculation across midnight
final now = DateTime.now();
final dateTimeFirst = DateTime(now.year, now.month, now.day, timeFirst.hour, timeFirst.minute, timeFirst.second);
var dateTimeLast = DateTime(now.year, now.month, now.day, timeLast.hour, timeLast.minute, timeLast.second);
// Handle crossing midnight
if (dateTimeLast.isBefore(dateTimeFirst)) {
dateTimeLast = dateTimeLast.add(const Duration(days: 1));
}
final differenceInSeconds = dateTimeLast.difference(dateTimeFirst).inSeconds;
if (differenceInSeconds <= 0) {
_showSnackBar("Time Last Deploy must be after Time First Deploy.", isError: true);
return;
}
final flowrate = distance / differenceInSeconds;
setState(() { setState(() {
_flowrateValueController.text = flowrate.toStringAsFixed(4); _flowrateValueController.text = flowrate.toStringAsFixed(4);
}); });
} catch (e) { } catch (e) {
_showSnackBar("Invalid time format. Please use HH:mm:ss.", isError: true); _showSnackBar("Error calculating flowrate.", isError: true);
} }
} }
@ -283,18 +280,21 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
} }
Future<void> _handleConnectionAttempt(String type) async { Future<void> _handleConnectionAttempt(String type) async {
// Uses the correct _samplingService instance
final bool hasPermissions = await _samplingService.requestDevicePermissions(); final bool hasPermissions = await _samplingService.requestDevicePermissions();
if (!hasPermissions && mounted) { if (!hasPermissions && mounted) {
_showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true);
return; return;
} }
_disconnectFromAll(); _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); final bool connectionSuccess = await _connectToDevice(type);
if (connectionSuccess && mounted) { if (connectionSuccess && mounted) {
_dataSubscription?.cancel(); // Cancel previous subscription if any _dataSubscription?.cancel();
final stream = type == 'bluetooth' ? _samplingService.bluetoothDataStream : _samplingService.serialDataStream; final stream = type == 'bluetooth' ? _samplingService.bluetoothDataStream : _samplingService.serialDataStream;
_dataSubscription = stream.listen((readings) { _dataSubscription = stream.listen((readings) {
if (mounted) { if (mounted) {
@ -303,22 +303,21 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
}, onError: (error) { }, onError: (error) {
debugPrint("Error on data stream: $error"); debugPrint("Error on data stream: $error");
if (mounted) _showSnackBar("Data stream error: $error", isError: true); if (mounted) _showSnackBar("Data stream error: $error", isError: true);
_disconnect(type); // Disconnect on stream error _disconnect(type);
}, onDone: () { }, onDone: () {
debugPrint("Data stream done."); debugPrint("Data stream done.");
if (mounted) _disconnect(type); // Disconnect when stream closes if (mounted) _disconnect(type);
}); });
} }
} }
Future<bool> _connectToDevice(String type) async { Future<bool> _connectToDevice(String type) async {
// Uses the correct _samplingService instance if (mounted) setState(() => _isLoading = true);
setState(() => _isLoading = true);
bool success = false; bool success = false;
try { try {
if (type == 'bluetooth') { if (type == 'bluetooth') {
final devices = await _samplingService.getPairedBluetoothDevices(); final devices = await _samplingService.getPairedBluetoothDevices();
if (!mounted) return false; // Check mounted after async gap if (!mounted) return false;
if (devices.isEmpty) { if (devices.isEmpty) {
_showSnackBar('No paired Bluetooth devices found.', isError: true); _showSnackBar('No paired Bluetooth devices found.', isError: true);
return false; return false;
@ -350,13 +349,14 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
return success; return success;
} }
// --- START MODIFICATION: Countdown Timer Logic ---
void _startLockoutTimer() { void _startLockoutTimer() {
_lockoutTimer?.cancel(); _lockoutTimer?.cancel();
setState(() { if (mounted) {
_isLockedOut = true; setState(() {
_lockoutSecondsRemaining = 30; _isLockedOut = true;
}); _lockoutSecondsRemaining = 30;
});
}
_lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) { _lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_lockoutSecondsRemaining > 0) { if (_lockoutSecondsRemaining > 0) {
@ -375,26 +375,25 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
} }
}); });
} }
// --- END MODIFICATION ---
void _toggleAutoReading(String activeType) { void _toggleAutoReading(String activeType) {
final service = context.read<RiverInSituSamplingService>(); final service = context.read<RiverInSituSamplingService>();
setState(() { if (mounted) {
_isAutoReading = !_isAutoReading; setState(() {
if (_isAutoReading) { _isAutoReading = !_isAutoReading;
if (activeType == 'bluetooth') service.startBluetoothAutoReading(); else service.startSerialAutoReading(); if (_isAutoReading) {
_startLockoutTimer(); // --- MODIFICATION: Start countdown if (activeType == 'bluetooth') service.startBluetoothAutoReading(); else service.startSerialAutoReading();
} else { _startLockoutTimer();
if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading(); } else {
// NOTE: _lockoutTimer is intentionally NOT cancelled here so the lockout persists for the remaining duration if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading();
} }
}); });
}
} }
void _disconnect(String type) { // MODIFIED: Added isDisposing flag to guard setState
// --- START MODIFICATION --- void _disconnect(String type, {bool isDisposing = false}) {
final service = _samplingService; // NEW: Use the member variable final service = _samplingService;
// --- END MODIFICATION ---
if (type == 'bluetooth') { if (type == 'bluetooth') {
service.disconnectFromBluetooth(); service.disconnectFromBluetooth();
} else { } else {
@ -402,63 +401,59 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
} }
_dataSubscription?.cancel(); _dataSubscription?.cancel();
_dataSubscription = null; _dataSubscription = null;
_lockoutTimer?.cancel(); // --- MODIFICATION: Cancel timer on disconnect --- _lockoutTimer?.cancel();
if (mounted) {
_clearDataFields();
// FIX: Only call setState if widget is still mounted AND we are not disposing
if (mounted && !isDisposing) {
setState(() { setState(() {
_isAutoReading = false; _isAutoReading = false;
_isLockedOut = false; // --- MODIFICATION: Reset lockout state --- _isLockedOut = false;
_isLoading = false; // --- NEW: Also reset the loading flag --- _isLoading = false;
}); });
} }
} }
void _disconnectFromAll() { // MODIFIED: Added isDisposing flag to pass down to _disconnect
// --- START MODIFICATION --- void _disconnectFromAll({bool isDisposing = false}) {
final service = _samplingService; // NEW: Use the member variable final service = _samplingService;
// --- END MODIFICATION ---
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_disconnect('bluetooth'); _disconnect('bluetooth', isDisposing: isDisposing);
} }
if (service.serialConnectionState.value != SerialConnectionState.disconnected) { if (service.serialConnectionState.value != SerialConnectionState.disconnected) {
_disconnect('serial'); _disconnect('serial', isDisposing: isDisposing);
} }
} }
void _updateTextFields(Map<String, double> readings) { void _updateTextFields(Map<String, double> readings) {
const defaultValue = -999.0; const defaultValue = -999.0;
setState(() { if (mounted) {
_oxyConcController.text = (readings['Optical Dissolved Oxygen: Compensated mg/L'] ?? defaultValue).toStringAsFixed(5); setState(() {
_oxySatController.text = (readings['Optical Dissolved Oxygen: Compensated % Saturation'] ?? defaultValue).toStringAsFixed(5); _oxyConcController.text = (readings['Optical Dissolved Oxygen: Compensated mg/L'] ?? defaultValue).toStringAsFixed(5);
_phController.text = (readings['PH: PH units'] ?? defaultValue).toStringAsFixed(5); _oxySatController.text = (readings['Optical Dissolved Oxygen: Compensated % Saturation'] ?? defaultValue).toStringAsFixed(5);
_tempController.text = (readings['External Temp: Degrees Celcius'] ?? defaultValue).toStringAsFixed(5); _phController.text = (readings['PH: PH units'] ?? defaultValue).toStringAsFixed(5);
_ecController.text = (readings['Conductivity: us/cm'] ?? defaultValue).toStringAsFixed(5); _tempController.text = (readings['External Temp: Degrees Celcius'] ?? defaultValue).toStringAsFixed(5);
_salinityController.text = (readings['Conductivity: Salinity'] ?? defaultValue).toStringAsFixed(5); _ecController.text = (readings['Conductivity: us/cm'] ?? defaultValue).toStringAsFixed(5);
_tdsController.text = (readings['Conductivity:TDS mg/L'] ?? defaultValue).toStringAsFixed(5); _salinityController.text = (readings['Conductivity: Salinity'] ?? defaultValue).toStringAsFixed(5);
_turbidityController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5); _tdsController.text = (readings['Conductivity:TDS mg/L'] ?? defaultValue).toStringAsFixed(5);
_batteryController.text = (readings['Sonde: Battery Voltage'] ?? defaultValue).toStringAsFixed(5); _turbidityController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5);
_ammoniaController.text = (readings['Ammonium (NH4+) mg/L'] ?? 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 { void _validateAndProceed() async {
// --- START MODIFICATION: Add lockout check ---
if (_isLockedOut) { if (_isLockedOut) {
_showSnackBar("Please wait for the initial reading period to complete.", isError: true); _showSnackBar("Please wait for the initial reading period to complete.", isError: true);
return; 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) { if (_isAutoReading) {
_showStopReadingDialog(); // Still show dialog if reading is actively running _showStopReadingDialog();
return; return;
} }
// Remove the forced disconnect check here because user can proceed if they stopped reading manually
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) {
return; return;
@ -466,15 +461,14 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_formKey.currentState!.save(); _formKey.currentState!.save();
final currentReadings = _captureReadingsToMap(); final currentReadings = _captureReadingsToMap();
// Directly load river-specific limits from the new table via DatabaseHelper.
final List<Map<String, dynamic>> riverLimits = await _dbHelper.loadRiverParameterLimits() ?? []; final List<Map<String, dynamic>> riverLimits = await _dbHelper.loadRiverParameterLimits() ?? [];
final outOfBoundsParams = _validateParameters(currentReadings, riverLimits); final outOfBoundsParams = _validateParameters(currentReadings, riverLimits);
setState(() { if (mounted) {
_outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet(); setState(() {
}); _outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet();
});
}
if (outOfBoundsParams.isNotEmpty) { if (outOfBoundsParams.isNotEmpty) {
_showParameterLimitDialog(outOfBoundsParams, currentReadings); _showParameterLimitDialog(outOfBoundsParams, currentReadings);
@ -482,7 +476,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_saveDataAndMoveOn(currentReadings); _saveDataAndMoveOn(currentReadings);
} }
} }
// --- END: MODIFIED VALIDATION FLOW ---
Map<String, double> _captureReadingsToMap() { Map<String, double> _captureReadingsToMap() {
final Map<String, double> readings = {}; final Map<String, double> readings = {};
@ -551,8 +544,16 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
if (_selectedFlowrateMethod == 'Surface Drifter') { if (_selectedFlowrateMethod == 'Surface Drifter') {
widget.data.flowrateSurfaceDrifterHeight = double.tryParse(_sdHeightController.text); widget.data.flowrateSurfaceDrifterHeight = double.tryParse(_sdHeightController.text);
widget.data.flowrateSurfaceDrifterDistance = double.tryParse(_sdDistanceController.text); widget.data.flowrateSurfaceDrifterDistance = double.tryParse(_sdDistanceController.text);
widget.data.flowrateSurfaceDrifterTimeFirst = _sdTimeFirstController.text;
widget.data.flowrateSurfaceDrifterTimeLast = _sdTimeLastController.text; 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;
widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); widget.data.flowrateValue = double.tryParse(_flowrateValueController.text);
} else if (_selectedFlowrateMethod == 'Flowmeter') { } else if (_selectedFlowrateMethod == 'Flowmeter') {
widget.data.flowrateSurfaceDrifterHeight = null; widget.data.flowrateSurfaceDrifterHeight = null;
@ -573,12 +574,14 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
return; return;
} }
setState(() { if (mounted) {
_outOfBoundsKeys.clear(); setState(() {
if (_previousReadingsForComparison != null) { _outOfBoundsKeys.clear();
_previousReadingsForComparison = null; if (_previousReadingsForComparison != null) {
} _previousReadingsForComparison = null;
}); }
});
}
widget.onNext(); widget.onNext();
} }
@ -608,9 +611,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
} }
Map<String, dynamic>? _getActiveConnectionDetails() { Map<String, dynamic>? _getActiveConnectionDetails() {
// --- START FIX: Use read() instead of watch() ---
final service = context.read<RiverInSituSamplingService>(); final service = context.read<RiverInSituSamplingService>();
// --- END FIX ---
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
return {'type': 'bluetooth', 'state': service.bluetoothConnectionState.value, 'name': service.connectedBluetoothDeviceName}; return {'type': 'bluetooth', 'state': service.bluetoothConnectionState.value, 'name': service.connectedBluetoothDeviceName};
@ -627,23 +628,16 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final activeConnection = _getActiveConnectionDetails(); final activeConnection = _getActiveConnectionDetails();
final String? activeType = activeConnection?['type'] as String?; 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; final bool shouldDisableInput = _isAutoReading || _isLockedOut;
// --- END MODIFICATION ---
return WillPopScope( return WillPopScope(
onWillPop: () async { onWillPop: () async {
if (_isLockedOut) { if (_isLockedOut) {
_showSnackBar("Please wait for the initial reading period to complete.", isError: true); _showSnackBar("Please wait for the initial reading period to complete.", isError: true);
return false; // Prevent back navigation return false;
} }
_disconnectFromAll(); _disconnectFromAll();
return true; // Allow back navigation return true;
}, },
child: Form( child: Form(
key: _formKey, key: _formKey,
@ -674,7 +668,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
ValueListenableBuilder<String?>( ValueListenableBuilder<String?>(
valueListenable: service.sondeId, valueListenable: service.sondeId,
builder: (context, sondeId, child) { builder: (context, sondeId, child) {
// --- START FIX: Only update if non-null to prevent clearing on disconnect ---
if (sondeId != null && sondeId.isNotEmpty) { if (sondeId != null && sondeId.isNotEmpty) {
final newSondeId = sondeId; final newSondeId = sondeId;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -684,7 +677,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
} }
}); });
} }
// --- END FIX ---
return TextFormField( return TextFormField(
controller: _sondeIdController, controller: _sondeIdController,
decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'), decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'),
@ -720,13 +712,10 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
), ),
const Divider(height: 32), const Divider(height: 32),
// --- MODIFIED: Use 'shouldDisableInput' instead of 'isDeviceConnected' ---
_buildFlowrateSection(isInputDisabled: shouldDisableInput), _buildFlowrateSection(isInputDisabled: shouldDisableInput),
const SizedBox(height: 32), const SizedBox(height: 32),
// --- MODIFIED: Enable Next button if reading stopped (even if connected) ---
ElevatedButton( ElevatedButton(
// Disable if locked out OR reading is active
onPressed: (_isLockedOut || _isAutoReading) ? null : _validateAndProceed, onPressed: (_isLockedOut || _isAutoReading) ? null : _validateAndProceed,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text( child: Text(
@ -735,7 +724,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
: (_isAutoReading ? 'Stop Reading to Proceed' : 'Next') : (_isAutoReading ? 'Stop Reading to Proceed' : 'Next')
), ),
), ),
// --- END MODIFICATION ---
], ],
), ),
), ),
@ -786,14 +774,12 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
if (isConnecting || _isLoading) if (isConnecting || _isLoading)
const CircularProgressIndicator() const CircularProgressIndicator()
else if (isConnected) else if (isConnected)
// Replaced Row with Wrap to fix horizontal overflow with countdown timer
Wrap( Wrap(
alignment: WrapAlignment.spaceEvenly, alignment: WrapAlignment.spaceEvenly,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8.0, // Horizontal space between buttons spacing: 8.0,
runSpacing: 4.0, // Vertical space if it wraps runSpacing: 4.0,
children: [ children: [
// Add countdown to Stop Reading button
ElevatedButton.icon( ElevatedButton.icon(
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
label: Text(_isAutoReading label: Text(_isAutoReading
@ -810,7 +796,9 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.link_off), icon: const Icon(Icons.link_off),
label: const Text('Disconnect'), label: const Text('Disconnect'),
onPressed: () => _disconnect(type), // --- MODIFICATION: Disable button if locked out ---
onPressed: _isLockedOut ? null : () => _disconnect(type),
// --- END MODIFICATION ---
style: TextButton.styleFrom(foregroundColor: Colors.red), style: TextButton.styleFrom(foregroundColor: Colors.red),
) )
], ],
@ -966,9 +954,11 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
TextButton( TextButton(
child: const Text('Resample'), child: const Text('Resample'),
onPressed: () { onPressed: () {
setState(() { if (mounted) {
_previousReadingsForComparison = readings; setState(() {
}); _previousReadingsForComparison = readings;
});
}
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
), ),
@ -985,7 +975,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
); );
} }
// Updated to include disable logic
Widget _buildFlowrateSection({bool isInputDisabled = false}) { Widget _buildFlowrateSection({bool isInputDisabled = false}) {
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0), margin: const EdgeInsets.symmetric(vertical: 4.0),
@ -1012,14 +1001,12 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Wrap content in AbsorbPointer and Opacity if connected
AbsorbPointer( AbsorbPointer(
absorbing: isInputDisabled, absorbing: isInputDisabled,
child: Opacity( child: Opacity(
opacity: isInputDisabled ? 0.5 : 1.0, opacity: isInputDisabled ? 0.5 : 1.0,
child: Column( child: Column(
children: [ children: [
// Replaced Row with Wrap to fix horizontal overflow for radio buttons
Wrap( Wrap(
alignment: WrapAlignment.spaceAround, alignment: WrapAlignment.spaceAround,
spacing: 8.0, spacing: 8.0,
@ -1027,10 +1014,9 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
children: [ children: [
_buildFlowrateRadioButton("Surface Drifter"), _buildFlowrateRadioButton("Surface Drifter"),
_buildFlowrateRadioButton("Flowmeter"), _buildFlowrateRadioButton("Flowmeter"),
_buildFlowrateRadioButton("NA"), // Not Applicable _buildFlowrateRadioButton("NA"),
], ],
), ),
// Conditional fields based on selected method
if (_selectedFlowrateMethod == 'Surface Drifter') if (_selectedFlowrateMethod == 'Surface Drifter')
_buildSurfaceDrifterFields(), _buildSurfaceDrifterFields(),
if (_selectedFlowrateMethod == 'Flowmeter') if (_selectedFlowrateMethod == 'Flowmeter')
@ -1058,7 +1044,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
Text( Text(
title, title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, // Add ellipsis handling for safety overflow: TextOverflow.ellipsis,
), ),
], ],
); );
@ -1068,12 +1054,12 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
return Padding( return Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TextFormField( TextFormField(
controller: _sdHeightController, controller: _sdHeightController,
decoration: const InputDecoration(labelText: 'Height (m)'), decoration: const InputDecoration(labelText: 'Height (m)'),
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
// Add validation if needed
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
@ -1083,20 +1069,46 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
validator: (v) => v == null || v.isEmpty ? 'Distance is required' : null, validator: (v) => v == null || v.isEmpty ? 'Distance is required' : null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( const Text("Duration of Travel", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
controller: _sdTimeFirstController, const SizedBox(height: 8),
decoration: const InputDecoration(labelText: 'Time First Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)), Row(
readOnly: true, children: [
onTap: () => _selectTime(context, _sdTimeFirstController), Expanded(
validator: (v) => v == null || v.isEmpty ? 'Start time is required' : null, child: TextFormField(
), controller: _sdDurationHourController,
const SizedBox(height: 16), decoration: const InputDecoration(
TextFormField( labelText: 'Hours',
controller: _sdTimeLastController, counterText: "",
decoration: const InputDecoration(labelText: 'Time Last Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)), ),
readOnly: true, keyboardType: TextInputType.number,
onTap: () => _selectTime(context, _sdTimeLastController), maxLength: 2,
validator: (v) => v == null || v.isEmpty ? 'End time is required' : null, ),
),
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), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
@ -1108,7 +1120,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
controller: _flowrateValueController, controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Calculated Flowrate (m/s)'), decoration: const InputDecoration(labelText: 'Calculated Flowrate (m/s)'),
readOnly: true, readOnly: true,
// Add validator if calculation must be done?
), ),
], ],
), ),
@ -1128,7 +1139,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
} }
Widget _buildNAField() { Widget _buildNAField() {
// Fix: Use controller to set value instead of initialValue to avoid conflict crash
if (_flowrateValueController.text != 'NA') { if (_flowrateValueController.text != 'NA') {
_flowrateValueController.text = 'NA'; _flowrateValueController.text = 'NA';
} }
@ -1138,8 +1148,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
child: TextFormField( child: TextFormField(
controller: _flowrateValueController, controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), decoration: const InputDecoration(labelText: 'Flowrate (m/s)'),
// initialValue: 'NA', // Removed to fix AssertionError: initialValue == null || controller == null readOnly: true,
readOnly: true, // Make it read-only
), ),
); );
} }

View File

@ -69,6 +69,9 @@ class _RiverInSituStep4AdditionalInfoState
stationCode: stationCode, // Pass the station code here stationCode: stationCode, // Pass the station code here
); );
// FIX: Check if widget is still mounted after the await call to prevent defunct exception
if (!mounted) return;
if (file != null) { if (file != null) {
setState(() => setImageCallback(file)); setState(() => setImageCallback(file));
} else if (mounted) { } else if (mounted) {

View File

@ -221,7 +221,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
ListTile( ListTile(
leading: const Icon(Icons.info_outline), leading: const Icon(Icons.info_outline),
title: const Text('App Version'), title: const Text('App Version'),
subtitle: const Text('MMS Version 3.12.03'), subtitle: const Text('MMS Version 3.12.06'),
dense: true, dense: true,
), ),
ListTile( ListTile(

View File

@ -179,6 +179,36 @@ class ApiService {
await _baseService.get(baseUrl, 'profile'); 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<void> 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<Map<String, dynamic>>.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<Map<String, dynamic>>.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<Map<String, dynamic>> _fetchDelta(String endpoint, String? lastSyncTimestamp) async { Future<Map<String, dynamic>> _fetchDelta(String endpoint, String? lastSyncTimestamp) async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
String url = endpoint; String url = endpoint;

View File

@ -53,6 +53,10 @@ class MarineInSituSamplingService {
final TelegramService _telegramService; final TelegramService _telegramService;
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
// --- START FIX: Activity Tracker ---
bool _isDisposed = false;
// --- END FIX ---
MarineInSituSamplingService(this._telegramService); MarineInSituSamplingService(this._telegramService);
static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
@ -69,7 +73,7 @@ class MarineInSituSamplingService {
}) async { }) async {
final picker = ImagePicker(); final picker = ImagePicker();
final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024); 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(); final bytes = await photo.readAsBytes();
img.Image? originalImage = img.decodeImage(bytes); 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 startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 2));
void stopSerialAutoReading() => _serialManager.stopAutoReading(); void stopSerialAutoReading() => _serialManager.stopAutoReading();
void dispose() { void dispose() {
_isDisposed = true;
_bluetoothManager.dispose(); _bluetoothManager.dispose();
_serialManager.dispose(); _serialManager.dispose();
} }
@ -333,14 +344,14 @@ class MarineInSituSamplingService {
bool anyFtpSuccess = false; bool anyFtpSuccess = false;
// --- START FIX: Check if FTP is enabled AND if it was already successful --- // --- 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) { if (!isFtpEnabled) {
debugPrint("FTP submission disabled for $moduleName by user preference. Skipping FTP."); debugPrint("FTP submission disabled for $moduleName by user preference. Skipping FTP.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user preference.', 'success': true}]}; ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user preference.', 'success': true}]};
anyFtpSuccess = true; anyFtpSuccess = true;
} else if (previousFtpSuccess) { } 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}]}; ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful in previous attempt.', 'success': true}]};
anyFtpSuccess = true; anyFtpSuccess = true;
} else { } else {
@ -478,10 +489,7 @@ class MarineInSituSamplingService {
// Save/Update local log first // Save/Update local log first
if (savedLogPath != null && savedLogPath.isNotEmpty) { if (savedLogPath != null && savedLogPath.isNotEmpty) {
// Need to reconstruct the map with file paths for updating // 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<String, dynamic> logUpdateData = data.toMap(); Map<String, dynamic> logUpdateData = data.toMap();
// --- END: MODIFICATION (FIXED ERROR) ---
final imageFiles = data.toApiImageFiles(); final imageFiles = data.toApiImageFiles();
imageFiles.forEach((key, file) { imageFiles.forEach((key, file) {
logUpdateData[key] = file?.path; // Add paths back 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."; 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 return {'success': true, 'message': successMessage, 'reportId': data.reportId}; // Return timestamp ID
} }
@ -622,10 +628,7 @@ class MarineInSituSamplingService {
final baseFileName = _generateBaseFileName(data); // Use helper final baseFileName = _generateBaseFileName(data); // Use helper
// Prepare log data map including file paths // 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<String, dynamic> logMapData = data.toMap(); Map<String, dynamic> logMapData = data.toMap();
// --- END: MODIFICATION (FIXED ERROR) ---
final imageFileMap = data.toApiImageFiles(); final imageFileMap = data.toApiImageFiles();
imageFileMap.forEach((key, file) { imageFileMap.forEach((key, file) {
logMapData[key] = file?.path; // Store path or null 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 // Find the limit data for this parameter AND this specific station
final limitData = allLimits.firstWhere( 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: () => <String, dynamic>{}, // Use explicit type orElse: () => <String, dynamic>{}, // Use explicit type
); );
@ -888,7 +892,7 @@ class MarineInSituSamplingService {
if (isHit) { if (isHit) {
final valueStr = value.toStringAsFixed(5); final valueStr = value.toStringAsFixed(5);
final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A'; final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A';
final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/á'; final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A';
String limitStr; String limitStr;
if (lowerStr != 'N/A' && upperStr != 'N/A') { if (lowerStr != 'N/A' && upperStr != 'N/A') {
limitStr = '$lowerStr - $upperStr'; limitStr = '$lowerStr - $upperStr';

View File

@ -52,6 +52,10 @@ class MarineInvestigativeSamplingService {
final TelegramService _telegramService; final TelegramService _telegramService;
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
// --- START FIX: Activity Tracker ---
bool _isDisposed = false;
// --- END FIX ---
MarineInvestigativeSamplingService(this._telegramService); MarineInvestigativeSamplingService(this._telegramService);
static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
@ -68,7 +72,9 @@ class MarineInvestigativeSamplingService {
}) async { }) async {
final picker = ImagePicker(); final picker = ImagePicker();
final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024); 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(); final bytes = await photo.readAsBytes();
img.Image? originalImage = img.decodeImage(bytes); 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 startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 2));
void stopSerialAutoReading() => _serialManager.stopAutoReading(); void stopSerialAutoReading() => _serialManager.stopAutoReading();
void dispose() { void dispose() {
_isDisposed = true; // --- FIX: Track disposal ---
_bluetoothManager.dispose(); _bluetoothManager.dispose();
_serialManager.dispose(); _serialManager.dispose();
} }
@ -459,7 +472,7 @@ class MarineInvestigativeSamplingService {
Future<Map<String, dynamic>> _performOfflineQueuing({ Future<Map<String, dynamic>> _performOfflineQueuing({
required MarineInvesManualSamplingData data, required MarineInvesManualSamplingData data,
required String moduleName, required String moduleName,
String? logDirectory, // Added for potential update String? logDirectory, // Pass for potential update
}) async { }) async {
final serverConfig = await _serverConfigService.getActiveApiConfig(); final serverConfig = await _serverConfigService.getActiveApiConfig();
final serverName = serverConfig?['config_name'] as String? ?? 'Default'; final serverName = serverConfig?['config_name'] as String? ?? 'Default';
@ -789,8 +802,11 @@ class MarineInvestigativeSamplingService {
final limitName = _parameterKeyToLimitName[key]; final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return; if (limitName == null) return;
// --- FIX: Ensure robust string-based ID comparison ---
final limitData = allLimits.firstWhere( 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: () => <String, dynamic>{}, orElse: () => <String, dynamic>{},
); );
@ -902,5 +918,4 @@ class MarineInvestigativeSamplingService {
return buffer.toString(); return buffer.toString();
} }
// --- END: NEW METHOD ---
} }

View File

@ -35,17 +35,29 @@ class UserPreferencesService {
return; return;
} }
debugPrint("Applying and auto-saving default submission preferences for the first time."); debugPrint("Checking availability of configs for default preference application...");
try { try {
// Get all possible configs from the database just once // Get all possible configs from the database just once
final allApiConfigs = await _dbHelper.loadApiConfigs() ?? []; final allApiConfigs = await _dbHelper.loadApiConfigs() ?? [];
final allFtpConfigs = await _dbHelper.loadFtpConfigs() ?? []; 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) { for (var module in _configurableModules) {
final moduleKey = module['key']!; final moduleKey = module['key']!;
// 1. Save master switches to enable API and FTP for the module. // 1. Save master switches to enable API and FTP for the module.
// FORCE them to be TRUE by default.
await saveModulePreference( await saveModulePreference(
moduleName: moduleKey, moduleName: moduleKey,
isApiEnabled: true, isApiEnabled: true,
@ -54,8 +66,10 @@ class UserPreferencesService {
// 2. Determine default API links // 2. Determine default API links
final defaultApiLinks = allApiConfigs.map((config) { final defaultApiLinks = allApiConfigs.map((config) {
bool isActive = (config['is_active'] == 1 || config['is_active'] == true); // Robust check for active status (handles int 1, string '1', bool true)
bool isPstwHq = (config['config_name'] == 'PSTW_HQ'); 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; bool isEnabled;
@ -82,7 +96,10 @@ class UserPreferencesService {
final defaultFtpLinks = allFtpConfigs.map((config) { final defaultFtpLinks = allFtpConfigs.map((config) {
final String configModule = config['ftp_module'] ?? ''; 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 // Enable if the config's module matches the current moduleKey AND it's active
bool isEnabled = (configModule == expectedFtpModuleKey) && isActive; bool isEnabled = (configModule == expectedFtpModuleKey) && isActive;
@ -107,13 +124,14 @@ class UserPreferencesService {
/// Retrieves a module's master submission preferences. /// 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<Map<String, dynamic>?> getModulePreference(String moduleName) async { Future<Map<String, dynamic>?> getModulePreference(String moduleName) async {
final preference = await _dbHelper.getModulePreference(moduleName); final preference = await _dbHelper.getModulePreference(moduleName);
if (preference != null) { if (preference != null) {
return preference; 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. /// 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; isEnabled = matchingLink['is_enabled'] as bool? ?? false;
} else { } else {
// No preference saved for this config. Apply default logic. // No preference saved for this config. Apply default logic.
bool isActive = (config['is_active'] == 1 || config['is_active'] == true); // Robust check for active status
bool isPstwHq = (config['config_name'] == 'PSTW_HQ'); 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 --- // --- MODIFIED: Special logic for Marine Report ---
if (moduleName == 'marine_report') { if (moduleName == 'marine_report') {
@ -218,7 +238,10 @@ class UserPreferencesService {
} else { } else {
// No preference saved for this config. Apply default logic. // No preference saved for this config. Apply default logic.
final String configModule = config['ftp_module'] ?? ''; 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 // Use the mapped key for comparison
isEnabled = (configModule == expectedFtpModuleKey) && isActive; isEnabled = (configModule == expectedFtpModuleKey) && isActive;
} }
@ -248,7 +271,7 @@ class UserPreferencesService {
/// destinations to send data to. /// destinations to send data to.
Future<List<Map<String, dynamic>>> getEnabledApiConfigsForModule(String moduleName) async { Future<List<Map<String, dynamic>>> getEnabledApiConfigsForModule(String moduleName) async {
// 1. Check the master switch for the module. // 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)) { if (pref == null || !(pref['is_api_enabled'] as bool)) {
debugPrint("API submissions are disabled for module '$moduleName'."); debugPrint("API submissions are disabled for module '$moduleName'.");
return []; // Return empty list if API is disabled or not set. 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. /// Retrieves only the FTP configurations that are actively enabled for a given module.
Future<List<Map<String, dynamic>>> getEnabledFtpConfigsForModule(String moduleName) async { Future<List<Map<String, dynamic>>> 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)) { if (pref == null || !(pref['is_ftp_enabled'] as bool)) {
debugPrint("FTP submissions are disabled for module '$moduleName'."); debugPrint("FTP submissions are disabled for module '$moduleName'.");
return []; return [];

View File

@ -75,4 +75,4 @@ flutter:
flutter_launcher_icons: flutter_launcher_icons:
android: true android: true
ios: true ios: true
image_path: "assets/icon_3_512x512.png" image_path: "assets/icon_4_512x512.png"