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: [
IconButton(
icon: const Icon(Icons.person),

View File

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

View File

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

View File

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

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

View File

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

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:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/user_preferences_service.dart';
import 'package:environment_monitoring_app/auth_provider.dart';
import 'package:environment_monitoring_app/home_page.dart';
@ -23,6 +24,7 @@ class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _passwordController = TextEditingController();
bool _isLoading = false;
String _errorMessage = '';
String _loadingMessage = ''; // To show dynamic status updates
@override
void dispose() {
@ -39,6 +41,7 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() {
_isLoading = true;
_errorMessage = '';
_loadingMessage = 'Authenticating...';
});
final auth = Provider.of<AuthProvider>(context, listen: false);
@ -58,10 +61,42 @@ class _LoginScreenState extends State<LoginScreen> {
// --- Online Success ---
final String token = result['data']['token'];
final Map<String, dynamic> profile = result['data']['profile'];
await auth.login(token, profile, password);
// --- FIRST TIME VS SUBSEQUENT LOGIN LOGIC ---
if (auth.isFirstLogin) {
await auth.setIsFirstLogin(false);
// >> FIRST TIME: BLOCKING WAIT REQUIRED <<
// We must ensure configs are present before the user reaches the dashboard
// so that the default routing (API/FTP) is set up correctly.
setState(() {
_loadingMessage = 'Setting up environment...\nFetching server configurations...';
});
try {
debugPrint("First time login: Fetching initial configurations (Blocking)...");
// 1. Fetch raw config data from server (API & FTP tables)
await apiService.fetchInitialConfigurations();
// 2. Apply default logic to "tick" the active servers
// We await this so the DB is ready before navigation.
await UserPreferencesService().applyAndSaveDefaultPreferencesIfNeeded();
// 3. Mark first login as done
await auth.setIsFirstLogin(false);
debugPrint("First time setup complete.");
} catch (e) {
debugPrint("Warning: Initial setup encountered an error: $e");
// Proceed anyway; user can sync manually later if needed.
}
} else {
// >> SUBSEQUENT LOGIN: NO BLOCKING <<
// User wants fast access. We skip the 'await' for configs.
// The app will rely on local data immediately.
// The HomePage will handle background syncing later.
debugPrint("Subsequent login: Skipping blocking config fetch. Using local data.");
}
if (!mounted) return;
@ -72,6 +107,7 @@ class _LoginScreenState extends State<LoginScreen> {
// --- Online Failure (API Error) ---
setState(() {
_errorMessage = result['message'] ?? 'Invalid email or password.';
_isLoading = false;
});
_showSnackBar(_errorMessage, isError: true);
}
@ -86,21 +122,22 @@ class _LoginScreenState extends State<LoginScreen> {
_showSnackBar("Connection failed. Trying offline login...", isError: true);
// FIX: Removed the unreliable connectivity check. Treat all exceptions here as a reason to try offline.
await _attemptOfflineLogin(auth, email, password);
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
// --- END: MODIFIED Internet-First Strategy ---
}
/// Helper function to perform offline validation and update UI.
Future<void> _attemptOfflineLogin(AuthProvider auth, String email, String password) async {
setState(() {
_loadingMessage = 'Verifying offline credentials...';
});
final bool offlineSuccess = await auth.loginOffline(email, password);
if (mounted) {
setState(() {
_isLoading = false;
});
if (offlineSuccess) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomePage()),
@ -141,7 +178,7 @@ class _LoginScreenState extends State<LoginScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
"PSTW MMS V4",
"PSTW MMS",
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
@ -166,7 +203,7 @@ class _LoginScreenState extends State<LoginScreen> {
),
],
image: const DecorationImage(
image: AssetImage('assets/icon_3_512x512.png'),
image: AssetImage('assets/icon_4_512x512.png'),
fit: BoxFit.cover, // Ensures the image fills the circle
),
),
@ -189,7 +226,17 @@ class _LoginScreenState extends State<LoginScreen> {
),
const SizedBox(height: 24),
_isLoading
? const CircularProgressIndicator()
? Column(
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
_loadingMessage,
style: const TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
)
: ElevatedButton(
onPressed: _login,
style: ElevatedButton.styleFrom(

View File

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

View File

@ -1,7 +1,7 @@
// lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart
import 'dart:io';
import 'dart:typed_data'; // <-- ADDED: Required for Uint8List
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
@ -27,7 +27,7 @@ class MarineInvesManualStep2SiteInfo extends StatefulWidget {
class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2SiteInfo> {
final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false; // <-- ADDED: State variable from in-situ
bool _isPickingImage = false;
late final TextEditingController _eventRemarksController;
late final TextEditingController _labRemarksController;
@ -50,7 +50,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
super.dispose();
}
// --- START: MODIFIED _setImage function (from in-situ) ---
/// Handles picking and processing an image using the dedicated service.
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return;
@ -60,21 +59,19 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
// Always pass `isRequired: true` to the service to enforce landscape check
// 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);
// --- FIX: Check if mounted after async call to prevent framework crash ---
if (!mounted) return;
if (file != null) {
setState(() => setImageCallback(file));
} else if (mounted) {
// Corrected snackbar message
} else {
_showSnackBar('Image selection failed. Please ensure all photos are taken in landscape (horizontal) mode.', isError: true);
}
if (mounted) {
setState(() => _isPickingImage = false);
}
setState(() => _isPickingImage = false);
}
// --- END: MODIFIED _setImage function ---
/// Validates the form and all required images before proceeding.
void _goToNextStep() {
@ -86,7 +83,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
return;
}
// Form validation handles the conditional requirement for Event Remarks
if (!_formKey.currentState!.validate()) {
return;
}
@ -106,7 +102,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
@override
Widget build(BuildContext context) {
// Logic to determine if Event Remarks are required
final bool areAdditionalPhotosAttached = widget.data.phPaperImage != null ||
widget.data.optionalImage1 != null ||
widget.data.optionalImage2 != null ||
@ -118,7 +113,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
// --- Section: On-Site Information ---
Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24),
DropdownButtonFormField<String>(
@ -146,9 +140,7 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
),
const SizedBox(height: 24),
// --- Section: Required Photos ---
Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge),
// MODIFIED: Matched in-situ text
const Text(
"All photos must be in landscape (horizontal) orientation. A watermark will be applied automatically.",
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),
const SizedBox(height: 24),
// --- Section: Additional photos and conditional remarks ---
Text("Additional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
_buildImagePicker('Examine Preservative (pH paper)', 'PH_PAPER', widget.data.phPaperImage, (file) => widget.data.phPaperImage = file, isRequired: false),
@ -172,7 +163,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
Text("Remarks", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
// Event Remarks field is conditionally required
TextFormField(
controller: _eventRemarksController,
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}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
@ -223,7 +211,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: FutureBuilder<Uint8List>(
// Use ValueKey to ensure FutureBuilder refetches when the file path changes
key: ValueKey(imageFile.path),
future: imageFile.readAsBytes(),
builder: (context, snapshot) {
@ -243,7 +230,6 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
child: const Icon(Icons.error, color: Colors.red, size: 40),
);
}
// Display the image from memory
return Image.memory(
snapshot.data!,
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 '../../../../models/marine_inves_manual_sampling_data.dart';
import '../../../../services/marine_investigative_sampling_service.dart';
import '../../../../bluetooth/bluetooth_manager.dart'; // For connection state enum
import '../../../../serial/serial_manager.dart'; // For connection state enum
import '../../../../bluetooth/bluetooth_manager.dart';
import '../../../../serial/serial_manager.dart';
import '../../../../bluetooth/widgets/bluetooth_device_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 {
final service = context.read<MarineInvestigativeSamplingService>();
final hasPermissions = await service.requestDevicePermissions();
if (!mounted) return;
if (!hasPermissions && mounted) {
_showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true);
return;
}
_disconnectFromAll();
await Future.delayed(const Duration(milliseconds: 250));
if (!mounted) return;
final bool connectionSuccess = await _connectToDevice(type);
if (connectionSuccess && mounted) {
_dataSubscription?.cancel();
@ -200,6 +204,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
try {
if (type == 'bluetooth') {
final devices = await service.getPairedBluetoothDevices();
if (!mounted) return false;
if (devices.isEmpty && mounted) {
_showSnackBar('No paired Bluetooth devices found.', isError: true);
return false;
@ -211,6 +216,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
}
} else if (type == 'serial') {
final devices = await service.getAvailableSerialDevices();
if (!mounted) return false;
if (devices.isEmpty && mounted) {
_showSnackBar('No USB Serial devices found.', isError: true);
return false;
@ -337,7 +343,6 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
});
}
// --- START: MODIFIED _validateAndProceed ---
void _validateAndProceed() {
if (_isLockedOut) {
_showSnackBar("Please wait for the initial reading period to complete.", isError: true);
@ -356,8 +361,6 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
final currentReadings = _captureReadingsToMap();
List<Map<String, dynamic>> outOfBoundsParams = [];
// --- NEW CONDITIONAL LOGIC ---
// Only check limits if it's a Manual Station
if (widget.data.stationTypeSelection == 'Existing Manual Station') {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
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();
});
} else {
// If not a manual station, ensure any old highlights are cleared
setState(() {
_outOfBoundsKeys.clear();
});
}
// --- END NEW CONDITIONAL LOGIC ---
if (outOfBoundsParams.isNotEmpty) {
_showParameterLimitDialog(outOfBoundsParams, currentReadings);
@ -380,7 +381,6 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
_saveDataAndMoveOn(currentReadings);
}
}
// --- END: MODIFIED _validateAndProceed ---
Map<String, double> _captureReadingsToMap() {
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) {
final List<Map<String, dynamic>> invalidParams = [];
int? stationId;
// This check is now redundant due to _validateAndProceed, but safe to keep
if (widget.data.stationTypeSelection == 'Existing Manual Station') {
stationId = widget.data.selectedStation?['station_id'];
}
debugPrint("--- Parameter Validation Start (Investigative) ---");
debugPrint("Selected Station ID: $stationId (from 'man_station_id')");
final dynamic stationId = widget.data.selectedStation?['station_id'] ?? widget.data.selectedStation?['man_station_id'];
double? _parseLimitValue(dynamic value) {
if (value == null) return null;
@ -417,23 +409,17 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return;
debugPrint("Checking parameter: '$limitName' (key: '$key')");
Map<String, dynamic> limitData = {};
if (stationId != null) {
limitData = limits.firstWhere(
(l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(),
(l) => l['param_parameter_list'] == limitName &&
(l['station_id']?.toString() == stationId.toString() ||
l['man_station_id']?.toString() == stationId.toString()),
orElse: () => {},
);
}
if (limitData.isNotEmpty) {
debugPrint(" > Found station-specific limit for Station ID $stationId: $limitData");
} else {
debugPrint(" > No station-specific limit found for Station ID $stationId. Skipping check for this parameter.");
}
if (limitData.isNotEmpty) {
final lowerLimit = _parseLimitValue(limitData['param_lower_limit']);
final upperLimit = _parseLimitValue(limitData['param_upper_limit']);
@ -450,8 +436,6 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
}
});
debugPrint("--- Parameter Validation End ---");
return invalidParams;
}
@ -531,9 +515,9 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
onWillPop: () async {
if (_isLockedOut) {
_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(
key: _formKey,
@ -832,7 +816,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
ElevatedButton.icon(
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
label: Text(_isAutoReading
? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading')
? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\\s)' : 'Stop Reading')
: 'Start Reading'),
onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type),
style: ElevatedButton.styleFrom(

View File

@ -178,14 +178,14 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text("NPE Parameter Limit Detected"),
title: const Text("Notification Pollution Event (NPE 1) Limit Detected"),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'The following parameters have fallen under the Non-Permissible Event (NPE) limit:'),
'The following parameters have fallen under the Notification Pollution Event (NPE) limit:'),
const SizedBox(height: 16),
Table(
columnWidths: const {

View File

@ -77,7 +77,7 @@ class _MarineManualPreDepartureChecklistScreenState
// Iterate through the map structure to initialize data
_checklistSections.forEach((section, 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] = '';
_remarksVisibility[item] = false;
}

View File

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

View File

@ -111,6 +111,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// --- FIX: Check if mounted before performing logic or calling setState ---
if (mounted) {
// Use the member variable, not context
final service = _samplingService;
@ -196,12 +197,16 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
Future<void> _handleConnectionAttempt(String type) async {
final service = context.read<MarineInSituSamplingService>();
final hasPermissions = await service.requestDevicePermissions();
if (!mounted) return; // --- FIX: Prevent context usage if unmounted ---
if (!hasPermissions && mounted) {
_showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true);
return;
}
_disconnectFromAll();
await Future.delayed(const Duration(milliseconds: 250));
if (!mounted) return; // --- FIX ---
final bool connectionSuccess = await _connectToDevice(type);
if (connectionSuccess && mounted) {
_dataSubscription?.cancel();
@ -219,6 +224,8 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
try {
if (type == 'bluetooth') {
final devices = await service.getPairedBluetoothDevices();
if (!mounted) return false; // --- FIX ---
if (devices.isEmpty && mounted) {
_showSnackBar('No paired Bluetooth devices found.', isError: true);
return false;
@ -230,6 +237,8 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
}
} else if (type == 'serial') {
final devices = await service.getAvailableSerialDevices();
if (!mounted) return false; // --- FIX ---
if (devices.isEmpty && mounted) {
_showSnackBar('No USB Serial devices found.', isError: true);
return false;
@ -409,20 +418,13 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) {
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("Selected Station ID: $stationId");
debugPrint("Total Marine Limits Loaded: ${limits.length}");
// --- START DEBUG: Add type inspection ---
if (limits.isNotEmpty && stationId != null) {
debugPrint("Inspecting the first loaded limit record: ${limits.first}");
debugPrint("Type of Selected Station ID ($stationId): ${stationId.runtimeType}");
debugPrint("Type of man_station_id in first limit record: ${limits.first['man_station_id']?.runtimeType}");
}
// --- END DEBUG ---
double? _parseLimitValue(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
@ -441,21 +443,17 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
Map<String, dynamic> limitData = {};
if (stationId != null) {
// --- START FIX: Use type-safe comparison ---
// --- START FIX: Use robust string comparison to handle data type differences ---
limitData = limits.firstWhere(
(l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(),
(l) => l['param_parameter_list'] == limitName &&
(l['station_id']?.toString() == stationId.toString() || l['man_station_id']?.toString() == stationId.toString()),
orElse: () => {},
);
// --- END FIX ---
}
if (limitData.isNotEmpty) {
debugPrint(" > Found station-specific limit for Station ID $stationId: $limitData");
} else {
debugPrint(" > No station-specific limit found for Station ID $stationId. Skipping check for this parameter.");
}
if (limitData.isNotEmpty) {
debugPrint(" > Found station-specific limit: $limitData");
final lowerLimit = _parseLimitValue(limitData['param_lower_limit']);
final upperLimit = _parseLimitValue(limitData['param_upper_limit']);
@ -468,6 +466,8 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
'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];
if (limitName == null) return;
// Find limits for this specific station
// FIX: Use robust string comparison to handle data type differences (int vs String)
// Checks both 'station_id' and 'man_station_id' keys in the limits list
final limitData = marineLimits.firstWhere(
(l) =>
l['param_parameter_list'] == limitName &&
l['station_id']?.toString() == stationId.toString(),
(l['station_id']?.toString() == stationId.toString() ||
l['man_station_id']?.toString() == stationId.toString()),
orElse: () => {},
);
@ -179,14 +181,14 @@ class _InSituStep4SummaryState extends State<InSituStep4Summary> {
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text("NPE Parameter Limit Detected"),
title: const Text("Notification Pollution Event (NPE 1) Limit Detected"),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'The following parameters have fallen under the Non-Permissible Event (NPE) limit:'),
'The following parameters have fallen under the Notification Pollution Event (NPE) limit:'),
const SizedBox(height: 16),
Table(
columnWidths: const {

View File

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

View File

@ -28,6 +28,9 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
final _formKey = GlobalKey<FormState>();
bool _isLoadingLocation = false;
// New flag to track if user manually typed the location
bool _isManualLocationEntry = false;
late final TextEditingController _firstSamplerController;
late final TextEditingController _dateController;
late final TextEditingController _timeController;
@ -117,6 +120,9 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
final position = await service.getCurrentLocation();
if (mounted) {
setState(() {
// Reset manual entry flag because we successfully used GPS
_isManualLocationEntry = false;
widget.data.currentLatitude = position.latitude.toString();
widget.data.currentLongitude = position.longitude.toString();
_currentLatController.text = widget.data.currentLatitude!;
@ -153,6 +159,11 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
setState(() {
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 auth = Provider.of<AuthProvider>(context, listen: false);
final currentLat = double.parse(widget.data.currentLatitude!);
final currentLon = double.parse(widget.data.currentLongitude!);
final currentLat = double.tryParse(widget.data.currentLatitude ?? '');
final currentLon = double.tryParse(widget.data.currentLongitude ?? '');
if (currentLat == null || currentLon == null) return;
final allStations = auth.riverManualStations ?? [];
final List<Map<String, dynamic>> nearbyStations = [];
@ -232,10 +246,18 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
}
void _goToNextStep() {
Future<void> _goToNextStep() async {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
// NEW: Check if manually entered, ask for confirmation
if (_isManualLocationEntry) {
final confirmed = await _showLocationConfirmationDialog();
if (confirmed != true) {
return; // Stop if user cancels
}
}
final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000;
if (distanceInMeters > 50) {
@ -247,6 +269,38 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<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 {
final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks);
final dialogFormKey = GlobalKey<FormState>();
@ -436,9 +490,42 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
Text("Location Verification", style: Theme.of(context).textTheme.titleLarge),
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),
TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')),
// MODIFIED: Enabled manual entry for Current Longitude
TextFormField(
controller: _currentLonController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Current Longitude *',
hintText: 'e.g. 101.68685',
),
validator: (val) => val == null || val.isEmpty ? "Longitude is required" : null,
onChanged: (val) {
// Set manual flag to true and recalculate
_isManualLocationEntry = true;
widget.data.currentLongitude = val;
_calculateDistance();
},
),
if (widget.data.distanceDifferenceInKm != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),
@ -471,7 +558,7 @@ class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTrien
OutlinedButton.icon(
onPressed: _isLoadingLocation ? null : _getCurrentLocation,
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),

View File

@ -87,8 +87,12 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
final _flowrateValueController = TextEditingController();
final _sdHeightController = TextEditingController();
final _sdDistanceController = TextEditingController();
final _sdTimeFirstController = TextEditingController();
final _sdTimeLastController = TextEditingController();
// --- MODIFICATION: Duration controllers instead of TimeFirst/Last ---
final _sdDurationHourController = TextEditingController();
final _sdDurationMinuteController = TextEditingController();
final _sdDurationSecondController = TextEditingController();
// --- END MODIFICATION ---
@override
void initState() {
@ -101,15 +105,9 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
@override
void dispose() {
_dataSubscription?.cancel();
_lockoutTimer?.cancel(); // --- MODIFICATION: Cancel timer on dispose ---
if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_samplingService.disconnectFromBluetooth();
}
if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) {
_samplingService.disconnectFromSerial();
}
// --- MODIFICATION: Robust cleanup on dispose ---
_disconnectFromAll();
// --- END MODIFICATION ---
_disposeControllers();
_disposeFlowrateControllers();
@ -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() {
widget.data.dataCaptureDate = widget.data.samplingDate;
widget.data.dataCaptureTime = widget.data.samplingTime;
@ -196,16 +213,29 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
_flowrateValueController.text = widget.data.flowrateValue?.toString() ?? '';
_sdHeightController.text = widget.data.flowrateSurfaceDrifterHeight?.toString() ?? '';
_sdDistanceController.text = widget.data.flowrateSurfaceDrifterDistance?.toString() ?? '';
_sdTimeFirstController.text = widget.data.flowrateSurfaceDrifterTimeFirst ?? '';
_sdTimeLastController.text = widget.data.flowrateSurfaceDrifterTimeLast ?? '';
// --- MODIFICATION: Parse existing duration if available ---
if (widget.data.flowrateSurfaceDrifterTimeLast != null &&
widget.data.flowrateSurfaceDrifterTimeLast!.contains(':')) {
final parts = widget.data.flowrateSurfaceDrifterTimeLast!.split(':');
if (parts.length == 3) {
_sdDurationHourController.text = parts[0];
_sdDurationMinuteController.text = parts[1];
_sdDurationSecondController.text = parts[2];
}
}
// --- END MODIFICATION ---
}
void _disposeFlowrateControllers() {
_flowrateValueController.dispose();
_sdHeightController.dispose();
_sdDistanceController.dispose();
_sdTimeFirstController.dispose();
_sdTimeLastController.dispose();
// --- MODIFICATION: Dispose duration controllers ---
_sdDurationHourController.dispose();
_sdDurationMinuteController.dispose();
_sdDurationSecondController.dispose();
// --- END MODIFICATION ---
}
void _onFlowrateMethodChanged(String? value) {
@ -215,13 +245,14 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
if (value == 'NA') {
_flowrateValueController.text = 'NA';
} else if (value == 'Flowmeter') {
// --- MODIFICATION: Clear flowrate value for Flowmeter ---
_flowrateValueController.clear();
// --- END MODIFICATION ---
_sdHeightController.clear();
_sdDistanceController.clear();
_sdTimeFirstController.clear();
_sdTimeLastController.clear();
// --- MODIFICATION: Clear duration fields ---
_sdDurationHourController.clear();
_sdDurationMinuteController.clear();
_sdDurationSecondController.clear();
// --- END MODIFICATION ---
} else { // Surface Drifter
_flowrateValueController.clear(); // Will be calculated
@ -231,42 +262,32 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
void _calculateFlowrate() {
final distance = double.tryParse(_sdDistanceController.text);
final timeFirstStr = _sdTimeFirstController.text;
final timeLastStr = _sdTimeLastController.text;
// --- MODIFICATION: Calculate using duration ---
final hours = int.tryParse(_sdDurationHourController.text) ?? 0;
final minutes = int.tryParse(_sdDurationMinuteController.text) ?? 0;
final seconds = int.tryParse(_sdDurationSecondController.text) ?? 0;
if (distance == null || timeFirstStr.isEmpty || timeLastStr.isEmpty) {
_showSnackBar("Please fill in Distance, Time First, and Time Last.", isError: true);
if (distance == null) {
_showSnackBar("Please enter the Distance.", isError: true);
return;
}
final totalSeconds = (hours * 3600) + (minutes * 60) + seconds;
if (totalSeconds <= 0) {
_showSnackBar("Total duration must be greater than zero.", isError: true);
return;
}
try {
final timeFormat = DateFormat("HH:mm:ss");
// Use a common date (like today) to allow time difference calculation across midnight
final now = DateTime.now();
final timeFirst = timeFormat.parse(timeFirstStr);
final dateTimeFirst = DateTime(now.year, now.month, now.day, timeFirst.hour, timeFirst.minute, timeFirst.second);
final timeLast = timeFormat.parse(timeLastStr);
var dateTimeLast = DateTime(now.year, now.month, now.day, timeLast.hour, timeLast.minute, timeLast.second);
// Handle crossing midnight
if (dateTimeLast.isBefore(dateTimeFirst)) {
dateTimeLast = dateTimeLast.add(const Duration(days: 1));
}
final differenceInSeconds = dateTimeLast.difference(dateTimeFirst).inSeconds;
if (differenceInSeconds <= 0) {
_showSnackBar("Time Last Deploy must be after Time First Deploy.", isError: true);
return;
}
final flowrate = distance / differenceInSeconds;
final flowrate = distance / totalSeconds;
setState(() {
_flowrateValueController.text = flowrate.toStringAsFixed(4);
});
} catch (e) {
_showSnackBar("Invalid time format. Please use HH:mm:ss.", isError: true);
_showSnackBar("Error calculating flowrate.", isError: true);
}
// --- END MODIFICATION ---
}
Future<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);
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);
if (connectionSuccess && mounted) {
_dataSubscription?.cancel();
@ -350,7 +378,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
return success;
}
// --- START MODIFICATION: Countdown Timer Logic ---
// --- START: MODIFIED VALIDATION FLOW ---
void _startLockoutTimer() {
_lockoutTimer?.cancel();
setState(() {
@ -375,7 +403,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
}
});
}
// --- END MODIFICATION ---
void _toggleAutoReading(String activeType) {
final service = context.read<RiverInSituSamplingService>();
@ -400,9 +427,14 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
} else {
service.disconnectFromSerial();
}
// --- MODIFICATION: Unconditional cleanup ---
_dataSubscription?.cancel();
_dataSubscription = null;
_lockoutTimer?.cancel(); // --- MODIFICATION: Cancel timer on disconnect ---
_lockoutTimer?.cancel();
_clearDataFields(); // Clear UI
// --- END MODIFICATION ---
if (mounted) {
setState(() {
_isAutoReading = false;
@ -413,15 +445,30 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
}
void _disconnectFromAll() {
// --- START MODIFICATION ---
final service = _samplingService; // NEW: Use the member variable
// --- END MODIFICATION ---
// --- MODIFICATION: Unconditional cleanup ---
// 1. Cancel local listeners first
_dataSubscription?.cancel();
_dataSubscription = null;
_lockoutTimer?.cancel();
// 2. Disconnect services if active
final service = _samplingService;
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_disconnect('bluetooth');
service.disconnectFromBluetooth();
}
if (service.serialConnectionState.value != SerialConnectionState.disconnected) {
_disconnect('serial');
service.disconnectFromSerial();
}
// 3. Reset local state
if (mounted) {
setState(() {
_isAutoReading = false;
_isLockedOut = false;
_isLoading = false;
});
}
// --- END MODIFICATION ---
}
void _updateTextFields(Map<String, double> readings) {
@ -440,7 +487,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
});
}
// --- START: MODIFIED VALIDATION FLOW ---
void _validateAndProceed() async {
// --- START MODIFICATION: Add lockout check ---
if (_isLockedOut) {
@ -450,7 +496,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
// --- END MODIFICATION ---
// --- 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) {
_showStopReadingDialog();
return;
@ -478,7 +523,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
_saveDataAndMoveOn(currentReadings);
}
}
// --- END: MODIFIED VALIDATION FLOW ---
Map<String, double> _captureReadingsToMap() {
final Map<String, double> readings = {};
@ -532,6 +576,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
void _saveDataAndMoveOn(Map<String, double> readings) {
try {
const defaultValue = -999.0;
widget.data.sondeId = _sondeIdController.text; // Ensure ID is saved
widget.data.temperature = readings['temperature'] ?? defaultValue;
widget.data.ph = readings['ph'] ?? defaultValue;
widget.data.salinity = readings['salinity'] ?? defaultValue;
@ -547,8 +592,18 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
if (_selectedFlowrateMethod == 'Surface Drifter') {
widget.data.flowrateSurfaceDrifterHeight = double.tryParse(_sdHeightController.text);
widget.data.flowrateSurfaceDrifterDistance = double.tryParse(_sdDistanceController.text);
widget.data.flowrateSurfaceDrifterTimeFirst = _sdTimeFirstController.text;
widget.data.flowrateSurfaceDrifterTimeLast = _sdTimeLastController.text;
// --- MODIFICATION: Save formatted duration ---
String twoDigits(int n) => n.toString().padLeft(2, "0");
String formattedDuration =
"${twoDigits(int.tryParse(_sdDurationHourController.text) ?? 0)}:"
"${twoDigits(int.tryParse(_sdDurationMinuteController.text) ?? 0)}:"
"${twoDigits(int.tryParse(_sdDurationSecondController.text) ?? 0)}";
widget.data.flowrateSurfaceDrifterTimeFirst = "00:00:00";
widget.data.flowrateSurfaceDrifterTimeLast = formattedDuration;
// --- END MODIFICATION ---
widget.data.flowrateValue = double.tryParse(_flowrateValueController.text);
} else if (_selectedFlowrateMethod == 'Flowmeter') {
widget.data.flowrateSurfaceDrifterHeight = null;
@ -809,7 +864,9 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
TextButton.icon(
icon: const Icon(Icons.link_off),
label: const Text('Disconnect'),
onPressed: () => _disconnect(type),
// --- MODIFICATION: Disabled during lockout ---
onPressed: _isLockedOut ? null : () => _disconnect(type),
// --- END MODIFICATION ---
style: TextButton.styleFrom(foregroundColor: Colors.red),
)
],
@ -985,7 +1042,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
);
}
// Updated to include disable logic
Widget _buildFlowrateSection({bool isInputDisabled = false}) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0),
@ -1012,14 +1068,12 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
),
),
const SizedBox(height: 8),
// Wrap content in AbsorbPointer and Opacity if connected
AbsorbPointer(
absorbing: isInputDisabled,
child: Opacity(
opacity: isInputDisabled ? 0.5 : 1.0,
child: Column(
children: [
// Replaced Row with Wrap to fix horizontal overflow for radio buttons
Wrap(
alignment: WrapAlignment.spaceAround,
spacing: 8.0,
@ -1027,10 +1081,9 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
children: [
_buildFlowrateRadioButton("Surface Drifter"),
_buildFlowrateRadioButton("Flowmeter"),
_buildFlowrateRadioButton("NA"), // Not Applicable
_buildFlowrateRadioButton("NA"),
],
),
// Conditional fields based on selected method
if (_selectedFlowrateMethod == 'Surface Drifter')
_buildSurfaceDrifterFields(),
if (_selectedFlowrateMethod == 'Flowmeter')
@ -1058,7 +1111,7 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
Text(
title,
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(
padding: const EdgeInsets.only(top: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _sdHeightController,
decoration: const InputDecoration(labelText: 'Height (m)'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
// Add validation if needed
),
const SizedBox(height: 16),
TextFormField(
@ -1083,21 +1136,51 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
validator: (v) => v == null || v.isEmpty ? 'Distance is required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _sdTimeFirstController,
decoration: const InputDecoration(labelText: 'Time First Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)),
readOnly: true,
onTap: () => _selectTime(context, _sdTimeFirstController),
validator: (v) => v == null || v.isEmpty ? 'Start time is required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _sdTimeLastController,
decoration: const InputDecoration(labelText: 'Time Last Deploy (HH:mm:ss) *', suffixIcon: Icon(Icons.timer)),
readOnly: true,
onTap: () => _selectTime(context, _sdTimeLastController),
validator: (v) => v == null || v.isEmpty ? 'End time is required' : null,
// --- MODIFICATION: Duration Input Fields ---
const Text("Duration of Travel", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextFormField(
controller: _sdDurationHourController,
decoration: const InputDecoration(
labelText: 'Hours',
counterText: "",
),
keyboardType: TextInputType.number,
maxLength: 2,
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _sdDurationMinuteController,
decoration: const InputDecoration(
labelText: 'Minutes',
counterText: "",
),
keyboardType: TextInputType.number,
maxLength: 2,
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _sdDurationSecondController,
decoration: const InputDecoration(
labelText: 'Seconds',
counterText: "",
),
keyboardType: TextInputType.number,
maxLength: 2,
),
),
],
),
// --- END MODIFICATION ---
const SizedBox(height: 16),
ElevatedButton(
onPressed: _calculateFlowrate,
@ -1108,7 +1191,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Calculated Flowrate (m/s)'),
readOnly: true,
// Add validator if calculation must be done?
),
],
),
@ -1128,7 +1210,6 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
}
Widget _buildNAField() {
// Fix: Use controller to set value instead of initialValue to avoid conflict crash
if (_flowrateValueController.text != 'NA') {
_flowrateValueController.text = 'NA';
}
@ -1138,10 +1219,8 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
child: TextFormField(
controller: _flowrateValueController,
decoration: const InputDecoration(labelText: 'Flowrate (m/s)'),
// initialValue: 'NA', // Removed to fix AssertionError: initialValue == null || controller == null
readOnly: true, // Make it read-only
readOnly: true,
),
);
}
} // End of State class
}

View File

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

View File

@ -46,6 +46,9 @@ class _RiverInSituStep2SiteInfoState extends State<RiverInSituStep2SiteInfo> {
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return;
// Safety check before starting
if (!mounted) return;
setState(() => _isPickingImage = true);
final service = Provider.of<RiverInSituSamplingService>(context, listen: false);
@ -62,6 +65,10 @@ class _RiverInSituStep2SiteInfoState extends State<RiverInSituStep2SiteInfo> {
stationCode: stationCode, // Pass the station code here
);
// --- CRITICAL FIX: Check if widget is still mounted after the await call ---
// This prevents "Looking up a deactivated widget's ancestor" if user closed camera/app.
if (!mounted) return;
if (file != null) {
setState(() => setImageCallback(file));
} else if (mounted) {
@ -201,4 +208,4 @@ class _RiverInSituStep2SiteInfoState extends State<RiverInSituStep2SiteInfo> {
),
);
}
}
}

View File

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

View File

@ -69,6 +69,9 @@ class _RiverInSituStep4AdditionalInfoState
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) {
setState(() => setImageCallback(file));
} else if (mounted) {

View File

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

View File

@ -179,6 +179,36 @@ class ApiService {
await _baseService.get(baseUrl, 'profile');
}
// --- NEW METHOD FOR FIRST TIME LOGIN ---
/// Fetches critical configuration data (API and FTP settings) needed for
/// immediate operation. This should be called during the login/splash sequence.
Future<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 {
final baseUrl = await _serverConfigService.getActiveApiUrl();
String url = endpoint;

View File

@ -53,6 +53,10 @@ class MarineInSituSamplingService {
final TelegramService _telegramService;
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
// --- START FIX: Activity Tracker ---
bool _isDisposed = false;
// --- END FIX ---
MarineInSituSamplingService(this._telegramService);
static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
@ -69,7 +73,7 @@ class MarineInSituSamplingService {
}) async {
final picker = ImagePicker();
final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024);
if (photo == null) return null;
if (photo == null || _isDisposed) return null;
final bytes = await photo.readAsBytes();
img.Image? originalImage = img.decodeImage(bytes);
@ -156,11 +160,18 @@ class MarineInSituSamplingService {
}
}
void disconnectFromSerial() => _serialManager.disconnect();
// --- START FIX: Handle Thread Interruption during disconnect ---
void disconnectFromSerial() {
stopSerialAutoReading();
_serialManager.disconnect();
}
// --- END FIX ---
void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 2));
void stopSerialAutoReading() => _serialManager.stopAutoReading();
void dispose() {
_isDisposed = true;
_bluetoothManager.dispose();
_serialManager.dispose();
}
@ -333,14 +344,14 @@ class MarineInSituSamplingService {
bool anyFtpSuccess = false;
// --- START FIX: Check if FTP is enabled AND if it was already successful ---
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
bool previousFtpSuccess = previousStatus == 'L4' || previousStatus == 'S4';
if (!isFtpEnabled) {
debugPrint("FTP submission disabled for $moduleName by user preference. Skipping FTP.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user preference.', 'success': true}]};
anyFtpSuccess = true;
} else if (previousFtpSuccess) {
debugPrint("FTP submission skipped because it was already successful (Status: ${data.submissionStatus}).");
debugPrint("FTP submission skipped because it was already successful (Status: $previousStatus).");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful in previous attempt.', 'success': true}]};
anyFtpSuccess = true;
} else {
@ -478,10 +489,7 @@ class MarineInSituSamplingService {
// Save/Update local log first
if (savedLogPath != null && savedLogPath.isNotEmpty) {
// Need to reconstruct the map with file paths for updating
// --- START: MODIFICATION (FIXED ERROR) ---
// Changed data.toDbJson() to data.toMap() to get a Map, not a String.
Map<String, dynamic> logUpdateData = data.toMap();
// --- END: MODIFICATION (FIXED ERROR) ---
final imageFiles = data.toApiImageFiles();
imageFiles.forEach((key, file) {
logUpdateData[key] = file?.path; // Add paths back
@ -515,8 +523,6 @@ class MarineInSituSamplingService {
);
const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored.";
// Log final queued state to central DB
// await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, apiRecordId: null, logDirectory: savedLogPath);
return {'success': true, 'message': successMessage, 'reportId': data.reportId}; // Return timestamp ID
}
@ -622,10 +628,7 @@ class MarineInSituSamplingService {
final baseFileName = _generateBaseFileName(data); // Use helper
// Prepare log data map including file paths
// --- START: MODIFICATION (FIXED ERROR) ---
// Changed data.toDbJson() to data.toMap() to get a Map, not a String.
Map<String, dynamic> logMapData = data.toMap();
// --- END: MODIFICATION (FIXED ERROR) ---
final imageFileMap = data.toApiImageFiles();
imageFileMap.forEach((key, file) {
logMapData[key] = file?.path; // Store path or null
@ -795,7 +798,8 @@ class MarineInSituSamplingService {
// Find the limit data for this parameter AND this specific station
final limitData = allLimits.firstWhere(
(l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(),
(l) => l['param_parameter_list'] == limitName &&
(l['station_id']?.toString() == stationId.toString() || l['man_station_id']?.toString() == stationId.toString()),
orElse: () => <String, dynamic>{}, // Use explicit type
);
@ -888,7 +892,7 @@ class MarineInSituSamplingService {
if (isHit) {
final valueStr = value.toStringAsFixed(5);
final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A';
final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/á';
final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A';
String limitStr;
if (lowerStr != 'N/A' && upperStr != 'N/A') {
limitStr = '$lowerStr - $upperStr';

View File

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

View File

@ -35,17 +35,29 @@ class UserPreferencesService {
return;
}
debugPrint("Applying and auto-saving default submission preferences for the first time.");
debugPrint("Checking availability of configs for default preference application...");
try {
// Get all possible configs from the database just once
final allApiConfigs = await _dbHelper.loadApiConfigs() ?? [];
final allFtpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
// --- CRITICAL CHECK ---
// If we haven't synced data from the server yet, do NOT attempt to apply defaults.
// If we proceed now, we would save "empty" preferences and the flag would be set to true,
// preventing the defaults from ever being applied correctly later.
if (allApiConfigs.isEmpty && allFtpConfigs.isEmpty) {
debugPrint("No API or FTP configs found in local DB. Skipping default application. Will retry later.");
return;
}
debugPrint("Configs found. Applying and auto-saving default submission preferences.");
for (var module in _configurableModules) {
final moduleKey = module['key']!;
// 1. Save master switches to enable API and FTP for the module.
// FORCE them to be TRUE by default.
await saveModulePreference(
moduleName: moduleKey,
isApiEnabled: true,
@ -54,8 +66,10 @@ class UserPreferencesService {
// 2. Determine default API links
final defaultApiLinks = allApiConfigs.map((config) {
bool isActive = (config['is_active'] == 1 || config['is_active'] == true);
bool isPstwHq = (config['config_name'] == 'PSTW_HQ');
// Robust check for active status (handles int 1, string '1', bool true)
final activeVal = config['is_active'];
final bool isActive = activeVal == 1 || activeVal == true || activeVal.toString() == '1';
final bool isPstwHq = (config['config_name'] == 'PSTW_HQ');
bool isEnabled;
@ -82,7 +96,10 @@ class UserPreferencesService {
final defaultFtpLinks = allFtpConfigs.map((config) {
final String configModule = config['ftp_module'] ?? '';
final bool isActive = (config['is_active'] == 1 || config['is_active'] == true);
// Robust check for active status (Handles your SQL data where is_active is 1)
final activeVal = config['is_active'];
final bool isActive = activeVal == 1 || activeVal == true || activeVal.toString() == '1';
// Enable if the config's module matches the current moduleKey AND it's active
bool isEnabled = (configModule == expectedFtpModuleKey) && isActive;
@ -107,13 +124,14 @@ class UserPreferencesService {
/// Retrieves a module's master submission preferences.
/// This method now returns null if no preference is found.
/// This method now returns a default TRUE object if no preference is found.
Future<Map<String, dynamic>?> getModulePreference(String moduleName) async {
final preference = await _dbHelper.getModulePreference(moduleName);
if (preference != null) {
return preference;
}
return null;
// Return default enabled state (TRUE) if not found in DB yet
return {'is_api_enabled': true, 'is_ftp_enabled': true};
}
/// Saves or updates a module's master on/off switches for API and FTP submissions.
@ -162,8 +180,10 @@ class UserPreferencesService {
isEnabled = matchingLink['is_enabled'] as bool? ?? false;
} else {
// No preference saved for this config. Apply default logic.
bool isActive = (config['is_active'] == 1 || config['is_active'] == true);
bool isPstwHq = (config['config_name'] == 'PSTW_HQ');
// Robust check for active status
final activeVal = config['is_active'];
final bool isActive = activeVal == 1 || activeVal == true || activeVal.toString() == '1';
final bool isPstwHq = (config['config_name'] == 'PSTW_HQ');
// --- MODIFIED: Special logic for Marine Report ---
if (moduleName == 'marine_report') {
@ -218,7 +238,10 @@ class UserPreferencesService {
} else {
// No preference saved for this config. Apply default logic.
final String configModule = config['ftp_module'] ?? '';
final bool isActive = (config['is_active'] == 1 || config['is_active'] == true);
// Robust check for active status
final activeVal = config['is_active'];
final bool isActive = activeVal == 1 || activeVal == true || activeVal.toString() == '1';
// Use the mapped key for comparison
isEnabled = (configModule == expectedFtpModuleKey) && isActive;
}
@ -248,7 +271,7 @@ class UserPreferencesService {
/// destinations to send data to.
Future<List<Map<String, dynamic>>> getEnabledApiConfigsForModule(String moduleName) async {
// 1. Check the master switch for the module.
final pref = await _dbHelper.getModulePreference(moduleName); // Use direct DB call
final pref = await getModulePreference(moduleName); // Use method that has default
if (pref == null || !(pref['is_api_enabled'] as bool)) {
debugPrint("API submissions are disabled for module '$moduleName'.");
return []; // Return empty list if API is disabled or not set.
@ -266,7 +289,7 @@ class UserPreferencesService {
/// Retrieves only the FTP configurations that are actively enabled for a given module.
Future<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)) {
debugPrint("FTP submissions are disabled for module '$moduleName'.");
return [];

View File

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