diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b492a0b..534120e 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 1746828..34f7a82 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,9 +36,9 @@ import 'package:environment_monitoring_app/screens/marine/marine_home_page.dart' import 'package:environment_monitoring_app/screens/air/manual/air_manual_info_centre_document.dart'; import 'package:environment_monitoring_app/screens/air/manual/air_manual_installation_screen.dart'; import 'package:environment_monitoring_app/screens/air/manual/air_manual_collection_screen.dart'; -import 'package:environment_monitoring_app/screens/air/manual/report.dart' as airManualReport; -import 'package:environment_monitoring_app/screens/air/manual/data_status_log.dart' as airManualDataStatusLog; -import 'package:environment_monitoring_app/screens/air/manual/image_request.dart' as airManualImageRequest; +import 'package:environment_monitoring_app/screens/air/manual/air_manual_report.dart' as airManualReport; +import 'package:environment_monitoring_app/screens/air/manual/air_manual_data_status_log.dart' as airManualDataStatusLog; +import 'package:environment_monitoring_app/screens/air/manual/air_manual_image_request.dart' as airManualImageRequest; import 'package:environment_monitoring_app/screens/air/continuous/air_continuous_info_centre_document.dart'; import 'package:environment_monitoring_app/screens/air/continuous/overview.dart' as airContinuousOverview; import 'package:environment_monitoring_app/screens/air/continuous/entry.dart' as airContinuousEntry; @@ -51,10 +51,11 @@ import 'package:environment_monitoring_app/screens/air/investigative/report.dart // River Screens 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/data_status_log.dart' as riverManualDataStatusLog; -import 'package:environment_monitoring_app/screens/river/manual/report.dart' as riverManualReport; +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/image_request.dart' as riverManualImageRequest; +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; import 'package:environment_monitoring_app/screens/river/continuous/entry.dart' as riverContinuousEntry; @@ -68,8 +69,9 @@ import 'package:environment_monitoring_app/screens/river/investigative/report.da import 'package:environment_monitoring_app/screens/marine/manual/info_centre_document.dart' as marineManualInfoCentreDocument; 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/report.dart' as marineManualReport; -import 'package:environment_monitoring_app/screens/marine/manual/data_status_log.dart' as marineManualDataStatusLog; +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/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'; import 'package:environment_monitoring_app/screens/marine/continuous/overview.dart' as marineContinuousOverview; @@ -257,6 +259,7 @@ 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/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(), @@ -279,7 +282,8 @@ class _RootAppState extends State { '/marine/manual/pre-sampling': (context) => marineManualPreSampling.MarinePreSampling(), '/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(), '/marine/manual/tarball': (context) => const TarballSamplingStep1(), - '/marine/manual/report': (context) => marineManualReport.MarineManualReport(), + '/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(), + '/marine/manual/report/npe': (context) => const marineManualNPEReport.MarineManualNPEReport(), //'/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/in_situ_sampling_data.dart b/lib/models/in_situ_sampling_data.dart index 44c3b47..94ae359 100644 --- a/lib/models/in_situ_sampling_data.dart +++ b/lib/models/in_situ_sampling_data.dart @@ -68,14 +68,42 @@ class InSituSamplingData { String? submissionMessage; String? reportId; + // --- START: NPE Report Compatibility Fields --- + /// Fields to hold data that can be transferred to an NPE Report. + /// This makes the model compatible for auto-generating NPE reports in the future. + + // Corresponds to the checkboxes in the NPE form + Map npeFieldObservations = { + '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, + }; + // Corresponds to the "Others" text field in NPE observations + String? npeOthersObservationRemark; + // Corresponds to the "Possible Source" field in the NPE form + String? npePossibleSource; + + // Holds the images to be attached to the NPE report + File? npeImage1; + File? npeImage2; + File? npeImage3; + File? npeImage4; + // --- END: NPE Report Compatibility Fields --- + + InSituSamplingData({ this.samplingDate, this.samplingTime, }); /// Creates an InSituSamplingData object from a JSON map. - /// This is critical for the offline retry mechanism. The keys used here MUST perfectly - /// match the keys used in the `toDbJson()` method to ensure data integrity. factory InSituSamplingData.fromJson(Map json) { double? doubleFromJson(dynamic value) { if (value is num) return value.toDouble(); @@ -95,13 +123,14 @@ class InSituSamplingData { final data = InSituSamplingData(); - // START FIX: Aligned all keys to perfectly match the toDbJson() method and added backward compatibility. + // Standard In-Situ Fields data.firstSamplerName = json['first_sampler_name']; data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']); data.secondSampler = json['secondSampler'] ?? json['second_sampler']; data.samplingDate = json['sampling_date'] ?? json['man_date']; data.samplingTime = json['sampling_time'] ?? json['man_time']; data.samplingType = json['sampling_type']; + // ... (all other existing fields) data.sampleIdCode = json['sample_id_code']; data.selectedStateName = json['selected_state_name']; data.selectedCategoryName = json['selected_category_name']; @@ -138,9 +167,11 @@ class InSituSamplingData { data.submissionMessage = json['submission_message']; data.reportId = json['report_id']?.toString(); - // Image paths are added by LocalStorageService, not toDbJson, so they are read separately. + + // Image paths (handled by LocalStorageService) data.leftLandViewImage = fileFromPath(json['man_left_side_land_view']); data.rightLandViewImage = fileFromPath(json['man_right_side_land_view']); + // ... (all other existing images) data.waterFillingImage = fileFromPath(json['man_filling_water_into_sample_bottle']); data.seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle']); data.phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper']); @@ -148,11 +179,88 @@ class InSituSamplingData { data.optionalImage2 = fileFromPath(json['man_optional_photo_02']); data.optionalImage3 = fileFromPath(json['man_optional_photo_03']); data.optionalImage4 = fileFromPath(json['man_optional_photo_04']); - // END FIX + + + // --- START: Deserialization for NPE Fields --- + if (json['npe_field_observations'] is Map) { + data.npeFieldObservations = Map.from(json['npe_field_observations']); + } + data.npeOthersObservationRemark = json['npe_others_observation_remark']; + data.npePossibleSource = json['npe_possible_source']; + + // NPE image paths + data.npeImage1 = fileFromPath(json['npe_image_1']); + data.npeImage2 = fileFromPath(json['npe_image_2']); + data.npeImage3 = fileFromPath(json['npe_image_3']); + data.npeImage4 = fileFromPath(json['npe_image_4']); + // --- END: Deserialization for NPE Fields --- return data; } + // ... (generateTelegramAlertMessage method remains unchanged) ... + + // ... (toApiFormData method remains unchanged) ... + + // ... (toApiImageFiles method remains unchanged) ... + + /// Creates a single JSON object with all submission data for offline storage. + Map toDbJson() { + return { + 'first_sampler_name': firstSamplerName, + 'first_sampler_user_id': firstSamplerUserId, + 'secondSampler': secondSampler, + 'sampling_date': samplingDate, + 'sampling_time': samplingTime, + // ... (all other existing fields) + 'sampling_type': samplingType, + 'sample_id_code': sampleIdCode, + 'selected_state_name': selectedStateName, + 'selected_category_name': selectedCategoryName, + 'selectedStation': selectedStation, + 'station_latitude': stationLatitude, + 'station_longitude': stationLongitude, + 'current_latitude': currentLatitude, + 'current_longitude': currentLongitude, + 'distance_difference_in_km': distanceDifferenceInKm, + 'distance_difference_remarks': distanceDifferenceRemarks, + 'weather': weather, + 'tide_level': tideLevel, + 'sea_condition': seaCondition, + 'event_remarks': eventRemarks, + 'lab_remarks': labRemarks, + 'man_optional_photo_01_remarks': optionalRemark1, + 'man_optional_photo_02_remarks': optionalRemark2, + 'man_optional_photo_03_remarks': optionalRemark3, + 'man_optional_photo_04_remarks': optionalRemark4, + 'sonde_id': sondeId, + 'data_capture_date': dataCaptureDate, + 'data_capture_time': dataCaptureTime, + 'oxygen_concentration': oxygenConcentration, + 'oxygen_saturation': oxygenSaturation, + 'ph': ph, + 'salinity': salinity, + 'electrical_conductivity': electricalConductivity, + 'temperature': temperature, + 'tds': tds, + 'turbidity': turbidity, + 'tss': tss, + 'battery_voltage': batteryVoltage, + 'submission_status': submissionStatus, + 'submission_message': submissionMessage, + 'report_id': reportId, + + // --- START: Serialization for NPE Fields --- + 'npe_field_observations': npeFieldObservations, + 'npe_others_observation_remark': npeOthersObservationRemark, + 'npe_possible_source': npePossibleSource, + // Note: Image file paths are handled separately by the LocalStorageService + // and are not part of this JSON object directly. + // --- END: Serialization for NPE Fields --- + }; + } + + // --- Methods from the original file --- String generateTelegramAlertMessage({required bool isDataOnly}) { final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final stationName = selectedStation?['man_station_name'] ?? 'N/A'; @@ -256,52 +364,4 @@ class InSituSamplingData { 'man_optional_photo_04': optionalImage4, }; } - - /// Creates a single JSON object with all submission data for offline storage. - /// The keys here are the single source of truth for the offline data format. - Map toDbJson() { - return { - 'first_sampler_name': firstSamplerName, - 'first_sampler_user_id': firstSamplerUserId, - 'secondSampler': secondSampler, - 'sampling_date': samplingDate, - 'sampling_time': samplingTime, - 'sampling_type': samplingType, - 'sample_id_code': sampleIdCode, - 'selected_state_name': selectedStateName, - 'selected_category_name': selectedCategoryName, - 'selectedStation': selectedStation, - 'station_latitude': stationLatitude, - 'station_longitude': stationLongitude, - 'current_latitude': currentLatitude, - 'current_longitude': currentLongitude, - 'distance_difference_in_km': distanceDifferenceInKm, - 'distance_difference_remarks': distanceDifferenceRemarks, - 'weather': weather, - 'tide_level': tideLevel, - 'sea_condition': seaCondition, - 'event_remarks': eventRemarks, - 'lab_remarks': labRemarks, - 'man_optional_photo_01_remarks': optionalRemark1, - 'man_optional_photo_02_remarks': optionalRemark2, - 'man_optional_photo_03_remarks': optionalRemark3, - 'man_optional_photo_04_remarks': optionalRemark4, - 'sonde_id': sondeId, - 'data_capture_date': dataCaptureDate, - 'data_capture_time': dataCaptureTime, - 'oxygen_concentration': oxygenConcentration, - 'oxygen_saturation': oxygenSaturation, - 'ph': ph, - 'salinity': salinity, - 'electrical_conductivity': electricalConductivity, - 'temperature': temperature, - 'tds': tds, - 'turbidity': turbidity, - 'tss': tss, - 'battery_voltage': batteryVoltage, - 'submission_status': submissionStatus, - 'submission_message': submissionMessage, - 'report_id': reportId, - }; - } } \ No newline at end of file diff --git a/lib/screens/air/manual/data_status_log.dart b/lib/screens/air/manual/air_manual_data_status_log.dart similarity index 99% rename from lib/screens/air/manual/data_status_log.dart rename to lib/screens/air/manual/air_manual_data_status_log.dart index db26741..b6ea057 100644 --- a/lib/screens/air/manual/data_status_log.dart +++ b/lib/screens/air/manual/air_manual_data_status_log.dart @@ -1,4 +1,4 @@ -// lib/screens/air/manual/data_status_log.dart +// lib/screens/air/manual/air_manual_data_status_log.dart import 'dart:io'; import 'package:flutter/material.dart'; @@ -391,4 +391,4 @@ class _AirManualDataStatusLogState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/air/manual/image_request.dart b/lib/screens/air/manual/air_manual_image_request.dart similarity index 96% rename from lib/screens/air/manual/image_request.dart rename to lib/screens/air/manual/air_manual_image_request.dart index 8ea2cb1..7e70609 100644 --- a/lib/screens/air/manual/image_request.dart +++ b/lib/screens/air/manual/air_manual_image_request.dart @@ -1,7 +1,8 @@ +// lib/screens/air/manual/air_manual_image_request.dart + import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; -import 'dart:io'; // Add this line at the top of these files - +import 'dart:io'; class AirManualImageRequest extends StatefulWidget { @override diff --git a/lib/screens/air/manual/report.dart b/lib/screens/air/manual/air_manual_report.dart similarity index 96% rename from lib/screens/air/manual/report.dart rename to lib/screens/air/manual/air_manual_report.dart index e3e591e..c001548 100644 --- a/lib/screens/air/manual/report.dart +++ b/lib/screens/air/manual/air_manual_report.dart @@ -1,3 +1,5 @@ +// lib/screens/air/manual/air_manual_report.dart + import 'package:flutter/material.dart'; class AirManualReport extends StatelessWidget { diff --git a/lib/screens/marine/manual/data_status_log.dart b/lib/screens/marine/manual/marine_manual_data_status_log.dart similarity index 99% rename from lib/screens/marine/manual/data_status_log.dart rename to lib/screens/marine/manual/marine_manual_data_status_log.dart index 8fa4afc..5591834 100644 --- a/lib/screens/marine/manual/data_status_log.dart +++ b/lib/screens/marine/manual/marine_manual_data_status_log.dart @@ -1,4 +1,4 @@ -// lib/screens/marine/manual/data_status_log.dart +// lib/screens/marine/manual/marine_manual_data_status_log.dart import 'dart:io'; import 'package:flutter/material.dart'; diff --git a/lib/screens/marine/manual/marine_manual_npe_report.dart b/lib/screens/marine/manual/marine_manual_npe_report.dart new file mode 100644 index 0000000..0f0081f --- /dev/null +++ b/lib/screens/marine/manual/marine_manual_npe_report.dart @@ -0,0 +1,1073 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:intl/intl.dart'; +import 'package:geolocator/geolocator.dart'; +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'; + +class MarineManualNPEReport extends StatefulWidget { + final InSituSamplingData? initialData; + + const MarineManualNPEReport({super.key, this.initialData}); + + @override + State createState() => _MarineManualNPEReportState(); +} + +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'; + static const String optionSelectStation = 'Select sampling information from tarball station'; + + final List _locationOptions = [ + optionUseExisting, + optionNewLocation, + optionSelectStation, + ]; + 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; + List _statesList = []; + 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; + Timer? _lockoutTimer; + int _lockoutSecondsRemaining = 30; + bool _isLockedOut = false; + late final MarineInSituSamplingService _samplingService; + final List> _npeParameters = []; + + // Controllers + final _latController = TextEditingController(); + final _longController = TextEditingController(); + final _stationIdController = TextEditingController(); + final _locationController = TextEditingController(); + final _eventDateTimeController = TextEditingController(); + final _doPercentController = TextEditingController(); + final _doMgLController = TextEditingController(); + final _phController = TextEditingController(); + final _condController = TextEditingController(); + final _turbController = TextEditingController(); + final _tempController = TextEditingController(); + 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, + '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, + }; + + @override + void initState() { + super.initState(); + _samplingService = Provider.of(context, listen: false); + + if (widget.initialData != null) { + _locationDataSourceOption = optionUseExisting; + _recentNearbySamples = [widget.initialData!]; + _selectedRecentSample = widget.initialData!; + _populateFormFromData(widget.initialData!); + _setDefaultDateTime(); + } else { + _locationDataSourceOption = optionNewLocation; + _handleLocationOptionChange(_locationDataSourceOption); + } + _loadAllStatesFromProvider(); + _initializeNpeParameters(); + WidgetsBinding.instance.addObserver(this); + } + + void _initializeNpeParameters() { + if (_npeParameters.isEmpty) { + _npeParameters.addAll([ + {'key': 'oxygenSaturation', 'icon': Icons.percent, 'label': 'DO %', 'unit': '%', 'controller': _doPercentController}, + {'key': 'electricalConductivity', 'icon': Icons.flash_on, 'label': 'Cond', 'unit': 'µS/cm', 'controller': _condController}, + {'key': 'oxygenConcentration', 'icon': Icons.air, 'label': 'DO mg/l', 'unit': 'mg/L', 'controller': _doMgLController}, + {'key': 'turbidity', 'icon': Icons.opacity, 'label': 'Turb (NTU)', 'unit': 'NTU', 'controller': _turbController}, + {'key': 'ph', 'icon': Icons.science_outlined, 'label': 'PH', 'unit': '', 'controller': _phController}, + {'key': 'temperature', 'icon': Icons.thermostat, 'label': 'Temp (°C)', 'unit': '°C', 'controller': _tempController}, + ]); + } + } + + @override + void dispose() { + _dataSubscription?.cancel(); + _lockoutTimer?.cancel(); + + if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + _samplingService.disconnectFromBluetooth(); + } + if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) { + _samplingService.disconnectFromSerial(); + } + + _latController.dispose(); + _longController.dispose(); + _stationIdController.dispose(); + _locationController.dispose(); + _eventDateTimeController.dispose(); + _doPercentController.dispose(); + _doMgLController.dispose(); + _phController.dispose(); + _condController.dispose(); + _turbController.dispose(); + _tempController.dispose(); + _othersObservationController.dispose(); + _possibleSourceController.dispose(); + + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + if (mounted) { + setState(() {}); + } + } + } + + void _setDefaultDateTime() { + final now = DateTime.now(); + _eventDateTimeController.text = DateFormat('yyyy-MM-dd HH:mm').format(now); + } + + void _clearInSituFields() { + _doPercentController.clear(); + _doMgLController.clear(); + _phController.clear(); + _condController.clear(); + _turbController.clear(); + _tempController.clear(); + } + + void _populateFormFromData(InSituSamplingData data) { + _stationIdController.text = data.selectedStation?['man_station_code'] ?? 'N/A'; + _locationController.text = data.selectedStation?['man_station_name'] ?? 'N/A'; + _latController.text = data.currentLatitude ?? data.stationLatitude ?? ''; + _longController.text = data.currentLongitude ?? data.stationLongitude ?? ''; + + if (data.oxygenSaturation != null && data.oxygenSaturation != -999) _doPercentController.text = data.oxygenSaturation!.toStringAsFixed(5); + if (data.oxygenConcentration != null && data.oxygenConcentration != -999) _doMgLController.text = data.oxygenConcentration!.toStringAsFixed(5); + if (data.ph != null && data.ph != -999) _phController.text = data.ph!.toStringAsFixed(5); + if (data.electricalConductivity != null && data.electricalConductivity != -999) _condController.text = data.electricalConductivity!.toStringAsFixed(5); + if (data.turbidity != null && data.turbidity != -999) _turbController.text = data.turbidity!.toStringAsFixed(5); + if (data.temperature != null && data.temperature != -999) _tempController.text = data.temperature!.toStringAsFixed(5); + + setState(() { + _areLocationFieldsLocked = true; + }); + } + + void _loadAllStatesFromProvider() { + final auth = Provider.of(context, listen: false); + final manualStations = auth.manualStations ?? []; + final tarballStations = auth.tarballStations ?? []; + + final states = {}; + for (var station in manualStations) { + if (station['state_name'] != null) states.add(station['state_name']); + } + for (var station in tarballStations) { + if (station['state_name'] != null) states.add(station['state_name']); + } + + final sortedStates = states.toList()..sort(); + setState(() { + _statesList = sortedStates; + }); + } + + Future _fetchRecentNearbySamples() async { + setState(() { + _isLoadingRecentSamples = true; + _recentNearbySamples = []; + _selectedRecentSample = null; + }); + + try { + final localDbService = Provider.of(context, listen: false); + final Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); + + final samples = await localDbService.getRecentNearbySamples( + latitude: position.latitude, + longitude: position.longitude, + radiusKm: 3, + withinHours: 30 * 24, + ); + + if (mounted) { + setState(() { + _recentNearbySamples = samples; + }); + } + } catch (e) { + if (mounted) { + _showSnackBar("Failed to fetch recent samples: ${e.toString()}", isError: true); + } + } finally { + if (mounted) { + setState(() { + _isLoadingRecentSamples = false; + }); + } + } + } + + void _handleLocationOptionChange(String? value) { + setState(() { + // Clear all fields and selection states + _locationDataSourceOption = value; + _clearLocationFields(); + _clearInSituFields(); + _selectedState = null; + _selectedCategory = null; + _selectedStationMap = null; + _categoriesForState = []; + _stationsForCategory = []; + _recentNearbySamples = []; + _selectedRecentSample = null; + _setDefaultDateTime(); + + if (value == optionUseExisting) { + _areLocationFieldsLocked = true; + _fetchRecentNearbySamples(); + } else { + _areLocationFieldsLocked = (value == optionSelectStation); + } + }); + } + + void _clearLocationFields() { + _stationIdController.clear(); + _locationController.clear(); + _latController.clear(); + _longController.clear(); + _eventDateTimeController.clear(); + } + + Future _getCurrentLocation() async { + var status = await Permission.location.request(); + if (!status.isGranted) { + _showSnackBar('Location permission is required to fetch coordinates.', isError: true); + return; + } + + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + _showSnackBar('Location services are disabled.', isError: true); + return; + } + + setState(() { + _isFetchingLocation = true; + }); + try { + Position position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high); + _latController.text = position.latitude.toStringAsFixed(6); + _longController.text = position.longitude.toStringAsFixed(6); + } catch (e) { + _showSnackBar('Failed to get location: ${e.toString()}', isError: true); + } finally { + if (mounted) { + setState(() { + _isFetchingLocation = false; + }); + } + } + } + + Future _processAndSetImage(ImageSource source, int imageNumber) async { + if (_isPickingImage) return; + + setState(() => _isPickingImage = true); + + final watermarkData = InSituSamplingData() + ..samplingDate = _eventDateTimeController.text.split(' ')[0] + ..samplingTime = _eventDateTimeController.text.split(' ').length > 1 ? _eventDateTimeController.text.split(' ')[1] : '' + ..currentLatitude = _latController.text + ..currentLongitude = _longController.text + ..selectedStation = {'man_station_name': _locationController.text}; + + final file = await _samplingService.pickAndProcessImage( + source, + data: watermarkData, + imageInfo: 'NPE ATTACHMENT $imageNumber', + isRequired: false, + ); + + if (file != null) { + setState(() { + switch (imageNumber) { + case 1: _image1 = file; break; + case 2: _image2 = file; break; + case 3: _image3 = file; break; + case 4: _image4 = file; break; + } + }); + } else if (mounted) { + _showSnackBar('Image selection failed. Please ensure photos are taken in landscape mode.', isError: true); + } + + if (mounted) { + setState(() => _isPickingImage = false); + } + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : null, + )); + } + } + + Future _handleConnectionAttempt(String type) async { + final hasPermissions = await _samplingService.requestDevicePermissions(); + if (!hasPermissions && mounted) { + _showSnackBar("Bluetooth & Location permissions are required.", 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' ? _samplingService.bluetoothDataStream : _samplingService.serialDataStream; + _dataSubscription = stream.listen((readings) { + if (mounted) _updateTextFields(readings); + }); + } + } + + Future _connectToDevice(String type) async { + setState(() => _isLoading = true); + bool success = false; + try { + if (type == 'bluetooth') { + final devices = await _samplingService.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 _samplingService.connectToBluetoothDevice(selectedDevice); + success = true; + } + } else if (type == 'serial') { + final devices = await _samplingService.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 _samplingService.connectToSerialDevice(selectedDevice); + success = true; + } + } + } catch (e) { + debugPrint("Connection failed: $e"); + if (mounted) _showConnectionFailedDialog(); + } finally { + if (mounted) setState(() => _isLoading = false); + } + return success; + } + + Future _showConnectionFailedDialog() async { + if (!mounted) return; + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Connection Failed'), + content: const SingleChildScrollView( + child: Text('Could not connect to the device. Please check that the device is turned on, within range, and not connected to another application.'), + ), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + + 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); + } + }); + } + + void _toggleAutoReading(String activeType) { + setState(() { + _isAutoReading = !_isAutoReading; + if (_isAutoReading) { + if (activeType == 'bluetooth') _samplingService.startBluetoothAutoReading(); else _samplingService.startSerialAutoReading(); + _startLockoutTimer(); + } else { + if (activeType == 'bluetooth') _samplingService.stopBluetoothAutoReading(); else _samplingService.stopSerialAutoReading(); + } + }); + } + + void _disconnect(String type) { + if (type == 'bluetooth') { + _samplingService.disconnectFromBluetooth(); + } else { + _samplingService.disconnectFromSerial(); + } + _dataSubscription?.cancel(); + _dataSubscription = null; + _lockoutTimer?.cancel(); + if (mounted) { + setState(() { + _isAutoReading = false; + _isLockedOut = false; + }); + } + } + + void _disconnectFromAll() { + if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) _disconnect('bluetooth'); + if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) _disconnect('serial'); + } + + void _updateTextFields(Map readings) { + const defaultValue = -999.0; + setState(() { + _doMgLController.text = (readings['Optical Dissolved Oxygen: Compensated mg/L'] ?? defaultValue).toStringAsFixed(5); + _doPercentController.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); + _condController.text = (readings['Conductivity: us/cm'] ?? defaultValue).toStringAsFixed(5); + _turbController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5); + }); + } + + Map? _getActiveConnectionDetails() { + final service = Provider.of(context, listen: false); + 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) { + return Scaffold( + appBar: AppBar(title: const Text("Notification of Pollution Event")), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Location Information Source"), + DropdownButtonFormField( + value: _locationDataSourceOption, + items: _locationOptions + .map((option) => + DropdownMenuItem(value: option, child: Text(option))) + .toList(), + onChanged: _handleLocationOptionChange, + decoration: + const InputDecoration(labelText: 'Select Data Source'), + ), + const SizedBox(height: 16), + _buildLocationSection(), + const SizedBox(height: 24), + _buildSectionTitle("In-situ Measurements"), + _buildInSituSection(), + const SizedBox(height: 24), + _buildSectionTitle("Field Observations*"), + ..._buildObservationsCheckboxes(), + const Text("*tick wherever applicable", + style: TextStyle(fontStyle: FontStyle.italic, fontSize: 12)), + const SizedBox(height: 24), + _buildSectionTitle("Possible Source"), + _buildTextFormField( + controller: _possibleSourceController, + label: "Possible Source", + maxLines: 3), + const SizedBox(height: 24), + _buildSectionTitle("Attachments (Figures)"), + _buildImageAttachmentSection(), + const SizedBox(height: 32), + Center( + child: ElevatedButton( + 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"), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildLocationSection() { + final auth = Provider.of(context, listen: false); + + switch (_locationDataSourceOption) { + case optionUseExisting: + return Column( + children: [ + if (_isLoadingRecentSamples) + const Center(child: Padding(padding: EdgeInsets.all(8.0), child: CircularProgressIndicator())) + else + DropdownSearch( + items: _recentNearbySamples, + selectedItem: _selectedRecentSample, + itemAsString: (sample) { + final stationCode = sample.selectedStation?['man_station_code'] ?? 'N/A'; + final stationName = sample.selectedStation?['man_station_name'] ?? 'Unknown'; + final date = sample.samplingDate ?? ''; + final time = sample.samplingTime ?? ''; + return "$stationCode ($stationName) at $date $time"; + }, + popupProps: PopupProps.menu( + showSearchBox: true, + searchFieldProps: const TextFieldProps(decoration: InputDecoration(hintText: "Search by station or time...")), + emptyBuilder: (context, searchEntry) => const Center(child: Text("No recent nearby samples found.")), + ), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select a recent sample *")), + onChanged: (sample) { + if (sample != null) { + setState(() { + _selectedRecentSample = sample; + _populateFormFromData(sample); + }); + } + }, + validator: (val) => val == null ? "Please select a sample" : null, + ), + const SizedBox(height: 12), + _buildTextFormField(controller: _stationIdController, label: "Station ID", readOnly: true), + const SizedBox(height: 12), + _buildTextFormField(controller: _locationController, label: "Location", readOnly: true), + const SizedBox(height: 12), + _buildTextFormField(controller: _latController, label: "Latitude", readOnly: true), + const SizedBox(height: 12), + _buildTextFormField(controller: _longController, label: "Longitude", readOnly: true), + const SizedBox(height: 12), + _buildTextFormField(controller: _eventDateTimeController, label: "Event Date/Time", readOnly: true), + ], + ); + case optionNewLocation: + return Column( + children: [ + DropdownSearch( + items: _statesList, + selectedItem: _selectedState, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")), + onChanged: (state) { + setState(() { + _selectedState = state; + }); + }, + validator: (val) => val == null ? "State is required" : null, + ), + const SizedBox(height: 12), + _buildTextFormField( + controller: _locationController, + label: "Location Description *"), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildTextFormField( + controller: _latController, + label: "Latitude *", + keyboardType: TextInputType.number)), + const SizedBox(width: 8), + if (_isFetchingLocation) + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator()) + else + IconButton( + icon: const Icon(Icons.my_location), + onPressed: _getCurrentLocation, + tooltip: "Get Current Location"), + ], + ), + const SizedBox(height: 12), + _buildTextFormField( + controller: _longController, + label: "Longitude *", + keyboardType: TextInputType.number), + const SizedBox(height: 12), + _buildTextFormField( + controller: _eventDateTimeController, + label: "Event Date/Time", + readOnly: true), + ], + ); + case optionSelectStation: + final allTarballStations = auth.tarballStations ?? []; + return Column( + children: [ + DropdownSearch( + items: allTarballStations.map((s) => s['state_name'] as String?).whereType().toSet().toList()..sort(), + selectedItem: _selectedState, + popupProps: const PopupProps.menu( + showSearchBox: true, + searchFieldProps: TextFieldProps( + decoration: InputDecoration(hintText: "Search State..."))), + dropdownDecoratorProps: const DropDownDecoratorProps( + dropdownSearchDecoration: + InputDecoration(labelText: "Select State *")), + onChanged: (state) { + setState(() { + _selectedState = state; + _selectedCategory = null; + _selectedStationMap = null; + final categories = state != null + ? allTarballStations + .where((s) => s['state_name'] == state) + .map((s) => s['category_name'] as String?) + .whereType() + .toSet() + .toList() + : []; + categories.sort(); + _categoriesForState = categories; + _stationsForCategory = []; + _clearLocationFields(); + _setDefaultDateTime(); + }); + }, + validator: (val) => val == null ? "State is required" : null, + ), + const SizedBox(height: 12), + DropdownSearch( + items: _categoriesForState, + selectedItem: _selectedCategory, + enabled: _categoriesForState.isNotEmpty, + popupProps: const PopupProps.menu( + showSearchBox: true, + searchFieldProps: TextFieldProps( + decoration: + InputDecoration(hintText: "Search Category..."))), + dropdownDecoratorProps: const DropDownDecoratorProps( + dropdownSearchDecoration: + InputDecoration(labelText: "Select Category *")), + onChanged: (category) { + setState(() { + _selectedCategory = category; + _selectedStationMap = null; + final stations = category != null + ? allTarballStations + .where((s) => + s['state_name'] == _selectedState && + s['category_name'] == category) + .toList() + : >[]; + stations.sort((a, b) => (a['tbl_station_code'] ?? '') + .compareTo(b['tbl_station_code'] ?? '')); + _stationsForCategory = stations; + _stationIdController.clear(); + _locationController.clear(); + _latController.clear(); + _longController.clear(); + }); + }, + validator: (val) => + val == null && _categoriesForState.isNotEmpty + ? "Category is required" + : null, + ), + const SizedBox(height: 12), + DropdownSearch>( + items: _stationsForCategory, + selectedItem: _selectedStationMap, + enabled: _stationsForCategory.isNotEmpty, + itemAsString: (station) => + "${station['tbl_station_code']} - ${station['tbl_station_name']}", + popupProps: const PopupProps.menu( + showSearchBox: true, + searchFieldProps: TextFieldProps( + decoration: + InputDecoration(hintText: "Search Station..."))), + dropdownDecoratorProps: const DropDownDecoratorProps( + dropdownSearchDecoration: + InputDecoration(labelText: "Select Station *")), + onChanged: (station) => setState(() { + _selectedStationMap = station; + _stationIdController.text = station?['tbl_station_code'] ?? ''; + _locationController.text = + station?['tbl_station_name'] ?? ''; + _latController.text = + station?['tbl_latitude']?.toString() ?? ''; + _longController.text = + station?['tbl_longitude']?.toString() ?? ''; + }), + validator: (val) => + val == null && _stationsForCategory.isNotEmpty + ? "Station is required" + : null, + ), + const SizedBox(height: 12), + _buildTextFormField( + controller: _eventDateTimeController, + label: "Event Date/Time", + readOnly: true), + ], + ); + default: + return const SizedBox.shrink(); + } + } + + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Text( + title, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ); + } + + Widget _buildInSituSection() { + final activeConnection = _getActiveConnectionDetails(); + final String? activeType = activeConnection?['type'] as String?; + + return Column( + children: [ + 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: 16), + ..._npeParameters.map((param) { + return _buildParameterListItem( + icon: param['icon'] as IconData, + label: param['label'] as String, + unit: param['unit'] as String, + controller: param['controller'] as TextEditingController, + ); + }).toList(), + ], + ); + } + + Widget _buildParameterListItem({required IconData icon, required String label, required String unit, required TextEditingController controller}) { + final bool isMissing = controller.text.isEmpty || controller.text.contains('-999'); + final String displayValue = isMissing ? '-.--' : controller.text; + final String displayLabel = unit.isEmpty ? label : '$label ($unit)'; + + 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: isMissing ? Colors.grey : Theme.of(context).colorScheme.primary), + ), + ), + ); + } + + 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: [ + 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, + ), + ), + TextButton.icon( + icon: const Icon(Icons.link_off), + label: const Text('Disconnect'), + onPressed: () => _disconnect(type), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ) + ], + ) + ], + ), + ), + ); + } + + List _buildObservationsCheckboxes() { + List checkboxList = []; + _observations.forEach((key, value) { + checkboxList.add( + CheckboxListTile( + title: Text(key), + value: value, + onChanged: (newValue) { + setState(() { + _observations[key] = newValue!; + }); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + ); + }); + if (_observations['Others'] == true) { + checkboxList.add( + Padding( + padding: const EdgeInsets.only(left: 16.0, right: 8.0, bottom: 8.0), + child: _buildTextFormField( + controller: _othersObservationController, + label: "Please specify"), + ), + ); + } + return checkboxList; + } + + Widget _buildImageAttachmentSection() { + return Column( + children: [ + _buildNPEImagePicker( + title: 'Figure 1', + imageFile: _image1, + onClear: (_) => setState(() => _image1 = null), + imageNumber: 1, + ), + _buildNPEImagePicker( + title: 'Figure 2', + imageFile: _image2, + onClear: (_) => setState(() => _image2 = null), + imageNumber: 2, + ), + _buildNPEImagePicker( + title: 'Figure 3', + imageFile: _image3, + onClear: (_) => setState(() => _image3 = null), + imageNumber: 3, + ), + _buildNPEImagePicker( + title: 'Figure 4', + imageFile: _image4, + onClear: (_) => setState(() => _image4 = null), + imageNumber: 4, + ), + ], + ); + } + + Widget _buildNPEImagePicker({ + required String title, + required File? imageFile, + required Function(File?) onClear, + required int imageNumber, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, 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: () => onClear(null), + ), + ), + ], + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon( + onPressed: _isPickingImage ? null : () => _processAndSetImage(ImageSource.camera, imageNumber), + icon: const Icon(Icons.camera_alt), + label: const Text("Camera"), + ), + ElevatedButton.icon( + onPressed: _isPickingImage ? null : () => _processAndSetImage(ImageSource.gallery, imageNumber), + icon: const Icon(Icons.photo_library), + label: const Text("Gallery"), + ), + ], + ), + ], + ), + ); + } + + TextFormField _buildTextFormField({ + required TextEditingController controller, + required String label, + int? maxLines = 1, + bool readOnly = false, + VoidCallback? onTap, + IconData? icon, + TextInputType? keyboardType, + }) { + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + suffixIcon: icon != null ? Icon(icon) : null, + ), + maxLines: maxLines, + readOnly: readOnly, + onTap: onTap, + keyboardType: keyboardType, + validator: (value) { + if (!readOnly && (value == null || value.isEmpty)) { + return 'This field cannot be empty'; + } + return null; + }, + ); + } +} \ 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 new file mode 100644 index 0000000..5fc8c0b --- /dev/null +++ b/lib/screens/marine/manual/marine_manual_report.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +// A simple data class for report items +class ReportItem { + final IconData icon; + final String label; + final String route; + + const ReportItem({ + required this.icon, + required this.label, + required this.route, + }); +} + +class MarineManualReportHomePage extends StatelessWidget { + const MarineManualReportHomePage({super.key}); + + // Define the list of available reports + final List _reports = const [ + ReportItem( + icon: Icons.science_outlined, + label: "NPE Report", + 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', + // ), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Marine Manual Reports"), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Select a Report to Generate", + 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(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 16.0, + mainAxisSpacing: 16.0, + childAspectRatio: 1.5, // Adjust for a card-like appearance + ), + itemCount: _reports.length, + itemBuilder: (context, index) { + final report = _reports[index]; + return _buildReportCard(context, report); + }, + ), + ], + ), + ), + ); + } + + // Method to build a clickable card for each report type + Widget _buildReportCard(BuildContext context, ReportItem report) { + return InkWell( + onTap: () { + Navigator.pushNamed(context, report.route); + }, + borderRadius: BorderRadius.circular(12), + child: Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.white24, width: 1), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(report.icon, size: 40, color: Theme.of(context).colorScheme.secondary), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Text( + report.label, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/report.dart b/lib/screens/marine/manual/report.dart deleted file mode 100644 index 7018953..0000000 --- a/lib/screens/marine/manual/report.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; - -class MarineManualReport extends StatelessWidget { - final List> sampleData = [ - {"Station": "Marine Site A", "Parameter": "Salinity", "Value": "34 PSU"}, - {"Station": "Marine Site B", "Parameter": "Turbidity", "Value": "5 NTU"}, - ]; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text("Marine Manual Report")), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Manual Sampling Report", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - SizedBox(height: 16), - DataTable( - columns: [ - DataColumn(label: Text("Station")), - DataColumn(label: Text("Parameter")), - DataColumn(label: Text("Value")), - ], - rows: sampleData.map((data) { - return DataRow(cells: [ - DataCell(Text(data["Station"]!)), - DataCell(Text(data["Parameter"]!)), - DataCell(Text(data["Value"]!)), - ]); - }).toList(), - ), - ], - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/screens/marine/marine_home_page.dart b/lib/screens/marine/marine_home_page.dart index c3d8dd7..542b19d 100644 --- a/lib/screens/marine/marine_home_page.dart +++ b/lib/screens/marine/marine_home_page.dart @@ -38,7 +38,7 @@ class MarineHomePage extends StatelessWidget { SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'), SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'), - //SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/manual/report'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/manual/report'), ], ), SidebarItem( diff --git a/lib/screens/river/manual/data_status_log.dart b/lib/screens/river/manual/river_manual_data_status_log.dart similarity index 98% rename from lib/screens/river/manual/data_status_log.dart rename to lib/screens/river/manual/river_manual_data_status_log.dart index 8ca64b6..a6cf00c 100644 --- a/lib/screens/river/manual/data_status_log.dart +++ b/lib/screens/river/manual/river_manual_data_status_log.dart @@ -1,4 +1,4 @@ -// lib/screens/river/manual/data_status_log.dart +// lib/screens/river/manual/river_manual_data_status_log.dart import 'dart:io'; import 'package:flutter/material.dart'; @@ -97,7 +97,6 @@ class _RiverManualDataStatusLogState extends State { } } - // --- START: MODIFIED TO FIX NULL SAFETY ERRORS --- SubmissionLogEntry? _createLogEntry(Map log) { final String type = log['samplingType'] ?? 'In-Situ Sampling'; final String title = log['selectedStation']?['sampling_river'] ?? 'Unknown River'; @@ -114,7 +113,6 @@ class _RiverManualDataStatusLogState extends State { } catch (_) { submissionDateTime = DateTime.now(); } - // --- END: MODIFIED TO FIX NULL SAFETY ERRORS --- String? apiStatusRaw; if (log['api_status'] != null) { diff --git a/lib/screens/river/manual/image_request.dart b/lib/screens/river/manual/river_manual_image_request.dart similarity index 95% rename from lib/screens/river/manual/image_request.dart rename to lib/screens/river/manual/river_manual_image_request.dart index 7cacac0..0a27ccc 100644 --- a/lib/screens/river/manual/image_request.dart +++ b/lib/screens/river/manual/river_manual_image_request.dart @@ -1,6 +1,8 @@ +// lib/screens/river/manual/river_manual_image_request.dart + import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; -import 'dart:io'; // Add this line at the top of these files +import 'dart:io'; class RiverManualImageRequest extends StatefulWidget { diff --git a/lib/screens/river/manual/report.dart b/lib/screens/river/manual/river_manual_report.dart similarity index 95% rename from lib/screens/river/manual/report.dart rename to lib/screens/river/manual/river_manual_report.dart index 210bd06..b2ced31 100644 --- a/lib/screens/river/manual/report.dart +++ b/lib/screens/river/manual/river_manual_report.dart @@ -1,3 +1,5 @@ +// lib/screens/river/manual/river_manual_report.dart + import 'package:flutter/material.dart'; class RiverManualReport extends StatelessWidget { diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart index 868bb12..704864f 100644 --- a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart +++ b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart @@ -36,6 +36,12 @@ class _RiverInSituStep3DataCaptureState extends State 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(); } @@ -320,8 +355,12 @@ class _RiverInSituStep3DataCaptureState extends State _isAutoReading = false); + setState(() { + _isAutoReading = false; + _isLockedOut = false; // --- MODIFICATION: Reset lockout state --- + }); } } @@ -353,6 +392,13 @@ class _RiverInSituStep3DataCaptureState extends State _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'))), - ], - ), + // --- 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(), + 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), - ElevatedButton( - onPressed: _validateAndProceed, - style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), - child: const Text('Next'), - ), - ], + 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}) { @@ -643,15 +702,21 @@ class _RiverInSituStep3DataCaptureState extends State _toggleAutoReading(type), + label: Text(_isAutoReading + ? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading') + : 'Start Reading'), + onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type), style: ElevatedButton.styleFrom( - backgroundColor: _isAutoReading ? Colors.orange : Colors.green, + 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'), diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 17bb9ab..b46a4d0 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.08'), + subtitle: const Text('MMS V4 1.2.09'), dense: true, ), ListTile( diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index 89c6e2d..88c3f30 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -6,8 +6,8 @@ import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:path/path.dart' as p; -// --- ADDED: Import dio for downloading --- import 'package:dio/dio.dart'; +import 'package:geolocator/geolocator.dart'; import '../models/air_installation_data.dart'; import '../models/air_collection_data.dart'; @@ -15,7 +15,6 @@ import '../models/tarball_data.dart'; import '../models/in_situ_sampling_data.dart'; import '../models/river_in_situ_sampling_data.dart'; -/// A comprehensive service for handling all local data storage for offline submissions. class LocalStorageService { // ======================================================================= @@ -27,14 +26,11 @@ class LocalStorageService { return status.isGranted; } - // --- MODIFIED: This method now accepts a serverName to create a server-specific root directory. --- Future _getPublicMMSV4Directory({required String serverName}) async { if (await _requestPermissions()) { final Directory? externalDir = await getExternalStorageDirectory(); if (externalDir != null) { final publicRootPath = externalDir.path.split('/Android/')[0]; - // Create a subdirectory for the specific server configuration. - // If serverName is empty, it returns the root MMSV4 folder. final mmsv4Dir = Directory(p.join(publicRootPath, 'MMSV4', serverName)); if (!await mmsv4Dir.exists()) { await mmsv4Dir.create(recursive: true); @@ -46,7 +42,6 @@ class LocalStorageService { return null; } - // --- ADDED: A public method to retrieve the root log directory. --- Future getLogDirectory({required String serverName, required String module, required String subModule}) async { final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); if (mmsv4Dir == null) return null; @@ -61,7 +56,6 @@ class LocalStorageService { // Part 2: Air Manual Sampling Methods (LOGGING RESTORED) // ======================================================================= - // --- MODIFIED: Method now requires serverName to get the correct base directory. --- Future _getAirManualBaseDir({required String serverName}) async { final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); if (mmsv4Dir == null) return null; @@ -73,8 +67,6 @@ class LocalStorageService { return airDir; } - /// Saves or updates an air sampling record, including copying all associated images to permanent local storage. - // --- MODIFIED: Method now requires serverName. --- Future saveAirSamplingRecord(Map data, String refID, {required String serverName}) async { final baseDir = await _getAirManualBaseDir(serverName: serverName); if (baseDir == null) { @@ -88,11 +80,9 @@ class LocalStorageService { await eventDir.create(recursive: true); } - // Helper function to copy a file and return its new, permanent path Future copyImageToLocal(dynamic imageFile) async { - if (imageFile is! File) return null; // Gracefully handle non-File types + if (imageFile is! File) return null; try { - // Check if the file is already in the permanent directory to avoid re-copying if (p.dirname(imageFile.path) == eventDir.path) { return imageFile.path; } @@ -105,25 +95,18 @@ class LocalStorageService { } } - // Create a mutable copy of the data map to avoid modifying the original final Map serializableData = Map.from(data); - // --- MODIFIED: Inject the server name into the data being saved. --- serializableData['serverConfigName'] = serverName; - - // Define the keys for installation images to look for in the map final installationImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4']; - // Process top-level (installation) images for (final key in installationImageKeys) { - // Check if the key exists and the value is a File object if (serializableData.containsKey(key) && serializableData[key] is File) { final newPath = await copyImageToLocal(serializableData[key]); - serializableData['${key}Path'] = newPath; // Creates 'imageFrontPath', etc. + serializableData['${key}Path'] = newPath; } } - // Process nested collection images, if they exist if (serializableData['collectionData'] is Map) { final collectionMap = Map.from(serializableData['collectionData']); final collectionImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'imageChart', 'imageFilterPaper', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4']; @@ -139,7 +122,6 @@ class LocalStorageService { final Map finalData = Map.from(serializableData); - // Recursive helper to remove File objects before JSON encoding void cleanMap(Map map) { map.removeWhere((key, value) => value is File); map.forEach((key, value) { @@ -149,7 +131,6 @@ class LocalStorageService { cleanMap(finalData); - final jsonFile = File(p.join(eventDir.path, 'data.json')); await jsonFile.writeAsString(jsonEncode(finalData)); debugPrint("Air sampling log and images saved to: ${eventDir.path}"); @@ -163,9 +144,8 @@ class LocalStorageService { } } - // --- MODIFIED: This method now scans all server subdirectories to find all logs. --- Future>> getAllAirSamplingLogs() async { - final mmsv4Root = await _getPublicMMSV4Directory(serverName: ''); // Get root MMSV4 without a server subfolder + final mmsv4Root = await _getPublicMMSV4Directory(serverName: ''); if (mmsv4Root == null || !await mmsv4Root.exists()) return []; final List> allLogs = []; @@ -314,7 +294,6 @@ class LocalStorageService { } } - // ======================================================================= // Part 4: Marine In-Situ Specific Methods (LOGGING RESTORED) // ======================================================================= @@ -347,12 +326,9 @@ class LocalStorageService { await eventDir.create(recursive: true); } - // --- START FIX: Explicitly include the final status and message --- - // This ensures the status calculated in the service layer is saved correctly. final Map jsonData = data.toDbJson(); jsonData['submissionStatus'] = data.submissionStatus; jsonData['submissionMessage'] = data.submissionMessage; - // --- END FIX --- jsonData['serverConfigName'] = serverName; @@ -436,6 +412,58 @@ class LocalStorageService { } } + Future> getRecentNearbySamples({ + required double latitude, + required double longitude, + required double radiusKm, + required int withinHours, + }) async { + final allLogs = await getAllInSituLogs(); + final List recentNearbySamples = []; + final cutoffDateTime = DateTime.now().subtract(Duration(hours: withinHours)); + final double radiusInMeters = radiusKm * 1000; + + for (var log in allLogs) { + try { + final sampleData = InSituSamplingData.fromJson(log); + + if (sampleData.samplingDate == null || sampleData.samplingTime == null) { + continue; + } + final sampleDateTime = DateTime.tryParse('${sampleData.samplingDate} ${sampleData.samplingTime}'); + if (sampleDateTime == null || sampleDateTime.isBefore(cutoffDateTime)) { + continue; + } + + final sampleLat = double.tryParse(sampleData.currentLatitude ?? ''); + final sampleLon = double.tryParse(sampleData.currentLongitude ?? ''); + if (sampleLat == null || sampleLon == null) { + continue; + } + + final distanceInMeters = Geolocator.distanceBetween( + latitude, + longitude, + sampleLat, + sampleLon, + ); + + if (distanceInMeters <= radiusInMeters) { + recentNearbySamples.add(sampleData); + } + } catch (e) { + debugPrint("Error processing in-situ log for nearby search: $e"); + } + } + recentNearbySamples.sort((a, b) { + final dtA = DateTime.tryParse('${a.samplingDate} ${a.samplingTime}'); + final dtB = DateTime.tryParse('${b.samplingDate} ${b.samplingTime}'); + if (dtA == null || dtB == null) return 0; + return dtB.compareTo(dtA); + }); + return recentNearbySamples; + } + // ======================================================================= // Part 5: River In-Situ Specific Methods (LOGGING RESTORED) // ======================================================================= @@ -475,7 +503,6 @@ class LocalStorageService { await eventDir.create(recursive: true); } - // --- START: MODIFIED TO USE toMap() FOR COMPLETE DATA SERIALIZATION --- final Map jsonData = data.toMap(); jsonData['serverConfigName'] = serverName; @@ -485,16 +512,13 @@ class LocalStorageService { if (imageFile != null) { final String originalFileName = p.basename(imageFile.path); if (p.dirname(imageFile.path) == eventDir.path) { - // If file is already in the correct directory, just store the path jsonData[entry.key] = imageFile.path; } else { - // Otherwise, copy it to the permanent directory final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); jsonData[entry.key] = newFile.path; } } } - // --- END: MODIFIED TO USE toMap() FOR COMPLETE DATA SERIALIZATION --- final jsonFile = File(p.join(eventDir.path, 'data.json')); await jsonFile.writeAsString(jsonEncode(jsonData)); @@ -568,9 +592,7 @@ class LocalStorageService { final Dio _dio = Dio(); - /// Gets the directory for storing Info Centre documents, creating it if it doesn't exist. Future _getInfoCentreDocumentsDirectory() async { - // We use serverName: '' to ensure documents are stored in a common root MMSV4 folder, not server-specific ones. final mmsv4Dir = await _getPublicMMSV4Directory(serverName: ''); if (mmsv4Dir == null) return null; @@ -581,7 +603,6 @@ class LocalStorageService { return docDir; } - /// Constructs the full local file path for a given document URL. Future getLocalDocumentPath(String docUrl) async { final docDir = await _getInfoCentreDocumentsDirectory(); if (docDir == null) return null; @@ -590,14 +611,12 @@ class LocalStorageService { return p.join(docDir.path, fileName); } - /// Checks if a document has already been downloaded. Future isDocumentDownloaded(String docUrl) async { final filePath = await getLocalDocumentPath(docUrl); if (filePath == null) return false; return await File(filePath).exists(); } - /// Downloads a document from a URL and saves it to the local `MMSV4/info_centre_documents` folder. Future downloadDocument({ required String docUrl, required Function(double) onReceiveProgress, @@ -618,7 +637,6 @@ class LocalStorageService { }, ); } catch (e) { - // If the download fails, delete the partially downloaded file to prevent corruption. final file = File(filePath); if (await file.exists()) { await file.delete();