update feature to auto link tarball and manual sampling with report
This commit is contained in:
parent
821bb89ac4
commit
de4c0c471c
@ -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<RootApp> {
|
||||
'/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<RootApp> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// --- END: MODIFIED RootApp ---
|
||||
|
||||
class SessionAwareWrapper extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
@ -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<String, dynamic>? selectedStation; // For existing stations
|
||||
String? latitude;
|
||||
String? longitude;
|
||||
|
||||
@ -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<String, String> toFormData() {
|
||||
final Map<String, String> 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<String, dynamic> toDbJson() {
|
||||
return {
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
710
lib/screens/marine/manual/reports/npe_report_from_in_situ.dart
Normal file
710
lib/screens/marine/manual/reports/npe_report_from_in_situ.dart
Normal 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 ---
|
||||
|
||||
}
|
||||
686
lib/screens/marine/manual/reports/npe_report_from_tarball.dart
Normal file
686
lib/screens/marine/manual/reports/npe_report_from_tarball.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
648
lib/screens/marine/manual/reports/npe_report_new_location.dart
Normal file
648
lib/screens/marine/manual/reports/npe_report_new_location.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<TarballSamplingStep3Summar
|
||||
bool _isLoading = false;
|
||||
|
||||
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);
|
||||
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final appSettings = authProvider.appSettings;
|
||||
|
||||
final tarballService = Provider.of<MarineTarballSamplingService>(context, listen: false);
|
||||
|
||||
// Delegate the entire submission process to the dedicated service.
|
||||
final result = await tarballService.submitTarballSample(
|
||||
data: widget.data,
|
||||
appSettings: appSettings,
|
||||
@ -45,8 +87,25 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
|
||||
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
|
||||
);
|
||||
|
||||
// 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
|
||||
Widget build(BuildContext context) {
|
||||
@ -122,10 +181,6 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
|
||||
_buildImageCard("Right Side Coastal View", widget.data.rightCoastalViewImage),
|
||||
_buildImageCard("Drawing Vertical Lines", widget.data.verticalLinesImage),
|
||||
_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(
|
||||
visible: widget.data.classificationId != 1,
|
||||
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}) {
|
||||
// Only build the card if there is an image or a remark to show.
|
||||
if (image == null && (remark == null || remark.isEmpty)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
@ -250,7 +302,7 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
|
||||
if (remark != null && remark.isNotEmpty)
|
||||
Padding(
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../../../auth_provider.dart';
|
||||
import '../../../../models/in_situ_sampling_data.dart';
|
||||
import '../../../../models/marine_manual_npe_report_data.dart';
|
||||
import '../reports/npe_report_from_in_situ.dart';
|
||||
|
||||
class InSituStep4Summary extends StatefulWidget {
|
||||
final InSituSamplingData data;
|
||||
@ -341,11 +342,11 @@ class _InSituStep4SummaryState extends State<InSituStep4Summary> {
|
||||
|
||||
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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user