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/marine_manual_pre_sampling.dart' as marineManualPreSampling;
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/in_situ_sampling.dart' as marineManualInSituSampling;
|
import 'package:environment_monitoring_app/screens/marine/manual/in_situ_sampling.dart' as marineManualInSituSampling;
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_report.dart' as marineManualReport;
|
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_report.dart' as marineManualReport;
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_npe_report.dart' as marineManualNPEReport;
|
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_npe_report_hub.dart';
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart' as marineManualPreDepartureChecklist;
|
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart' as marineManualPreDepartureChecklist;
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart' as marineManualSondeCalibration;
|
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart' as marineManualSondeCalibration;
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart' as marineManualEquipmentMaintenance;
|
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart' as marineManualEquipmentMaintenance;
|
||||||
@ -167,7 +167,6 @@ void setupPeriodicServices(TelegramService telegramService, RetryService retrySe
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- START: MODIFIED RootApp ---
|
|
||||||
class RootApp extends StatefulWidget {
|
class RootApp extends StatefulWidget {
|
||||||
const RootApp({super.key});
|
const RootApp({super.key});
|
||||||
|
|
||||||
@ -330,14 +329,12 @@ class _RootAppState extends State<RootApp> {
|
|||||||
'/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(),
|
'/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(),
|
||||||
'/marine/manual/tarball': (context) => const TarballSamplingStep1(),
|
'/marine/manual/tarball': (context) => const TarballSamplingStep1(),
|
||||||
'/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(),
|
'/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(),
|
||||||
'/marine/manual/report/npe': (context) => const marineManualNPEReport.MarineManualNPEReport(),
|
'/marine/manual/report/npe': (context) => const MarineManualNPEReportHub(),
|
||||||
'/marine/manual/report/pre-departure': (context) => const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(),
|
'/marine/manual/report/pre-departure': (context) => const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(),
|
||||||
'/marine/manual/report/calibration': (context) => const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(),
|
'/marine/manual/report/calibration': (context) => const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(),
|
||||||
'/marine/manual/report/maintenance': (context) => const marineManualEquipmentMaintenance.MarineManualEquipmentMaintenanceScreen(),
|
'/marine/manual/report/maintenance': (context) => const marineManualEquipmentMaintenance.MarineManualEquipmentMaintenanceScreen(),
|
||||||
//'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute
|
|
||||||
'/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(),
|
'/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(),
|
||||||
|
|
||||||
|
|
||||||
// Marine Continuous
|
// Marine Continuous
|
||||||
'/marine/continuous/info': (context) => const MarineContinuousInfoCentreDocument(),
|
'/marine/continuous/info': (context) => const MarineContinuousInfoCentreDocument(),
|
||||||
'/marine/continuous/overview': (context) => marineContinuousOverview.OverviewScreen(),
|
'/marine/continuous/overview': (context) => marineContinuousOverview.OverviewScreen(),
|
||||||
@ -355,7 +352,6 @@ class _RootAppState extends State<RootApp> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// --- END: MODIFIED RootApp ---
|
|
||||||
|
|
||||||
class SessionAwareWrapper extends StatefulWidget {
|
class SessionAwareWrapper extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// lib/models/marine_manual_npe_report_data.dart
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
@ -11,7 +13,7 @@ class MarineManualNpeReportData {
|
|||||||
|
|
||||||
// --- Location Info ---
|
// --- Location Info ---
|
||||||
String? locationDescription; // For new locations
|
String? locationDescription; // For new locations
|
||||||
String? stateName; // For new locations
|
String? stateName; // For new locations or tarball stations
|
||||||
Map<String, dynamic>? selectedStation; // For existing stations
|
Map<String, dynamic>? selectedStation; // For existing stations
|
||||||
String? latitude;
|
String? latitude;
|
||||||
String? longitude;
|
String? longitude;
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:environment_monitoring_app/models/marine_manual_npe_report_data.dart';
|
||||||
|
|
||||||
/// This class holds all the data collected across the multi-step tarball sampling form.
|
/// This class holds all the data collected across the multi-step tarball sampling form.
|
||||||
/// It acts as a temporary data container that is passed between screens.
|
/// It acts as a temporary data container that is passed between screens.
|
||||||
class TarballSamplingData {
|
class TarballSamplingData {
|
||||||
@ -44,6 +46,25 @@ class TarballSamplingData {
|
|||||||
String? submissionStatus;
|
String? submissionStatus;
|
||||||
String? submissionMessage;
|
String? submissionMessage;
|
||||||
|
|
||||||
|
/// Converts tarball data into a pre-filled NPE Report data object.
|
||||||
|
MarineManualNpeReportData toNpeReportData() {
|
||||||
|
final npeData = MarineManualNpeReportData();
|
||||||
|
|
||||||
|
npeData.firstSamplerName = firstSampler;
|
||||||
|
npeData.firstSamplerUserId = firstSamplerUserId;
|
||||||
|
npeData.eventDate = samplingDate;
|
||||||
|
npeData.eventTime = samplingTime;
|
||||||
|
npeData.selectedStation = selectedStation;
|
||||||
|
npeData.latitude = currentLatitude;
|
||||||
|
npeData.longitude = currentLongitude;
|
||||||
|
npeData.stateName = selectedStateName;
|
||||||
|
|
||||||
|
// Pre-tick the relevant observation for a tarball event.
|
||||||
|
npeData.fieldObservations['Observation of tar balls'] = true;
|
||||||
|
|
||||||
|
return npeData;
|
||||||
|
}
|
||||||
|
|
||||||
/// Generates a formatted Telegram alert message for successful submissions.
|
/// Generates a formatted Telegram alert message for successful submissions.
|
||||||
String generateTelegramAlertMessage({required bool isDataOnly}) {
|
String generateTelegramAlertMessage({required bool isDataOnly}) {
|
||||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||||
@ -78,7 +99,6 @@ class TarballSamplingData {
|
|||||||
|
|
||||||
/// Converts the form's text and selection data into a Map suitable for JSON encoding.
|
/// Converts the form's text and selection data into a Map suitable for JSON encoding.
|
||||||
/// This map will be sent as the body of the first API request.
|
/// This map will be sent as the body of the first API request.
|
||||||
// START CHANGE: Corrected the toFormData method to include all required fields for the API.
|
|
||||||
Map<String, String> toFormData() {
|
Map<String, String> toFormData() {
|
||||||
final Map<String, String> data = {
|
final Map<String, String> data = {
|
||||||
// Required fields that were missing or not being sent correctly
|
// Required fields that were missing or not being sent correctly
|
||||||
@ -107,7 +127,6 @@ class TarballSamplingData {
|
|||||||
};
|
};
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
// END CHANGE
|
|
||||||
|
|
||||||
/// Gathers all non-null image files into a Map.
|
/// Gathers all non-null image files into a Map.
|
||||||
/// This map is used to build the multipart request for the second API call (image upload).
|
/// This map is used to build the multipart request for the second API call (image upload).
|
||||||
@ -124,8 +143,6 @@ class TarballSamplingData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ADDED: Methods to format data for FTP submission as separate JSON files ---
|
|
||||||
|
|
||||||
/// Creates a single JSON object with all submission data, mimicking 'db.json'
|
/// Creates a single JSON object with all submission data, mimicking 'db.json'
|
||||||
Map<String, dynamic> toDbJson() {
|
Map<String, dynamic> toDbJson() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -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/auth_provider.dart';
|
||||||
import 'package:environment_monitoring_app/models/tarball_data.dart';
|
import 'package:environment_monitoring_app/models/tarball_data.dart';
|
||||||
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart';
|
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/screens/marine/manual/reports/npe_report_from_tarball.dart';
|
||||||
|
|
||||||
|
|
||||||
class TarballSamplingStep3Summary extends StatefulWidget {
|
class TarballSamplingStep3Summary extends StatefulWidget {
|
||||||
@ -21,13 +22,54 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
Future<void> _submitForm() async {
|
Future<void> _submitForm() async {
|
||||||
|
// Check if the classification indicates a tarball incident.
|
||||||
|
// We assume 'None' has an ID of 1.
|
||||||
|
final bool isIncident = widget.data.classificationId != 1;
|
||||||
|
bool proceedWithSubmission = false;
|
||||||
|
|
||||||
|
if (isIncident) {
|
||||||
|
// Show a dialog to inform the user about the redirection to NPE report.
|
||||||
|
final bool? userConfirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Tarball Incident Detected'),
|
||||||
|
content: const Text(
|
||||||
|
'You have selected a tarball classification that indicates an incident. You will be redirected to the NPE report screen after submission.'),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
child: const Text('Okay'),
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userConfirmed == true) {
|
||||||
|
proceedWithSubmission = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For "None" classification, proceed without a dialog.
|
||||||
|
proceedWithSubmission = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proceedWithSubmission) {
|
||||||
|
return; // User cancelled, so we stop here.
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||||
final appSettings = authProvider.appSettings;
|
final appSettings = authProvider.appSettings;
|
||||||
|
|
||||||
final tarballService = Provider.of<MarineTarballSamplingService>(context, listen: false);
|
final tarballService = Provider.of<MarineTarballSamplingService>(context, listen: false);
|
||||||
|
|
||||||
|
// Delegate the entire submission process to the dedicated service.
|
||||||
final result = await tarballService.submitTarballSample(
|
final result = await tarballService.submitTarballSample(
|
||||||
data: widget.data,
|
data: widget.data,
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
@ -45,8 +87,25 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
|
|||||||
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
|
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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);
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -122,10 +181,6 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
|
|||||||
_buildImageCard("Right Side Coastal View", widget.data.rightCoastalViewImage),
|
_buildImageCard("Right Side Coastal View", widget.data.rightCoastalViewImage),
|
||||||
_buildImageCard("Drawing Vertical Lines", widget.data.verticalLinesImage),
|
_buildImageCard("Drawing Vertical Lines", widget.data.verticalLinesImage),
|
||||||
_buildImageCard("Drawing Horizontal Line", widget.data.horizontalLineImage),
|
_buildImageCard("Drawing Horizontal Line", widget.data.horizontalLineImage),
|
||||||
|
|
||||||
// --- START: MODIFICATION ---
|
|
||||||
// Wrapped the optional photos section in a Visibility widget.
|
|
||||||
// It will only be shown if the classification ID is not 1 (i.e., not "None").
|
|
||||||
Visibility(
|
Visibility(
|
||||||
visible: widget.data.classificationId != 1,
|
visible: widget.data.classificationId != 1,
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -141,7 +196,6 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// --- END: MODIFICATION ---
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@ -219,11 +273,9 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImageCard(String title, File? image, {String? remark}) {
|
Widget _buildImageCard(String title, File? image, {String? remark}) {
|
||||||
// Only build the card if there is an image or a remark to show.
|
|
||||||
if (image == null && (remark == null || remark.isEmpty)) {
|
if (image == null && (remark == null || remark.isEmpty)) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -250,7 +302,7 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
|
|||||||
if (remark != null && remark.isNotEmpty)
|
if (remark != null && remark.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic, color: Colors.grey)),
|
child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic, color: Colors.black54)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../../../auth_provider.dart';
|
import '../../../../auth_provider.dart';
|
||||||
import '../../../../models/in_situ_sampling_data.dart';
|
import '../../../../models/in_situ_sampling_data.dart';
|
||||||
import '../../../../models/marine_manual_npe_report_data.dart';
|
import '../../../../models/marine_manual_npe_report_data.dart';
|
||||||
|
import '../reports/npe_report_from_in_situ.dart';
|
||||||
|
|
||||||
class InSituStep4Summary extends StatefulWidget {
|
class InSituStep4Summary extends StatefulWidget {
|
||||||
final InSituSamplingData data;
|
final InSituSamplingData data;
|
||||||
@ -341,11 +342,11 @@ class _InSituStep4SummaryState extends State<InSituStep4Summary> {
|
|||||||
|
|
||||||
if (result['success'] == true) {
|
if (result['success'] == true) {
|
||||||
if (shouldOpenNpeReport) {
|
if (shouldOpenNpeReport) {
|
||||||
final npeData = widget.data.toNpeReportData();
|
// Navigate to the correct screen without passing data, as requested.
|
||||||
// This works offline because it's just passing local data
|
Navigator.pushAndRemoveUntil(
|
||||||
Navigator.of(context).pushNamed(
|
context,
|
||||||
'/marine/manual/report/npe',
|
MaterialPageRoute(builder: (context) => const NPEReportFromInSitu()),
|
||||||
arguments: npeData,
|
(route) => route.isFirst,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Submission successful, and no NPE report needed, so go home.
|
// Submission successful, and no NPE report needed, so go home.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user