update feature to auto link tarball and manual sampling with report

This commit is contained in:
ALim Aidrus 2025-10-19 23:13:10 +08:00
parent 821bb89ac4
commit de4c0c471c
9 changed files with 2218 additions and 26 deletions

View File

@ -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/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/in_situ_sampling.dart' as marineManualInSituSampling;
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_report.dart' as marineManualReport; import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_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_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_sonde_calibration_screen.dart' as marineManualSondeCalibration;
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart' as marineManualEquipmentMaintenance; 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 { class RootApp extends StatefulWidget {
const RootApp({super.key}); const RootApp({super.key});
@ -330,14 +329,12 @@ class _RootAppState extends State<RootApp> {
'/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(), '/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(),
'/marine/manual/tarball': (context) => const TarballSamplingStep1(), '/marine/manual/tarball': (context) => const TarballSamplingStep1(),
'/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(), '/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/pre-departure': (context) => const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(),
'/marine/manual/report/calibration': (context) => const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(), '/marine/manual/report/calibration': (context) => const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(),
'/marine/manual/report/maintenance': (context) => const marineManualEquipmentMaintenance.MarineManualEquipmentMaintenanceScreen(), '/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/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(),
// Marine Continuous // Marine Continuous
'/marine/continuous/info': (context) => const MarineContinuousInfoCentreDocument(), '/marine/continuous/info': (context) => const MarineContinuousInfoCentreDocument(),
'/marine/continuous/overview': (context) => marineContinuousOverview.OverviewScreen(), '/marine/continuous/overview': (context) => marineContinuousOverview.OverviewScreen(),
@ -355,7 +352,6 @@ class _RootAppState extends State<RootApp> {
); );
} }
} }
// --- END: MODIFIED RootApp ---
class SessionAwareWrapper extends StatefulWidget { class SessionAwareWrapper extends StatefulWidget {
final Widget child; final Widget child;

View File

@ -1,3 +1,5 @@
// lib/models/marine_manual_npe_report_data.dart
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
@ -11,7 +13,7 @@ class MarineManualNpeReportData {
// --- Location Info --- // --- Location Info ---
String? locationDescription; // For new locations String? locationDescription; // For new locations
String? stateName; // For new locations String? stateName; // For new locations or tarball stations
Map<String, dynamic>? selectedStation; // For existing stations Map<String, dynamic>? selectedStation; // For existing stations
String? latitude; String? latitude;
String? longitude; String? longitude;

View File

@ -3,6 +3,8 @@
import 'dart:io'; import 'dart:io';
import 'dart:convert'; 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. /// 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. /// It acts as a temporary data container that is passed between screens.
class TarballSamplingData { class TarballSamplingData {
@ -44,6 +46,25 @@ class TarballSamplingData {
String? submissionStatus; String? submissionStatus;
String? submissionMessage; 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. /// Generates a formatted Telegram alert message for successful submissions.
String generateTelegramAlertMessage({required bool isDataOnly}) { String generateTelegramAlertMessage({required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; 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. /// 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. /// 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<String, String> toFormData() { Map<String, String> toFormData() {
final Map<String, String> data = { final Map<String, String> data = {
// Required fields that were missing or not being sent correctly // Required fields that were missing or not being sent correctly
@ -107,7 +127,6 @@ class TarballSamplingData {
}; };
return data; return data;
} }
// END CHANGE
/// Gathers all non-null image files into a Map. /// 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). /// 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' /// Creates a single JSON object with all submission data, mimicking 'db.json'
Map<String, dynamic> toDbJson() { Map<String, dynamic> toDbJson() {
return { return {

View File

@ -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,
),
);
}
}

View File

@ -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<NPEReportFromInSitu> createState() => _NPEReportFromInSituState();
}
class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _isPickingImage = false;
// Data handling
bool _isLoadingRecentSamples = true;
List<InSituSamplingData> _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<MarineInSituSamplingService>(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<void> _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<LocalStorageService>(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<void> _submitNpeReport() async {
if (!_formKey.currentState!.validate()) {
_showSnackBar('Please fill in all required fields.', isError: true);
return;
}
setState(() => _isLoading = true);
final auth = Provider.of<AuthProvider>(context, listen: false);
final service = Provider.of<MarineNpeReportService>(context, listen: false);
_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<void> _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<String, double> 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<String, dynamic>? _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<void> _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<bool> _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<void> _showConnectionFailedDialog() async {
if (!mounted) return;
return showDialog<void>(
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: <Widget>[
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<InSituSamplingData>(
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<Widget> _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 ---
}

View File

@ -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<NPEReportFromTarball> createState() => _NPEReportFromTarballState();
}
class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _isPickingImage = false;
bool _areFieldsLocked = false;
// Data
final MarineManualNpeReportData _npeData = MarineManualNpeReportData();
String? _selectedState;
String? _selectedCategory;
List<String> _categoriesForState = [];
List<Map<String, dynamic>> _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<MarineInSituSamplingService>(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<AuthProvider>(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<void> _submitNpeReport() async {
if (!_formKey.currentState!.validate()) {
_showSnackBar('Please fill in all required fields.', isError: true);
return;
}
setState(() => _isLoading = true);
final auth = Provider.of<AuthProvider>(context, listen: false);
final service = Provider.of<MarineNpeReportService>(context, listen: false);
_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<void> _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<String, double> 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<String, dynamic>? _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<void> _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<bool> _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<void> _showConnectionFailedDialog() async {
if (!mounted) return;
return showDialog<void>(
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: <Widget>[
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<AuthProvider>(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<String>(
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<String>(
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<Map<String, dynamic>>(
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<Widget> _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),
),
),
);
}
}

View File

@ -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<NPEReportNewLocation> createState() => _NPEReportNewLocationState();
}
class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _isPickingImage = false;
bool _isFetchingLocation = false;
// Data
final MarineManualNpeReportData _npeData = MarineManualNpeReportData();
List<String> _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<MarineInSituSamplingService>(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<AuthProvider>(context, listen: false);
final manualStations = auth.manualStations ?? [];
final tarballStations = auth.tarballStations ?? [];
final states = <String>{};
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<void> _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<void> _submitNpeReport() async {
if (!_formKey.currentState!.validate()) {
_showSnackBar('Please fill in all required fields.', isError: true);
return;
}
setState(() => _isLoading = true);
final auth = Provider.of<AuthProvider>(context, listen: false);
final service = Provider.of<MarineNpeReportService>(context, listen: false);
_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<void> _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<String, double> 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<String, dynamic>? _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<void> _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<bool> _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<void> _showConnectionFailedDialog() async {
if (!mounted) return;
return showDialog<void>(
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: <Widget>[
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<String>(
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<Widget> _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),
),
),
);
}
}

View File

@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart';
import 'package:environment_monitoring_app/models/tarball_data.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/services/marine_tarball_sampling_service.dart';
import 'package:environment_monitoring_app/screens/marine/manual/reports/npe_report_from_tarball.dart';
class TarballSamplingStep3Summary extends StatefulWidget { class TarballSamplingStep3Summary extends StatefulWidget {
@ -21,13 +22,54 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
bool _isLoading = false; bool _isLoading = false;
Future<void> _submitForm() async { Future<void> _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<bool>(
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: <Widget>[
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); setState(() => _isLoading = true);
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings; final appSettings = authProvider.appSettings;
final tarballService = Provider.of<MarineTarballSamplingService>(context, listen: false); final tarballService = Provider.of<MarineTarballSamplingService>(context, listen: false);
// Delegate the entire submission process to the dedicated service.
final result = await tarballService.submitTarballSample( final result = await tarballService.submitTarballSample(
data: widget.data, data: widget.data,
appSettings: appSettings, appSettings: appSettings,
@ -45,7 +87,24 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
); );
Navigator.of(context).popUntil((route) => 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 @override
@ -122,10 +181,6 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
_buildImageCard("Right Side Coastal View", widget.data.rightCoastalViewImage), _buildImageCard("Right Side Coastal View", widget.data.rightCoastalViewImage),
_buildImageCard("Drawing Vertical Lines", widget.data.verticalLinesImage), _buildImageCard("Drawing Vertical Lines", widget.data.verticalLinesImage),
_buildImageCard("Drawing Horizontal Line", widget.data.horizontalLineImage), _buildImageCard("Drawing Horizontal Line", widget.data.horizontalLineImage),
// --- START: MODIFICATION ---
// Wrapped the optional photos section in a Visibility widget.
// It will only be shown if the classification ID is not 1 (i.e., not "None").
Visibility( Visibility(
visible: widget.data.classificationId != 1, visible: widget.data.classificationId != 1,
child: Column( child: Column(
@ -141,7 +196,6 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
], ],
), ),
), ),
// --- END: MODIFICATION ---
], ],
), ),
@ -219,11 +273,9 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
} }
Widget _buildImageCard(String title, File? image, {String? remark}) { Widget _buildImageCard(String title, File? image, {String? remark}) {
// Only build the card if there is an image or a remark to show.
if (image == null && (remark == null || remark.isEmpty)) { if (image == null && (remark == null || remark.isEmpty)) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column( child: Column(
@ -250,7 +302,7 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
if (remark != null && remark.isNotEmpty) if (remark != null && remark.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic, color: Colors.grey)), child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic, color: Colors.black54)),
), ),
], ],
), ),

View File

@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import '../../../../auth_provider.dart'; import '../../../../auth_provider.dart';
import '../../../../models/in_situ_sampling_data.dart'; import '../../../../models/in_situ_sampling_data.dart';
import '../../../../models/marine_manual_npe_report_data.dart'; import '../../../../models/marine_manual_npe_report_data.dart';
import '../reports/npe_report_from_in_situ.dart';
class InSituStep4Summary extends StatefulWidget { class InSituStep4Summary extends StatefulWidget {
final InSituSamplingData data; final InSituSamplingData data;
@ -341,11 +342,11 @@ class _InSituStep4SummaryState extends State<InSituStep4Summary> {
if (result['success'] == true) { if (result['success'] == true) {
if (shouldOpenNpeReport) { if (shouldOpenNpeReport) {
final npeData = widget.data.toNpeReportData(); // Navigate to the correct screen without passing data, as requested.
// This works offline because it's just passing local data Navigator.pushAndRemoveUntil(
Navigator.of(context).pushNamed( context,
'/marine/manual/report/npe', MaterialPageRoute(builder: (context) => const NPEReportFromInSitu()),
arguments: npeData, (route) => route.isFirst,
); );
} else { } else {
// Submission successful, and no NPE report needed, so go home. // Submission successful, and no NPE report needed, so go home.