From de4c0c471ccb3d4315521b2a19b93e787c206cae Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Sun, 19 Oct 2025 23:13:10 +0800 Subject: [PATCH] update feature to auto link tarball and manual sampling with report --- lib/main.dart | 8 +- lib/models/marine_manual_npe_report_data.dart | 4 +- lib/models/tarball_data.dart | 25 +- .../reports/marine_manual_npe_report_hub.dart | 80 ++ .../reports/npe_report_from_in_situ.dart | 710 ++++++++++++++++++ .../reports/npe_report_from_tarball.dart | 686 +++++++++++++++++ .../reports/npe_report_new_location.dart | 648 ++++++++++++++++ .../tarball_sampling_step3_summary.dart | 72 +- .../widgets/in_situ_step_4_summary.dart | 11 +- 9 files changed, 2218 insertions(+), 26 deletions(-) create mode 100644 lib/screens/marine/manual/reports/marine_manual_npe_report_hub.dart create mode 100644 lib/screens/marine/manual/reports/npe_report_from_in_situ.dart create mode 100644 lib/screens/marine/manual/reports/npe_report_from_tarball.dart create mode 100644 lib/screens/marine/manual/reports/npe_report_new_location.dart diff --git a/lib/main.dart b/lib/main.dart index 2967acf..bf7143f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -75,7 +75,7 @@ import 'package:environment_monitoring_app/screens/marine/manual/info_centre_doc import 'package:environment_monitoring_app/screens/marine/manual/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/reports/marine_manual_npe_report.dart' as marineManualNPEReport; +import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_npe_report_hub.dart'; import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart' as marineManualPreDepartureChecklist; import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart' as marineManualSondeCalibration; import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart' as marineManualEquipmentMaintenance; @@ -167,7 +167,6 @@ void setupPeriodicServices(TelegramService telegramService, RetryService retrySe }); } -// --- START: MODIFIED RootApp --- class RootApp extends StatefulWidget { const RootApp({super.key}); @@ -330,14 +329,12 @@ class _RootAppState extends State { '/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(), '/marine/manual/tarball': (context) => const TarballSamplingStep1(), '/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(), - '/marine/manual/report/npe': (context) => const marineManualNPEReport.MarineManualNPEReport(), + '/marine/manual/report/npe': (context) => const MarineManualNPEReportHub(), '/marine/manual/report/pre-departure': (context) => const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(), '/marine/manual/report/calibration': (context) => const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(), '/marine/manual/report/maintenance': (context) => const marineManualEquipmentMaintenance.MarineManualEquipmentMaintenanceScreen(), - //'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute '/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(), - // Marine Continuous '/marine/continuous/info': (context) => const MarineContinuousInfoCentreDocument(), '/marine/continuous/overview': (context) => marineContinuousOverview.OverviewScreen(), @@ -355,7 +352,6 @@ class _RootAppState extends State { ); } } -// --- END: MODIFIED RootApp --- class SessionAwareWrapper extends StatefulWidget { final Widget child; diff --git a/lib/models/marine_manual_npe_report_data.dart b/lib/models/marine_manual_npe_report_data.dart index 12e0ef7..4326639 100644 --- a/lib/models/marine_manual_npe_report_data.dart +++ b/lib/models/marine_manual_npe_report_data.dart @@ -1,3 +1,5 @@ +// lib/models/marine_manual_npe_report_data.dart + import 'dart:io'; import 'dart:convert'; @@ -11,7 +13,7 @@ class MarineManualNpeReportData { // --- Location Info --- String? locationDescription; // For new locations - String? stateName; // For new locations + String? stateName; // For new locations or tarball stations Map? selectedStation; // For existing stations String? latitude; String? longitude; diff --git a/lib/models/tarball_data.dart b/lib/models/tarball_data.dart index 1148fdb..017f49e 100644 --- a/lib/models/tarball_data.dart +++ b/lib/models/tarball_data.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'dart:convert'; +import 'package:environment_monitoring_app/models/marine_manual_npe_report_data.dart'; + /// This class holds all the data collected across the multi-step tarball sampling form. /// It acts as a temporary data container that is passed between screens. class TarballSamplingData { @@ -44,6 +46,25 @@ class TarballSamplingData { String? submissionStatus; String? submissionMessage; + /// Converts tarball data into a pre-filled NPE Report data object. + MarineManualNpeReportData toNpeReportData() { + final npeData = MarineManualNpeReportData(); + + npeData.firstSamplerName = firstSampler; + npeData.firstSamplerUserId = firstSamplerUserId; + npeData.eventDate = samplingDate; + npeData.eventTime = samplingTime; + npeData.selectedStation = selectedStation; + npeData.latitude = currentLatitude; + npeData.longitude = currentLongitude; + npeData.stateName = selectedStateName; + + // Pre-tick the relevant observation for a tarball event. + npeData.fieldObservations['Observation of tar balls'] = true; + + return npeData; + } + /// Generates a formatted Telegram alert message for successful submissions. String generateTelegramAlertMessage({required bool isDataOnly}) { final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; @@ -78,7 +99,6 @@ class TarballSamplingData { /// Converts the form's text and selection data into a Map suitable for JSON encoding. /// This map will be sent as the body of the first API request. - // START CHANGE: Corrected the toFormData method to include all required fields for the API. Map toFormData() { final Map data = { // Required fields that were missing or not being sent correctly @@ -107,7 +127,6 @@ class TarballSamplingData { }; return data; } - // END CHANGE /// Gathers all non-null image files into a Map. /// This map is used to build the multipart request for the second API call (image upload). @@ -124,8 +143,6 @@ class TarballSamplingData { }; } - // --- ADDED: Methods to format data for FTP submission as separate JSON files --- - /// Creates a single JSON object with all submission data, mimicking 'db.json' Map toDbJson() { return { diff --git a/lib/screens/marine/manual/reports/marine_manual_npe_report_hub.dart b/lib/screens/marine/manual/reports/marine_manual_npe_report_hub.dart new file mode 100644 index 0000000..67e9897 --- /dev/null +++ b/lib/screens/marine/manual/reports/marine_manual_npe_report_hub.dart @@ -0,0 +1,80 @@ +// lib/screens/marine/manual/reports/marine_manual_npe_report_hub.dart + +import 'package:flutter/material.dart'; +import 'npe_report_from_in_situ.dart'; +import 'npe_report_from_tarball.dart'; +import 'npe_report_new_location.dart'; + +class MarineManualNPEReportHub extends StatelessWidget { + const MarineManualNPEReportHub({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Create NPE Report"), + ), + body: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + _buildOptionCard( + context: context, + icon: Icons.sync_alt, + title: 'From Recent In-Situ Sample', + subtitle: 'Use data from a recent, nearby manual sampling event.', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const NPEReportFromInSitu()), + ); + }, + ), + _buildOptionCard( + context: context, + icon: Icons.public, + title: 'From Tarball Station', + subtitle: 'Select a tarball station to report a pollution event.', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const NPEReportFromTarball()), + ); + }, + ), + _buildOptionCard( + context: context, + icon: Icons.add_location_alt_outlined, + title: 'For a New Location', + subtitle: 'Manually enter location details for a new pollution observation.', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const NPEReportNewLocation()), + ); + }, + ), + ], + ), + ); + } + + Widget _buildOptionCard({ + required BuildContext context, + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return Card( + elevation: 2, + margin: const EdgeInsets.symmetric(vertical: 8.0), + child: ListTile( + leading: Icon(icon, size: 40, color: Theme.of(context).primaryColor), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(subtitle), + trailing: const Icon(Icons.arrow_forward_ios), + onTap: onTap, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/reports/npe_report_from_in_situ.dart b/lib/screens/marine/manual/reports/npe_report_from_in_situ.dart new file mode 100644 index 0000000..8e0912b --- /dev/null +++ b/lib/screens/marine/manual/reports/npe_report_from_in_situ.dart @@ -0,0 +1,710 @@ +// lib/screens/marine/manual/reports/npe_report_from_in_situ.dart + +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:provider/provider.dart'; +import 'package:dropdown_search/dropdown_search.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.dart'; // Ensure this import is present + +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 NPEReportFromInSitu extends StatefulWidget { + const NPEReportFromInSitu({super.key}); + + @override + State createState() => _NPEReportFromInSituState(); +} + +class _NPEReportFromInSituState extends State { + final _formKey = GlobalKey(); + bool _isLoading = false; + bool _isPickingImage = false; + + // Data handling + bool _isLoadingRecentSamples = true; + List _recentNearbySamples = []; + InSituSamplingData? _selectedRecentSample; + final MarineManualNpeReportData _npeData = MarineManualNpeReportData(); + + // Controllers + final _stationIdController = TextEditingController(); + final _locationController = TextEditingController(); + final _eventDateTimeController = TextEditingController(); + final _latController = TextEditingController(); + final _longController = TextEditingController(); + final _possibleSourceController = TextEditingController(); + final _othersObservationController = TextEditingController(); + // ADDED: Controllers for in-situ measurements + final _doPercentController = TextEditingController(); + final _doMgLController = TextEditingController(); + final _phController = TextEditingController(); + final _condController = TextEditingController(); + final _turbController = TextEditingController(); + final _tempController = TextEditingController(); + + // In-Situ related + late final MarineInSituSamplingService _samplingService; + // ADDED: State variables for device connection and reading + StreamSubscription? _dataSubscription; + bool _isAutoReading = false; + Timer? _lockoutTimer; + int _lockoutSecondsRemaining = 30; + bool _isLockedOut = false; + + + @override + void initState() { + super.initState(); + _samplingService = Provider.of(context, listen: false); + _fetchRecentNearbySamples(); + } + + @override + void dispose() { + // ADDED: Cancel subscriptions and timers, disconnect devices + _dataSubscription?.cancel(); + _lockoutTimer?.cancel(); + if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + _samplingService.disconnectFromBluetooth(); + } + if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) { + _samplingService.disconnectFromSerial(); + } + // Dispose all controllers + _stationIdController.dispose(); + _locationController.dispose(); + _eventDateTimeController.dispose(); + _latController.dispose(); + _longController.dispose(); + _possibleSourceController.dispose(); + _othersObservationController.dispose(); + // ADDED: Dispose new controllers + _doPercentController.dispose(); + _doMgLController.dispose(); + _phController.dispose(); + _condController.dispose(); + _turbController.dispose(); + _tempController.dispose(); + super.dispose(); + } + + // UPDATED: Method now includes permission checks + Future _fetchRecentNearbySamples() async { + setState(() => _isLoadingRecentSamples = true); + bool serviceEnabled; + LocationPermission permission; + + try { + // 1. Check if location services are enabled. + serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + _showSnackBar('Location services are disabled. Please enable them.', isError: true); + if (mounted) setState(() => _isLoadingRecentSamples = false); + return; + } + + // 2. Check current permission status. + permission = await Geolocator.checkPermission(); + + // 3. Request permission if denied or not determined. + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + _showSnackBar('Location permission is required to find nearby samples.', isError: true); + if (mounted) setState(() => _isLoadingRecentSamples = false); + return; + } + } + + // 4. Handle permanent denial. + if (permission == LocationPermission.deniedForever) { + _showSnackBar('Location permission permanently denied. Please enable it in app settings.', isError: true); + // Optionally, offer to open settings + await openAppSettings(); // Requires permission_handler package + if (mounted) setState(() => _isLoadingRecentSamples = false); + return; + } + + // 5. If permission is granted, get the location and fetch samples. + final Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); + final localDbService = Provider.of(context, listen: false); + final samples = await localDbService.getRecentNearbySamples( + latitude: position.latitude, longitude: position.longitude, radiusKm: 3, withinHours: 30 * 24); // 30 days + 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 _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 ?? ''; + final now = DateTime.now(); + _eventDateTimeController.text = DateFormat('yyyy-MM-dd HH:mm').format(now); + + // ADDED: Populate in-situ measurement fields from selected sample + _doPercentController.text = data.oxygenSaturation?.toStringAsFixed(5) ?? ''; + _doMgLController.text = data.oxygenConcentration?.toStringAsFixed(5) ?? ''; + _phController.text = data.ph?.toStringAsFixed(5) ?? ''; + _condController.text = data.electricalConductivity?.toStringAsFixed(5) ?? ''; + _turbController.text = data.turbidity?.toStringAsFixed(5) ?? ''; + _tempController.text = data.temperature?.toStringAsFixed(5) ?? ''; + } + + 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); + + _npeData.firstSamplerName = auth.profileData?['user_name']; + _npeData.firstSamplerUserId = auth.profileData?['user_id']; + _npeData.eventDate = _eventDateTimeController.text.split(' ')[0]; + _npeData.eventTime = _eventDateTimeController.text.split(' ').length > 1 ? _eventDateTimeController.text.split(' ')[1] : ''; + _npeData.latitude = _latController.text; + _npeData.longitude = _longController.text; + _npeData.selectedStation = _selectedRecentSample?.selectedStation; + _npeData.locationDescription = _locationController.text; + _npeData.possibleSource = _possibleSourceController.text; + _npeData.othersObservationRemark = _othersObservationController.text; + // ADDED: Read values from in-situ measurement controllers + _npeData.oxygenSaturation = double.tryParse(_doPercentController.text); + _npeData.electricalConductivity = double.tryParse(_condController.text); + _npeData.oxygenConcentration = double.tryParse(_doMgLController.text); + _npeData.turbidity = double.tryParse(_turbController.text); + _npeData.ph = double.tryParse(_phController.text); + _npeData.temperature = double.tryParse(_tempController.text); + + final result = await service.submitNpeReport(data: _npeData, 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( + content: Text(message), + backgroundColor: isError ? Colors.red : null, + )); + } + } + + 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: _npeData.image1 = file; break; + case 2: _npeData.image2 = file; break; + case 3: _npeData.image3 = file; break; + case 4: _npeData.image4 = file; break; + } + }); + } + if (mounted) setState(() => _isPickingImage = false); + } + + // --- START: ADDED IN-SITU DEVICE CONNECTION AND READING METHODS --- + 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() { + if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + return { + 'type': 'bluetooth', + 'state': _samplingService.bluetoothConnectionState.value, + 'name': _samplingService.connectedBluetoothDeviceName + }; + } + if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) { + return {'type': 'serial', 'state': _samplingService.serialConnectionState.value, 'name': _samplingService.connectedSerialDeviceName}; + } + return 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); // Use main loading indicator + 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); // Stop main loading indicator + } + return success; + } + + void _disconnectFromAll() { + if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) _disconnect('bluetooth'); + if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) _disconnect('serial'); + } + + 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; + }); + } + } + // --- END: ADDED IN-SITU DEVICE CONNECTION AND READING METHODS --- + + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("NPE from In-Situ Sample")), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(20.0), + children: [ + _buildSectionTitle("1. Select Recent Sample"), + if (_isLoadingRecentSamples) + const Center(child: Padding(padding: EdgeInsets.all(8.0), child: CircularProgressIndicator())) + else + DropdownSearch( + items: _recentNearbySamples, + itemAsString: (s) => "${s.selectedStation?['man_station_code']} at ${s.samplingDate} ${s.samplingTime}", + popupProps: PopupProps.menu( + showSearchBox: true, searchFieldProps: const TextFieldProps(decoration: InputDecoration(hintText: "Search..."))), + 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: 16), + _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), + const SizedBox(height: 24), + + // ADDED: In-Situ Measurements Section + _buildSectionTitle("2. In-situ Measurements (Optional)"), + _buildInSituSection(), // Calls the builder for device connection & parameters + const SizedBox(height: 24), + + // Sections renumbered + _buildSectionTitle("3. Field Observations*"), + ..._buildObservationsCheckboxes(), + const SizedBox(height: 24), + + _buildSectionTitle("4. Possible Source"), + _buildTextFormField(controller: _possibleSourceController, label: "Possible Source", maxLines: 3), + const SizedBox(height: 24), + + _buildSectionTitle("5. Attachments (Figures)"), + _buildImageAttachmentSection(), + const SizedBox(height: 32), + + Center( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 15) + ), + onPressed: _isLoading ? null : _submitNpeReport, + child: _isLoading ? const CircularProgressIndicator(color: Colors.white) : const Text("Submit Report"), + ), + ), + ], + ), + ), + ); + } + + 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)), + ); + } + + // FIXED: Correct implementation for _buildObservationsCheckboxes + List _buildObservationsCheckboxes() { + // Use the correct pattern from npe_report_from_tarball.dart + return [ + for (final key in _npeData.fieldObservations.keys) + CheckboxListTile( + title: Text(key), + value: _npeData.fieldObservations[key], + onChanged: (newValue) => setState(() => _npeData.fieldObservations[key] = newValue!), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + // Conditionally add the 'Others' text field + if (_npeData.fieldObservations['Others'] ?? false) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: _buildTextFormField( + controller: _othersObservationController, + label: "Please specify", + // Make it optional by removing '*' from the label here or adjust validator + ), + ), + ]; + } + + + Widget _buildImageAttachmentSection() { + return Column( + children: [ + _buildNPEImagePicker(title: 'Figure 1', imageFile: _npeData.image1, onClear: () => setState(() => _npeData.image1 = null), imageNumber: 1), + _buildNPEImagePicker(title: 'Figure 2', imageFile: _npeData.image2, onClear: () => setState(() => _npeData.image2 = null), imageNumber: 2), + _buildNPEImagePicker(title: 'Figure 3', imageFile: _npeData.image3, onClear: () => setState(() => _npeData.image3 = null), imageNumber: 3), + _buildNPEImagePicker(title: 'Figure 4', imageFile: _npeData.image4, onClear: () => setState(() => _npeData.image4 = null), imageNumber: 4), + ], + ); + } + + Widget _buildNPEImagePicker({required String title, File? imageFile, required VoidCallback 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), + 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, + ), + ), + ], + ) + : 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}) { + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder() + ), + maxLines: maxLines, + readOnly: readOnly, + validator: (value) { + // Allow empty if not required (no '*') + if (!label.contains('*') && (value == null || value.trim().isEmpty)) return null; + // Require non-empty if required ('*') + if (label.contains('*') && !readOnly && (value == null || value.trim().isEmpty)) return 'This field is required'; + return null; + }, + ); + } + + // --- START: ADDED IN-SITU WIDGET BUILDERS --- + 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), + _buildParameterListItem(icon: Icons.percent, label: "DO", unit: "%", controller: _doPercentController), + _buildParameterListItem(icon: Icons.flash_on, label: "Cond", unit: "µS/cm", controller: _condController), + _buildParameterListItem(icon: Icons.air, label: "DO", unit: "mg/L", controller: _doMgLController), + _buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController), + _buildParameterListItem(icon: Icons.science_outlined, label: "PH", unit: "", controller: _phController), + _buildParameterListItem(icon: Icons.thermostat, label: "Temp", unit: "°C", controller: _tempController), + ], + ); + } + + 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) // Show loading indicator during connection attempt OR general form loading + 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), + ) + ], + ) + // No button needed if disconnected and not loading + ], + ), + ), + ); + } + + Widget _buildParameterListItem({required IconData icon, required String label, required String unit, required TextEditingController controller}) { + // ReadOnly text field used to display the value, looks like standard text but allows copying. + final bool isMissing = controller.text.isEmpty || controller.text.contains('-999'); + // Display value with 5 decimal places if not missing, otherwise '-.--' + final String displayValue = isMissing ? '-.--' : (double.tryParse(controller.text)?.toStringAsFixed(5) ?? '-.--'); + 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: SizedBox( // Use SizedBox to constrain width if needed + width: 120, // Adjust width as necessary + child: TextFormField( + // Use a unique key based on the controller to force rebuild when text changes + key: ValueKey(controller.text), + initialValue: displayValue, // Use initialValue instead of controller directly + readOnly: true, // Make it read-only + textAlign: TextAlign.right, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isMissing ? Colors.grey : Theme.of(context).colorScheme.primary, + ), + decoration: const InputDecoration( + border: InputBorder.none, // Remove underline/border + contentPadding: EdgeInsets.zero, // Remove padding + ), + ), + ), + ), + ); + } +// --- END: ADDED IN-SITU WIDGET BUILDERS --- + +} \ No newline at end of file diff --git a/lib/screens/marine/manual/reports/npe_report_from_tarball.dart b/lib/screens/marine/manual/reports/npe_report_from_tarball.dart new file mode 100644 index 0000000..c253c9b --- /dev/null +++ b/lib/screens/marine/manual/reports/npe_report_from_tarball.dart @@ -0,0 +1,686 @@ +// lib/screens/marine/manual/reports/npe_report_from_tarball.dart + +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:provider/provider.dart'; +import 'package:dropdown_search/dropdown_search.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 '../../../../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 NPEReportFromTarball extends StatefulWidget { + final MarineManualNpeReportData? initialData; + const NPEReportFromTarball({super.key, this.initialData}); + + @override + State createState() => _NPEReportFromTarballState(); +} + +class _NPEReportFromTarballState extends State { + final _formKey = GlobalKey(); + bool _isLoading = false; + bool _isPickingImage = false; + bool _areFieldsLocked = false; + + // Data + final MarineManualNpeReportData _npeData = MarineManualNpeReportData(); + String? _selectedState; + String? _selectedCategory; + List _categoriesForState = []; + List> _stationsForCategory = []; + + // Controllers + final _stationIdController = TextEditingController(); + final _locationController = TextEditingController(); + final _eventDateTimeController = TextEditingController(); + final _latController = TextEditingController(); + final _longController = TextEditingController(); + final _possibleSourceController = TextEditingController(); + final _othersObservationController = TextEditingController(); + final _doPercentController = TextEditingController(); + final _doMgLController = TextEditingController(); + final _phController = TextEditingController(); + final _condController = TextEditingController(); + final _turbController = TextEditingController(); + final _tempController = TextEditingController(); + + // In-Situ + late final MarineInSituSamplingService _samplingService; + StreamSubscription? _dataSubscription; + bool _isAutoReading = false; + Timer? _lockoutTimer; + int _lockoutSecondsRemaining = 30; + bool _isLockedOut = false; + + @override + void initState() { + super.initState(); + _samplingService = Provider.of(context, listen: false); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.initialData != null) { + _populateFormFromTarballData(widget.initialData!); + } else { + _setDefaultDateTime(); + } + }); + } + + @override + void dispose() { + _stationIdController.dispose(); + _locationController.dispose(); + _eventDateTimeController.dispose(); + _latController.dispose(); + _longController.dispose(); + _possibleSourceController.dispose(); + _othersObservationController.dispose(); + _doPercentController.dispose(); + _doMgLController.dispose(); + _phController.dispose(); + _condController.dispose(); + _turbController.dispose(); + _tempController.dispose(); + _dataSubscription?.cancel(); + _lockoutTimer?.cancel(); + super.dispose(); + } + + void _setDefaultDateTime() { + final now = DateTime.now(); + _eventDateTimeController.text = DateFormat('yyyy-MM-dd HH:mm').format(now); + } + + void _populateFormFromTarballData(MarineManualNpeReportData data) { + final auth = Provider.of(context, listen: false); + final allTarballStations = auth.tarballStations ?? []; + final station = data.selectedStation; + + if (station == null) return; + + setState(() { + _areFieldsLocked = true; + _selectedState = station['state_name']; + _selectedCategory = station['category_name']; + _npeData.selectedStation = station; + + _categoriesForState = allTarballStations + .where((s) => s['state_name'] == _selectedState) + .map((s) => s['category_name'] as String) + .toSet() + .toList(); + + _stationsForCategory = allTarballStations + .where((s) => s['state_name'] == _selectedState && s['category_name'] == _selectedCategory) + .toList(); + + _stationIdController.text = station['tbl_station_code'] ?? ''; + _locationController.text = station['tbl_station_name'] ?? ''; + _latController.text = data.latitude ?? station['tbl_latitude']?.toString() ?? ''; + _longController.text = data.longitude ?? station['tbl_longitude']?.toString() ?? ''; + + if (data.eventDate != null && data.eventTime != null) { + _eventDateTimeController.text = '${data.eventDate} ${data.eventTime}'; + } else { + _setDefaultDateTime(); + } + + _npeData.fieldObservations.clear(); + _npeData.fieldObservations.addAll(data.fieldObservations); + }); + } + + 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); + + _npeData.firstSamplerName = auth.profileData?['user_name']; + _npeData.firstSamplerUserId = auth.profileData?['user_id']; + _npeData.eventDate = _eventDateTimeController.text.split(' ')[0]; + _npeData.eventTime = _eventDateTimeController.text.split(' ').length > 1 ? _eventDateTimeController.text.split(' ')[1] : ''; + _npeData.stateName = _selectedState; + _npeData.latitude = _latController.text; + _npeData.longitude = _longController.text; + _npeData.possibleSource = _possibleSourceController.text; + _npeData.othersObservationRemark = _othersObservationController.text; + _npeData.oxygenSaturation = double.tryParse(_doPercentController.text); + _npeData.electricalConductivity = double.tryParse(_condController.text); + _npeData.oxygenConcentration = double.tryParse(_doMgLController.text); + _npeData.turbidity = double.tryParse(_turbController.text); + _npeData.ph = double.tryParse(_phController.text); + _npeData.temperature = double.tryParse(_tempController.text); + + final result = await service.submitNpeReport(data: _npeData, authProvider: auth); + setState(() => _isLoading = false); + if (mounted) { + _showSnackBar(result['message'], isError: result['success'] != true); + if (result['success'] == true) { + Navigator.of(context).popUntil((route) => route.isFirst); + } + } + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : null, + )); + } + } + + 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: _npeData.image1 = file; break; + case 2: _npeData.image2 = file; break; + case 3: _npeData.image3 = file; break; + case 4: _npeData.image4 = file; break; + } + }); + } + if (mounted) setState(() => _isPickingImage = false); + } + + 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() { + if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + return {'type': 'bluetooth', 'state': _samplingService.bluetoothConnectionState.value, 'name': _samplingService.connectedBluetoothDeviceName}; + } + if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) { + return {'type': 'serial', 'state': _samplingService.serialConnectionState.value, 'name': _samplingService.connectedSerialDeviceName}; + } + return 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; + } + + void _disconnectFromAll() { + if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) _disconnect('bluetooth'); + if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) _disconnect('serial'); + } + + 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; + }); + } + } + + @override + Widget build(BuildContext context) { + final auth = Provider.of(context, listen: false); + final allTarballStations = auth.tarballStations ?? []; + + return Scaffold( + appBar: AppBar(title: const Text("NPE from Tarball Station")), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(20.0), + children: [ + _buildSectionTitle("1. Select Tarball Station"), + DropdownSearch( + items: allTarballStations.map((s) => s['state_name'] as String).toSet().toList()..sort(), + selectedItem: _selectedState, + enabled: !_areFieldsLocked, + 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; + _npeData.selectedStation = null; + _stationIdController.clear(); + _locationController.clear(); + _latController.clear(); + _longController.clear(); + _categoriesForState = state != null ? allTarballStations.where((s) => s['state_name'] == state).map((s) => s['category_name'] as String).toSet().toList() : []; + _stationsForCategory = []; + }); + }, + validator: (val) => val == null ? "State is required" : null, + ), + const SizedBox(height: 12), + DropdownSearch( + items: _categoriesForState, + selectedItem: _selectedCategory, + enabled: !_areFieldsLocked && _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; + _npeData.selectedStation = null; + _stationIdController.clear(); + _locationController.clear(); + _latController.clear(); + _longController.clear(); + _stationsForCategory = category != null ? allTarballStations.where((s) => s['state_name'] == _selectedState && s['category_name'] == category).toList() : []; + }); + }, + validator: (val) => val == null && _categoriesForState.isNotEmpty ? "Category is required" : null, + ), + const SizedBox(height: 12), + DropdownSearch>( + items: _stationsForCategory, + selectedItem: _npeData.selectedStation, + enabled: !_areFieldsLocked && _stationsForCategory.isNotEmpty, + itemAsString: (s) => "${s['tbl_station_code']} - ${s['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(() { + _npeData.selectedStation = 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), + const SizedBox(height: 24), + + _buildSectionTitle("2. In-situ Measurements (Optional)"), + _buildInSituSection(), + const SizedBox(height: 24), + + _buildSectionTitle("3. Field Observations*"), + ..._buildObservationsCheckboxes(), + const SizedBox(height: 24), + + _buildSectionTitle("4. Possible Source"), + _buildTextFormField(controller: _possibleSourceController, label: "Possible Source", maxLines: 3), + const SizedBox(height: 24), + + _buildSectionTitle("5. Attachments (Figures)"), + _buildImageAttachmentSection(), + const SizedBox(height: 32), + + Center( + child: ElevatedButton( + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 15)), + onPressed: _isLoading ? null : _submitNpeReport, + child: _isLoading ? const CircularProgressIndicator(color: Colors.white) : const Text("Submit Report"), + ), + ), + ], + ), + ), + ); + } + + 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)), + ); + } + + List _buildObservationsCheckboxes() { + return [ + for (final key in _npeData.fieldObservations.keys) + CheckboxListTile( + title: Text(key), + value: _npeData.fieldObservations[key], + onChanged: (newValue) => setState(() => _npeData.fieldObservations[key] = newValue!), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + if (_npeData.fieldObservations['Others'] ?? false) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: _buildTextFormField( + controller: _othersObservationController, + label: "Please specify", + ), + ), + ]; + } + + Widget _buildImageAttachmentSection() { + return Column( + children: [ + _buildNPEImagePicker(title: 'Figure 1', imageFile: _npeData.image1, onClear: () => setState(() => _npeData.image1 = null), imageNumber: 1), + _buildNPEImagePicker(title: 'Figure 2', imageFile: _npeData.image2, onClear: () => setState(() => _npeData.image2 = null), imageNumber: 2), + _buildNPEImagePicker(title: 'Figure 3', imageFile: _npeData.image3, onClear: () => setState(() => _npeData.image3 = null), imageNumber: 3), + _buildNPEImagePicker(title: 'Figure 4', imageFile: _npeData.image4, onClear: () => setState(() => _npeData.image4 = null), imageNumber: 4), + ], + ); + } + + Widget _buildNPEImagePicker({required String title, File? imageFile, required VoidCallback 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), + 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, + ), + ), + ], + ) + : 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 + }) { + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + maxLines: maxLines, + readOnly: readOnly, + validator: (value) { + if (!label.contains('*')) return null; + if (!readOnly && (value == null || value.trim().isEmpty)) { + return 'This field cannot be empty'; + } + return null; + }, + ); + } + + 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), + _buildParameterListItem(icon: Icons.percent, label: "DO", unit: "%", controller: _doPercentController), + _buildParameterListItem(icon: Icons.flash_on, label: "Cond", unit: "µS/cm", controller: _condController), + _buildParameterListItem(icon: Icons.air, label: "DO", unit: "mg/L", controller: _doMgLController), + _buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController), + _buildParameterListItem(icon: Icons.science_outlined, label: "PH", unit: "", controller: _phController), + _buildParameterListItem(icon: Icons.thermostat, label: "Temp", unit: "°C", controller: _tempController), + ], + ); + } + + 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), + ) + ], + ) + ], + ), + ), + ); + } + + 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), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/reports/npe_report_new_location.dart b/lib/screens/marine/manual/reports/npe_report_new_location.dart new file mode 100644 index 0000000..8db0f24 --- /dev/null +++ b/lib/screens/marine/manual/reports/npe_report_new_location.dart @@ -0,0 +1,648 @@ +// lib/screens/marine/manual/reports/npe_report_new_location.dart + +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:provider/provider.dart'; +import 'package:dropdown_search/dropdown_search.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.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 '../../../../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 NPEReportNewLocation extends StatefulWidget { + const NPEReportNewLocation({super.key}); + + @override + State createState() => _NPEReportNewLocationState(); +} + +class _NPEReportNewLocationState extends State { + final _formKey = GlobalKey(); + bool _isLoading = false; + bool _isPickingImage = false; + bool _isFetchingLocation = false; + + // Data + final MarineManualNpeReportData _npeData = MarineManualNpeReportData(); + List _statesList = []; + + // Controllers + final _locationController = TextEditingController(); + final _eventDateTimeController = TextEditingController(); + final _latController = TextEditingController(); + final _longController = TextEditingController(); + final _possibleSourceController = TextEditingController(); + final _othersObservationController = TextEditingController(); + final _doPercentController = TextEditingController(); + final _doMgLController = TextEditingController(); + final _phController = TextEditingController(); + final _condController = TextEditingController(); + final _turbController = TextEditingController(); + final _tempController = TextEditingController(); + + // In-Situ + late final MarineInSituSamplingService _samplingService; + StreamSubscription? _dataSubscription; + bool _isAutoReading = false; + Timer? _lockoutTimer; + int _lockoutSecondsRemaining = 30; + bool _isLockedOut = false; + + @override + void initState() { + super.initState(); + _samplingService = Provider.of(context, listen: false); + _setDefaultDateTime(); + _loadAllStatesFromProvider(); + } + + @override + void dispose() { + _dataSubscription?.cancel(); + _lockoutTimer?.cancel(); + if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + _samplingService.disconnectFromBluetooth(); + } + if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) { + _samplingService.disconnectFromSerial(); + } + _locationController.dispose(); + _eventDateTimeController.dispose(); + _latController.dispose(); + _longController.dispose(); + _possibleSourceController.dispose(); + _othersObservationController.dispose(); + _doPercentController.dispose(); + _doMgLController.dispose(); + _phController.dispose(); + _condController.dispose(); + _turbController.dispose(); + _tempController.dispose(); + super.dispose(); + } + + void _setDefaultDateTime() { + final now = DateTime.now(); + _eventDateTimeController.text = DateFormat('yyyy-MM-dd HH:mm').format(now); + } + + 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']); + } + setState(() => _statesList = states.toList()..sort()); + } + + Future _getCurrentLocation() async { + var status = await Permission.location.request(); + if (!status.isGranted) { + _showSnackBar('Location permission is required.', 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 _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); + + _npeData.firstSamplerName = auth.profileData?['user_name']; + _npeData.firstSamplerUserId = auth.profileData?['user_id']; + _npeData.eventDate = _eventDateTimeController.text.split(' ')[0]; + _npeData.eventTime = _eventDateTimeController.text.split(' ').length > 1 ? _eventDateTimeController.text.split(' ')[1] : ''; + _npeData.locationDescription = _locationController.text; + _npeData.latitude = _latController.text; + _npeData.longitude = _longController.text; + _npeData.possibleSource = _possibleSourceController.text; + _npeData.othersObservationRemark = _othersObservationController.text; + _npeData.oxygenSaturation = double.tryParse(_doPercentController.text); + _npeData.electricalConductivity = double.tryParse(_condController.text); + _npeData.oxygenConcentration = double.tryParse(_doMgLController.text); + _npeData.turbidity = double.tryParse(_turbController.text); + _npeData.ph = double.tryParse(_phController.text); + _npeData.temperature = double.tryParse(_tempController.text); + + final result = await service.submitNpeReport(data: _npeData, 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(content: Text(message), backgroundColor: isError ? Colors.red : null)); + } + } + + 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: + _npeData.image1 = file; + break; + case 2: + _npeData.image2 = file; + break; + case 3: + _npeData.image3 = file; + break; + case 4: + _npeData.image4 = file; + break; + } + }); + } + if (mounted) setState(() => _isPickingImage = false); + } + + 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() { + if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + return { + 'type': 'bluetooth', + 'state': _samplingService.bluetoothConnectionState.value, + 'name': _samplingService.connectedBluetoothDeviceName + }; + } + if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) { + return {'type': 'serial', 'state': _samplingService.serialConnectionState.value, 'name': _samplingService.connectedSerialDeviceName}; + } + return 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; + } + + void _disconnectFromAll() { + if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) _disconnect('bluetooth'); + if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) _disconnect('serial'); + } + + 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; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("NPE for New Location")), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(20.0), + children: [ + _buildSectionTitle("1. Location Information"), + DropdownSearch( + items: _statesList, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")), + onChanged: (state) => setState(() => _npeData.stateName = 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), + const SizedBox(height: 24), + + _buildSectionTitle("2. In-situ Measurements (Optional)"), + _buildInSituSection(), + const SizedBox(height: 24), + + _buildSectionTitle("3. Field Observations*"), + ..._buildObservationsCheckboxes(), + const SizedBox(height: 24), + + _buildSectionTitle("4. Possible Source"), + _buildTextFormField(controller: _possibleSourceController, label: "Possible Source", maxLines: 3), + const SizedBox(height: 24), + + _buildSectionTitle("5. Attachments (Figures)"), + _buildImageAttachmentSection(), + const SizedBox(height: 32), + + Center( + child: ElevatedButton( + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 15)), + onPressed: _isLoading ? null : _submitNpeReport, + child: _isLoading ? const CircularProgressIndicator(color: Colors.white) : const Text("Submit Report"), + ), + ), + ], + ), + ), + ); + } + + 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), + ), + ); + } + + List _buildObservationsCheckboxes() { + return [ + for (final key in _npeData.fieldObservations.keys) + CheckboxListTile( + title: Text(key), + value: _npeData.fieldObservations[key], + onChanged: (newValue) => setState(() => _npeData.fieldObservations[key] = newValue!), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + if (_npeData.fieldObservations['Others'] ?? false) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: _buildTextFormField( + controller: _othersObservationController, + label: "Please specify", + ), + ), + ]; + } + + Widget _buildImageAttachmentSection() { + return Column( + children: [ + _buildNPEImagePicker(title: 'Figure 1', imageFile: _npeData.image1, onClear: () => setState(() => _npeData.image1 = null), imageNumber: 1), + _buildNPEImagePicker(title: 'Figure 2', imageFile: _npeData.image2, onClear: () => setState(() => _npeData.image2 = null), imageNumber: 2), + _buildNPEImagePicker(title: 'Figure 3', imageFile: _npeData.image3, onClear: () => setState(() => _npeData.image3 = null), imageNumber: 3), + _buildNPEImagePicker(title: 'Figure 4', imageFile: _npeData.image4, onClear: () => setState(() => _npeData.image4 = null), imageNumber: 4), + ], + ); + } + + Widget _buildNPEImagePicker({required String title, File? imageFile, required VoidCallback 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), + 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, + ), + ), + ], + ) + : 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, + TextInputType? keyboardType, + }) { + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + maxLines: maxLines, + readOnly: readOnly, + keyboardType: keyboardType, + validator: (value) { + if (!label.contains('*')) return null; + if (!readOnly && (value == null || value.trim().isEmpty)) { + return 'This field cannot be empty'; + } + return null; + }, + ); + } + + 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), + _buildParameterListItem(icon: Icons.percent, label: "DO", unit: "%", controller: _doPercentController), + _buildParameterListItem(icon: Icons.flash_on, label: "Cond", unit: "µS/cm", controller: _condController), + _buildParameterListItem(icon: Icons.air, label: "DO", unit: "mg/L", controller: _doMgLController), + _buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController), + _buildParameterListItem(icon: Icons.science_outlined, label: "PH", unit: "", controller: _phController), + _buildParameterListItem(icon: Icons.thermostat, label: "Temp", unit: "°C", controller: _tempController), + ], + ); + } + + 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), + ) + ], + ) + ], + ), + ), + ); + } + + 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), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart index 7afef30..f554df1 100644 --- a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart +++ b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart'; +import 'package:environment_monitoring_app/screens/marine/manual/reports/npe_report_from_tarball.dart'; class TarballSamplingStep3Summary extends StatefulWidget { @@ -21,13 +22,54 @@ class _TarballSamplingStep3SummaryState extends State _submitForm() async { + // Check if the classification indicates a tarball incident. + // We assume 'None' has an ID of 1. + final bool isIncident = widget.data.classificationId != 1; + bool proceedWithSubmission = false; + + if (isIncident) { + // Show a dialog to inform the user about the redirection to NPE report. + final bool? userConfirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Tarball Incident Detected'), + content: const Text( + 'You have selected a tarball classification that indicates an incident. You will be redirected to the NPE report screen after submission.'), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(false), + ), + FilledButton( + child: const Text('Okay'), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ); + }, + ); + + if (userConfirmed == true) { + proceedWithSubmission = true; + } + } else { + // For "None" classification, proceed without a dialog. + proceedWithSubmission = true; + } + + if (!proceedWithSubmission) { + return; // User cancelled, so we stop here. + } + setState(() => _isLoading = true); final authProvider = Provider.of(context, listen: false); final appSettings = authProvider.appSettings; - final tarballService = Provider.of(context, listen: false); + // Delegate the entire submission process to the dedicated service. final result = await tarballService.submitTarballSample( data: widget.data, appSettings: appSettings, @@ -45,7 +87,24 @@ class _TarballSamplingStep3SummaryState extends State route.isFirst); + // After a successful submission, decide where to navigate. + if (result['success'] == true) { + if (isIncident) { + // Convert the tarball data to the format needed for the NPE report. + final npeData = widget.data.toNpeReportData(); + // Navigate to the NPE report screen, passing the pre-filled data. + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (context) => NPEReportFromTarball(initialData: npeData), + ), + (route) => route.isFirst, + ); + } else { + // If not an incident, go back to the home screen as before. + Navigator.of(context).popUntil((route) => route.isFirst); + } + } } @override @@ -122,10 +181,6 @@ class _TarballSamplingStep3SummaryState extends State { if (result['success'] == true) { if (shouldOpenNpeReport) { - final npeData = widget.data.toNpeReportData(); - // This works offline because it's just passing local data - Navigator.of(context).pushNamed( - '/marine/manual/report/npe', - arguments: npeData, + // Navigate to the correct screen without passing data, as requested. + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const NPEReportFromInSitu()), + (route) => route.isFirst, ); } else { // Submission successful, and no NPE report needed, so go home.