updated on mms to edc data
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 32 KiB |
BIN
assets/icon_4_512x512.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
assets/icon_5_512x512.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 626 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 998 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 27 KiB |
@ -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),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 ---
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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 ---
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
@ -610,4 +617,3 @@ class _NearbyStationsDialog extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
// --- END: New Dialog Widget ---
|
||||
@ -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.");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@ -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),
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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),
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 ---
|
||||
}
|
||||
@ -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 [];
|
||||
|
||||
@ -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"
|
||||