diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 534120e..e48488c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -29,7 +29,7 @@ diff --git a/lib/main.dart b/lib/main.dart index 34f7a82..d8ce94f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(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 { // 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 { '/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(), diff --git a/lib/models/marine_manual_equipment_maintenance_data.dart b/lib/models/marine_manual_equipment_maintenance_data.dart new file mode 100644 index 0000000..d999614 --- /dev/null +++ b/lib/models/marine_manual_equipment_maintenance_data.dart @@ -0,0 +1,23 @@ +class MarineManualEquipmentMaintenanceData { + int? performedByUserId; + String? equipmentName; + String? maintenanceDate; + String? maintenanceType; + String? workDescription; + String? partsReplaced; + String? status; + String? remarks; + + Map 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, + }; + } +} \ No newline at end of file diff --git a/lib/models/marine_manual_npe_report_data.dart b/lib/models/marine_manual_npe_report_data.dart new file mode 100644 index 0000000..12e0ef7 --- /dev/null +++ b/lib/models/marine_manual_npe_report_data.dart @@ -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? 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 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 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 toApiFormData() { + final Map 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 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(); + } +} \ No newline at end of file diff --git a/lib/models/marine_manual_pre_departure_checklist_data.dart b/lib/models/marine_manual_pre_departure_checklist_data.dart new file mode 100644 index 0000000..724d5af --- /dev/null +++ b/lib/models/marine_manual_pre_departure_checklist_data.dart @@ -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 checklistItems = {}; + + // Key: Item description, Value: Remarks text + Map remarks = {}; + + MarineManualPreDepartureChecklistData(); + + Map toApiFormData() { + return { + 'reporter_user_id': reporterUserId.toString(), + 'submission_date': submissionDate, + 'checklist_items': jsonEncode(checklistItems), + 'remarks': jsonEncode(remarks), + }; + } +} \ No newline at end of file diff --git a/lib/models/marine_manual_sonde_calibration_data.dart b/lib/models/marine_manual_sonde_calibration_data.dart new file mode 100644 index 0000000..149d53a --- /dev/null +++ b/lib/models/marine_manual_sonde_calibration_data.dart @@ -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 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, + }; + } +} \ No newline at end of file diff --git a/lib/models/river_manual_triennial_sampling_data.dart b/lib/models/river_manual_triennial_sampling_data.dart new file mode 100644 index 0000000..c22e631 --- /dev/null +++ b/lib/models/river_manual_triennial_sampling_data.dart @@ -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? secondSampler; + String? samplingDate; + String? samplingTime; + String? samplingType; + String? sampleIdCode; + + String? selectedStateName; + String? selectedCategoryName; + Map? 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 toApiFormData() { + final Map 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 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 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, + }; + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/marine_manual_report.dart b/lib/screens/marine/manual/marine_manual_report.dart index 5fc8c0b..8807de5 100644 --- a/lib/screens/marine/manual/marine_manual_report.dart +++ b/lib/screens/marine/manual/marine_manual_report.dart @@ -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 _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]), + ), ], ), ), diff --git a/lib/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart b/lib/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart new file mode 100644 index 0000000..2d271aa --- /dev/null +++ b/lib/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart @@ -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 createState() => + _MarineManualEquipmentMaintenanceScreenState(); +} + +class _MarineManualEquipmentMaintenanceScreenState + extends State { + final _formKey = GlobalKey(); + final _data = MarineManualEquipmentMaintenanceData(); + bool _isLoading = false; + + bool _isOnline = true; + late StreamSubscription> _connectivitySubscription; + + final _dateController = TextEditingController(); + final _performedByController = TextEditingController(); + + @override + void initState() { + super.initState(); + final auth = Provider.of(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 _checkInitialConnectivity() async { + final connectivityResult = await Connectivity().checkConnectivity(); + _updateConnectionStatus(connectivityResult); + } + + void _updateConnectionStatus(List 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 _submit() async { + if (!_formKey.currentState!.validate()) return; + _formKey.currentState!.save(); + setState(() => _isLoading = true); + + try { + final auth = Provider.of(context, listen: false); + final service = Provider.of(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( + decoration: const InputDecoration(labelText: 'Maintenance Type *', border: OutlineInputBorder()), + items: ['Routine', 'Repair', 'Inspection', 'Replacement'].map((String value) { + return DropdownMenuItem(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( + decoration: const InputDecoration(labelText: 'Final Status *', border: OutlineInputBorder()), + items: ['Completed', 'Pending Parts', 'In Progress', 'Requires Follow-up'].map((String value) { + return DropdownMenuItem(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'), + ), + ) + ], + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/marine_manual_npe_report.dart b/lib/screens/marine/manual/reports/marine_manual_npe_report.dart similarity index 93% rename from lib/screens/marine/manual/marine_manual_npe_report.dart rename to lib/screens/marine/manual/reports/marine_manual_npe_report.dart index 0f0081f..556bd75 100644 --- a/lib/screens/marine/manual/marine_manual_npe_report.dart +++ b/lib/screens/marine/manual/reports/marine_manual_npe_report.dart @@ -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 with WidgetsBindingObserver { final _formKey = GlobalKey(); - // 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 with Widg bool _areLocationFieldsLocked = false; bool _isFetchingLocation = false; - // Data for Option 1 bool _isLoadingRecentSamples = false; List _recentNearbySamples = []; InSituSamplingData? _selectedRecentSample; - // Data for Dropdown Selections String? _selectedState; String? _selectedCategory; Map? _selectedStationMap; @@ -56,14 +55,12 @@ class _MarineManualNPEReportState extends State with Widg List _categoriesForState = []; List> _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 with Widg late final MarineInSituSamplingService _samplingService; final List> _npeParameters = []; - // Controllers final _latController = TextEditingController(); final _longController = TextEditingController(); final _stationIdController = TextEditingController(); @@ -88,7 +84,6 @@ class _MarineManualNPEReportState extends State with Widg final _othersObservationController = TextEditingController(); final _possibleSourceController = TextEditingController(); - // Checkbox states final Map _observations = { 'Oil slick on the water surface/ Oil spill': false, 'Discoloration of the sea water': false, @@ -263,7 +258,6 @@ class _MarineManualNPEReportState extends State 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 with Widg Future _processAndSetImage(ImageSource source, int imageNumber) async { if (_isPickingImage) return; - setState(() => _isPickingImage = true); final watermarkData = InSituSamplingData() @@ -362,6 +355,56 @@ class _MarineManualNPEReportState extends State with Widg } } + Future _submitNpeReport() async { + if (!_formKey.currentState!.validate()) { + _showSnackBar('Please fill in all required fields.', isError: true); + return; + } + + setState(() => _isLoading = true); + + final auth = Provider.of(context, listen: false); + final service = Provider.of(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 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 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; diff --git a/lib/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart b/lib/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart new file mode 100644 index 0000000..528befb --- /dev/null +++ b/lib/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart @@ -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 { + final _data = MarineManualPreDepartureChecklistData(); + bool _isLoading = false; + + final Map _remarksVisibility = {}; + + // NEW: State variables for connectivity + bool _isOnline = true; + late StreamSubscription> _connectivitySubscription; + + + final List _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 _checkInitialConnectivity() async { + final connectivityResult = await Connectivity().checkConnectivity(); + _updateConnectionStatus(connectivityResult); + } + + // NEW: Callback method to update UI based on connectivity changes + void _updateConnectionStatus(List 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 _submit() async { + setState(() => _isLoading = true); + + try { + final auth = Provider.of(context, listen: false); + final service = Provider.of(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', + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart b/lib/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart new file mode 100644 index 0000000..6c52dac --- /dev/null +++ b/lib/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart @@ -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 createState() => + _MarineManualSondeCalibrationScreenState(); +} + +class _MarineManualSondeCalibrationScreenState + extends State { + final _formKey = GlobalKey(); + final _data = MarineManualSondeCalibrationData(); + bool _isLoading = false; + + // State for connectivity + bool _isOnline = true; + late StreamSubscription> _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 _checkInitialConnectivity() async { + final connectivityResult = await Connectivity().checkConnectivity(); + _updateConnectionStatus(connectivityResult); + } + + void _updateConnectionStatus(List 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 _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(context, listen: false); + final service = Provider.of(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 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( + decoration: const InputDecoration(labelText: 'Overall Status *', border: OutlineInputBorder()), + items: ['Pass', 'Fail', 'Pass with Issues'].map((String value) { + return DropdownMenuItem(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, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/triennial/river_manual_triennial_sampling.dart b/lib/screens/river/manual/triennial/river_manual_triennial_sampling.dart new file mode 100644 index 0000000..e282f47 --- /dev/null +++ b/lib/screens/river/manual/triennial/river_manual_triennial_sampling.dart @@ -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 createState() => _RiverManualTriennialSamplingScreenState(); +} + +class _RiverManualTriennialSamplingScreenState extends State { + 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 _submitForm() async { + setState(() => _isLoading = true); + + final samplingService = Provider.of(context, listen: false); + final authProvider = Provider.of(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), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_1_sampling_info.dart b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_1_sampling_info.dart new file mode 100644 index 0000000..5024904 --- /dev/null +++ b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_1_sampling_info.dart @@ -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 createState() => _RiverManualTriennialStep1SamplingInfoState(); +} + +class _RiverManualTriennialStep1SamplingInfoState extends State { + final _formKey = GlobalKey(); + 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 _statesList = []; + List> _stationsForState = []; + final List _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(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().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 _getCurrentLocation() async { + setState(() => _isLoadingLocation = true); + final service = Provider.of(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(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 _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 _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(context, listen: false); + final auth = Provider.of(context, listen: false); + + final currentLat = double.parse(widget.data.currentLatitude!); + final currentLon = double.parse(widget.data.currentLongitude!); + final allStations = auth.riverManualStations ?? []; + final List> 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>( + context: context, + builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations), + ); + + if (selectedStation != null) { + _updateFormWithSelectedStation(selectedStation); + } + } + + void _updateFormWithSelectedStation(Map station) { + final allStations = Provider.of(context, listen: false).riverManualStations ?? []; + setState(() { + widget.data.selectedStateName = station['state_name']; + _stationsForState = allStations + .where((s) => s['state_name'] == widget.data.selectedStateName) + .toList() + ..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? '')); + + 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 _showDistanceRemarkDialog() async { + final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks); + final dialogFormKey = GlobalKey(); + + return showDialog( + 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: [ + 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(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>( + 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( + 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( + 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>( + 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: [ + 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> 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; + 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'), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_2_site_info.dart b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_2_site_info.dart new file mode 100644 index 0000000..37c99c1 --- /dev/null +++ b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_2_site_info.dart @@ -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 createState() => _RiverManualTriennialStep2SiteInfoState(); +} + +class _RiverManualTriennialStep2SiteInfoState extends State { + final _formKey = GlobalKey(); + bool _isPickingImage = false; + + late final TextEditingController _eventRemarksController; + late final TextEditingController _labRemarksController; + final List _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(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( + 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(), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_3_data_capture.dart b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_3_data_capture.dart new file mode 100644 index 0000000..62a13c6 --- /dev/null +++ b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_3_data_capture.dart @@ -0,0 +1,1007 @@ +// lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_3_data_capture.dart + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; +import 'package:usb_serial/usb_serial.dart'; +import 'package:intl/intl.dart'; + +import '../../../../../auth_provider.dart'; +import '../../../../../models/river_manual_triennial_sampling_data.dart'; +import '../../../../../services/api_service.dart'; // Import to access DatabaseHelper +import '../../../../../services/river_in_situ_sampling_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 RiverManualTriennialStep3DataCapture extends StatefulWidget { + final RiverManualTriennialSamplingData data; + final VoidCallback onNext; + + const RiverManualTriennialStep3DataCapture({ + super.key, + required this.data, + required this.onNext, + }); + + @override + State createState() => _RiverManualTriennialStep3DataCaptureState(); +} + +class _RiverManualTriennialStep3DataCaptureState extends State with WidgetsBindingObserver { + final _formKey = GlobalKey(); + bool _isLoading = false; + bool _isAutoReading = false; + StreamSubscription? _dataSubscription; + + // --- START: Added for lockout timer --- + Timer? _lockoutTimer; + int _lockoutSecondsRemaining = 30; + bool _isLockedOut = false; + // --- END: Added for lockout timer --- + + late final RiverInSituSamplingService _samplingService; + + // --- START: Added for direct database access --- + final DatabaseHelper _dbHelper = DatabaseHelper(); + // --- END: Added for direct database access --- + + Map? _previousReadingsForComparison; + Set _outOfBoundsKeys = {}; + + final Map _parameterKeyToLimitName = const { + 'oxygenConcentration': 'Oxygen Conc', + 'oxygenSaturation': 'Oxygen Sat', + 'ph': 'pH', + 'salinity': 'Salinity', + 'electricalConductivity': 'Conductivity', + 'temperature': 'Temperature', + 'tds': 'TDS', + 'turbidity': 'Turbidity', + 'ammonia': 'Ammonia', + 'batteryVoltage': 'Battery', + }; + + final List> _parameters = []; + + // Sonde parameter controllers + final _sondeIdController = TextEditingController(); + final _dateController = TextEditingController(); + final _timeController = TextEditingController(); + final _oxyConcController = TextEditingController(); + final _oxySatController = TextEditingController(); + final _phController = TextEditingController(); + final _salinityController = TextEditingController(); + final _ecController = TextEditingController(); + final _tempController = TextEditingController(); + final _tdsController = TextEditingController(); + final _turbidityController = TextEditingController(); + final _ammoniaController = TextEditingController(); + final _batteryController = TextEditingController(); + + // Flowrate controllers and state + String? _selectedFlowrateMethod; + final _flowrateValueController = TextEditingController(); + final _sdHeightController = TextEditingController(); + final _sdDistanceController = TextEditingController(); + final _sdTimeFirstController = TextEditingController(); + final _sdTimeLastController = TextEditingController(); + + @override + void initState() { + super.initState(); + _samplingService = Provider.of(context, listen: false); + _initializeControllers(); + _initializeFlowrateControllers(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + _dataSubscription?.cancel(); + _lockoutTimer?.cancel(); // --- MODIFICATION: Cancel timer on dispose --- + + if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + _samplingService.disconnectFromBluetooth(); + } + if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) { + _samplingService.disconnectFromSerial(); + } + + _disposeControllers(); + _disposeFlowrateControllers(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + if (mounted) { + setState(() {}); + } + } + } + + void _initializeControllers() { + widget.data.dataCaptureDate = widget.data.samplingDate; + widget.data.dataCaptureTime = widget.data.samplingTime; + + _sondeIdController.text = widget.data.sondeId ?? ''; + _dateController.text = widget.data.dataCaptureDate ?? ''; + _timeController.text = widget.data.dataCaptureTime ?? ''; + + _oxyConcController.text = widget.data.oxygenConcentration?.toString() ?? '-999.0'; + _oxySatController.text = widget.data.oxygenSaturation?.toString() ?? '-999.0'; + _phController.text = widget.data.ph?.toString() ?? '-999.0'; + _salinityController.text = widget.data.salinity?.toString() ?? '-999.0'; + _ecController.text = widget.data.electricalConductivity?.toString() ?? '-999.0'; + _tempController.text = widget.data.temperature?.toString() ?? '-999.0'; + _tdsController.text = widget.data.tds?.toString() ?? '-999.0'; + _turbidityController.text = widget.data.turbidity?.toString() ?? '-999.0'; + _ammoniaController.text = widget.data.ammonia?.toString() ?? '-999.0'; + _batteryController.text = widget.data.batteryVoltage?.toString() ?? '-999.0'; + + if (_parameters.isEmpty) { + _parameters.addAll([ + {'key': 'oxygenConcentration', 'icon': Icons.air, 'label': 'Oxygen Conc.', 'unit': 'mg/L', 'controller': _oxyConcController}, + {'key': 'oxygenSaturation', 'icon': Icons.percent, 'label': 'Oxygen Sat.', 'unit': '%', 'controller': _oxySatController}, + {'key': 'ph', 'icon': Icons.science_outlined, 'label': 'pH', 'unit': '', 'controller': _phController}, + {'key': 'salinity', 'icon': Icons.waves, 'label': 'Salinity', 'unit': 'ppt', 'controller': _salinityController}, + {'key': 'electricalConductivity', 'icon': Icons.flash_on, 'label': 'Conductivity', 'unit': 'ยตS/cm', 'controller': _ecController}, + {'key': 'temperature', 'icon': Icons.thermostat, 'label': 'Temperature', 'unit': 'ยฐC', 'controller': _tempController}, + {'key': 'tds', 'icon': Icons.grain, 'label': 'TDS', 'unit': 'mg/L', 'controller': _tdsController}, + {'key': 'turbidity', 'icon': Icons.opacity, 'label': 'Turbidity', 'unit': 'NTU', 'controller': _turbidityController}, + {'key': 'ammonia', 'icon': Icons.science, 'label': 'Ammonia', 'unit': 'mg/L', 'controller': _ammoniaController}, + {'key': 'batteryVoltage', 'icon': Icons.battery_charging_full, 'label': 'Battery', 'unit': 'V', 'controller': _batteryController}, + ]); + } + } + + void _disposeControllers() { + _sondeIdController.dispose(); + _dateController.dispose(); + _timeController.dispose(); + _oxyConcController.dispose(); + _oxySatController.dispose(); + _phController.dispose(); + _salinityController.dispose(); + _ecController.dispose(); + _tempController.dispose(); + _tdsController.dispose(); + _turbidityController.dispose(); + _ammoniaController.dispose(); + _batteryController.dispose(); + } + + void _initializeFlowrateControllers() { + _selectedFlowrateMethod = widget.data.flowrateMethod; + _flowrateValueController.text = widget.data.flowrateValue?.toString() ?? ''; + _sdHeightController.text = widget.data.flowrateSurfaceDrifterHeight?.toString() ?? ''; + _sdDistanceController.text = widget.data.flowrateSurfaceDrifterDistance?.toString() ?? ''; + _sdTimeFirstController.text = widget.data.flowrateSurfaceDrifterTimeFirst ?? ''; + _sdTimeLastController.text = widget.data.flowrateSurfaceDrifterTimeLast ?? ''; + } + + void _disposeFlowrateControllers() { + _flowrateValueController.dispose(); + _sdHeightController.dispose(); + _sdDistanceController.dispose(); + _sdTimeFirstController.dispose(); + _sdTimeLastController.dispose(); + } + + void _onFlowrateMethodChanged(String? value) { + setState(() { + _selectedFlowrateMethod = value; + if (value == 'NA') { + _flowrateValueController.text = 'NA'; + } else if (value == 'Flowmeter') { + _flowrateValueController.clear(); + } else { + _flowrateValueController.clear(); + } + }); + } + + void _calculateFlowrate() { + final distance = double.tryParse(_sdDistanceController.text); + final timeFirstStr = _sdTimeFirstController.text; + final timeLastStr = _sdTimeLastController.text; + + if (distance == null || timeFirstStr.isEmpty || timeLastStr.isEmpty) { + _showSnackBar("Please fill in Distance, Time First, and Time Last.", isError: true); + return; + } + + try { + final timeFormat = DateFormat("HH:mm:ss"); + final timeFirst = timeFormat.parse(timeFirstStr); + final timeLast = timeFormat.parse(timeLastStr); + final differenceInSeconds = timeLast.difference(timeFirst).inSeconds; + if (differenceInSeconds <= 0) { + _showSnackBar("Time Last Deploy must be after Time First Deploy.", isError: true); + return; + } + final flowrate = distance / differenceInSeconds; + setState(() { + _flowrateValueController.text = flowrate.toStringAsFixed(4); + }); + } catch (e) { + _showSnackBar("Invalid time format. Please use HH:mm:ss.", isError: true); + } + } + + Future _selectTime(BuildContext context, TextEditingController controller) async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null) { + final now = DateTime.now(); + final dt = DateTime(now.year, now.month, now.day, picked.hour, picked.minute); + setState(() { + controller.text = DateFormat('HH:mm:ss').format(dt); + }); + } + } + + Future _handleConnectionAttempt(String type) async { + final service = context.read(); + final bool hasPermissions = await service.requestDevicePermissions(); + if (!hasPermissions && mounted) { + _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); + return; + } + _disconnectFromAll(); + await Future.delayed(const Duration(milliseconds: 250)); + final bool connectionSuccess = await _connectToDevice(type); + if (connectionSuccess && mounted) { + _dataSubscription?.cancel(); + final stream = type == 'bluetooth' ? service.bluetoothDataStream : service.serialDataStream; + _dataSubscription = stream.listen((readings) { + if (mounted) { + _updateTextFields(readings); + } + }); + } + } + + Future _connectToDevice(String type) async { + setState(() => _isLoading = true); + final service = context.read(); + bool success = false; + try { + if (type == 'bluetooth') { + final devices = await service.getPairedBluetoothDevices(); + if (devices.isEmpty && mounted) { + _showSnackBar('No paired Bluetooth devices found.', isError: true); + return false; + } + final selectedDevice = await showBluetoothDeviceListDialog(context: context, devices: devices); + if (selectedDevice != null) { + await service.connectToBluetoothDevice(selectedDevice); + success = true; + } + } else if (type == 'serial') { + final devices = await service.getAvailableSerialDevices(); + if (devices.isEmpty && mounted) { + _showSnackBar('No USB Serial devices found.', isError: true); + return false; + } + final selectedDevice = await showSerialPortListDialog(context: context, devices: devices); + if (selectedDevice != null) { + await service.connectToSerialDevice(selectedDevice); + success = true; + } + } + } catch (e) { + if (mounted) _showSnackBar('Connection failed: $e', isError: true); + success = false; + } finally { + if (mounted) setState(() => _isLoading = false); + } + return success; + } + + // --- START MODIFICATION: Countdown Timer Logic --- + void _startLockoutTimer() { + _lockoutTimer?.cancel(); + setState(() { + _isLockedOut = true; + _lockoutSecondsRemaining = 30; + }); + + _lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_lockoutSecondsRemaining > 0) { + if (mounted) { + setState(() { + _lockoutSecondsRemaining--; + }); + } + } else { + timer.cancel(); + if (mounted) { + setState(() { + _isLockedOut = false; + }); + } + } + }); + } + // --- END MODIFICATION --- + + void _toggleAutoReading(String activeType) { + final service = context.read(); + setState(() { + _isAutoReading = !_isAutoReading; + if (_isAutoReading) { + if (activeType == 'bluetooth') service.startBluetoothAutoReading(); else service.startSerialAutoReading(); + _startLockoutTimer(); // --- MODIFICATION: Start countdown + } else { + if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading(); + } + }); + } + + void _disconnect(String type) { + final service = context.read(); + if (type == 'bluetooth') { + service.disconnectFromBluetooth(); + } else { + service.disconnectFromSerial(); + } + _dataSubscription?.cancel(); + _dataSubscription = null; + _lockoutTimer?.cancel(); // --- MODIFICATION: Cancel timer on disconnect --- + if (mounted) { + setState(() { + _isAutoReading = false; + _isLockedOut = false; // --- MODIFICATION: Reset lockout state --- + }); + } + } + + void _disconnectFromAll() { + final service = context.read(); + if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + _disconnect('bluetooth'); + } + if (service.serialConnectionState.value != SerialConnectionState.disconnected) { + _disconnect('serial'); + } + } + + void _updateTextFields(Map readings) { + const defaultValue = -999.0; + setState(() { + _oxyConcController.text = (readings['Optical Dissolved Oxygen: Compensated mg/L'] ?? defaultValue).toStringAsFixed(5); + _oxySatController.text = (readings['Optical Dissolved Oxygen: Compensated % Saturation'] ?? defaultValue).toStringAsFixed(5); + _phController.text = (readings['PH: PH units'] ?? defaultValue).toStringAsFixed(5); + _tempController.text = (readings['External Temp: Degrees Celcius'] ?? defaultValue).toStringAsFixed(5); + _ecController.text = (readings['Conductivity: us/cm'] ?? defaultValue).toStringAsFixed(5); + _salinityController.text = (readings['Conductivity: Salinity'] ?? defaultValue).toStringAsFixed(5); + _tdsController.text = (readings['Conductivity:TDS mg/L'] ?? defaultValue).toStringAsFixed(5); + _turbidityController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5); + _batteryController.text = (readings['Sonde: Battery Voltage'] ?? defaultValue).toStringAsFixed(5); + _ammoniaController.text = (readings['Ammonium (NH4+) mg/L'] ?? defaultValue).toStringAsFixed(5); + }); + } + + // --- START: MODIFIED VALIDATION FLOW --- + void _validateAndProceed() async { + // --- START MODIFICATION: Add lockout check --- + if (_isLockedOut) { + _showSnackBar("Please wait for the initial reading period to complete.", isError: true); + return; + } + // --- END MODIFICATION --- + + if (_isAutoReading) { + _showStopReadingDialog(); + return; + } + + if (!_formKey.currentState!.validate()) { + return; + } + _formKey.currentState!.save(); + + final currentReadings = _captureReadingsToMap(); + + // Directly load river-specific limits from the new table via DatabaseHelper. + final List> riverLimits = await _dbHelper.loadRiverParameterLimits() ?? []; + + final outOfBoundsParams = _validateParameters(currentReadings, riverLimits); + + setState(() { + _outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet(); + }); + + if (outOfBoundsParams.isNotEmpty) { + _showParameterLimitDialog(outOfBoundsParams, currentReadings); + } else { + _saveDataAndMoveOn(currentReadings); + } + } + // --- END: MODIFIED VALIDATION FLOW --- + + Map _captureReadingsToMap() { + final Map readings = {}; + for (var param in _parameters) { + final key = param['key'] as String; + final controller = param['controller'] as TextEditingController; + readings[key] = double.tryParse(controller.text) ?? -999.0; + } + return readings; + } + + List> _validateParameters(Map readings, List> limits) { + final List> invalidParams = []; + + 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 == -999.0) return; + + final limitName = _parameterKeyToLimitName[key]; + if (limitName == null) return; + + final limitData = limits.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)) { + final paramInfo = _parameters.firstWhere((p) => p['key'] == key, orElse: () => {}); + invalidParams.add({ + 'label': paramInfo['label'] ?? key, + 'value': value, + 'lower_limit': lowerLimit, + 'upper_limit': upperLimit, + }); + } + } + }); + return invalidParams; + } + + void _saveDataAndMoveOn(Map readings) { + try { + const defaultValue = -999.0; + widget.data.temperature = readings['temperature'] ?? defaultValue; + widget.data.ph = readings['ph'] ?? defaultValue; + widget.data.salinity = readings['salinity'] ?? defaultValue; + widget.data.electricalConductivity = readings['electricalConductivity'] ?? defaultValue; + widget.data.oxygenConcentration = readings['oxygenConcentration'] ?? defaultValue; + widget.data.oxygenSaturation = readings['oxygenSaturation'] ?? defaultValue; + widget.data.tds = readings['tds'] ?? defaultValue; + widget.data.turbidity = readings['turbidity'] ?? defaultValue; + widget.data.ammonia = readings['ammonia'] ?? defaultValue; + widget.data.batteryVoltage = readings['batteryVoltage'] ?? defaultValue; + + widget.data.flowrateMethod = _selectedFlowrateMethod; + if (_selectedFlowrateMethod == 'Surface Drifter') { + widget.data.flowrateSurfaceDrifterHeight = double.tryParse(_sdHeightController.text); + widget.data.flowrateSurfaceDrifterDistance = double.tryParse(_sdDistanceController.text); + widget.data.flowrateSurfaceDrifterTimeFirst = _sdTimeFirstController.text; + widget.data.flowrateSurfaceDrifterTimeLast = _sdTimeLastController.text; + widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); + } else if (_selectedFlowrateMethod == 'Flowmeter') { + widget.data.flowrateValue = double.tryParse(_flowrateValueController.text); + } else { // NA + widget.data.flowrateValue = null; + } + + } catch (e) { + _showSnackBar("Could not save parameters due to a data format error.", isError: true); + return; + } + + setState(() { + _outOfBoundsKeys.clear(); + if (_previousReadingsForComparison != null) { + _previousReadingsForComparison = null; + } + }); + + widget.onNext(); + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : null, + )); + } + } + + void _showStopReadingDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Data Collection Active'), + content: const Text('Please stop the live data collection before proceeding.'), + actions: [ + TextButton(child: const Text('OK'), onPressed: () => Navigator.of(context).pop()) + ] + ); + } + ); + } + + Map? _getActiveConnectionDetails() { + final service = context.watch(); + if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + return {'type': 'bluetooth', 'state': service.bluetoothConnectionState.value, 'name': service.connectedBluetoothDeviceName}; + } + if (service.serialConnectionState.value != SerialConnectionState.disconnected) { + return {'type': 'serial', 'state': service.serialConnectionState.value, 'name': service.connectedSerialDeviceName}; + } + return null; + } + + @override + Widget build(BuildContext context) { + final service = context.watch(); + final activeConnection = _getActiveConnectionDetails(); + final String? activeType = activeConnection?['type'] as String?; + + // --- START MODIFICATION: Add WillPopScope to block back navigation --- + return WillPopScope( + onWillPop: () async { + if (_isLockedOut) { + _showSnackBar("Please wait for the initial reading period to complete.", isError: true); + return false; // Prevent back navigation + } + return true; // Allow back navigation + }, + child: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + Text("Data Capture", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: activeType == 'bluetooth' + ? FilledButton.icon(icon: const Icon(Icons.bluetooth_connected), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth')) + : OutlinedButton.icon(icon: const Icon(Icons.bluetooth), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth')), + ), + const SizedBox(width: 16), + Expanded( + child: activeType == 'serial' + ? FilledButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial')) + : OutlinedButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial')), + ), + ], + ), + const SizedBox(height: 16), + if (activeConnection != null) + _buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']), + const SizedBox(height: 24), + ValueListenableBuilder( + valueListenable: service.sondeId, + builder: (context, sondeId, child) { + final newSondeId = sondeId ?? ''; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _sondeIdController.text != newSondeId) { + _sondeIdController.text = newSondeId; + widget.data.sondeId = newSondeId; + } + }); + return TextFormField( + controller: _sondeIdController, + decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'), + validator: (v) => v == null || v.isEmpty ? 'Sonde ID is required' : null, + onChanged: (value) { widget.data.sondeId = value; }, + onSaved: (v) => widget.data.sondeId = v, + ); + }, + ), + 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'))), + ], + ), + + if (_previousReadingsForComparison != null) + _buildComparisonView(), + + const Divider(height: 32), + Column( + children: _parameters.map((param) { + return _buildParameterListItem( + icon: param['icon'] as IconData, + label: param['label'] as String, + unit: param['unit'] as String, + controller: param['controller'] as TextEditingController, + isOutOfBounds: _outOfBoundsKeys.contains(param['key']), + ); + }).toList(), + ), + const Divider(height: 32), + _buildFlowrateSection(), + const SizedBox(height: 32), + // --- START MODIFICATION: Add countdown to Next button --- + ElevatedButton( + onPressed: _isLockedOut ? null : _validateAndProceed, + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: Text(_isLockedOut ? 'Next ($_lockoutSecondsRemaining\s)' : 'Next'), + ), + // --- END MODIFICATION --- + ], + ), + ), + ); + // --- END MODIFICATION --- + } + + Widget _buildParameterListItem({ required IconData icon, required String label, required String unit, required TextEditingController controller, bool isOutOfBounds = false}) { + final bool isMissing = controller.text.isEmpty || controller.text.contains('-999'); + final String displayValue = isMissing ? '-.--' : controller.text; + final String displayLabel = unit.isEmpty ? label : '$label ($unit)'; + + final Color valueColor = isOutOfBounds + ? Colors.red + : (isMissing ? Colors.grey : Theme.of(context).colorScheme.primary); + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4.0), + child: ListTile( + leading: Icon(icon, color: Theme.of(context).primaryColor, size: 32), + title: Text(displayLabel), + trailing: Text( + displayValue, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: valueColor), + ), + ), + ); + } + + Widget _buildConnectionCard({required String type, required dynamic connectionState, String? deviceName}) { + final isConnected = connectionState == BluetoothConnectionState.connected || connectionState == SerialConnectionState.connected; + final isConnecting = connectionState == BluetoothConnectionState.connecting || connectionState == SerialConnectionState.connecting; + Color statusColor = isConnected ? Colors.green : Colors.red; + String statusText = isConnected ? 'Connected to ${deviceName ?? 'device'}' : 'Disconnected'; + if (isConnecting) { + statusColor = Colors.orange; + statusText = 'Connecting...'; + } + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Text(statusText, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 16), + if (isConnecting || _isLoading) + const CircularProgressIndicator() + else if (isConnected) + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // --- START MODIFICATION: Add countdown to Stop Reading button --- + ElevatedButton.icon( + icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), + label: Text(_isAutoReading + ? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading') + : 'Start Reading'), + onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type), + style: ElevatedButton.styleFrom( + backgroundColor: _isAutoReading + ? (_isLockedOut ? Colors.grey.shade600 : Colors.orange) + : Colors.green, + foregroundColor: Colors.white, + ), + ), + // --- END MODIFICATION --- + TextButton.icon( + icon: const Icon(Icons.link_off), + label: const Text('Disconnect'), + onPressed: () => _disconnect(type), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ) + ], + ) + ], + ), + ), + ); + } + + Widget _buildComparisonView() { + final previousReadings = _previousReadingsForComparison!; + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + + return Card( + margin: const EdgeInsets.only(top: 24.0), + color: Theme.of(context).cardColor, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: DefaultTextStyle( + style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Resample Comparison", + style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Theme.of(context).primaryColor), + ), + const SizedBox(height: 8), + Table( + columnWidths: const { + 0: FlexColumnWidth(2), + 1: FlexColumnWidth(1.5), + 2: FlexColumnWidth(1.5), + }, + border: TableBorder( + horizontalInside: BorderSide(width: 1, color: Colors.grey.shade700, style: BorderStyle.solid), + verticalInside: BorderSide(width: 1, color: Colors.grey.shade700, style: BorderStyle.solid), + top: BorderSide(width: 1.5, color: Colors.grey.shade500), + bottom: BorderSide(width: 1.5, color: Colors.grey.shade500), + ), + children: [ + TableRow( + decoration: BoxDecoration(color: isDarkTheme ? Colors.grey.shade800 : Colors.grey.shade200), + children: [ + Padding(padding: const EdgeInsets.all(8.0), child: Text('Parameter', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleMedium?.color))), + Padding(padding: const EdgeInsets.all(8.0), child: Text('Previous', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleMedium?.color))), + Padding(padding: const EdgeInsets.all(8.0), child: Text('Current', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleMedium?.color))), + ], + ), + ..._parameters.map((param) { + final key = param['key'] as String; + final label = param['label'] as String; + final controller = param['controller'] as TextEditingController; + final previousValue = previousReadings[key]; + final bool isCurrentValueOutOfBounds = _outOfBoundsKeys.contains(key); + + return TableRow( + children: [ + Padding(padding: const EdgeInsets.all(8.0), child: Text(label)), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + previousValue == -999.0 ? '-.--' : previousValue!.toStringAsFixed(5), + style: TextStyle(color: isDarkTheme ? Colors.orange.shade200 : Colors.orange.shade700), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + controller.text.contains('-999') ? '-.--' : (double.tryParse(controller.text) ?? 0).toStringAsFixed(5), + style: TextStyle( + color: isCurrentValueOutOfBounds + ? Colors.red + : (isDarkTheme ? Colors.green.shade200 : Colors.green.shade700), + fontWeight: FontWeight.bold + ), + ), + ), + ], + ); + }).toList(), + ], + ), + ], + ), + ), + ), + ); + } + + Future _showParameterLimitDialog(List> invalidParams, Map readings) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + return AlertDialog( + title: const Text('Parameter Limit Warning'), + content: SingleChildScrollView( + child: DefaultTextStyle( + style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('The following parameters are outside the standard limits:'), + const SizedBox(height: 16), + Table( + columnWidths: const { + 0: FlexColumnWidth(2), + 1: FlexColumnWidth(2.5), + 2: FlexColumnWidth(1.5), + }, + border: TableBorder( + horizontalInside: BorderSide(width: 0.5, color: isDarkTheme ? Colors.grey.shade700 : Colors.grey.shade300), + verticalInside: BorderSide(width: 0.5, color: isDarkTheme ? Colors.grey.shade700 : Colors.grey.shade300), + top: BorderSide(width: 1, color: isDarkTheme ? Colors.grey.shade600 : Colors.grey.shade400), + bottom: BorderSide(width: 1, color: isDarkTheme ? Colors.grey.shade600 : Colors.grey.shade400), + ), + children: [ + TableRow( + decoration: BoxDecoration(color: isDarkTheme ? Colors.grey.shade800 : Colors.grey.shade200), + children: [ + Padding(padding: const EdgeInsets.all(6.0), child: Text('Parameter', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))), + Padding(padding: const EdgeInsets.all(6.0), child: Text('Limit Range', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))), + Padding(padding: const EdgeInsets.all(6.0), child: Text('Current', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))), + ], + ), + ...invalidParams.map((p) => TableRow( + children: [ + Padding(padding: const EdgeInsets.all(6.0), child: Text(p['label'])), + Padding(padding: const EdgeInsets.all(6.0), child: Text('${p['lower_limit']?.toStringAsFixed(5) ?? 'N/A'} - ${p['upper_limit']?.toStringAsFixed(5) ?? 'N/A'}')), + Padding( + padding: const EdgeInsets.all(6.0), + child: Text( + p['value'].toStringAsFixed(5), + style: const TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold), + ), + ), + ], + )).toList(), + ], + ), + const SizedBox(height: 16), + const Text('Do you want to resample or proceed with the current values? Please verify with standard solution.'), + ], + ), + ), + ), + actions: [ + TextButton( + child: const Text('Resample'), + onPressed: () { + setState(() { + _previousReadingsForComparison = readings; + }); + Navigator.of(context).pop(); + }, + ), + FilledButton( + child: const Text('Proceed Anyway'), + onPressed: () { + Navigator.of(context).pop(); + _saveDataAndMoveOn(readings); + }, + ), + ], + ); + }, + ); + } + + Widget _buildFlowrateSection() { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4.0), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Flowrate", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildFlowrateRadioButton("Surface Drifter"), + _buildFlowrateRadioButton("Flowmeter"), + _buildFlowrateRadioButton("NA"), + ], + ), + if (_selectedFlowrateMethod == 'Surface Drifter') + _buildSurfaceDrifterFields(), + if (_selectedFlowrateMethod == 'Flowmeter') + _buildFlowmeterField(), + if (_selectedFlowrateMethod == 'NA') + _buildNAField(), + ], + ), + ), + ); + } + + Widget _buildFlowrateRadioButton(String title) { + return Column( + children: [ + Radio( + value: title, + groupValue: _selectedFlowrateMethod, + onChanged: _onFlowrateMethodChanged, + ), + Text(title), + ], + ); + } + + Widget _buildSurfaceDrifterFields() { + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + children: [ + TextFormField( + controller: _sdHeightController, + decoration: const InputDecoration(labelText: 'Height (m)'), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + TextFormField( + controller: _sdDistanceController, + decoration: const InputDecoration(labelText: 'Distance (m)'), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + TextFormField( + controller: _sdTimeFirstController, + decoration: const InputDecoration(labelText: 'Time First Deploy (HH:mm:ss)', suffixIcon: Icon(Icons.timer)), + readOnly: true, + onTap: () => _selectTime(context, _sdTimeFirstController), + ), + const SizedBox(height: 16), + TextFormField( + controller: _sdTimeLastController, + decoration: const InputDecoration(labelText: 'Time Last Deploy (HH:mm:ss)', suffixIcon: Icon(Icons.timer)), + readOnly: true, + onTap: () => _selectTime(context, _sdTimeLastController), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _calculateFlowrate, + child: const Text('Get Flowrate'), + ), + const SizedBox(height: 16), + TextFormField( + controller: _flowrateValueController, + decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), + readOnly: true, + ), + ], + ), + ); + } + + Widget _buildFlowmeterField() { + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: TextFormField( + controller: _flowrateValueController, + decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), + keyboardType: TextInputType.number, + ), + ); + } + + Widget _buildNAField() { + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: TextFormField( + controller: _flowrateValueController, + decoration: const InputDecoration(labelText: 'Flowrate (m/s)'), + readOnly: true, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_4_additional_info.dart b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_4_additional_info.dart new file mode 100644 index 0000000..2c89364 --- /dev/null +++ b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_4_additional_info.dart @@ -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 createState() => + _RiverManualTriennialStep4AdditionalInfoState(); +} + +class _RiverManualTriennialStep4AdditionalInfoState + extends State { + final _formKey = GlobalKey(); + 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(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(), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_5_summary.dart b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_5_summary.dart new file mode 100644 index 0000000..41e91b4 --- /dev/null +++ b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_5_summary.dart @@ -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 _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 _getOutOfBoundsKeys(BuildContext context) { + final authProvider = Provider.of(context, listen: false); + // --- MODIFICATION: Use the new river-specific parameter limits list --- + final riverLimits = authProvider.riverParameterLimits ?? []; + // --- END MODIFICATION --- + final Set 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 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 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, + ); + } +} \ No newline at end of file diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index b46a4d0..7c6c724 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -744,7 +744,7 @@ class _SettingsScreenState extends State { 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( diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 5e29235..a4cca1a 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -947,6 +947,94 @@ class RiverApiService { }; } + Future> submitTriennialSample({ + required Map formData, + required Map imageFiles, + required List>? 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 = {}; + 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 _handleTriennialSuccessAlert( + Map formData, List>? 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 _handleInSituSuccessAlert( Map formData, List>? appSettings, {required bool isDataOnly}) async { try { diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index 88c3f30..0344fed 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -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 _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 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 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>> getAllNpeLogs() async { + final mmsv4Root = await _getPublicMMSV4Directory(serverName: ''); + if (mmsv4Root == null || !await mmsv4Root.exists()) return []; + + final List> allLogs = []; + final serverDirs = mmsv4Root.listSync().whereType(); + + 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; + data['logDirectory'] = entity.path; + allLogs.add(data); + } + } + } + } catch (e) { + debugPrint("Error reading NPE logs from ${baseDir.path}: $e"); + } + } + return allLogs; + } + + Future updateNpeLog(Map 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 _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 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 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>> getAllRiverManualTriennialLogs() async { + final mmsv4Root = await _getPublicMMSV4Directory(serverName: ''); + if (mmsv4Root == null || !await mmsv4Root.exists()) return []; + + final List> allLogs = []; + final serverDirs = mmsv4Root.listSync().whereType(); + + 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; + data['logDirectory'] = entity.path; + allLogs.add(data); + } + } + } + } catch (e) { + debugPrint("Error reading triennial logs from ${baseDir.path}: $e"); + } + } + return allLogs; + } + + Future updateRiverManualTriennialLog(Map 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(); diff --git a/lib/services/marine_manual_equipment_maintenance_service.dart b/lib/services/marine_manual_equipment_maintenance_service.dart new file mode 100644 index 0000000..d035690 --- /dev/null +++ b/lib/services/marine_manual_equipment_maintenance_service.dart @@ -0,0 +1,17 @@ +import '../auth_provider.dart'; +import '../models/marine_manual_equipment_maintenance_data.dart'; + +class MarineManualEquipmentMaintenanceService { + Future> 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).' + }; + } +} \ No newline at end of file diff --git a/lib/services/marine_manual_pre_departure_service.dart b/lib/services/marine_manual_pre_departure_service.dart new file mode 100644 index 0000000..d182884 --- /dev/null +++ b/lib/services/marine_manual_pre_departure_service.dart @@ -0,0 +1,18 @@ +import '../auth_provider.dart'; +import '../models/marine_manual_pre_departure_checklist_data.dart'; + +class MarineManualPreDepartureService { + Future> 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).' + }; + } +} \ No newline at end of file diff --git a/lib/services/marine_manual_sonde_calibration_service.dart b/lib/services/marine_manual_sonde_calibration_service.dart new file mode 100644 index 0000000..8c594dd --- /dev/null +++ b/lib/services/marine_manual_sonde_calibration_service.dart @@ -0,0 +1,14 @@ +import '../auth_provider.dart'; +import '../models/marine_manual_sonde_calibration_data.dart'; + +class MarineManualSondeCalibrationService { + Future> 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).'}; + } +} \ No newline at end of file diff --git a/lib/services/marine_npe_report_service.dart b/lib/services/marine_npe_report_service.dart new file mode 100644 index 0000000..2a109f3 --- /dev/null +++ b/lib/services/marine_npe_report_service.dart @@ -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> 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> _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 finalImageFiles = imageFilesWithNulls.cast(); + + bool anyApiSuccess = false; + Map apiDataResult = {}; + Map 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 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> _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> _generateAndUploadFtpFiles(MarineManualNpeReportData data, Map 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 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 ftpImageResult = {'success': true, 'statuses': []}; + if (imageZip != null) { + ftpImageResult = await _submissionFtpService.submit( + moduleName: moduleName, + fileToUpload: imageZip, + remotePath: '/${p.basename(imageZip.path)}', + ); + } + + return { + 'statuses': >[ + ...(ftpDataResult['statuses'] as List? ?? []), + ...(ftpImageResult['statuses'] as List? ?? []), + ], + }; + } + + Future _logAndSave({ + required MarineManualNpeReportData data, + required String status, + required String message, + required List> apiResults, + required List> ftpStatuses, + required String serverName, + required Map finalImageFiles, + String? logDirectory, + }) async { + data.submissionStatus = status; + data.submissionMessage = message; + final fileTimestamp = "${data.eventDate}_${data.eventTime}".replaceAll(':', '-').replaceAll(' ', '_'); + + if (logDirectory != null) { + final Map 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 _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"); + } + } +} \ No newline at end of file diff --git a/lib/services/river_manual_triennial_sampling_service.dart b/lib/services/river_manual_triennial_sampling_service.dart new file mode 100644 index 0000000..d0b7e82 --- /dev/null +++ b/lib/services/river_manual_triennial_sampling_service.dart @@ -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 getCurrentLocation() => _locationService.getCurrentLocation(); + double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2); + + Future 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 get bluetoothConnectionState => _bluetoothManager.connectionState; + ValueNotifier get serialConnectionState => _serialManager.connectionState; + + ValueNotifier get sondeId { + if (_bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected) { + return _bluetoothManager.sondeId; + } + return _serialManager.sondeId; + } + + Stream> get bluetoothDataStream => _bluetoothManager.dataStream; + Stream> get serialDataStream => _serialManager.dataStream; + String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value; + String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value; + + Future requestDevicePermissions() async { + Map 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> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices(); + Future 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> getAvailableSerialDevices() => _serialManager.getAvailableDevices(); + + Future 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 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> submitData({ + required RiverManualTriennialSamplingData data, + required List>? 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> _performOnlineSubmission({ + required RiverManualTriennialSamplingData data, + required List>? 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 finalImageFiles = imageFilesWithNulls.cast(); + + bool anyApiSuccess = false; + Map apiDataResult = {}; + Map 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 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> _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> _generateAndUploadFtpFiles(RiverManualTriennialSamplingData data, Map 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 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 ftpImageResult = {'success': true, 'statuses': []}; + if (imageZip != null) { + ftpImageResult = await _submissionFtpService.submit( + moduleName: moduleName, fileToUpload: imageZip, remotePath: '/${p.basename(imageZip.path)}'); + } + + return { + 'statuses': >[ + ...(ftpDataResult['statuses'] as List), + ...(ftpImageResult['statuses'] as List), + ], + }; + } + + Future _logAndSave({ + required RiverManualTriennialSamplingData data, + required String status, + required String message, + required List> apiResults, + required List> ftpStatuses, + required String serverName, + String? logDirectory, + }) async { + data.submissionStatus = status; + data.submissionMessage = message; + + if (logDirectory != null) { + final Map 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().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 _handleSuccessAlert(RiverManualTriennialSamplingData data, List>? 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"); + } + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index de95eb6..7d6f478 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 14ea1e6..540497d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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