add in report module for marine department
This commit is contained in:
parent
077efa745d
commit
768047ad18
@ -29,7 +29,7 @@
|
||||
|
||||
<!-- MMS V4 1.2.08 -->
|
||||
<application
|
||||
android:label="MMS V4 1.2.09"
|
||||
android:label="MMS V4 1.2.11"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
|
||||
@ -8,12 +8,17 @@ import 'package:provider/single_child_widget.dart';
|
||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
||||
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
|
||||
import 'package:environment_monitoring_app/services/river_manual_triennial_sampling_service.dart';
|
||||
import 'package:environment_monitoring_app/services/air_sampling_service.dart';
|
||||
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
||||
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
||||
import 'package:environment_monitoring_app/services/retry_service.dart';
|
||||
import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart';
|
||||
import 'package:environment_monitoring_app/services/marine_npe_report_service.dart';
|
||||
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart';
|
||||
import 'package:environment_monitoring_app/services/marine_manual_pre_departure_service.dart';
|
||||
import 'package:environment_monitoring_app/services/marine_manual_sonde_calibration_service.dart';
|
||||
import 'package:environment_monitoring_app/services/marine_manual_equipment_maintenance_service.dart';
|
||||
|
||||
import 'package:environment_monitoring_app/theme.dart';
|
||||
import 'package:environment_monitoring_app/auth_provider.dart';
|
||||
@ -52,9 +57,8 @@ import 'package:environment_monitoring_app/screens/air/investigative/report.dart
|
||||
import 'package:environment_monitoring_app/screens/river/manual/river_manual_info_centre_document.dart';
|
||||
import 'package:environment_monitoring_app/screens/river/manual/in_situ_sampling.dart' as riverManualInSituSampling;
|
||||
import 'package:environment_monitoring_app/screens/river/manual/river_manual_data_status_log.dart' as riverManualDataStatusLog;
|
||||
// MODIFIED: Import paths updated to new filenames
|
||||
import 'package:environment_monitoring_app/screens/river/manual/river_manual_report.dart' as riverManualReport;
|
||||
import 'package:environment_monitoring_app/screens/river/manual/triennial_sampling.dart' as riverManualTriennialSampling;
|
||||
import 'package:environment_monitoring_app/screens/river/manual/triennial/river_manual_triennial_sampling.dart' as riverManualTriennialSampling;
|
||||
import 'package:environment_monitoring_app/screens/river/manual/river_manual_image_request.dart' as riverManualImageRequest;
|
||||
import 'package:environment_monitoring_app/screens/river/continuous/river_continuous_info_centre_document.dart';
|
||||
import 'package:environment_monitoring_app/screens/river/continuous/overview.dart' as riverContinuousOverview;
|
||||
@ -70,7 +74,10 @@ import 'package:environment_monitoring_app/screens/marine/manual/info_centre_doc
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/pre_sampling.dart' as marineManualPreSampling;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/in_situ_sampling.dart' as marineManualInSituSampling;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_report.dart' as marineManualReport;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_npe_report.dart' as marineManualNPEReport;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_npe_report.dart' as marineManualNPEReport;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart';
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart';
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart';
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_data_status_log.dart' as marineManualDataStatusLog;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_image_request.dart' as marineManualImageRequest;
|
||||
import 'package:environment_monitoring_app/screens/marine/continuous/marine_continuous_info_centre_document.dart';
|
||||
@ -116,9 +123,14 @@ void main() async {
|
||||
Provider(create: (_) => LocalStorageService()),
|
||||
|
||||
Provider(create: (context) => RiverInSituSamplingService(telegramService)),
|
||||
Provider(create: (context) => RiverManualTriennialSamplingService(telegramService)),
|
||||
Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)),
|
||||
Provider(create: (context) => MarineInSituSamplingService(telegramService)),
|
||||
Provider(create: (context) => MarineTarballSamplingService(telegramService)),
|
||||
Provider(create: (context) => MarineNpeReportService(Provider.of<TelegramService>(context, listen: false))),
|
||||
Provider(create: (context) => MarineManualPreDepartureService()),
|
||||
Provider(create: (context) => MarineManualSondeCalibrationService()),
|
||||
Provider(create: (context) => MarineManualEquipmentMaintenanceService()),
|
||||
],
|
||||
child: const RootApp(),
|
||||
),
|
||||
@ -259,9 +271,8 @@ class _RootAppState extends State<RootApp> {
|
||||
// River Manual
|
||||
'/river/manual/info': (context) => const RiverManualInfoCentreDocument(),
|
||||
'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(),
|
||||
// MODIFIED: Routes updated to use new class names from aliased imports
|
||||
'/river/manual/report': (context) => riverManualReport.RiverManualReport(),
|
||||
'/river/manual/triennial': (context) => riverManualTriennialSampling.RiverTriennialSampling(),
|
||||
'/river/manual/triennial': (context) => riverManualTriennialSampling.RiverManualTriennialSamplingScreen(),
|
||||
'/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(),
|
||||
'/river/manual/image-request': (context) => riverManualImageRequest.RiverManualImageRequest(),
|
||||
|
||||
@ -284,6 +295,9 @@ class _RootAppState extends State<RootApp> {
|
||||
'/marine/manual/tarball': (context) => const TarballSamplingStep1(),
|
||||
'/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(),
|
||||
'/marine/manual/report/npe': (context) => const marineManualNPEReport.MarineManualNPEReport(),
|
||||
'/marine/manual/report/pre-departure': (context) => const MarineManualPreDepartureChecklistScreen(),
|
||||
'/marine/manual/report/calibration': (context) => const MarineManualSondeCalibrationScreen(),
|
||||
'/marine/manual/report/maintenance': (context) => const MarineManualEquipmentMaintenanceScreen(),
|
||||
//'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute
|
||||
'/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(),
|
||||
|
||||
|
||||
23
lib/models/marine_manual_equipment_maintenance_data.dart
Normal file
23
lib/models/marine_manual_equipment_maintenance_data.dart
Normal file
@ -0,0 +1,23 @@
|
||||
class MarineManualEquipmentMaintenanceData {
|
||||
int? performedByUserId;
|
||||
String? equipmentName;
|
||||
String? maintenanceDate;
|
||||
String? maintenanceType;
|
||||
String? workDescription;
|
||||
String? partsReplaced;
|
||||
String? status;
|
||||
String? remarks;
|
||||
|
||||
Map<String, dynamic> toApiFormData() {
|
||||
return {
|
||||
'performed_by_user_id': performedByUserId.toString(),
|
||||
'equipment_name': equipmentName,
|
||||
'maintenance_date': maintenanceDate,
|
||||
'maintenance_type': maintenanceType,
|
||||
'work_description': workDescription,
|
||||
'parts_replaced': partsReplaced,
|
||||
'status': status,
|
||||
'remarks': remarks,
|
||||
};
|
||||
}
|
||||
}
|
||||
172
lib/models/marine_manual_npe_report_data.dart
Normal file
172
lib/models/marine_manual_npe_report_data.dart
Normal file
@ -0,0 +1,172 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
/// A dedicated data model for the Marine Manual Notification of Pollution Event (NPE) report.
|
||||
class MarineManualNpeReportData {
|
||||
// --- Reporter & Event Info ---
|
||||
String? firstSamplerName;
|
||||
int? firstSamplerUserId;
|
||||
String? eventDate;
|
||||
String? eventTime;
|
||||
|
||||
// --- Location Info ---
|
||||
String? locationDescription; // For new locations
|
||||
String? stateName; // For new locations
|
||||
Map<String, dynamic>? selectedStation; // For existing stations
|
||||
String? latitude;
|
||||
String? longitude;
|
||||
|
||||
// --- In-Situ Measurements ---
|
||||
double? oxygenSaturation;
|
||||
double? electricalConductivity;
|
||||
double? oxygenConcentration;
|
||||
double? turbidity;
|
||||
double? ph;
|
||||
double? temperature;
|
||||
|
||||
// --- NPE Observations ---
|
||||
Map<String, bool> fieldObservations = {
|
||||
'Oil slick on the water surface/ Oil spill': false,
|
||||
'Discoloration of the sea water': false,
|
||||
'Formation of foam on the surface': false,
|
||||
'Coral bleaching or dead corals': false,
|
||||
'Observation of tar balls': false,
|
||||
'Excessive debris': false,
|
||||
'Red tides or algae blooms': false,
|
||||
'Silt plume': false,
|
||||
'Foul smell': false,
|
||||
'Others': false,
|
||||
};
|
||||
String? othersObservationRemark;
|
||||
String? possibleSource;
|
||||
|
||||
// --- Attachments ---
|
||||
File? image1;
|
||||
File? image2;
|
||||
File? image3;
|
||||
File? image4;
|
||||
|
||||
// --- Submission Status ---
|
||||
String? submissionStatus;
|
||||
String? submissionMessage;
|
||||
String? reportId;
|
||||
|
||||
MarineManualNpeReportData(); // Constructor
|
||||
|
||||
/// Creates a JSON object for offline database storage.
|
||||
Map<String, dynamic> toDbJson() {
|
||||
return {
|
||||
'firstSamplerName': firstSamplerName,
|
||||
'firstSamplerUserId': firstSamplerUserId,
|
||||
'eventDate': eventDate,
|
||||
'eventTime': eventTime,
|
||||
'locationDescription': locationDescription,
|
||||
'stateName': stateName,
|
||||
'selectedStation': selectedStation,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'oxygenSaturation': oxygenSaturation,
|
||||
'electricalConductivity': electricalConductivity,
|
||||
'oxygenConcentration': oxygenConcentration,
|
||||
'turbidity': turbidity,
|
||||
'ph': ph,
|
||||
'temperature': temperature,
|
||||
'fieldObservations': fieldObservations,
|
||||
'othersObservationRemark': othersObservationRemark,
|
||||
'possibleSource': possibleSource,
|
||||
'submissionStatus': submissionStatus,
|
||||
'submissionMessage': submissionMessage,
|
||||
'reportId': reportId,
|
||||
};
|
||||
}
|
||||
|
||||
/// Formats the data for the API POST request body.
|
||||
Map<String, String> toApiFormData() {
|
||||
final Map<String, String> map = {};
|
||||
void add(String key, dynamic value) {
|
||||
if (value != null && value.toString().isNotEmpty) {
|
||||
map[key] = value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
add('npe_date', eventDate);
|
||||
add('npe_time', eventTime);
|
||||
add('first_sampler_user_id', firstSamplerUserId);
|
||||
if (selectedStation != null) {
|
||||
add('station_id', selectedStation?['station_id'] ?? selectedStation?['tbl_station_id']);
|
||||
add('station_code', selectedStation?['man_station_code'] ?? selectedStation?['tbl_station_code']);
|
||||
add('station_name', selectedStation?['man_station_name'] ?? selectedStation?['tbl_station_name']);
|
||||
} else {
|
||||
add('station_name', locationDescription);
|
||||
add('state_name', stateName);
|
||||
}
|
||||
add('latitude', latitude);
|
||||
add('longitude', longitude);
|
||||
add('npe_oxygen_sat', oxygenSaturation);
|
||||
add('npe_conductivity', electricalConductivity);
|
||||
add('npe_oxygen_conc', oxygenConcentration);
|
||||
add('npe_turbidity', turbidity);
|
||||
add('npe_ph', ph);
|
||||
add('npe_temperature', temperature);
|
||||
|
||||
final selectedObs = fieldObservations.entries
|
||||
.where((entry) => entry.value)
|
||||
.map((entry) => entry.key)
|
||||
.toList();
|
||||
add('npe_observations', jsonEncode(selectedObs));
|
||||
if (fieldObservations['Others'] == true) {
|
||||
add('npe_others_remarks', othersObservationRemark);
|
||||
}
|
||||
add('npe_possible_source', possibleSource);
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/// Gathers all image files for the multipart API request.
|
||||
Map<String, File?> toApiImageFiles() {
|
||||
return {
|
||||
'npe_image_1': image1,
|
||||
'npe_image_2': image2,
|
||||
'npe_image_3': image3,
|
||||
'npe_image_4': image4,
|
||||
};
|
||||
}
|
||||
|
||||
/// Generates the Telegram alert message for this NPE report.
|
||||
String generateTelegramAlertMessage() {
|
||||
final locationDesc = selectedStation != null
|
||||
? '${selectedStation!['man_station_name'] ?? selectedStation!['tbl_station_name']}'
|
||||
: locationDescription ?? 'A custom location';
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('🚨 *Notification of Pollution Event (NPE) Submitted:*')
|
||||
..writeln()
|
||||
..writeln('*Location:* $locationDesc')
|
||||
..writeln('*Event Date:* $eventDate $eventTime')
|
||||
..writeln('*Submitted by:* $firstSamplerName')
|
||||
..writeln('*Status of Submission:* Successful');
|
||||
|
||||
final observations = fieldObservations.entries
|
||||
.where((e) => e.value)
|
||||
.map((e) => '- ${e.key}')
|
||||
.toList();
|
||||
|
||||
if (observations.isNotEmpty) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('📋 *Observations:*');
|
||||
buffer.writeAll(observations, '\n');
|
||||
if (fieldObservations['Others'] == true && othersObservationRemark != null && othersObservationRemark!.isNotEmpty) {
|
||||
buffer.writeln('\n - Remarks: $othersObservationRemark');
|
||||
}
|
||||
}
|
||||
|
||||
if (possibleSource != null && possibleSource!.isNotEmpty) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('*Possible Source:* $possibleSource');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
24
lib/models/marine_manual_pre_departure_checklist_data.dart
Normal file
24
lib/models/marine_manual_pre_departure_checklist_data.dart
Normal file
@ -0,0 +1,24 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class MarineManualPreDepartureChecklistData {
|
||||
String? reporterName;
|
||||
int? reporterUserId;
|
||||
String? submissionDate;
|
||||
|
||||
// Key: Item description, Value: true if 'Yes', false if 'No'
|
||||
Map<String, bool> checklistItems = {};
|
||||
|
||||
// Key: Item description, Value: Remarks text
|
||||
Map<String, String> remarks = {};
|
||||
|
||||
MarineManualPreDepartureChecklistData();
|
||||
|
||||
Map<String, dynamic> toApiFormData() {
|
||||
return {
|
||||
'reporter_user_id': reporterUserId.toString(),
|
||||
'submission_date': submissionDate,
|
||||
'checklist_items': jsonEncode(checklistItems),
|
||||
'remarks': jsonEncode(remarks),
|
||||
};
|
||||
}
|
||||
}
|
||||
46
lib/models/marine_manual_sonde_calibration_data.dart
Normal file
46
lib/models/marine_manual_sonde_calibration_data.dart
Normal file
@ -0,0 +1,46 @@
|
||||
class MarineManualSondeCalibrationData {
|
||||
int? calibratedByUserId;
|
||||
String? sondeId;
|
||||
String? calibrationDateTime;
|
||||
|
||||
// pH values
|
||||
double? ph4Initial;
|
||||
double? ph4Calibrated;
|
||||
double? ph7Initial;
|
||||
double? ph7Calibrated;
|
||||
double? ph10Initial;
|
||||
double? ph10Calibrated;
|
||||
|
||||
// Other parameters
|
||||
double? condInitial;
|
||||
double? condCalibrated;
|
||||
double? doInitial;
|
||||
double? doCalibrated;
|
||||
double? turbidityInitial;
|
||||
double? turbidityCalibrated;
|
||||
|
||||
String? calibrationStatus;
|
||||
String? remarks;
|
||||
|
||||
Map<String, dynamic> toApiFormData() {
|
||||
return {
|
||||
'calibrated_by_user_id': calibratedByUserId.toString(),
|
||||
'sonde_id': sondeId,
|
||||
'calibration_datetime': calibrationDateTime,
|
||||
'ph_4_initial': ph4Initial?.toString(),
|
||||
'ph_4_calibrated': ph4Calibrated?.toString(),
|
||||
'ph_7_initial': ph7Initial?.toString(),
|
||||
'ph_7_calibrated': ph7Calibrated?.toString(),
|
||||
'ph_10_initial': ph10Initial?.toString(),
|
||||
'ph_10_calibrated': ph10Calibrated?.toString(),
|
||||
'cond_initial': condInitial?.toString(),
|
||||
'cond_calibrated': condCalibrated?.toString(),
|
||||
'do_initial': doInitial?.toString(),
|
||||
'do_calibrated': doCalibrated?.toString(),
|
||||
'turbidity_initial': turbidityInitial?.toString(),
|
||||
'turbidity_calibrated': turbidityCalibrated?.toString(),
|
||||
'calibration_status': calibrationStatus,
|
||||
'remarks': remarks,
|
||||
};
|
||||
}
|
||||
}
|
||||
205
lib/models/river_manual_triennial_sampling_data.dart
Normal file
205
lib/models/river_manual_triennial_sampling_data.dart
Normal file
@ -0,0 +1,205 @@
|
||||
// lib/models/river_manual_triennial_sampling_data.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
class RiverManualTriennialSamplingData {
|
||||
// --- Step 1: Sampling & Station Info ---
|
||||
String? firstSamplerName;
|
||||
int? firstSamplerUserId;
|
||||
Map<String, dynamic>? secondSampler;
|
||||
String? samplingDate;
|
||||
String? samplingTime;
|
||||
String? samplingType;
|
||||
String? sampleIdCode;
|
||||
|
||||
String? selectedStateName;
|
||||
String? selectedCategoryName;
|
||||
Map<String, dynamic>? selectedStation;
|
||||
|
||||
String? stationLatitude;
|
||||
String? stationLongitude;
|
||||
String? currentLatitude;
|
||||
String? currentLongitude;
|
||||
double? distanceDifferenceInKm;
|
||||
String? distanceDifferenceRemarks;
|
||||
|
||||
// --- Step 2: Site Info & Photos ---
|
||||
String? weather;
|
||||
String? eventRemarks;
|
||||
String? labRemarks;
|
||||
|
||||
File? backgroundStationImage;
|
||||
File? upstreamRiverImage;
|
||||
File? downstreamRiverImage;
|
||||
|
||||
// --- Step 4: Additional Photos ---
|
||||
File? sampleTurbidityImage;
|
||||
|
||||
File? optionalImage1;
|
||||
String? optionalRemark1;
|
||||
File? optionalImage2;
|
||||
String? optionalRemark2;
|
||||
File? optionalImage3;
|
||||
String? optionalRemark3;
|
||||
File? optionalImage4;
|
||||
String? optionalRemark4;
|
||||
|
||||
// --- Step 3: Data Capture ---
|
||||
String? sondeId;
|
||||
String? dataCaptureDate;
|
||||
String? dataCaptureTime;
|
||||
double? oxygenConcentration;
|
||||
double? oxygenSaturation;
|
||||
double? ph;
|
||||
double? salinity;
|
||||
double? electricalConductivity;
|
||||
double? temperature;
|
||||
double? tds;
|
||||
double? turbidity;
|
||||
double? ammonia;
|
||||
double? batteryVoltage;
|
||||
|
||||
// --- ADDED: Missing flowrate properties ---
|
||||
String? flowrateMethod;
|
||||
double? flowrateSurfaceDrifterHeight;
|
||||
double? flowrateSurfaceDrifterDistance;
|
||||
String? flowrateSurfaceDrifterTimeFirst;
|
||||
String? flowrateSurfaceDrifterTimeLast;
|
||||
double? flowrateValue;
|
||||
|
||||
// --- Post-Submission Status ---
|
||||
String? submissionStatus;
|
||||
String? submissionMessage;
|
||||
String? reportId;
|
||||
|
||||
RiverManualTriennialSamplingData({
|
||||
this.samplingDate,
|
||||
this.samplingTime,
|
||||
});
|
||||
|
||||
Map<String, String> toApiFormData() {
|
||||
final Map<String, String> map = {};
|
||||
|
||||
void add(String key, dynamic value) {
|
||||
if (value != null) {
|
||||
map[key] = value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
add('first_sampler_user_id', firstSamplerUserId);
|
||||
add('r_tri_second_sampler_id', secondSampler?['user_id']);
|
||||
add('r_tri_date', samplingDate);
|
||||
add('r_tri_time', samplingTime);
|
||||
add('r_tri_type', samplingType);
|
||||
add('r_tri_sample_id_code', sampleIdCode);
|
||||
add('station_id', selectedStation?['station_id']);
|
||||
add('r_tri_current_latitude', currentLatitude);
|
||||
add('r_tri_current_longitude', currentLongitude);
|
||||
add('r_tri_distance_difference', distanceDifferenceInKm);
|
||||
add('r_tri_distance_difference_remarks', distanceDifferenceRemarks);
|
||||
add('r_tri_weather', weather);
|
||||
add('r_tri_event_remark', eventRemarks);
|
||||
add('r_tri_lab_remark', labRemarks);
|
||||
add('r_tri_optional_photo_01_remarks', optionalRemark1);
|
||||
add('r_tri_optional_photo_02_remarks', optionalRemark2);
|
||||
add('r_tri_optional_photo_03_remarks', optionalRemark3);
|
||||
add('r_tri_optional_photo_04_remarks', optionalRemark4);
|
||||
add('r_tri_sondeID', sondeId);
|
||||
add('data_capture_date', dataCaptureDate);
|
||||
add('data_capture_time', dataCaptureTime);
|
||||
add('r_tri_oxygen_conc', oxygenConcentration);
|
||||
add('r_tri_oxygen_sat', oxygenSaturation);
|
||||
add('r_tri_ph', ph);
|
||||
add('r_tri_salinity', salinity);
|
||||
add('r_tri_conductivity', electricalConductivity);
|
||||
add('r_tri_temperature', temperature);
|
||||
add('r_tri_tds', tds);
|
||||
add('r_tri_turbidity', turbidity);
|
||||
add('r_tri_ammonia', ammonia);
|
||||
add('r_tri_battery_volt', batteryVoltage);
|
||||
add('r_tri_flowrate_method', flowrateMethod);
|
||||
add('r_tri_flowrate_sd_height', flowrateSurfaceDrifterHeight);
|
||||
add('r_tri_flowrate_sd_distance', flowrateSurfaceDrifterDistance);
|
||||
add('r_tri_flowrate_sd_time_first', flowrateSurfaceDrifterTimeFirst);
|
||||
add('r_tri_flowrate_sd_time_last', flowrateSurfaceDrifterTimeLast);
|
||||
add('r_tri_flowrate_value', flowrateValue);
|
||||
add('first_sampler_name', firstSamplerName);
|
||||
add('r_tri_station_code', selectedStation?['sampling_station_code']);
|
||||
add('r_tri_station_name', selectedStation?['sampling_river']);
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
Map<String, File?> toApiImageFiles() {
|
||||
return {
|
||||
'r_tri_background_station': backgroundStationImage,
|
||||
'r_tri_upstream_river': upstreamRiverImage,
|
||||
'r_tri_downstream_river': downstreamRiverImage,
|
||||
'r_tri_sample_turbidity': sampleTurbidityImage,
|
||||
'r_tri_optional_photo_01': optionalImage1,
|
||||
'r_tri_optional_photo_02': optionalImage2,
|
||||
'r_tri_optional_photo_03': optionalImage3,
|
||||
'r_tri_optional_photo_04': optionalImage4,
|
||||
};
|
||||
}
|
||||
|
||||
// --- ADDED: Missing toMap() method ---
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'firstSamplerName': firstSamplerName,
|
||||
'firstSamplerUserId': firstSamplerUserId,
|
||||
'secondSampler': secondSampler,
|
||||
'samplingDate': samplingDate,
|
||||
'samplingTime': samplingTime,
|
||||
'samplingType': samplingType,
|
||||
'sampleIdCode': sampleIdCode,
|
||||
'selectedStateName': selectedStateName,
|
||||
'selectedCategoryName': selectedCategoryName,
|
||||
'selectedStation': selectedStation,
|
||||
'stationLatitude': stationLatitude,
|
||||
'stationLongitude': stationLongitude,
|
||||
'currentLatitude': currentLatitude,
|
||||
'currentLongitude': currentLongitude,
|
||||
'distanceDifferenceInKm': distanceDifferenceInKm,
|
||||
'distanceDifferenceRemarks': distanceDifferenceRemarks,
|
||||
'weather': weather,
|
||||
'eventRemarks': eventRemarks,
|
||||
'labRemarks': labRemarks,
|
||||
'backgroundStationImage': backgroundStationImage?.path,
|
||||
'upstreamRiverImage': upstreamRiverImage?.path,
|
||||
'downstreamRiverImage': downstreamRiverImage?.path,
|
||||
'sampleTurbidityImage': sampleTurbidityImage?.path,
|
||||
'optionalImage1': optionalImage1?.path,
|
||||
'optionalRemark1': optionalRemark1,
|
||||
'optionalImage2': optionalImage2?.path,
|
||||
'optionalRemark2': optionalRemark2,
|
||||
'optionalImage3': optionalImage3?.path,
|
||||
'optionalRemark3': optionalRemark3,
|
||||
'optionalImage4': optionalImage4?.path,
|
||||
'optionalRemark4': optionalRemark4,
|
||||
'sondeId': sondeId,
|
||||
'dataCaptureDate': dataCaptureDate,
|
||||
'dataCaptureTime': dataCaptureTime,
|
||||
'oxygenConcentration': oxygenConcentration,
|
||||
'oxygenSaturation': oxygenSaturation,
|
||||
'ph': ph,
|
||||
'salinity': salinity,
|
||||
'electricalConductivity': electricalConductivity,
|
||||
'temperature': temperature,
|
||||
'tds': tds,
|
||||
'turbidity': turbidity,
|
||||
'ammonia': ammonia,
|
||||
'batteryVoltage': batteryVoltage,
|
||||
'flowrateMethod': flowrateMethod,
|
||||
'flowrateSurfaceDrifterHeight': flowrateSurfaceDrifterHeight,
|
||||
'flowrateSurfaceDrifterDistance': flowrateSurfaceDrifterDistance,
|
||||
'flowrateSurfaceDrifterTimeFirst': flowrateSurfaceDrifterTimeFirst,
|
||||
'flowrateSurfaceDrifterTimeLast': flowrateSurfaceDrifterTimeLast,
|
||||
'flowrateValue': flowrateValue,
|
||||
'submissionStatus': submissionStatus,
|
||||
'submissionMessage': submissionMessage,
|
||||
'reportId': reportId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -5,30 +5,45 @@ class ReportItem {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String route;
|
||||
final String formCode; // Added for clarity in the UI
|
||||
|
||||
const ReportItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.route,
|
||||
required this.formCode,
|
||||
});
|
||||
}
|
||||
|
||||
class MarineManualReportHomePage extends StatelessWidget {
|
||||
const MarineManualReportHomePage({super.key});
|
||||
|
||||
// Define the list of available reports
|
||||
// Updated list to include all reports
|
||||
final List<ReportItem> _reports = const [
|
||||
ReportItem(
|
||||
icon: Icons.science_outlined,
|
||||
label: "NPE Report",
|
||||
icon: Icons.warning_amber_rounded,
|
||||
label: "Notification of Pollution Event",
|
||||
formCode: "F-MM06",
|
||||
route: '/marine/manual/report/npe',
|
||||
),
|
||||
// You can add other future reports here. For example:
|
||||
// ReportItem(
|
||||
// icon: Icons.assessment_outlined,
|
||||
// label: "Quarterly Summary",
|
||||
// route: '/marine/manual/report/quarterly',
|
||||
// ),
|
||||
ReportItem(
|
||||
icon: Icons.biotech_rounded,
|
||||
label: "Sonde Calibration",
|
||||
formCode: "F-MM02",
|
||||
route: '/marine/manual/report/calibration',
|
||||
),
|
||||
ReportItem(
|
||||
icon: Icons.checklist_rtl_rounded,
|
||||
label: "Pre-Departure & Safety Checklist",
|
||||
formCode: "F-MM03",
|
||||
route: '/marine/manual/report/pre-departure',
|
||||
),
|
||||
ReportItem(
|
||||
icon: Icons.build_circle_outlined,
|
||||
label: "Equipment Maintenance",
|
||||
formCode: "F-MM01",
|
||||
route: '/marine/manual/report/maintenance',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
@ -43,13 +58,12 @@ class MarineManualReportHomePage extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Select a Report to Generate",
|
||||
"Select a Report to Create",
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Using a GridView for the report items for a clean layout
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@ -57,7 +71,7 @@ class MarineManualReportHomePage extends StatelessWidget {
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16.0,
|
||||
mainAxisSpacing: 16.0,
|
||||
childAspectRatio: 1.5, // Adjust for a card-like appearance
|
||||
childAspectRatio: 1.2, // Adjusted for better text fit
|
||||
),
|
||||
itemCount: _reports.length,
|
||||
itemBuilder: (context, index) {
|
||||
@ -71,7 +85,6 @@ class MarineManualReportHomePage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Method to build a clickable card for each report type
|
||||
Widget _buildReportCard(BuildContext context, ReportItem report) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
@ -82,7 +95,7 @@ class MarineManualReportHomePage extends StatelessWidget {
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: Colors.white24, width: 1),
|
||||
side: const BorderSide(color: Colors.white24, width: 1),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@ -100,6 +113,11 @@ class MarineManualReportHomePage extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
report.formCode, // Displaying the form code
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[400]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -0,0 +1,246 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../../../auth_provider.dart';
|
||||
import '../../../../models/marine_manual_equipment_maintenance_data.dart';
|
||||
import '../../../../services/marine_manual_equipment_maintenance_service.dart';
|
||||
|
||||
class MarineManualEquipmentMaintenanceScreen extends StatefulWidget {
|
||||
const MarineManualEquipmentMaintenanceScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MarineManualEquipmentMaintenanceScreen> createState() =>
|
||||
_MarineManualEquipmentMaintenanceScreenState();
|
||||
}
|
||||
|
||||
class _MarineManualEquipmentMaintenanceScreenState
|
||||
extends State<MarineManualEquipmentMaintenanceScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _data = MarineManualEquipmentMaintenanceData();
|
||||
bool _isLoading = false;
|
||||
|
||||
bool _isOnline = true;
|
||||
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
|
||||
|
||||
final _dateController = TextEditingController();
|
||||
final _performedByController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||
_dateController.text = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
_performedByController.text = auth.profileData?['username'] ?? 'Unknown User';
|
||||
_checkInitialConnectivity();
|
||||
_connectivitySubscription =
|
||||
Connectivity().onConnectivityChanged.listen(_updateConnectionStatus);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectivitySubscription.cancel();
|
||||
_dateController.dispose();
|
||||
_performedByController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkInitialConnectivity() async {
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
_updateConnectionStatus(connectivityResult);
|
||||
}
|
||||
|
||||
void _updateConnectionStatus(List<ConnectivityResult> result) {
|
||||
final bool currentlyOnline = !result.contains(ConnectivityResult.none);
|
||||
if (_isOnline != currentlyOnline) {
|
||||
setState(() {
|
||||
_isOnline = currentlyOnline;
|
||||
});
|
||||
if (currentlyOnline && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text("You are back online."),
|
||||
backgroundColor: Colors.green,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_formKey.currentState!.save();
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||
final service = Provider.of<MarineManualEquipmentMaintenanceService>(context, listen: false);
|
||||
|
||||
_data.performedByUserId = auth.profileData?['user_id'];
|
||||
_data.maintenanceDate = _dateController.text;
|
||||
|
||||
final result = await service.submitMaintenanceReport(data: _data, authProvider: auth);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(result['message']),
|
||||
backgroundColor: result['success'] == true ? Colors.green : Colors.red,
|
||||
));
|
||||
if (result['success'] == true) Navigator.of(context).pop();
|
||||
}
|
||||
} on SocketException {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text("Submission failed. Please check your network connection."),
|
||||
backgroundColor: Colors.red,
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text("An unexpected error occurred: $e"),
|
||||
backgroundColor: Colors.red,
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Equipment Maintenance (F-MM01)'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (!_isOnline)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Colors.red,
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: const Text(
|
||||
'No Internet Connection. You cannot submit the report.',
|
||||
style: TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Report Details', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
decoration: const InputDecoration(labelText: 'Equipment Name / ID *', border: OutlineInputBorder()),
|
||||
validator: (val) => val == null || val.isEmpty ? 'This field is required' : null,
|
||||
onSaved: (val) => _data.equipmentName = val,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _dateController,
|
||||
readOnly: true,
|
||||
decoration: const InputDecoration(labelText: 'Maintenance Date', border: OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(labelText: 'Maintenance Type *', border: OutlineInputBorder()),
|
||||
items: ['Routine', 'Repair', 'Inspection', 'Replacement'].map((String value) {
|
||||
return DropdownMenuItem<String>(value: value, child: Text(value));
|
||||
}).toList(),
|
||||
onChanged: (val) => _data.maintenanceType = val,
|
||||
validator: (val) => val == null ? 'Please select a type' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(labelText: 'Final Status *', border: OutlineInputBorder()),
|
||||
items: ['Completed', 'Pending Parts', 'In Progress', 'Requires Follow-up'].map((String value) {
|
||||
return DropdownMenuItem<String>(value: value, child: Text(value));
|
||||
}).toList(),
|
||||
onChanged: (val) => _data.status = val,
|
||||
validator: (val) => val == null ? 'Please select a status' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _performedByController,
|
||||
readOnly: true,
|
||||
decoration: const InputDecoration(labelText: 'Performed By', border: OutlineInputBorder()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Description & Remarks', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
decoration: const InputDecoration(labelText: 'Description of Work Done', border: OutlineInputBorder(), alignLabelWithHint: true),
|
||||
maxLines: 4,
|
||||
onSaved: (val) => _data.workDescription = val,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
decoration: const InputDecoration(labelText: 'Parts Replaced (if any)', border: OutlineInputBorder(), alignLabelWithHint: true),
|
||||
maxLines: 3,
|
||||
onSaved: (val) => _data.partsReplaced = val,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
decoration: const InputDecoration(labelText: 'General Remarks', border: OutlineInputBorder(), alignLabelWithHint: true),
|
||||
maxLines: 3,
|
||||
onSaved: (val) => _data.remarks = val,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'))),
|
||||
const SizedBox(width: 10.0),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading || !_isOnline ? null : _submit,
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('Submit'),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -8,14 +8,16 @@ import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:dropdown_search/dropdown_search.dart';
|
||||
|
||||
import '../../../auth_provider.dart';
|
||||
import '../../../models/in_situ_sampling_data.dart';
|
||||
import '../../../services/marine_in_situ_sampling_service.dart';
|
||||
import '../../../services/local_storage_service.dart';
|
||||
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';
|
||||
import '../../../../auth_provider.dart';
|
||||
import '../../../../models/in_situ_sampling_data.dart';
|
||||
import '../../../../models/marine_manual_npe_report_data.dart';
|
||||
import '../../../../services/marine_in_situ_sampling_service.dart';
|
||||
import '../../../../services/marine_npe_report_service.dart';
|
||||
import '../../../../services/local_storage_service.dart';
|
||||
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';
|
||||
|
||||
class MarineManualNPEReport extends StatefulWidget {
|
||||
final InSituSamplingData? initialData;
|
||||
@ -29,7 +31,6 @@ class MarineManualNPEReport extends StatefulWidget {
|
||||
class _MarineManualNPEReportState extends State<MarineManualNPEReport> with WidgetsBindingObserver {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Dropdown and Location State
|
||||
String? _locationDataSourceOption;
|
||||
static const String optionUseExisting = 'Use existing sampling information and data from manual station';
|
||||
static const String optionNewLocation = 'new location pollution event observe';
|
||||
@ -43,12 +44,10 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
|
||||
bool _areLocationFieldsLocked = false;
|
||||
bool _isFetchingLocation = false;
|
||||
|
||||
// Data for Option 1
|
||||
bool _isLoadingRecentSamples = false;
|
||||
List<InSituSamplingData> _recentNearbySamples = [];
|
||||
InSituSamplingData? _selectedRecentSample;
|
||||
|
||||
// Data for Dropdown Selections
|
||||
String? _selectedState;
|
||||
String? _selectedCategory;
|
||||
Map<String, dynamic>? _selectedStationMap;
|
||||
@ -56,14 +55,12 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
|
||||
List<String> _categoriesForState = [];
|
||||
List<Map<String, dynamic>> _stationsForCategory = [];
|
||||
|
||||
// Image State
|
||||
File? _image1;
|
||||
File? _image2;
|
||||
File? _image3;
|
||||
File? _image4;
|
||||
bool _isPickingImage = false;
|
||||
|
||||
// In-Situ Measurement State & Logic
|
||||
bool _isLoading = false;
|
||||
bool _isAutoReading = false;
|
||||
StreamSubscription? _dataSubscription;
|
||||
@ -73,7 +70,6 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
|
||||
late final MarineInSituSamplingService _samplingService;
|
||||
final List<Map<String, dynamic>> _npeParameters = [];
|
||||
|
||||
// Controllers
|
||||
final _latController = TextEditingController();
|
||||
final _longController = TextEditingController();
|
||||
final _stationIdController = TextEditingController();
|
||||
@ -88,7 +84,6 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
|
||||
final _othersObservationController = TextEditingController();
|
||||
final _possibleSourceController = TextEditingController();
|
||||
|
||||
// Checkbox states
|
||||
final Map<String, bool> _observations = {
|
||||
'Oil slick on the water surface/ Oil spill': false,
|
||||
'Discoloration of the sea water': false,
|
||||
@ -263,7 +258,6 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
|
||||
|
||||
void _handleLocationOptionChange(String? value) {
|
||||
setState(() {
|
||||
// Clear all fields and selection states
|
||||
_locationDataSourceOption = value;
|
||||
_clearLocationFields();
|
||||
_clearInSituFields();
|
||||
@ -327,7 +321,6 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
|
||||
|
||||
Future<void> _processAndSetImage(ImageSource source, int imageNumber) async {
|
||||
if (_isPickingImage) return;
|
||||
|
||||
setState(() => _isPickingImage = true);
|
||||
|
||||
final watermarkData = InSituSamplingData()
|
||||
@ -362,6 +355,56 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitNpeReport() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
_showSnackBar('Please fill in all required fields.', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||
final service = Provider.of<MarineNpeReportService>(context, listen: false);
|
||||
|
||||
final MarineManualNpeReportData data = MarineManualNpeReportData()
|
||||
..firstSamplerName = auth.profileData?['user_name']
|
||||
..firstSamplerUserId = auth.profileData?['user_id']
|
||||
..eventDate = _eventDateTimeController.text.split(' ')[0]
|
||||
..eventTime = _eventDateTimeController.text.split(' ').length > 1 ? _eventDateTimeController.text.split(' ')[1] : ''
|
||||
..latitude = _latController.text
|
||||
..longitude = _longController.text
|
||||
..selectedStation = _selectedStationMap ?? _selectedRecentSample?.selectedStation
|
||||
..locationDescription = _locationController.text
|
||||
..stateName = _selectedState
|
||||
..oxygenSaturation = double.tryParse(_doPercentController.text)
|
||||
..electricalConductivity = double.tryParse(_condController.text)
|
||||
..oxygenConcentration = double.tryParse(_doMgLController.text)
|
||||
..turbidity = double.tryParse(_turbController.text)
|
||||
..ph = double.tryParse(_phController.text)
|
||||
..temperature = double.tryParse(_tempController.text)
|
||||
..fieldObservations = _observations
|
||||
..othersObservationRemark = _othersObservationController.text
|
||||
..possibleSource = _possibleSourceController.text
|
||||
..image1 = _image1
|
||||
..image2 = _image2
|
||||
..image3 = _image3
|
||||
..image4 = _image4;
|
||||
|
||||
final result = await service.submitNpeReport(
|
||||
data: data,
|
||||
authProvider: auth,
|
||||
);
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
if (mounted) {
|
||||
_showSnackBar(result['message'], isError: result['success'] != true);
|
||||
if (result['success'] == true) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, {bool isError = false}) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
@ -570,13 +613,10 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 40, vertical: 15)),
|
||||
onPressed: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Submitting NPE Report...')));
|
||||
}
|
||||
},
|
||||
child: const Text("Submit Report"),
|
||||
onPressed: _isLoading ? null : _submitNpeReport,
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text("Submit Report"),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -1063,7 +1103,8 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
|
||||
onTap: onTap,
|
||||
keyboardType: keyboardType,
|
||||
validator: (value) {
|
||||
if (!readOnly && (value == null || value.isEmpty)) {
|
||||
if (!label.contains('*')) return null;
|
||||
if (!readOnly && (value == null || value.trim().isEmpty)) {
|
||||
return 'This field cannot be empty';
|
||||
}
|
||||
return null;
|
||||
@ -0,0 +1,291 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:toggle_switch/toggle_switch.dart';
|
||||
import '../../../../../auth_provider.dart';
|
||||
import '../../../../models/marine_manual_pre_departure_checklist_data.dart';
|
||||
import '../../../../services/marine_manual_pre_departure_service.dart';
|
||||
|
||||
class MarineManualPreDepartureChecklistScreen extends StatefulWidget {
|
||||
const MarineManualPreDepartureChecklistScreen({super.key});
|
||||
|
||||
@override
|
||||
_MarineManualPreDepartureChecklistScreenState createState() =>
|
||||
_MarineManualPreDepartureChecklistScreenState();
|
||||
}
|
||||
|
||||
class _MarineManualPreDepartureChecklistScreenState
|
||||
extends State<MarineManualPreDepartureChecklistScreen> {
|
||||
final _data = MarineManualPreDepartureChecklistData();
|
||||
bool _isLoading = false;
|
||||
|
||||
final Map<String, bool> _remarksVisibility = {};
|
||||
|
||||
// NEW: State variables for connectivity
|
||||
bool _isOnline = true;
|
||||
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
|
||||
|
||||
|
||||
final List<String> _checklistItemsText = [
|
||||
'MMWQM Standard Operation Procedure (SOP)',
|
||||
'Back-up Sampling Sheet and Chain of Custody form',
|
||||
'YSI EXO2 Sonde Include Sensor (pH/Turbidity/Conductivity/Dissolved Oxygen)',
|
||||
'Varn Dorn Sampler with Rope and Messenger',
|
||||
'Laptop',
|
||||
'Smart pre-installed with application (apps for manual sampling - MMS)',
|
||||
'GPS Navigation',
|
||||
'Calibration standards (pH/Turbidity/Conductivity)',
|
||||
'Distilled water (D.I)',
|
||||
'Universal pH Indicator paper',
|
||||
'Personal Floating Devices (PFD)',
|
||||
'First aid kits',
|
||||
'Sampling Shoes',
|
||||
'Sufficient set of cooler box and sampling bottles',
|
||||
'Ice packets',
|
||||
'Disposable gloves',
|
||||
'Black plastic bags',
|
||||
'Maker pen, pen and brown tapes',
|
||||
'Zipper Bags',
|
||||
'Aluminium Foil',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (var item in _checklistItemsText) {
|
||||
_data.checklistItems[item] = false;
|
||||
_data.remarks[item] = '';
|
||||
_remarksVisibility[item] = false;
|
||||
}
|
||||
|
||||
// NEW: Check initial connection and start listening for changes
|
||||
_checkInitialConnectivity();
|
||||
_connectivitySubscription =
|
||||
Connectivity().onConnectivityChanged.listen(_updateConnectionStatus);
|
||||
}
|
||||
|
||||
// NEW: Dispose the connectivity listener to prevent memory leaks
|
||||
@override
|
||||
void dispose() {
|
||||
_connectivitySubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// NEW: Method to check the first time the screen loads
|
||||
Future<void> _checkInitialConnectivity() async {
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
_updateConnectionStatus(connectivityResult);
|
||||
}
|
||||
|
||||
// NEW: Callback method to update UI based on connectivity changes
|
||||
void _updateConnectionStatus(List<ConnectivityResult> result) {
|
||||
final bool currentlyOnline = !result.contains(ConnectivityResult.none);
|
||||
if (_isOnline != currentlyOnline) {
|
||||
setState(() {
|
||||
_isOnline = currentlyOnline;
|
||||
});
|
||||
|
||||
if (currentlyOnline && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("You are back online."),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||
final service = Provider.of<MarineManualPreDepartureService>(context, listen: false);
|
||||
|
||||
_data.reporterUserId = auth.profileData?['user_id'];
|
||||
_data.reporterName = auth.profileData?['user_name'];
|
||||
_data.submissionDate = DateTime.now().toIso8601String().split('T').first;
|
||||
|
||||
final result = await service.submitChecklist(data: _data, authProvider: auth);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result['message']),
|
||||
backgroundColor: result['success'] == true ? Colors.green : Colors.red,
|
||||
),
|
||||
);
|
||||
if (result['success'] == true) Navigator.of(context).pop();
|
||||
}
|
||||
} on SocketException {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Submission failed. Please check your network connection."),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("An unexpected error occurred: $e"),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Pre-Departure Checklist (F-MM03)'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// NEW: Offline banner that shows when there's no internet
|
||||
if (!_isOnline)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Colors.red,
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: const Text(
|
||||
'No Internet Connection. You cannot submit the report.',
|
||||
style: TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
ListView.separated(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: _checklistItemsText.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _checklistItemsText[index];
|
||||
return _buildChecklistItem(item);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10.0),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
// NEW: Submit button is disabled when loading OR offline
|
||||
onPressed: _isLoading || !_isOnline ? null : _submit,
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('Submit'),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChecklistItem(String title) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ToggleSwitch(
|
||||
minWidth: 70.0,
|
||||
cornerRadius: 20.0,
|
||||
activeBgColor: [Theme.of(context).colorScheme.primary],
|
||||
activeFgColor: Colors.white,
|
||||
inactiveBgColor: Colors.grey[700],
|
||||
inactiveFgColor: Colors.white,
|
||||
initialLabelIndex: _data.checklistItems[title]! ? 0 : 1,
|
||||
totalSwitches: 2,
|
||||
labels: const ['Yes', 'No'],
|
||||
radiusStyle: true,
|
||||
onToggle: (index) {
|
||||
setState(() {
|
||||
_data.checklistItems[title] = index == 0;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_remarksVisibility[title]!)
|
||||
TextFormField(
|
||||
initialValue: _data.remarks[title],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Remarks',
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (value) {
|
||||
_data.remarks[title] = value;
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_remarksVisibility[title] = !_remarksVisibility[title]!;
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
_remarksVisibility[title]!
|
||||
? Icons.remove_circle_outline
|
||||
: Icons.add_comment_outlined,
|
||||
size: 16,
|
||||
),
|
||||
label: Text(
|
||||
_data.remarks[title]!.isNotEmpty
|
||||
? 'Edit Remarks'
|
||||
: 'Add Remarks',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,318 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../../../auth_provider.dart';
|
||||
import '../../../../models/marine_manual_sonde_calibration_data.dart';
|
||||
import '../../../../services/marine_manual_sonde_calibration_service.dart';
|
||||
|
||||
class MarineManualSondeCalibrationScreen extends StatefulWidget {
|
||||
const MarineManualSondeCalibrationScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MarineManualSondeCalibrationScreen> createState() =>
|
||||
_MarineManualSondeCalibrationScreenState();
|
||||
}
|
||||
|
||||
class _MarineManualSondeCalibrationScreenState
|
||||
extends State<MarineManualSondeCalibrationScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _data = MarineManualSondeCalibrationData();
|
||||
bool _isLoading = false;
|
||||
|
||||
// State for connectivity
|
||||
bool _isOnline = true;
|
||||
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
|
||||
|
||||
// Text Controllers
|
||||
final _sondeIdController = TextEditingController();
|
||||
final _dateTimeController = TextEditingController();
|
||||
final _remarksController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_dateTimeController.text = DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now());
|
||||
_checkInitialConnectivity();
|
||||
_connectivitySubscription =
|
||||
Connectivity().onConnectivityChanged.listen(_updateConnectionStatus);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectivitySubscription.cancel();
|
||||
_sondeIdController.dispose();
|
||||
_dateTimeController.dispose();
|
||||
_remarksController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkInitialConnectivity() async {
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
_updateConnectionStatus(connectivityResult);
|
||||
}
|
||||
|
||||
void _updateConnectionStatus(List<ConnectivityResult> result) {
|
||||
final bool currentlyOnline = !result.contains(ConnectivityResult.none);
|
||||
if (_isOnline != currentlyOnline) {
|
||||
setState(() {
|
||||
_isOnline = currentlyOnline;
|
||||
});
|
||||
if (currentlyOnline && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text("You are back online."),
|
||||
backgroundColor: Colors.green,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text("Please fill in all required fields."),
|
||||
backgroundColor: Colors.red,
|
||||
));
|
||||
return;
|
||||
}
|
||||
_formKey.currentState!.save();
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||
final service = Provider.of<MarineManualSondeCalibrationService>(context, listen: false);
|
||||
|
||||
_data.calibratedByUserId = auth.profileData?['user_id'];
|
||||
_data.sondeId = _sondeIdController.text;
|
||||
_data.calibrationDateTime = _dateTimeController.text;
|
||||
_data.remarks = _remarksController.text;
|
||||
|
||||
final result = await service.submitCalibration(data: _data, authProvider: auth);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(result['message']),
|
||||
backgroundColor: result['success'] == true ? Colors.green : Colors.red,
|
||||
));
|
||||
if (result['success'] == true) Navigator.of(context).pop();
|
||||
}
|
||||
} on SocketException {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text("Submission failed. Please check your network connection."),
|
||||
backgroundColor: Colors.red,
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text("An unexpected error occurred: $e"),
|
||||
backgroundColor: Colors.red,
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Sonde Calibration Form (F-MM02)'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (!_isOnline)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Colors.red,
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: const Text(
|
||||
'No Internet Connection. You cannot submit the report.',
|
||||
style: TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildGeneralInfoCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildCalibrationCard(
|
||||
title: 'pH Calibration',
|
||||
parameters: ['pH 4', 'pH 7', 'pH 10'],
|
||||
onSave: (param, type, value) {
|
||||
if (value == null) return;
|
||||
if (param == 'pH 4' && type == 'Initial') _data.ph4Initial = value;
|
||||
if (param == 'pH 4' && type == 'Calibrated') _data.ph4Calibrated = value;
|
||||
if (param == 'pH 7' && type == 'Initial') _data.ph7Initial = value;
|
||||
if (param == 'pH 7' && type == 'Calibrated') _data.ph7Calibrated = value;
|
||||
if (param == 'pH 10' && type == 'Initial') _data.ph10Initial = value;
|
||||
if (param == 'pH 10' && type == 'Calibrated') _data.ph10Calibrated = value;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildCalibrationCard(
|
||||
title: 'Other Parameters',
|
||||
parameters: ['Conductivity', 'Dissolved Oxygen', 'Turbidity'],
|
||||
onSave: (param, type, value) {
|
||||
if (value == null) return;
|
||||
if (param == 'Conductivity' && type == 'Initial') _data.condInitial = value;
|
||||
if (param == 'Conductivity' && type == 'Calibrated') _data.condCalibrated = value;
|
||||
if (param == 'Dissolved Oxygen' && type == 'Initial') _data.doInitial = value;
|
||||
if (param == 'Dissolved Oxygen' && type == 'Calibrated') _data.doCalibrated = value;
|
||||
if (param == 'Turbidity' && type == 'Initial') _data.turbidityInitial = value;
|
||||
if (param == 'Turbidity' && type == 'Calibrated') _data.turbidityCalibrated = value;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildSummaryCard(),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'))),
|
||||
const SizedBox(width: 10.0),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading || !_isOnline ? null : _submit,
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('Submit'),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGeneralInfoCard() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('General Information', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _sondeIdController,
|
||||
decoration: const InputDecoration(labelText: 'Sonde ID *', border: OutlineInputBorder()),
|
||||
validator: (val) => val == null || val.isEmpty ? 'Sonde ID is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _dateTimeController,
|
||||
readOnly: true,
|
||||
decoration: const InputDecoration(labelText: 'Calibration Date & Time', border: OutlineInputBorder()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCalibrationCard({
|
||||
required String title,
|
||||
required List<String> parameters,
|
||||
required Function(String, String, double?) onSave,
|
||||
}) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
...parameters.map((param) => _buildParameterRow(param, onSave)).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildParameterRow(String label, Function(String, String, double?) onSave) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(flex: 2, child: Text(label, style: Theme.of(context).textTheme.titleMedium)),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: TextFormField(
|
||||
decoration: const InputDecoration(labelText: 'Initial', border: OutlineInputBorder()),
|
||||
keyboardType: TextInputType.number,
|
||||
onSaved: (val) => onSave(label, 'Initial', double.tryParse(val ?? '')),
|
||||
)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: TextFormField(
|
||||
decoration: const InputDecoration(labelText: 'Calibrated', border: OutlineInputBorder()),
|
||||
keyboardType: TextInputType.number,
|
||||
onSaved: (val) => onSave(label, 'Calibrated', double.tryParse(val ?? '')),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Summary', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(labelText: 'Overall Status *', border: OutlineInputBorder()),
|
||||
items: ['Pass', 'Fail', 'Pass with Issues'].map((String value) {
|
||||
return DropdownMenuItem<String>(value: value, child: Text(value));
|
||||
}).toList(),
|
||||
onChanged: (val) {
|
||||
_data.calibrationStatus = val;
|
||||
},
|
||||
validator: (val) => val == null || val.isEmpty ? 'Status is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _remarksController,
|
||||
decoration: const InputDecoration(labelText: 'Remarks', border: OutlineInputBorder()),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
// lib/screens/river/manual/triennial/river_manual_triennial_sampling.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:environment_monitoring_app/auth_provider.dart';
|
||||
|
||||
import '../../../../models/river_manual_triennial_sampling_data.dart';
|
||||
import '../../../../services/river_manual_triennial_sampling_service.dart';
|
||||
// --- ADDED: Missing imports for step widgets ---
|
||||
import 'widgets/river_manual_triennial_step_1_sampling_info.dart';
|
||||
import 'widgets/river_manual_triennial_step_2_site_info.dart';
|
||||
import 'widgets/river_manual_triennial_step_3_data_capture.dart';
|
||||
import 'widgets/river_manual_triennial_step_4_additional_info.dart';
|
||||
import 'widgets/river_manual_triennial_step_5_summary.dart';
|
||||
|
||||
class RiverManualTriennialSamplingScreen extends StatefulWidget {
|
||||
const RiverManualTriennialSamplingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RiverManualTriennialSamplingScreen> createState() => _RiverManualTriennialSamplingScreenState();
|
||||
}
|
||||
|
||||
class _RiverManualTriennialSamplingScreenState extends State<RiverManualTriennialSamplingScreen> {
|
||||
final PageController _pageController = PageController();
|
||||
late RiverManualTriennialSamplingData _data;
|
||||
int _currentPage = 0;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_data = RiverManualTriennialSamplingData(
|
||||
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||
samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _nextPage() {
|
||||
if (_currentPage < 4) {
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _previousPage() {
|
||||
if (_currentPage > 0) {
|
||||
_pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitForm() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final samplingService = Provider.of<RiverManualTriennialSamplingService>(context, listen: false);
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final appSettings = authProvider.appSettings;
|
||||
|
||||
final result = await samplingService.submitData(
|
||||
data: _data,
|
||||
appSettings: appSettings,
|
||||
authProvider: authProvider
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
final bool isSuccess = result['success'] ?? false;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result['message'] ?? "An unknown error occurred."),
|
||||
backgroundColor: isSuccess ? Colors.green : Colors.red,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Triennial Sampling (${_currentPage + 1}/5)'),
|
||||
leading: _currentPage > 0
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _previousPage,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
onPageChanged: (page) => setState(() => _currentPage = page),
|
||||
children: [
|
||||
RiverManualTriennialStep1SamplingInfo(data: _data, onNext: _nextPage),
|
||||
RiverManualTriennialStep2SiteInfo(data: _data, onNext: _nextPage),
|
||||
RiverManualTriennialStep3DataCapture(data: _data, onNext: _nextPage),
|
||||
RiverManualTriennialStep4AdditionalInfo(data: _data, onNext: _nextPage),
|
||||
RiverManualTriennialStep5Summary(data: _data, onSubmit: _submitForm, isLoading: _isLoading),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,532 @@
|
||||
// lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_1_sampling_info.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:dropdown_search/dropdown_search.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:simple_barcode_scanner/simple_barcode_scanner.dart';
|
||||
|
||||
import '../../../../../auth_provider.dart';
|
||||
import '../../../../../models/river_manual_triennial_sampling_data.dart';
|
||||
import '../../../../../services/river_in_situ_sampling_service.dart';
|
||||
|
||||
class RiverManualTriennialStep1SamplingInfo extends StatefulWidget {
|
||||
final RiverManualTriennialSamplingData data;
|
||||
final VoidCallback onNext;
|
||||
|
||||
const RiverManualTriennialStep1SamplingInfo({
|
||||
super.key,
|
||||
required this.data,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RiverManualTriennialStep1SamplingInfo> createState() => _RiverManualTriennialStep1SamplingInfoState();
|
||||
}
|
||||
|
||||
class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTriennialStep1SamplingInfo> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoadingLocation = false;
|
||||
|
||||
late final TextEditingController _firstSamplerController;
|
||||
late final TextEditingController _dateController;
|
||||
late final TextEditingController _timeController;
|
||||
late final TextEditingController _barcodeController;
|
||||
late final TextEditingController _stationLatController;
|
||||
late final TextEditingController _stationLonController;
|
||||
late final TextEditingController _currentLatController;
|
||||
late final TextEditingController _currentLonController;
|
||||
|
||||
List<String> _statesList = [];
|
||||
List<Map<String, dynamic>> _stationsForState = [];
|
||||
final List<String> _samplingTypes = ['Schedule', 'Triennial'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeControllers();
|
||||
_initializeForm();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstSamplerController.dispose();
|
||||
_dateController.dispose();
|
||||
_timeController.dispose();
|
||||
_barcodeController.dispose();
|
||||
_stationLatController.dispose();
|
||||
_stationLonController.dispose();
|
||||
_currentLatController.dispose();
|
||||
_currentLonController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeControllers() {
|
||||
_firstSamplerController = TextEditingController();
|
||||
_dateController = TextEditingController();
|
||||
_timeController = TextEditingController();
|
||||
_barcodeController = TextEditingController(text: widget.data.sampleIdCode);
|
||||
_stationLatController = TextEditingController(text: widget.data.stationLatitude);
|
||||
_stationLonController = TextEditingController(text: widget.data.stationLongitude);
|
||||
_currentLatController = TextEditingController(text: widget.data.currentLatitude);
|
||||
_currentLonController = TextEditingController(text: widget.data.currentLongitude);
|
||||
}
|
||||
|
||||
void _initializeForm() {
|
||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||
|
||||
widget.data.firstSamplerName = auth.profileData?['first_name'] ?? 'Current User';
|
||||
widget.data.firstSamplerUserId = auth.profileData?['user_id'];
|
||||
_firstSamplerController.text = widget.data.firstSamplerName!;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (widget.data.samplingDate == null || widget.data.samplingDate!.isEmpty) {
|
||||
widget.data.samplingDate = DateFormat('yyyy-MM-dd').format(now);
|
||||
widget.data.samplingTime = DateFormat('HH:mm:ss').format(now);
|
||||
}
|
||||
_dateController.text = widget.data.samplingDate!;
|
||||
_timeController.text = widget.data.samplingTime!;
|
||||
|
||||
if (widget.data.samplingType == null) {
|
||||
widget.data.samplingType = 'Triennial';
|
||||
}
|
||||
|
||||
final allStations = auth.riverManualStations ?? [];
|
||||
if (allStations.isNotEmpty) {
|
||||
final states = allStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList();
|
||||
states.sort();
|
||||
|
||||
if (widget.data.selectedStateName != null) {
|
||||
_stationsForState = allStations
|
||||
.where((s) => s['state_name'] == widget.data.selectedStateName)
|
||||
.toList()
|
||||
..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? ''));
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_statesList = states;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _getCurrentLocation() async {
|
||||
setState(() => _isLoadingLocation = true);
|
||||
final service = Provider.of<RiverInSituSamplingService>(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!;
|
||||
_calculateDistance();
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if(mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoadingLocation = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _calculateDistance() {
|
||||
final lat1Str = widget.data.stationLatitude;
|
||||
final lon1Str = widget.data.stationLongitude;
|
||||
final lat2Str = widget.data.currentLatitude;
|
||||
final lon2Str = widget.data.currentLongitude;
|
||||
|
||||
if (lat1Str != null && lon1Str != null && lat2Str != null && lon2Str != null) {
|
||||
final service = Provider.of<RiverInSituSamplingService>(context, listen: false);
|
||||
final lat1 = double.tryParse(lat1Str);
|
||||
final lon1 = double.tryParse(lon1Str);
|
||||
final lat2 = double.tryParse(lat2Str);
|
||||
final lon2 = double.tryParse(lon2Str);
|
||||
|
||||
if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) {
|
||||
final distance = service.calculateDistance(lat1, lon1, lat2, lon2);
|
||||
setState(() {
|
||||
widget.data.distanceDifferenceInKm = distance;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scanBarcode() async {
|
||||
final result = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SimpleBarcodeScannerPage()),
|
||||
);
|
||||
if (result is String && result != '-1' && mounted) {
|
||||
setState(() {
|
||||
widget.data.sampleIdCode = result;
|
||||
_barcodeController.text = result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _findAndShowNearbyStations() async {
|
||||
if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
|
||||
await _getCurrentLocation();
|
||||
if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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 allStations = auth.riverManualStations ?? [];
|
||||
final List<Map<String, dynamic>> nearbyStations = [];
|
||||
|
||||
for (var station in allStations) {
|
||||
final stationLat = station['sampling_lat'];
|
||||
final stationLon = station['sampling_long'];
|
||||
|
||||
if (stationLat is num && stationLon is num) {
|
||||
final distance = service.calculateDistance(currentLat, currentLon, stationLat.toDouble(), stationLon.toDouble());
|
||||
if (distance <= 3.0) {
|
||||
nearbyStations.add({'station': station, 'distance': distance});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nearbyStations.sort((a, b) => a['distance'].compareTo(b['distance']));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final selectedStation = await showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations),
|
||||
);
|
||||
|
||||
if (selectedStation != null) {
|
||||
_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'] ?? ''));
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void _goToNextStep() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
_formKey.currentState!.save();
|
||||
|
||||
final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000;
|
||||
|
||||
if (distanceInMeters > 50) {
|
||||
_showDistanceRemarkDialog();
|
||||
} else {
|
||||
widget.data.distanceDifferenceRemarks = null;
|
||||
widget.onNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showDistanceRemarkDialog() async {
|
||||
final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks);
|
||||
final dialogFormKey = GlobalKey<FormState>();
|
||||
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Distance Warning'),
|
||||
content: SingleChildScrollView(
|
||||
child: Form(
|
||||
key: dialogFormKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Your current location is more than 50m away from the station.'),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: remarkController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Remarks *',
|
||||
hintText: 'Please provide a reason...',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Remarks are required to continue.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: const Text('Cancel'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
FilledButton(
|
||||
child: const Text('Confirm'),
|
||||
onPressed: () {
|
||||
if (dialogFormKey.currentState!.validate()) {
|
||||
setState(() {
|
||||
widget.data.distanceDifferenceRemarks = remarkController.text;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
widget.onNext();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||
final allStations = auth.riverManualStations ?? [];
|
||||
final allUsers = auth.allUsers ?? [];
|
||||
|
||||
final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList()
|
||||
..sort((a, b) => (a['first_name'] ?? '').compareTo(b['first_name'] ?? ''));
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
children: [
|
||||
Text("Sampling Information", style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(controller: _firstSamplerController, readOnly: true, decoration: const InputDecoration(labelText: '1st Sampler')),
|
||||
const SizedBox(height: 16),
|
||||
DropdownSearch<Map<String, dynamic>>(
|
||||
items: secondSamplersList,
|
||||
selectedItem: widget.data.secondSampler,
|
||||
itemAsString: (sampler) => "${sampler['first_name']} ${sampler['last_name']}",
|
||||
onChanged: (sampler) => widget.data.secondSampler = sampler,
|
||||
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Sampler..."))),
|
||||
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: '2nd Sampler (Optional)')),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Date'))),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: widget.data.samplingType,
|
||||
items: _samplingTypes.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(),
|
||||
onChanged: (value) => setState(() => widget.data.samplingType = value),
|
||||
decoration: const InputDecoration(labelText: 'Sampling Type *'),
|
||||
validator: (value) => value == null ? 'Please select a type' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _barcodeController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Sample ID Code *',
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
onPressed: _scanBarcode,
|
||||
),
|
||||
),
|
||||
validator: (val) => val == null || val.isEmpty ? "Sample ID is required" : null,
|
||||
onSaved: (val) => widget.data.sampleIdCode = val,
|
||||
onChanged: (val) => widget.data.sampleIdCode = val,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
DropdownSearch<String>(
|
||||
items: _statesList,
|
||||
selectedItem: widget.data.selectedStateName,
|
||||
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
|
||||
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")),
|
||||
onChanged: (state) {
|
||||
setState(() {
|
||||
widget.data.selectedStateName = state;
|
||||
widget.data.selectedStation = null;
|
||||
_stationLatController.clear();
|
||||
_stationLonController.clear();
|
||||
widget.data.distanceDifferenceInKm = null;
|
||||
|
||||
_stationsForState = state != null
|
||||
? (allStations
|
||||
.where((s) => s['state_name'] == state)
|
||||
.toList()
|
||||
..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? '')))
|
||||
: [];
|
||||
});
|
||||
},
|
||||
validator: (val) => val == null ? "State is required" : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
DropdownSearch<Map<String, dynamic>>(
|
||||
items: _stationsForState,
|
||||
selectedItem: widget.data.selectedStation,
|
||||
enabled: widget.data.selectedStateName != null,
|
||||
itemAsString: (station) =>
|
||||
"${station['sampling_station_code']} | ${station['sampling_river']} | ${station['sampling_basin']}",
|
||||
popupProps: const PopupProps.menu(
|
||||
showSearchBox: true,
|
||||
searchFieldProps: TextFieldProps(
|
||||
decoration: InputDecoration(hintText: "Search Station..."))),
|
||||
dropdownDecoratorProps: const DropDownDecoratorProps(
|
||||
dropdownSearchDecoration: InputDecoration(
|
||||
labelText: "Select Station *")),
|
||||
onChanged: (station) => setState(() {
|
||||
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();
|
||||
}),
|
||||
validator: (val) => widget.data.selectedStateName != null && val == null ? "Station is required" : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.explore_outlined),
|
||||
label: const Text("NEARBY STATION"),
|
||||
onPressed: _isLoadingLocation ? null : _findAndShowNearbyStations,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Text("Location Verification", style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')),
|
||||
if (widget.data.distanceDifferenceInKm != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green),
|
||||
),
|
||||
child: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
children: <TextSpan>[
|
||||
const TextSpan(text: 'Distance from Station: '),
|
||||
TextSpan(
|
||||
text: '${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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"),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _goToNextStep,
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: const Text('Next'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NearbyStationsDialog extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> nearbyStations;
|
||||
|
||||
const _NearbyStationsDialog({required this.nearbyStations});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Nearby Stations (within 3km)'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: nearbyStations.isEmpty
|
||||
? const Center(child: Text('No stations found.'))
|
||||
: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: nearbyStations.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = nearbyStations[index];
|
||||
final station = item['station'] as Map<String, dynamic>;
|
||||
final distanceInMeters = (item['distance'] as double) * 1000;
|
||||
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text("${station['sampling_station_code'] ?? 'N/A'}"),
|
||||
subtitle: Text("${station['sampling_river'] ?? 'N/A'}"),
|
||||
trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop(station);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,202 @@
|
||||
// lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_2_site_info.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../../../models/river_manual_triennial_sampling_data.dart';
|
||||
import '../../../../../services/river_manual_triennial_sampling_service.dart';
|
||||
|
||||
class RiverManualTriennialStep2SiteInfo extends StatefulWidget {
|
||||
final RiverManualTriennialSamplingData data;
|
||||
final VoidCallback onNext;
|
||||
|
||||
const RiverManualTriennialStep2SiteInfo({
|
||||
super.key,
|
||||
required this.data,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RiverManualTriennialStep2SiteInfo> createState() => _RiverManualTriennialStep2SiteInfoState();
|
||||
}
|
||||
|
||||
class _RiverManualTriennialStep2SiteInfoState extends State<RiverManualTriennialStep2SiteInfo> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isPickingImage = false;
|
||||
|
||||
late final TextEditingController _eventRemarksController;
|
||||
late final TextEditingController _labRemarksController;
|
||||
final List<String> _weatherOptions = ['Cloudy', 'Drizzle', 'Rainy', 'Sunny', 'Windy'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_eventRemarksController = TextEditingController(text: widget.data.eventRemarks);
|
||||
_labRemarksController = TextEditingController(text: widget.data.labRemarks);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventRemarksController.dispose();
|
||||
_labRemarksController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
|
||||
if (_isPickingImage) return;
|
||||
setState(() => _isPickingImage = true);
|
||||
|
||||
final service = Provider.of<RiverManualTriennialSamplingService>(context, listen: false);
|
||||
|
||||
final String? stationCode = widget.data.selectedStation?['sampling_station_code'];
|
||||
|
||||
final file = await service.pickAndProcessImage(
|
||||
source,
|
||||
data: widget.data,
|
||||
imageInfo: imageInfo,
|
||||
isRequired: isRequired,
|
||||
stationCode: stationCode,
|
||||
);
|
||||
|
||||
if (file != null) {
|
||||
setState(() => setImageCallback(file));
|
||||
} else if (mounted) {
|
||||
_showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isPickingImage = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _goToNextStep() {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
_formKey.currentState!.save();
|
||||
|
||||
if (widget.data.backgroundStationImage == null ||
|
||||
widget.data.upstreamRiverImage == null ||
|
||||
widget.data.downstreamRiverImage == null) {
|
||||
_showSnackBar('Please attach all 3 required photos before proceeding.', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
widget.onNext();
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, {bool isError = false}) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Colors.red : null,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
children: [
|
||||
Text("On-Site Information", style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: widget.data.weather,
|
||||
items: _weatherOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(),
|
||||
onChanged: (value) => setState(() => widget.data.weather = value),
|
||||
decoration: const InputDecoration(labelText: 'Weather *'),
|
||||
validator: (value) => value == null ? 'Weather is required' : null,
|
||||
onSaved: (value) => widget.data.weather = value,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _eventRemarksController,
|
||||
decoration: const InputDecoration(labelText: 'Event Remarks (Optional)', hintText: 'e.g., unusual smells, colors, etc.'),
|
||||
onSaved: (value) => widget.data.eventRemarks = value,
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _labRemarksController,
|
||||
decoration: const InputDecoration(labelText: 'Lab Remarks (Optional)'),
|
||||
onSaved: (value) => widget.data.labRemarks = value,
|
||||
maxLines: 3,
|
||||
),
|
||||
const Divider(height: 32),
|
||||
|
||||
Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge),
|
||||
const Text("All photos must be taken in landscape (horizontal) orientation.", style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
_buildImagePicker('Background Station', 'BACKGROUND_STATION', widget.data.backgroundStationImage, (file) => widget.data.backgroundStationImage = file, isRequired: true),
|
||||
_buildImagePicker('Upstream River', 'UPSTREAM_RIVER', widget.data.upstreamRiverImage, (file) => widget.data.upstreamRiverImage = file, isRequired: true),
|
||||
_buildImagePicker('Downstream River', 'DOWNSTREAM_RIVER', widget.data.downstreamRiverImage, (file) => widget.data.downstreamRiverImage = file, isRequired: true),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: _goToNextStep,
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: const Text('Next'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
if (imageFile != null)
|
||||
Stack(
|
||||
alignment: Alignment.topRight,
|
||||
children: [
|
||||
ClipRRect(borderRadius: BorderRadius.circular(8.0), child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)),
|
||||
Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
|
||||
child: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close, color: Colors.white, size: 20),
|
||||
onPressed: () => setState(() => setImageCallback(null)),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
|
||||
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
|
||||
],
|
||||
),
|
||||
if (remarkController != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: TextFormField(
|
||||
controller: remarkController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Remarks for $title',
|
||||
hintText: 'Add an optional remark...',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,192 @@
|
||||
// lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_4_additional_info.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../../../models/river_manual_triennial_sampling_data.dart';
|
||||
import '../../../../../services/river_manual_triennial_sampling_service.dart';
|
||||
|
||||
class RiverManualTriennialStep4AdditionalInfo extends StatefulWidget {
|
||||
final RiverManualTriennialSamplingData data;
|
||||
final VoidCallback onNext;
|
||||
|
||||
const RiverManualTriennialStep4AdditionalInfo({
|
||||
super.key,
|
||||
required this.data,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RiverManualTriennialStep4AdditionalInfo> createState() =>
|
||||
_RiverManualTriennialStep4AdditionalInfoState();
|
||||
}
|
||||
|
||||
class _RiverManualTriennialStep4AdditionalInfoState
|
||||
extends State<RiverManualTriennialStep4AdditionalInfo> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isPickingImage = false;
|
||||
|
||||
late final TextEditingController _optionalRemark1Controller;
|
||||
late final TextEditingController _optionalRemark2Controller;
|
||||
late final TextEditingController _optionalRemark3Controller;
|
||||
late final TextEditingController _optionalRemark4Controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_optionalRemark1Controller = TextEditingController(text: widget.data.optionalRemark1);
|
||||
_optionalRemark2Controller = TextEditingController(text: widget.data.optionalRemark2);
|
||||
_optionalRemark3Controller = TextEditingController(text: widget.data.optionalRemark3);
|
||||
_optionalRemark4Controller = TextEditingController(text: widget.data.optionalRemark4);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_optionalRemark1Controller.dispose();
|
||||
_optionalRemark2Controller.dispose();
|
||||
_optionalRemark3Controller.dispose();
|
||||
_optionalRemark4Controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
|
||||
if (_isPickingImage) return;
|
||||
setState(() => _isPickingImage = true);
|
||||
|
||||
// --- CORRECTED: Use the correct Triennial service ---
|
||||
final service = Provider.of<RiverManualTriennialSamplingService>(context, listen: false);
|
||||
final String? stationCode = widget.data.selectedStation?['sampling_station_code'];
|
||||
|
||||
final file = await service.pickAndProcessImage(
|
||||
source,
|
||||
data: widget.data, // This now correctly matches the method signature
|
||||
imageInfo: imageInfo,
|
||||
isRequired: isRequired,
|
||||
stationCode: stationCode,
|
||||
);
|
||||
|
||||
if (file != null) {
|
||||
setState(() => setImageCallback(file));
|
||||
} else if (mounted) {
|
||||
_showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isPickingImage = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _goToNextStep() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
_formKey.currentState!.save();
|
||||
|
||||
if (widget.data.sampleTurbidityImage == null) {
|
||||
_showSnackBar('Please attach the Sample Turbidity photo before proceeding.', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
widget.data.optionalRemark1 = _optionalRemark1Controller.text;
|
||||
widget.data.optionalRemark2 = _optionalRemark2Controller.text;
|
||||
widget.data.optionalRemark3 = _optionalRemark3Controller.text;
|
||||
widget.data.optionalRemark4 = _optionalRemark4Controller.text;
|
||||
widget.onNext();
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, {bool isError = false}) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Colors.red : null,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
children: [
|
||||
Text("Additional Photos",
|
||||
style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Text("Required Photo *", style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
_buildImagePicker('Sample Turbidity', 'SAMPLE_TURBIDITY', widget.data.sampleTurbidityImage, (file) => widget.data.sampleTurbidityImage = file, isRequired: true),
|
||||
|
||||
const Divider(height: 32),
|
||||
|
||||
Text("Optional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
_buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller, isRequired: false),
|
||||
_buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller, isRequired: false),
|
||||
_buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller, isRequired: false),
|
||||
_buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller, isRequired: false),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: _goToNextStep,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: const Text('Next'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
if (imageFile != null)
|
||||
Stack(
|
||||
alignment: Alignment.topRight,
|
||||
children: [
|
||||
ClipRRect(borderRadius: BorderRadius.circular(8.0), child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)),
|
||||
Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
|
||||
child: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close, color: Colors.white, size: 20),
|
||||
onPressed: () => setState(() => setImageCallback(null)),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
|
||||
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
|
||||
],
|
||||
),
|
||||
if (remarkController != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: TextFormField(
|
||||
controller: remarkController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Remarks for $title',
|
||||
hintText: 'Add an optional remark...',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,342 @@
|
||||
// lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_5_summary.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../../../auth_provider.dart';
|
||||
import '../../../../../models/river_manual_triennial_sampling_data.dart';
|
||||
|
||||
class RiverManualTriennialStep5Summary extends StatelessWidget {
|
||||
final RiverManualTriennialSamplingData data;
|
||||
final VoidCallback onSubmit;
|
||||
final bool isLoading;
|
||||
|
||||
const RiverManualTriennialStep5Summary({
|
||||
super.key,
|
||||
required this.data,
|
||||
required this.onSubmit,
|
||||
required this.isLoading,
|
||||
});
|
||||
|
||||
// --- START: MODIFICATION FOR HIGHLIGHTING ---
|
||||
// Added helper logic to re-validate parameters on the summary screen.
|
||||
|
||||
/// Maps the app's internal parameter keys to the names used in the database.
|
||||
static const Map<String, String> _parameterKeyToLimitName = {
|
||||
'oxygenConcentration': 'Oxygen Conc',
|
||||
'oxygenSaturation': 'Oxygen Sat',
|
||||
'ph': 'pH',
|
||||
'salinity': 'Salinity',
|
||||
'electricalConductivity': 'Conductivity',
|
||||
'temperature': 'Temperature',
|
||||
'tds': 'TDS',
|
||||
'turbidity': 'Turbidity',
|
||||
'ammonia': 'Ammonia',
|
||||
'batteryVoltage': 'Battery',
|
||||
};
|
||||
|
||||
/// Re-validates the final parameters against the defined limits.
|
||||
Set<String> _getOutOfBoundsKeys(BuildContext context) {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
// --- MODIFICATION: Use the new river-specific parameter limits list ---
|
||||
final riverLimits = authProvider.riverParameterLimits ?? [];
|
||||
// --- END MODIFICATION ---
|
||||
final Set<String> invalidKeys = {};
|
||||
|
||||
final readings = {
|
||||
'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation,
|
||||
'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity,
|
||||
'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity,
|
||||
'ammonia': data.ammonia, 'batteryVoltage': data.batteryVoltage,
|
||||
};
|
||||
|
||||
double? parseLimitValue(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
readings.forEach((key, value) {
|
||||
if (value == null || value == -999.0) return;
|
||||
|
||||
final limitName = _parameterKeyToLimitName[key];
|
||||
if (limitName == null) return;
|
||||
|
||||
final limitData = riverLimits.firstWhere((l) => l['param_parameter_list'] == limitName, orElse: () => {});
|
||||
|
||||
if (limitData.isNotEmpty) {
|
||||
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
|
||||
final upperLimit = parseLimitValue(limitData['param_upper_limit']);
|
||||
|
||||
if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) {
|
||||
invalidKeys.add(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return invalidKeys;
|
||||
}
|
||||
// --- END: MODIFICATION FOR HIGHLIGHTING ---
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// --- START: MODIFICATION FOR HIGHLIGHTING ---
|
||||
// Get the set of out-of-bounds keys before building the list.
|
||||
final outOfBoundsKeys = _getOutOfBoundsKeys(context);
|
||||
// --- END: MODIFICATION FOR HIGHLIGHTING ---
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
Text(
|
||||
"Please review all information before submitting.",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildSectionCard(
|
||||
context,
|
||||
"Sampling & Station Details",
|
||||
[
|
||||
_buildDetailRow("1st Sampler:", data.firstSamplerName),
|
||||
_buildDetailRow("2nd Sampler:", data.secondSampler?['first_name']?.toString()),
|
||||
_buildDetailRow("Sampling Date:", data.samplingDate),
|
||||
_buildDetailRow("Sampling Time:", data.samplingTime),
|
||||
_buildDetailRow("Sampling Type:", data.samplingType),
|
||||
_buildDetailRow("Sample ID Code:", data.sampleIdCode),
|
||||
const Divider(height: 20),
|
||||
_buildDetailRow("State:", data.selectedStateName),
|
||||
_buildDetailRow(
|
||||
"Station:",
|
||||
"${data.selectedStation?['sampling_station_code']} | ${data.selectedStation?['sampling_river']} | ${data.selectedStation?['sampling_basin']}"
|
||||
),
|
||||
_buildDetailRow("Station Location:", "${data.stationLatitude}, ${data.stationLongitude}"),
|
||||
],
|
||||
),
|
||||
|
||||
_buildSectionCard(
|
||||
context,
|
||||
"Site Info & Required Photos",
|
||||
[
|
||||
_buildDetailRow("Current Location:", "${data.currentLatitude}, ${data.currentLongitude}"),
|
||||
_buildDetailRow("Distance Difference:", data.distanceDifferenceInKm != null ? "${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters" : "N/A"),
|
||||
if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty)
|
||||
_buildDetailRow("Distance Remarks:", data.distanceDifferenceRemarks),
|
||||
const Divider(height: 20),
|
||||
|
||||
_buildDetailRow("Weather:", data.weather),
|
||||
_buildDetailRow("Event Remarks:", data.eventRemarks),
|
||||
_buildDetailRow("Lab Remarks:", data.labRemarks),
|
||||
const Divider(height: 20),
|
||||
|
||||
_buildImageCard("Background Station", data.backgroundStationImage),
|
||||
_buildImageCard("Upstream River", data.upstreamRiverImage),
|
||||
_buildImageCard("Downstream River", data.downstreamRiverImage),
|
||||
],
|
||||
),
|
||||
|
||||
_buildSectionCard(
|
||||
context,
|
||||
"Additional Photos & Remarks",
|
||||
[
|
||||
_buildImageCard("Sample Turbidity", data.sampleTurbidityImage),
|
||||
const Divider(height: 24),
|
||||
Text("Optional Photos", style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
_buildImageCard("Optional Photo 1", data.optionalImage1, remark: data.optionalRemark1),
|
||||
_buildImageCard("Optional Photo 2", data.optionalImage2, remark: data.optionalRemark2),
|
||||
_buildImageCard("Optional Photo 3", data.optionalImage3, remark: data.optionalRemark3),
|
||||
_buildImageCard("Optional Photo 4", data.optionalImage4, remark: data.optionalRemark4),
|
||||
],
|
||||
),
|
||||
|
||||
_buildSectionCard(
|
||||
context,
|
||||
"Captured Parameters",
|
||||
[
|
||||
_buildDetailRow("Sonde ID:", data.sondeId),
|
||||
_buildDetailRow("Capture Time:", "${data.dataCaptureDate} ${data.dataCaptureTime}"),
|
||||
const Divider(height: 20),
|
||||
// --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING ---
|
||||
_buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration, isOutOfBounds: outOfBoundsKeys.contains('oxygenConcentration')),
|
||||
_buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation, isOutOfBounds: outOfBoundsKeys.contains('oxygenSaturation')),
|
||||
_buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph, isOutOfBounds: outOfBoundsKeys.contains('ph')),
|
||||
_buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity, isOutOfBounds: outOfBoundsKeys.contains('salinity')),
|
||||
_buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity, isOutOfBounds: outOfBoundsKeys.contains('electricalConductivity')),
|
||||
_buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature, isOutOfBounds: outOfBoundsKeys.contains('temperature')),
|
||||
_buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds, isOutOfBounds: outOfBoundsKeys.contains('tds')),
|
||||
_buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity, isOutOfBounds: outOfBoundsKeys.contains('turbidity')),
|
||||
_buildParameterListItem(context, icon: Icons.science, label: "Ammonia", unit: "mg/L", value: data.ammonia, isOutOfBounds: outOfBoundsKeys.contains('ammonia')),
|
||||
_buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage, isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')),
|
||||
// --- END: MODIFICATION ---
|
||||
const Divider(height: 20),
|
||||
_buildFlowrateSummary(context),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ElevatedButton.icon(
|
||||
onPressed: onSubmit,
|
||||
icon: const Icon(Icons.cloud_upload),
|
||||
label: const Text('Confirm & Submit'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionCard(BuildContext context, String title, List<Widget> children) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const Divider(height: 20, thickness: 1),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String? value) {
|
||||
String displayValue = value?.replaceAll('null - null', '').replaceAll('null |', '').replaceAll('| null', '').trim() ?? 'N/A';
|
||||
if (displayValue.isEmpty || displayValue == "-") {
|
||||
displayValue = 'N/A';
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(displayValue, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING ---
|
||||
Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required double? value, bool isOutOfBounds = false}) {
|
||||
final bool isMissing = value == null || value == -999.0;
|
||||
// Format the value to 5 decimal places if it's a valid number.
|
||||
final String displayValue = isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim();
|
||||
|
||||
// Determine the color for the value based on theme and status.
|
||||
final Color? defaultTextColor = Theme.of(context).textTheme.bodyLarge?.color;
|
||||
final Color valueColor = isOutOfBounds
|
||||
? Colors.red
|
||||
: (isMissing ? Colors.grey : defaultTextColor ?? Colors.black);
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon, color: Theme.of(context).primaryColor, size: 28),
|
||||
title: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
trailing: Text(
|
||||
displayValue,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: valueColor,
|
||||
fontWeight: isOutOfBounds ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// --- END: MODIFICATION ---
|
||||
|
||||
Widget _buildImageCard(String title, File? image, {String? remark}) {
|
||||
final bool hasRemark = remark != null && remark.isNotEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
const SizedBox(height: 8),
|
||||
if (image != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
child: Image.file(image, key: UniqueKey(), height: 200, width: double.infinity, fit: BoxFit.cover),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
height: 100,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
border: Border.all(color: Colors.grey[300]!)),
|
||||
child: const Center(child: Text('No Image Attached', style: TextStyle(color: Colors.grey))),
|
||||
),
|
||||
if (hasRemark)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFlowrateSummary(BuildContext context) {
|
||||
final method = data.flowrateMethod ?? 'N/A';
|
||||
|
||||
List<Widget> children = [
|
||||
_buildDetailRow("Flowrate Method:", method),
|
||||
];
|
||||
|
||||
if (method == 'Surface Drifter') {
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 4.0),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildDetailRow("Height:", data.flowrateSurfaceDrifterHeight != null ? "${data.flowrateSurfaceDrifterHeight} m" : "N/A"),
|
||||
_buildDetailRow("Distance:", data.flowrateSurfaceDrifterDistance != null ? "${data.flowrateSurfaceDrifterDistance} m" : "N/A"),
|
||||
_buildDetailRow("Time First:", data.flowrateSurfaceDrifterTimeFirst ?? "N/A"),
|
||||
_buildDetailRow("Time Last:", data.flowrateSurfaceDrifterTimeLast ?? "N/A"),
|
||||
],
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
children.add(
|
||||
_buildDetailRow("Flowrate Value:", data.flowrateValue != null ? '${data.flowrateValue!.toStringAsFixed(4)} m/s' : 'NA')
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -744,7 +744,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('App Version'),
|
||||
subtitle: const Text('MMS V4 1.2.09'),
|
||||
subtitle: const Text('MMS V4 1.2.11'),
|
||||
dense: true,
|
||||
),
|
||||
ListTile(
|
||||
|
||||
@ -947,6 +947,94 @@ class RiverApiService {
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> submitTriennialSample({
|
||||
required Map<String, String> formData,
|
||||
required Map<String, File?> imageFiles,
|
||||
required List<Map<String, dynamic>>? appSettings,
|
||||
}) async {
|
||||
final baseUrl = await _serverConfigService.getActiveApiUrl();
|
||||
final dataResult = await _baseService.post(baseUrl, 'river/triennial/sample', formData);
|
||||
|
||||
if (dataResult['success'] != true) {
|
||||
return {
|
||||
'status': 'L1',
|
||||
'success': false,
|
||||
'message': 'Failed to submit triennial data: ${dataResult['message']}',
|
||||
'reportId': null
|
||||
};
|
||||
}
|
||||
|
||||
final recordId = dataResult['data']?['r_tri_id'];
|
||||
if (recordId == null) {
|
||||
return {
|
||||
'status': 'L2',
|
||||
'success': false,
|
||||
'message': 'Data submitted, but failed to get a record ID for images.',
|
||||
'reportId': null
|
||||
};
|
||||
}
|
||||
|
||||
final filesToUpload = <String, File>{};
|
||||
imageFiles.forEach((key, value) {
|
||||
if (value != null) filesToUpload[key] = value;
|
||||
});
|
||||
|
||||
if (filesToUpload.isEmpty) {
|
||||
_handleTriennialSuccessAlert(formData, appSettings, isDataOnly: true);
|
||||
return {
|
||||
'status': 'L3',
|
||||
'success': true,
|
||||
'message': 'Triennial data submitted successfully. No images were attached.',
|
||||
'reportId': recordId.toString()
|
||||
};
|
||||
}
|
||||
|
||||
final imageResult = await _baseService.postMultipart(
|
||||
baseUrl: baseUrl,
|
||||
endpoint: 'river/triennial/images',
|
||||
fields: {'r_tri_id': recordId.toString()},
|
||||
files: filesToUpload,
|
||||
);
|
||||
|
||||
if (imageResult['success'] != true) {
|
||||
return {
|
||||
'status': 'L2',
|
||||
'success': false,
|
||||
'message': 'Data submitted, but image upload failed: ${imageResult['message']}',
|
||||
'reportId': recordId.toString()
|
||||
};
|
||||
}
|
||||
|
||||
_handleTriennialSuccessAlert(formData, appSettings, isDataOnly: false);
|
||||
return {
|
||||
'status': 'S4',
|
||||
'success': true,
|
||||
'message': 'Triennial data and images submitted successfully.',
|
||||
'reportId': recordId.toString()
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _handleTriennialSuccessAlert(
|
||||
Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
|
||||
try {
|
||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||
final stationName = formData['r_tri_station_name'] ?? 'N/A';
|
||||
final stationCode = formData['r_tri_station_code'] ?? 'N/A';
|
||||
|
||||
final message = '✅ *River Triennial Sample ${submissionType} Submitted:*\n\n'
|
||||
'*Station:* $stationName ($stationCode)\n'
|
||||
'*Date:* ${formData['r_tri_date']}\n'
|
||||
'*User:* ${formData['first_sampler_name'] ?? 'N/A'}';
|
||||
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('river_triennial', message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('river_triennial', message, appSettings);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to handle River Triennial Telegram alert: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleInSituSuccessAlert(
|
||||
Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
|
||||
try {
|
||||
|
||||
@ -13,7 +13,9 @@ import '../models/air_installation_data.dart';
|
||||
import '../models/air_collection_data.dart';
|
||||
import '../models/tarball_data.dart';
|
||||
import '../models/in_situ_sampling_data.dart';
|
||||
import '../models/marine_manual_npe_report_data.dart';
|
||||
import '../models/river_in_situ_sampling_data.dart';
|
||||
import '../models/river_manual_triennial_sampling_data.dart';
|
||||
|
||||
class LocalStorageService {
|
||||
|
||||
@ -464,6 +466,111 @@ class LocalStorageService {
|
||||
return recentNearbySamples;
|
||||
}
|
||||
|
||||
// --- ADDED: Part 4.5: Marine NPE Report Specific Methods ---
|
||||
|
||||
Future<Directory?> _getNpeBaseDir({required String serverName}) async {
|
||||
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
|
||||
if (mmsv4Dir == null) return null;
|
||||
|
||||
final npeDir = Directory(p.join(mmsv4Dir.path, 'marine', 'marine_npe_report'));
|
||||
if (!await npeDir.exists()) {
|
||||
await npeDir.create(recursive: true);
|
||||
}
|
||||
return npeDir;
|
||||
}
|
||||
|
||||
Future<String?> saveNpeReportData(MarineManualNpeReportData data, {required String serverName}) async {
|
||||
final baseDir = await _getNpeBaseDir(serverName: serverName);
|
||||
if (baseDir == null) {
|
||||
debugPrint("Could not get public storage directory for NPE. Check permissions.");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final stationCode = data.selectedStation?['man_station_code'] ?? data.selectedStation?['tbl_station_code'] ?? 'CUSTOM_LOC';
|
||||
final timestamp = "${data.eventDate}_${data.eventTime?.replaceAll(':', '-')}";
|
||||
final eventFolderName = "${stationCode}_${timestamp}_NPE";
|
||||
final eventDir = Directory(p.join(baseDir.path, eventFolderName));
|
||||
|
||||
if (!await eventDir.exists()) {
|
||||
await eventDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final Map<String, dynamic> jsonData = data.toDbJson();
|
||||
jsonData['serverConfigName'] = serverName;
|
||||
|
||||
final imageFiles = data.toApiImageFiles();
|
||||
for (var entry in imageFiles.entries) {
|
||||
final File? imageFile = entry.value;
|
||||
if (imageFile != null && imageFile.path.isNotEmpty) {
|
||||
try {
|
||||
final String originalFileName = p.basename(imageFile.path);
|
||||
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName));
|
||||
jsonData[entry.key] = newFile.path;
|
||||
} catch (e) {
|
||||
debugPrint("Error processing NPE image file ${imageFile.path}: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final jsonFile = File(p.join(eventDir.path, 'data.json'));
|
||||
await jsonFile.writeAsString(jsonEncode(jsonData));
|
||||
debugPrint("NPE Report log saved to: ${jsonFile.path}");
|
||||
|
||||
return eventDir.path;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint("Error saving NPE report to local storage: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAllNpeLogs() async {
|
||||
final mmsv4Root = await _getPublicMMSV4Directory(serverName: '');
|
||||
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
|
||||
|
||||
final List<Map<String, dynamic>> allLogs = [];
|
||||
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
|
||||
|
||||
for (var serverDir in serverDirs) {
|
||||
final baseDir = Directory(p.join(serverDir.path, 'marine', 'marine_npe_report'));
|
||||
if (!await baseDir.exists()) continue;
|
||||
try {
|
||||
final entities = baseDir.listSync();
|
||||
for (var entity in entities) {
|
||||
if (entity is Directory) {
|
||||
final jsonFile = File(p.join(entity.path, 'data.json'));
|
||||
if (await jsonFile.exists()) {
|
||||
final content = await jsonFile.readAsString();
|
||||
final data = jsonDecode(content) as Map<String, dynamic>;
|
||||
data['logDirectory'] = entity.path;
|
||||
allLogs.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error reading NPE logs from ${baseDir.path}: $e");
|
||||
}
|
||||
}
|
||||
return allLogs;
|
||||
}
|
||||
|
||||
Future<void> updateNpeLog(Map<String, dynamic> updatedLogData) async {
|
||||
final logDir = updatedLogData['logDirectory'];
|
||||
if (logDir == null) return;
|
||||
|
||||
try {
|
||||
final jsonFile = File(p.join(logDir, 'data.json'));
|
||||
if (await jsonFile.exists()) {
|
||||
updatedLogData.remove('isResubmitting');
|
||||
await jsonFile.writeAsString(jsonEncode(updatedLogData));
|
||||
debugPrint("NPE Log updated successfully at: ${jsonFile.path}");
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error updating NPE log: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Part 5: River In-Situ Specific Methods (LOGGING RESTORED)
|
||||
// =======================================================================
|
||||
@ -587,7 +694,118 @@ class LocalStorageService {
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// --- ADDED: Part 6: Info Centre Document Management ---
|
||||
// Part 6: River Triennial Specific Methods
|
||||
// =======================================================================
|
||||
|
||||
Future<Directory?> _getRiverTriennialBaseDir({required String serverName}) async {
|
||||
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
|
||||
if (mmsv4Dir == null) return null;
|
||||
|
||||
final triennialDir = Directory(p.join(mmsv4Dir.path, 'river', 'river_triennial_sampling'));
|
||||
if (!await triennialDir.exists()) {
|
||||
await triennialDir.create(recursive: true);
|
||||
}
|
||||
return triennialDir;
|
||||
}
|
||||
|
||||
Future<String?> saveRiverManualTriennialSamplingData(RiverManualTriennialSamplingData data, {required String serverName}) async {
|
||||
final baseDir = await _getRiverTriennialBaseDir(serverName: serverName);
|
||||
if (baseDir == null) {
|
||||
debugPrint("Could not get public storage directory for River Triennial. Check permissions.");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN_STATION';
|
||||
final timestamp = "${data.samplingDate}_${data.samplingTime?.replaceAll(':', '-')}";
|
||||
final eventFolderName = "${stationCode}_$timestamp";
|
||||
final eventDir = Directory(p.join(baseDir.path, eventFolderName));
|
||||
|
||||
if (!await eventDir.exists()) {
|
||||
await eventDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final Map<String, dynamic> jsonData = data.toMap();
|
||||
jsonData['serverConfigName'] = serverName;
|
||||
|
||||
final imageFiles = data.toApiImageFiles();
|
||||
for (var entry in imageFiles.entries) {
|
||||
final File? imageFile = entry.value;
|
||||
if (imageFile != null) {
|
||||
final String originalFileName = p.basename(imageFile.path);
|
||||
if (p.dirname(imageFile.path) == eventDir.path) {
|
||||
jsonData[entry.key] = imageFile.path;
|
||||
} else {
|
||||
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName));
|
||||
jsonData[entry.key] = newFile.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final jsonFile = File(p.join(eventDir.path, 'data.json'));
|
||||
await jsonFile.writeAsString(jsonEncode(jsonData));
|
||||
debugPrint("River Triennial log saved to: ${jsonFile.path}");
|
||||
|
||||
return eventDir.path;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint("Error saving River Triennial log to local storage: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAllRiverManualTriennialLogs() async {
|
||||
final mmsv4Root = await _getPublicMMSV4Directory(serverName: '');
|
||||
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
|
||||
|
||||
final List<Map<String, dynamic>> allLogs = [];
|
||||
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
|
||||
|
||||
for (var serverDir in serverDirs) {
|
||||
final baseDir = Directory(p.join(serverDir.path, 'river', 'river_triennial_sampling'));
|
||||
if (!await baseDir.exists()) continue;
|
||||
try {
|
||||
final entities = baseDir.listSync();
|
||||
for (var entity in entities) {
|
||||
if (entity is Directory) {
|
||||
final jsonFile = File(p.join(entity.path, 'data.json'));
|
||||
if (await jsonFile.exists()) {
|
||||
final content = await jsonFile.readAsString();
|
||||
final data = jsonDecode(content) as Map<String, dynamic>;
|
||||
data['logDirectory'] = entity.path;
|
||||
allLogs.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error reading triennial logs from ${baseDir.path}: $e");
|
||||
}
|
||||
}
|
||||
return allLogs;
|
||||
}
|
||||
|
||||
Future<void> updateRiverManualTriennialLog(Map<String, dynamic> updatedLogData) async {
|
||||
final logDir = updatedLogData['logDirectory'];
|
||||
if (logDir == null) {
|
||||
debugPrint("Cannot update log: logDirectory key is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final jsonFile = File(p.join(logDir, 'data.json'));
|
||||
if (await jsonFile.exists()) {
|
||||
updatedLogData.remove('isResubmitting');
|
||||
await jsonFile.writeAsString(jsonEncode(updatedLogData));
|
||||
debugPrint("Log updated successfully at: ${jsonFile.path}");
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error updating river triennial log: $e");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =======================================================================
|
||||
// --- ADDED: Part 7: Info Centre Document Management ---
|
||||
// =======================================================================
|
||||
|
||||
final Dio _dio = Dio();
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
import '../auth_provider.dart';
|
||||
import '../models/marine_manual_equipment_maintenance_data.dart';
|
||||
|
||||
class MarineManualEquipmentMaintenanceService {
|
||||
Future<Map<String, dynamic>> submitMaintenanceReport({
|
||||
required MarineManualEquipmentMaintenanceData data,
|
||||
required AuthProvider authProvider,
|
||||
}) async {
|
||||
// TODO: Implement the full online/offline submission logic here.
|
||||
print("Submitting Equipment Maintenance Report...");
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
return {
|
||||
'success': true,
|
||||
'message': 'Equipment Maintenance Report submitted (simulation).'
|
||||
};
|
||||
}
|
||||
}
|
||||
18
lib/services/marine_manual_pre_departure_service.dart
Normal file
18
lib/services/marine_manual_pre_departure_service.dart
Normal file
@ -0,0 +1,18 @@
|
||||
import '../auth_provider.dart';
|
||||
import '../models/marine_manual_pre_departure_checklist_data.dart';
|
||||
|
||||
class MarineManualPreDepartureService {
|
||||
Future<Map<String, dynamic>> submitChecklist({
|
||||
required MarineManualPreDepartureChecklistData data,
|
||||
required AuthProvider authProvider,
|
||||
}) async {
|
||||
// TODO: Implement the full online/offline submission logic here,
|
||||
// similar to your MarineNpeReportService.
|
||||
print("Submitting Pre-Departure Checklist...");
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
return {
|
||||
'success': true,
|
||||
'message': 'Pre-Departure Checklist submitted (simulation).'
|
||||
};
|
||||
}
|
||||
}
|
||||
14
lib/services/marine_manual_sonde_calibration_service.dart
Normal file
14
lib/services/marine_manual_sonde_calibration_service.dart
Normal file
@ -0,0 +1,14 @@
|
||||
import '../auth_provider.dart';
|
||||
import '../models/marine_manual_sonde_calibration_data.dart';
|
||||
|
||||
class MarineManualSondeCalibrationService {
|
||||
Future<Map<String, dynamic>> submitCalibration({
|
||||
required MarineManualSondeCalibrationData data,
|
||||
required AuthProvider authProvider,
|
||||
}) async {
|
||||
// TODO: Implement online/offline submission logic.
|
||||
print("Submitting Sonde Calibration...");
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
return {'success': true, 'message': 'Sonde Calibration submitted (simulation).'};
|
||||
}
|
||||
}
|
||||
327
lib/services/marine_npe_report_service.dart
Normal file
327
lib/services/marine_npe_report_service.dart
Normal file
@ -0,0 +1,327 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../auth_provider.dart';
|
||||
import '../models/marine_manual_npe_report_data.dart';
|
||||
import 'local_storage_service.dart';
|
||||
import 'server_config_service.dart';
|
||||
import 'zipping_service.dart';
|
||||
import 'submission_api_service.dart';
|
||||
import 'submission_ftp_service.dart';
|
||||
import 'telegram_service.dart';
|
||||
import 'retry_service.dart';
|
||||
import 'api_service.dart';
|
||||
|
||||
class MarineNpeReportService {
|
||||
final SubmissionApiService _submissionApiService = SubmissionApiService();
|
||||
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
|
||||
final ZippingService _zippingService = ZippingService();
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final RetryService _retryService = RetryService();
|
||||
final TelegramService _telegramService;
|
||||
|
||||
MarineNpeReportService(this._telegramService);
|
||||
|
||||
Future<Map<String, dynamic>> submitNpeReport({
|
||||
required MarineManualNpeReportData data,
|
||||
required AuthProvider authProvider,
|
||||
String? logDirectory,
|
||||
}) async {
|
||||
const String moduleName = 'marine_npe_report';
|
||||
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
bool isOnline = connectivityResult != ConnectivityResult.none;
|
||||
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
|
||||
|
||||
if (isOnline && isOfflineSession) {
|
||||
debugPrint("NPE submission online during offline session. Attempting auto-relogin...");
|
||||
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
|
||||
if (transitionSuccess) {
|
||||
isOfflineSession = false;
|
||||
} else {
|
||||
isOnline = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOnline && !isOfflineSession) {
|
||||
debugPrint("Proceeding with direct ONLINE NPE submission...");
|
||||
return await _performNpeOnlineSubmission(
|
||||
data: data,
|
||||
moduleName: moduleName,
|
||||
authProvider: authProvider,
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
} else {
|
||||
debugPrint("Proceeding with OFFLINE NPE queuing mechanism...");
|
||||
return await _performNpeOfflineQueuing(
|
||||
data: data,
|
||||
moduleName: moduleName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _performNpeOnlineSubmission({
|
||||
required MarineManualNpeReportData data,
|
||||
required String moduleName,
|
||||
required AuthProvider authProvider,
|
||||
String? logDirectory,
|
||||
}) async {
|
||||
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
|
||||
final imageFilesWithNulls = data.toApiImageFiles();
|
||||
imageFilesWithNulls.removeWhere((key, value) => value == null);
|
||||
final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>();
|
||||
|
||||
bool anyApiSuccess = false;
|
||||
Map<String, dynamic> apiDataResult = {};
|
||||
Map<String, dynamic> apiImageResult = {};
|
||||
|
||||
try {
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/npe/report',
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
|
||||
if (apiDataResult['success'] == false &&
|
||||
(apiDataResult['message'] as String?)?.contains('Unauthorized') == true) {
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
if (reloginSuccess) {
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/npe/report',
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
data.reportId = apiDataResult['data']?['npe_id']?.toString();
|
||||
|
||||
if (data.reportId != null) {
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/npe/images',
|
||||
fields: {'npe_id': data.reportId!},
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) anyApiSuccess = false;
|
||||
}
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||
}
|
||||
}
|
||||
} on SocketException catch (e) {
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': "API submission failed with network error: $e"};
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/npe/report', method: 'POST', body: data.toApiFormData());
|
||||
if (finalImageFiles.isNotEmpty && data.reportId != null) {
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/npe/images', method: 'POST_MULTIPART', fields: {'npe_id': data.reportId!}, files: finalImageFiles);
|
||||
}
|
||||
} on TimeoutException catch (e) {
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': "API submission timed out: $e"};
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/npe/report', method: 'POST', body: data.toApiFormData());
|
||||
}
|
||||
|
||||
Map<String, dynamic> ftpResults = {'statuses': []};
|
||||
bool anyFtpSuccess = false;
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} on SocketException catch (e) {
|
||||
debugPrint("FTP submission failed with network error: $e");
|
||||
anyFtpSuccess = false;
|
||||
} on TimeoutException catch (e) {
|
||||
debugPrint("FTP submission timed out: $e");
|
||||
anyFtpSuccess = false;
|
||||
}
|
||||
|
||||
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
||||
String finalMessage;
|
||||
String finalStatus;
|
||||
|
||||
if (anyApiSuccess && anyFtpSuccess) {
|
||||
finalMessage = 'NPE Report submitted successfully to all destinations.';
|
||||
finalStatus = 'S4';
|
||||
} else if (anyApiSuccess && !anyFtpSuccess) {
|
||||
finalMessage = 'NPE Report sent to API, but some FTP uploads failed and were queued.';
|
||||
finalStatus = 'S3';
|
||||
} else if (!anyApiSuccess && anyFtpSuccess) {
|
||||
finalMessage = 'API submission for NPE Report failed and was queued, but files sent to FTP.';
|
||||
finalStatus = 'L4';
|
||||
} else {
|
||||
finalMessage = 'All NPE Report submission attempts failed and have been queued for retry.';
|
||||
finalStatus = 'L1';
|
||||
}
|
||||
|
||||
await _logAndSave(
|
||||
data: data,
|
||||
status: finalStatus,
|
||||
message: finalMessage,
|
||||
apiResults: [apiDataResult, apiImageResult],
|
||||
ftpStatuses: ftpResults['statuses'],
|
||||
serverName: serverName,
|
||||
finalImageFiles: finalImageFiles,
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
|
||||
if (overallSuccess) {
|
||||
_handleNpeSuccessAlert(data, authProvider);
|
||||
}
|
||||
|
||||
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _performNpeOfflineQueuing({
|
||||
required MarineManualNpeReportData data,
|
||||
required String moduleName,
|
||||
}) async {
|
||||
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
||||
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
||||
|
||||
data.submissionStatus = 'L1';
|
||||
data.submissionMessage = 'NPE Report queued due to being offline.';
|
||||
|
||||
final String? localLogPath = await _localStorageService.saveNpeReportData(data, serverName: serverName);
|
||||
|
||||
if (localLogPath == null) {
|
||||
const message = "Failed to save NPE report to local device storage.";
|
||||
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {});
|
||||
return {'success': false, 'message': message};
|
||||
}
|
||||
|
||||
await _retryService.queueTask(
|
||||
type: 'npe_submission',
|
||||
payload: {
|
||||
'module': moduleName,
|
||||
'localLogPath': localLogPath,
|
||||
'serverConfig': serverConfig,
|
||||
},
|
||||
);
|
||||
|
||||
const successMessage = "No internet connection. NPE Report has been saved and queued for upload.";
|
||||
return {'success': true, 'message': successMessage};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(MarineManualNpeReportData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
|
||||
final stationCode = data.selectedStation?['man_station_code'] ?? data.selectedStation?['tbl_station_code'] ?? 'CUSTOM_LOC';
|
||||
final fileTimestamp = "${data.eventDate}_${data.eventTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
||||
final baseFileName = '${stationCode}_${fileTimestamp}_NPE';
|
||||
|
||||
final Directory? logDirectory = await _localStorageService.getLogDirectory(
|
||||
serverName: serverName,
|
||||
module: 'marine',
|
||||
subModule: 'marine_npe_report',
|
||||
);
|
||||
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
|
||||
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
|
||||
await localSubmissionDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
|
||||
baseFileName: baseFileName,
|
||||
destinationDir: localSubmissionDir,
|
||||
);
|
||||
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
|
||||
if (dataZip != null) {
|
||||
ftpDataResult = await _submissionFtpService.submit(
|
||||
moduleName: moduleName,
|
||||
fileToUpload: dataZip,
|
||||
remotePath: '/${p.basename(dataZip.path)}',
|
||||
);
|
||||
}
|
||||
|
||||
final imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: imageFiles.values.toList(),
|
||||
baseFileName: baseFileName,
|
||||
destinationDir: localSubmissionDir,
|
||||
);
|
||||
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
|
||||
if (imageZip != null) {
|
||||
ftpImageResult = await _submissionFtpService.submit(
|
||||
moduleName: moduleName,
|
||||
fileToUpload: imageZip,
|
||||
remotePath: '/${p.basename(imageZip.path)}',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
'statuses': <Map<String, dynamic>>[
|
||||
...(ftpDataResult['statuses'] as List<dynamic>? ?? []),
|
||||
...(ftpImageResult['statuses'] as List<dynamic>? ?? []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _logAndSave({
|
||||
required MarineManualNpeReportData data,
|
||||
required String status,
|
||||
required String message,
|
||||
required List<Map<String, dynamic>> apiResults,
|
||||
required List<Map<String, dynamic>> ftpStatuses,
|
||||
required String serverName,
|
||||
required Map<String, File> finalImageFiles,
|
||||
String? logDirectory,
|
||||
}) async {
|
||||
data.submissionStatus = status;
|
||||
data.submissionMessage = message;
|
||||
final fileTimestamp = "${data.eventDate}_${data.eventTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
||||
|
||||
if (logDirectory != null) {
|
||||
final Map<String, dynamic> updatedLogData = data.toDbJson();
|
||||
updatedLogData['submissionStatus'] = status;
|
||||
updatedLogData['submissionMessage'] = message;
|
||||
updatedLogData['logDirectory'] = logDirectory;
|
||||
updatedLogData['serverConfigName'] = serverName;
|
||||
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
|
||||
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses);
|
||||
|
||||
final imageFilePaths = data.toApiImageFiles();
|
||||
imageFilePaths.forEach((key, file) {
|
||||
if (file != null) updatedLogData[key] = file.path;
|
||||
});
|
||||
|
||||
await _localStorageService.updateNpeLog(updatedLogData);
|
||||
} else {
|
||||
await _localStorageService.saveNpeReportData(data, serverName: serverName);
|
||||
}
|
||||
|
||||
final logData = {
|
||||
'submission_id': data.reportId ?? fileTimestamp,
|
||||
'module': 'marine',
|
||||
'type': 'NPE',
|
||||
'status': data.submissionStatus,
|
||||
'message': data.submissionMessage,
|
||||
'report_id': data.reportId,
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toDbJson()),
|
||||
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode(apiResults),
|
||||
'ftp_status': jsonEncode(ftpStatuses),
|
||||
};
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
}
|
||||
|
||||
Future<void> _handleNpeSuccessAlert(MarineManualNpeReportData data, AuthProvider authProvider) async {
|
||||
try {
|
||||
final message = data.generateTelegramAlertMessage();
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('marine_npe_report', message, authProvider.appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('marine_npe_report', message, authProvider.appSettings);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to handle NPE Telegram alert: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
488
lib/services/river_manual_triennial_sampling_service.dart
Normal file
488
lib/services/river_manual_triennial_sampling_service.dart
Normal file
@ -0,0 +1,488 @@
|
||||
// lib/services/river_manual_triennial_sampling_service.dart
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
import 'package:usb_serial/usb_serial.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
import '../auth_provider.dart';
|
||||
import 'location_service.dart';
|
||||
import '../models/river_manual_triennial_sampling_data.dart';
|
||||
import '../bluetooth/bluetooth_manager.dart';
|
||||
import '../serial/serial_manager.dart';
|
||||
import 'api_service.dart';
|
||||
import 'local_storage_service.dart';
|
||||
import 'server_config_service.dart';
|
||||
import 'zipping_service.dart';
|
||||
import 'submission_api_service.dart';
|
||||
import 'submission_ftp_service.dart';
|
||||
import 'telegram_service.dart';
|
||||
import 'retry_service.dart';
|
||||
|
||||
|
||||
class RiverManualTriennialSamplingService {
|
||||
final LocationService _locationService = LocationService();
|
||||
final BluetoothManager _bluetoothManager = BluetoothManager();
|
||||
final SerialManager _serialManager = SerialManager();
|
||||
final SubmissionApiService _submissionApiService = SubmissionApiService();
|
||||
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
final ZippingService _zippingService = ZippingService();
|
||||
final RetryService _retryService = RetryService();
|
||||
final TelegramService _telegramService;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
|
||||
|
||||
RiverManualTriennialSamplingService(this._telegramService);
|
||||
|
||||
Future<Position> getCurrentLocation() => _locationService.getCurrentLocation();
|
||||
double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2);
|
||||
|
||||
Future<File?> pickAndProcessImage(ImageSource source, { required RiverManualTriennialSamplingData data, required String imageInfo, bool isRequired = false, String? stationCode}) async {
|
||||
try {
|
||||
final XFile? pickedFile = await _picker.pickImage(
|
||||
source: source,
|
||||
imageQuality: 85,
|
||||
maxWidth: 1024,
|
||||
);
|
||||
|
||||
if (pickedFile == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final bytes = await pickedFile.readAsBytes();
|
||||
img.Image? originalImage = img.decodeImage(bytes);
|
||||
if (originalImage == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRequired && originalImage.height > originalImage.width) {
|
||||
debugPrint("Image rejected: Must be in landscape orientation.");
|
||||
return null;
|
||||
}
|
||||
|
||||
final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}";
|
||||
final font = img.arial24;
|
||||
final textWidth = watermarkTimestamp.length * 12;
|
||||
img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255),);
|
||||
img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0));
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final finalStationCode = stationCode ?? 'NA';
|
||||
final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-');
|
||||
final newFileName = "${finalStationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
|
||||
final filePath = p.join(tempDir.path, newFileName);
|
||||
|
||||
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('Error in pickAndProcessImage: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ValueNotifier<BluetoothConnectionState> get bluetoothConnectionState => _bluetoothManager.connectionState;
|
||||
ValueNotifier<SerialConnectionState> get serialConnectionState => _serialManager.connectionState;
|
||||
|
||||
ValueNotifier<String?> get sondeId {
|
||||
if (_bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected) {
|
||||
return _bluetoothManager.sondeId;
|
||||
}
|
||||
return _serialManager.sondeId;
|
||||
}
|
||||
|
||||
Stream<Map<String, double>> get bluetoothDataStream => _bluetoothManager.dataStream;
|
||||
Stream<Map<String, double>> get serialDataStream => _serialManager.dataStream;
|
||||
String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value;
|
||||
String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value;
|
||||
|
||||
Future<bool> requestDevicePermissions() async {
|
||||
Map<Permission, PermissionStatus> statuses = await [
|
||||
Permission.bluetoothScan,
|
||||
Permission.bluetoothConnect,
|
||||
Permission.locationWhenInUse,
|
||||
].request();
|
||||
|
||||
if (statuses[Permission.bluetoothScan] == PermissionStatus.granted &&
|
||||
statuses[Permission.bluetoothConnect] == PermissionStatus.granted) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<BluetoothDevice>> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices();
|
||||
Future<void> connectToBluetoothDevice(BluetoothDevice device) => _bluetoothManager.connect(device);
|
||||
void disconnectFromBluetooth() => _bluetoothManager.disconnect();
|
||||
void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 2));
|
||||
void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading();
|
||||
Future<List<UsbDevice>> getAvailableSerialDevices() => _serialManager.getAvailableDevices();
|
||||
|
||||
Future<bool> requestUsbPermission(UsbDevice device) async {
|
||||
try {
|
||||
return await platform.invokeMethod('requestUsbPermission', {'vid': device.vid, 'pid': device.pid}) ?? false;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint("Failed to request USB permission: '${e.message}'.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connectToSerialDevice(UsbDevice device) async {
|
||||
final bool permissionGranted = await requestUsbPermission(device);
|
||||
if (permissionGranted) {
|
||||
await _serialManager.connect(device);
|
||||
} else {
|
||||
throw Exception("USB permission was not granted.");
|
||||
}
|
||||
}
|
||||
|
||||
void disconnectFromSerial() => _serialManager.disconnect();
|
||||
void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 2));
|
||||
void stopSerialAutoReading() => _serialManager.stopAutoReading();
|
||||
void dispose() {
|
||||
_bluetoothManager.dispose();
|
||||
_serialManager.dispose();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> submitData({
|
||||
required RiverManualTriennialSamplingData data,
|
||||
required List<Map<String, dynamic>>? appSettings,
|
||||
required AuthProvider authProvider,
|
||||
String? logDirectory,
|
||||
}) async {
|
||||
const String moduleName = 'river_triennial';
|
||||
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
bool isOnline = connectivityResult != ConnectivityResult.none;
|
||||
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
|
||||
|
||||
if (isOnline && isOfflineSession) {
|
||||
debugPrint("River Triennial submission online during offline session. Attempting auto-relogin...");
|
||||
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
|
||||
if (transitionSuccess) {
|
||||
isOfflineSession = false;
|
||||
} else {
|
||||
isOnline = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOnline && !isOfflineSession) {
|
||||
debugPrint("Proceeding with direct ONLINE River Triennial submission...");
|
||||
return await _performOnlineSubmission(
|
||||
data: data,
|
||||
appSettings: appSettings,
|
||||
moduleName: moduleName,
|
||||
authProvider: authProvider,
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
} else {
|
||||
debugPrint("Proceeding with OFFLINE River Triennial queuing mechanism...");
|
||||
return await _performOfflineQueuing(
|
||||
data: data,
|
||||
moduleName: moduleName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _performOnlineSubmission({
|
||||
required RiverManualTriennialSamplingData data,
|
||||
required List<Map<String, dynamic>>? appSettings,
|
||||
required String moduleName,
|
||||
required AuthProvider authProvider,
|
||||
String? logDirectory,
|
||||
}) async {
|
||||
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
|
||||
final imageFilesWithNulls = data.toApiImageFiles();
|
||||
imageFilesWithNulls.removeWhere((key, value) => value == null);
|
||||
final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>();
|
||||
|
||||
bool anyApiSuccess = false;
|
||||
Map<String, dynamic> apiDataResult = {};
|
||||
Map<String, dynamic> apiImageResult = {};
|
||||
|
||||
try {
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/triennial/sample',
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
|
||||
if (apiDataResult['success'] == false &&
|
||||
(apiDataResult['message'] as String?)?.contains('Unauthorized') == true) {
|
||||
debugPrint("API submission failed with Unauthorized. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying data submission...");
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/triennial/sample',
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
data.reportId = apiDataResult['data']?['r_tri_id']?.toString();
|
||||
|
||||
if (data.reportId != null) {
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/triennial/images',
|
||||
fields: {'r_tri_id': data.reportId!},
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
anyApiSuccess = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||
}
|
||||
}
|
||||
} on SocketException catch (e) {
|
||||
final errorMessage = "API submission failed with network error: $e";
|
||||
debugPrint(errorMessage);
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': errorMessage};
|
||||
await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData());
|
||||
if (finalImageFiles.isNotEmpty && data.reportId != null) {
|
||||
await _retryService.addApiToQueue(endpoint: 'river/triennial/images', method: 'POST_MULTIPART', fields: {'r_tri_id': data.reportId!}, files: finalImageFiles);
|
||||
}
|
||||
} on TimeoutException catch (e) {
|
||||
final errorMessage = "API submission timed out: $e";
|
||||
debugPrint(errorMessage);
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': errorMessage};
|
||||
await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData());
|
||||
}
|
||||
|
||||
Map<String, dynamic> ftpResults = {'statuses': []};
|
||||
bool anyFtpSuccess = false;
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} on SocketException catch (e) {
|
||||
debugPrint("FTP submission failed with network error: $e");
|
||||
anyFtpSuccess = false;
|
||||
} on TimeoutException catch (e) {
|
||||
debugPrint("FTP submission timed out: $e");
|
||||
anyFtpSuccess = false;
|
||||
}
|
||||
|
||||
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
||||
String finalMessage;
|
||||
String finalStatus;
|
||||
|
||||
if (anyApiSuccess && anyFtpSuccess) {
|
||||
finalMessage = 'Data submitted successfully to all destinations.';
|
||||
finalStatus = 'S4';
|
||||
} else if (anyApiSuccess && !anyFtpSuccess) {
|
||||
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
|
||||
finalStatus = 'S3';
|
||||
} else if (!anyApiSuccess && anyFtpSuccess) {
|
||||
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
||||
finalStatus = 'L4';
|
||||
} else {
|
||||
finalMessage = 'All submission attempts failed and have been queued for retry.';
|
||||
finalStatus = 'L1';
|
||||
}
|
||||
|
||||
await _logAndSave(
|
||||
data: data,
|
||||
status: finalStatus,
|
||||
message: finalMessage,
|
||||
apiResults: [apiDataResult, apiImageResult],
|
||||
ftpStatuses: ftpResults['statuses'],
|
||||
serverName: serverName,
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
|
||||
if (overallSuccess) {
|
||||
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty);
|
||||
}
|
||||
|
||||
return {
|
||||
'status': finalStatus,
|
||||
'success': overallSuccess,
|
||||
'message': finalMessage,
|
||||
'reportId': data.reportId
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _performOfflineQueuing({
|
||||
required RiverManualTriennialSamplingData data,
|
||||
required String moduleName,
|
||||
}) async {
|
||||
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
||||
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
||||
|
||||
data.submissionStatus = 'L1';
|
||||
data.submissionMessage = 'Submission queued due to being offline.';
|
||||
|
||||
final String? localLogPath = await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName);
|
||||
|
||||
if (localLogPath == null) {
|
||||
const message = "Failed to save submission to local device storage.";
|
||||
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName);
|
||||
return {'status': 'Error', 'success': false, 'message': message};
|
||||
}
|
||||
|
||||
await _retryService.queueTask(
|
||||
type: 'river_triennial_submission',
|
||||
payload: {
|
||||
'module': moduleName,
|
||||
'localLogPath': p.join(localLogPath, 'data.json'),
|
||||
'serverConfig': serverConfig,
|
||||
},
|
||||
);
|
||||
|
||||
const successMessage = "No internet connection. Submission has been saved and queued for upload.";
|
||||
return {'status': 'Queued', 'success': true, 'message': successMessage};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverManualTriennialSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
|
||||
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN';
|
||||
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
||||
final baseFileName = "${stationCode}_$fileTimestamp";
|
||||
|
||||
final Directory? logDirectory = await _localStorageService.getLogDirectory(
|
||||
serverName: serverName,
|
||||
module: 'river',
|
||||
subModule: 'river_triennial_sampling',
|
||||
);
|
||||
|
||||
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
|
||||
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
|
||||
await localSubmissionDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: {'db.json': jsonEncode(data.toApiFormData())}, // Assuming similar structure is needed
|
||||
baseFileName: baseFileName,
|
||||
destinationDir: localSubmissionDir,
|
||||
);
|
||||
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
|
||||
if (dataZip != null) {
|
||||
ftpDataResult = await _submissionFtpService.submit(
|
||||
moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}');
|
||||
}
|
||||
|
||||
final imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: imageFiles.values.toList(),
|
||||
baseFileName: baseFileName,
|
||||
destinationDir: localSubmissionDir,
|
||||
);
|
||||
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
|
||||
if (imageZip != null) {
|
||||
ftpImageResult = await _submissionFtpService.submit(
|
||||
moduleName: moduleName, fileToUpload: imageZip, remotePath: '/${p.basename(imageZip.path)}');
|
||||
}
|
||||
|
||||
return {
|
||||
'statuses': <Map<String, dynamic>>[
|
||||
...(ftpDataResult['statuses'] as List),
|
||||
...(ftpImageResult['statuses'] as List),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _logAndSave({
|
||||
required RiverManualTriennialSamplingData data,
|
||||
required String status,
|
||||
required String message,
|
||||
required List<Map<String, dynamic>> apiResults,
|
||||
required List<Map<String, dynamic>> ftpStatuses,
|
||||
required String serverName,
|
||||
String? logDirectory,
|
||||
}) async {
|
||||
data.submissionStatus = status;
|
||||
data.submissionMessage = message;
|
||||
|
||||
if (logDirectory != null) {
|
||||
final Map<String, dynamic> updatedLogData = data.toMap();
|
||||
updatedLogData['logDirectory'] = logDirectory;
|
||||
updatedLogData['serverConfigName'] = serverName;
|
||||
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
|
||||
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses);
|
||||
|
||||
final imageFilePaths = data.toApiImageFiles();
|
||||
imageFilePaths.forEach((key, file) {
|
||||
if (file != null) {
|
||||
updatedLogData[key] = file.path;
|
||||
}
|
||||
});
|
||||
|
||||
await _localStorageService.updateRiverManualTriennialLog(updatedLogData);
|
||||
} else {
|
||||
await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName);
|
||||
}
|
||||
|
||||
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
|
||||
final logData = {
|
||||
'submission_id': data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
'module': 'river', 'type': data.samplingType ?? 'Triennial', 'status': status,
|
||||
'message': message, 'report_id': data.reportId, 'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()), 'image_data': jsonEncode(imagePaths),
|
||||
'server_name': serverName, 'api_status': jsonEncode(apiResults), 'ftp_status': jsonEncode(ftpStatuses),
|
||||
};
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
}
|
||||
|
||||
Future<void> _handleSuccessAlert(RiverManualTriennialSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
|
||||
try {
|
||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
|
||||
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'N/A';
|
||||
final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final submitter = data.firstSamplerName ?? 'N/A';
|
||||
final sondeID = data.sondeId ?? 'N/A';
|
||||
final distanceKm = data.distanceDifferenceInKm ?? 0;
|
||||
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
|
||||
final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A';
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('✅ *River Triennial Sample ${submissionType} Submitted:*')
|
||||
..writeln()
|
||||
..writeln('*Station Name & Code:* $stationName ($stationCode)')
|
||||
..writeln('*Date of Submitted:* $submissionDate')
|
||||
..writeln('*Submitted by User:* $submitter')
|
||||
..writeln('*Sonde ID:* $sondeID')
|
||||
..writeln('*Status of Submission:* Successful');
|
||||
|
||||
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('🔔 *Alert:*')
|
||||
..writeln('*Distance from station:* $distanceMeters meters');
|
||||
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
|
||||
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
||||
}
|
||||
}
|
||||
final String message = buffer.toString();
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('river_triennial', message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('river_triennial', message, appSettings);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to handle River Triennial Telegram alert: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -862,6 +862,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
toggle_switch:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: toggle_switch
|
||||
sha256: dca04512d7c23ed320d6c5ede1211a404f177d54d353bf785b07d15546a86ce5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -35,6 +35,7 @@ dependencies:
|
||||
url_launcher: ^6.2.6
|
||||
flutter_pdfview: ^1.3.2
|
||||
dio: ^5.4.3+1
|
||||
toggle_switch: ^2.3.0
|
||||
|
||||
# --- Device & Hardware Access ---
|
||||
image_picker: ^1.0.7
|
||||
|
||||
Loading…
Reference in New Issue
Block a user