add in pre-sampling hub screen

This commit is contained in:
ALim Aidrus 2025-10-16 15:07:32 +08:00
parent 8245ba0820
commit dff653883a
9 changed files with 678 additions and 211 deletions

View File

@ -29,7 +29,7 @@ class _HomePageState extends State<HomePage> {
}); });
}, },
), ),
title: const Text("MMS Version 3.2.01"), title: const Text("MMS Version 3.4.01"),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.person), icon: const Icon(Icons.person),

View File

@ -72,13 +72,13 @@ import 'package:environment_monitoring_app/screens/river/investigative/report.da
// Marine Screens // Marine Screens
import 'package:environment_monitoring_app/screens/marine/manual/info_centre_document.dart' as marineManualInfoCentreDocument; import 'package:environment_monitoring_app/screens/marine/manual/info_centre_document.dart' as marineManualInfoCentreDocument;
import 'package:environment_monitoring_app/screens/marine/manual/pre_sampling.dart' as marineManualPreSampling; import 'package:environment_monitoring_app/screens/marine/manual/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.dart' as marineManualNPEReport;
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart'; import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart' as marineManualPreDepartureChecklist;
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart'; 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'; 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/marine_manual_data_status_log.dart' as marineManualDataStatusLog; import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_data_status_log.dart' as marineManualDataStatusLog;
import 'package:environment_monitoring_app/screens/marine/manual/marine_image_request.dart' as marineManualImageRequest; import 'package:environment_monitoring_app/screens/marine/manual/marine_image_request.dart' as marineManualImageRequest;
import 'package:environment_monitoring_app/screens/marine/continuous/marine_continuous_info_centre_document.dart'; import 'package:environment_monitoring_app/screens/marine/continuous/marine_continuous_info_centre_document.dart';
@ -326,14 +326,14 @@ class _RootAppState extends State<RootApp> {
// Marine Manual // Marine Manual
'/marine/manual/info': (context) => marineManualInfoCentreDocument.MarineInfoCentreDocument(), '/marine/manual/info': (context) => marineManualInfoCentreDocument.MarineInfoCentreDocument(),
'/marine/manual/pre-sampling': (context) => marineManualPreSampling.MarinePreSampling(), '/marine/manual/pre-sampling': (context) => marineManualPreSampling.MarineManualPreSampling(),
'/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 marineManualNPEReport.MarineManualNPEReport(),
'/marine/manual/report/pre-departure': (context) => const MarineManualPreDepartureChecklistScreen(), '/marine/manual/report/pre-departure': (context) => const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(),
'/marine/manual/report/calibration': (context) => const MarineManualSondeCalibrationScreen(), '/marine/manual/report/calibration': (context) => const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(),
'/marine/manual/report/maintenance': (context) => const MarineManualEquipmentMaintenanceScreen(), '/marine/manual/report/maintenance': (context) => const marineManualEquipmentMaintenance.MarineManualEquipmentMaintenanceScreen(),
//'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute //'/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(),

View File

@ -3,6 +3,8 @@
import 'dart:io'; import 'dart:io';
import 'dart:convert'; // Added for jsonEncode import 'dart:convert'; // Added for jsonEncode
import 'package:environment_monitoring_app/models/marine_manual_npe_report_data.dart';
/// A data model class to hold all information for the multi-step /// A data model class to hold all information for the multi-step
/// In-Situ Sampling form. /// In-Situ Sampling form.
class InSituSamplingData { class InSituSamplingData {
@ -103,6 +105,61 @@ class InSituSamplingData {
this.samplingTime, this.samplingTime,
}); });
/// Creates a pre-populated NPE Report object from the current In-Situ data.
MarineManualNpeReportData toNpeReportData() {
final npeData = MarineManualNpeReportData();
// Transfer Reporter & Event Info
npeData.firstSamplerName = firstSamplerName;
npeData.firstSamplerUserId = firstSamplerUserId;
npeData.eventDate = samplingDate;
npeData.eventTime = samplingTime;
// Transfer Location Info
npeData.selectedStation = selectedStation;
npeData.latitude = currentLatitude;
npeData.longitude = currentLongitude;
// Transfer In-Situ Measurements relevant to NPE
npeData.oxygenSaturation = oxygenSaturation;
npeData.electricalConductivity = electricalConductivity;
npeData.oxygenConcentration = oxygenConcentration;
npeData.turbidity = turbidity;
npeData.ph = ph;
npeData.temperature = temperature;
// Pre-populate possible source with event remarks as a starting point for the user
npeData.possibleSource = eventRemarks;
// Pre-populate some common observations based on data
if ((turbidity ?? 0) > 50) { // Example threshold, adjust as needed
npeData.fieldObservations['Silt plume'] = true;
}
if ((oxygenConcentration ?? 999) < 4) { // Example threshold for low oxygen
npeData.fieldObservations['Foul smell'] = true;
}
// Transfer up to 4 available images
final availableImages = [
leftLandViewImage,
rightLandViewImage,
waterFillingImage,
seawaterColorImage,
phPaperImage,
optionalImage1,
optionalImage2,
optionalImage3,
optionalImage4,
].where((img) => img != null).cast<File>().toList();
if (availableImages.isNotEmpty) npeData.image1 = availableImages[0];
if (availableImages.length > 1) npeData.image2 = availableImages[1];
if (availableImages.length > 2) npeData.image3 = availableImages[2];
if (availableImages.length > 3) npeData.image4 = availableImages[3];
return npeData;
}
/// Creates an InSituSamplingData object from a JSON map. /// Creates an InSituSamplingData object from a JSON map.
factory InSituSamplingData.fromJson(Map<String, dynamic> json) { factory InSituSamplingData.fromJson(Map<String, dynamic> json) {
double? doubleFromJson(dynamic value) { double? doubleFromJson(dynamic value) {
@ -181,7 +238,7 @@ class InSituSamplingData {
data.optionalImage4 = fileFromPath(json['man_optional_photo_04']); data.optionalImage4 = fileFromPath(json['man_optional_photo_04']);
// --- START: Deserialization for NPE Fields --- // --- Deserialization for NPE Fields ---
if (json['npe_field_observations'] is Map) { if (json['npe_field_observations'] is Map) {
data.npeFieldObservations = Map<String, bool>.from(json['npe_field_observations']); data.npeFieldObservations = Map<String, bool>.from(json['npe_field_observations']);
} }
@ -193,17 +250,10 @@ class InSituSamplingData {
data.npeImage2 = fileFromPath(json['npe_image_2']); data.npeImage2 = fileFromPath(json['npe_image_2']);
data.npeImage3 = fileFromPath(json['npe_image_3']); data.npeImage3 = fileFromPath(json['npe_image_3']);
data.npeImage4 = fileFromPath(json['npe_image_4']); data.npeImage4 = fileFromPath(json['npe_image_4']);
// --- END: Deserialization for NPE Fields ---
return data; return data;
} }
// ... (generateTelegramAlertMessage method remains unchanged) ...
// ... (toApiFormData method remains unchanged) ...
// ... (toApiImageFiles method remains unchanged) ...
/// Creates a single JSON object with all submission data for offline storage. /// Creates a single JSON object with all submission data for offline storage.
Map<String, dynamic> toDbJson() { Map<String, dynamic> toDbJson() {
return { return {
@ -212,7 +262,6 @@ class InSituSamplingData {
'secondSampler': secondSampler, 'secondSampler': secondSampler,
'sampling_date': samplingDate, 'sampling_date': samplingDate,
'sampling_time': samplingTime, 'sampling_time': samplingTime,
// ... (all other existing fields)
'sampling_type': samplingType, 'sampling_type': samplingType,
'sample_id_code': sampleIdCode, 'sample_id_code': sampleIdCode,
'selected_state_name': selectedStateName, 'selected_state_name': selectedStateName,
@ -249,18 +298,12 @@ class InSituSamplingData {
'submission_status': submissionStatus, 'submission_status': submissionStatus,
'submission_message': submissionMessage, 'submission_message': submissionMessage,
'report_id': reportId, 'report_id': reportId,
// --- START: Serialization for NPE Fields ---
'npe_field_observations': npeFieldObservations, 'npe_field_observations': npeFieldObservations,
'npe_others_observation_remark': npeOthersObservationRemark, 'npe_others_observation_remark': npeOthersObservationRemark,
'npe_possible_source': npePossibleSource, 'npe_possible_source': npePossibleSource,
// Note: Image file paths are handled separately by the LocalStorageService
// and are not part of this JSON object directly.
// --- END: Serialization for NPE Fields ---
}; };
} }
// --- Methods from the original file ---
String generateTelegramAlertMessage({required bool isDataOnly}) { String generateTelegramAlertMessage({required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = selectedStation?['man_station_name'] ?? 'N/A'; final stationName = selectedStation?['man_station_name'] ?? 'N/A';

View File

@ -45,12 +45,6 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
@override @override
void dispose() { void dispose() {
_pageController.dispose(); _pageController.dispose();
// START FIX: Do not dispose the service here.
// The service is managed by the Provider at a higher level in the app. Disposing it
// here can cause the "deactivated widget's ancestor" error if other widgets
// are still listening to it during screen transitions.
// _samplingService.dispose();
// END FIX
super.dispose(); super.dispose();
} }
@ -72,7 +66,9 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
} }
} }
Future<void> _submitForm() async { /// This function contains the core logic for submitting data and returns the result.
/// UI actions like SnackBars and navigation are handled by the caller (summary screen).
Future<Map<String, dynamic>> _submitFormLogic() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
@ -85,17 +81,11 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
authProvider: authProvider, authProvider: authProvider,
); );
if (!mounted) return; if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
}
final message = result['message'] ?? 'An unknown error occurred.'; return result;
final color = (result['success'] == true) ? Colors.green : Colors.red;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
);
Navigator.of(context).popUntil((route) => route.isFirst);
} }
@ -125,7 +115,7 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
InSituStep1SamplingInfo(data: _data, onNext: _nextPage), InSituStep1SamplingInfo(data: _data, onNext: _nextPage),
InSituStep2SiteInfo(data: _data, onNext: _nextPage), InSituStep2SiteInfo(data: _data, onNext: _nextPage),
InSituStep3DataCapture(data: _data, onNext: _nextPage), InSituStep3DataCapture(data: _data, onNext: _nextPage),
InSituStep4Summary(data: _data, onSubmit: _submitForm, isLoading: _isLoading), InSituStep4Summary(data: _data, onSubmit: _submitFormLogic, isLoading: _isLoading),
], ],
), ),
), ),

View File

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_report.dart';
class MarineManualPreSampling extends StatelessWidget {
const MarineManualPreSampling({super.key});
// This list includes the pre-sampling items, excluding the NPE report.
// The routes point to the same screens used by the report module.
final List<ReportItem> _preSamplingItems = const [
ReportItem(
icon: Icons.biotech_rounded,
label: "Sonde Calibration",
formCode: "F-MM02",
route: '/marine/manual/report/calibration',
),
ReportItem(
icon: Icons.checklist_rtl_rounded,
label: "Pre-Departure & Safety Checklist",
formCode: "F-MM03",
route: '/marine/manual/report/pre-departure',
),
ReportItem(
icon: Icons.build_circle_outlined,
label: "Equipment Maintenance",
formCode: "F-MM01",
route: '/marine/manual/report/maintenance',
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Marine Pre-Sampling"),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Select a Pre-Sampling Form",
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
childAspectRatio: 1.2, // Adjusted for better text fit
),
itemCount: _preSamplingItems.length,
itemBuilder: (context, index) {
final item = _preSamplingItems[index];
return _buildReportCard(context, item);
},
),
],
),
),
);
}
// This widget is copied from MarineManualReportHomePage for visual consistency.
Widget _buildReportCard(BuildContext context, ReportItem item) {
return InkWell(
onTap: () {
Navigator.pushNamed(context, item.route);
},
borderRadius: BorderRadius.circular(12),
child: Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Colors.white24, width: 1),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(item.icon, size: 40, color: Theme.of(context).colorScheme.secondary),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
item.label,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 4),
Text(
item.formCode, // Displaying the form code
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[400]),
),
],
),
),
);
}
}

View File

@ -1,60 +0,0 @@
import 'package:flutter/material.dart';
class MarinePreSampling extends StatefulWidget {
@override
State<MarinePreSampling> createState() => _MarinePreSamplingState();
}
class _MarinePreSamplingState extends State<MarinePreSampling> {
final _formKey = GlobalKey<FormState>();
String site = '';
String weather = '';
String tide = '';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Marine Pre-Sampling")),
body: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: ListView(
children: [
Text("Enter Pre-Sampling Conditions", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 24),
TextFormField(
decoration: InputDecoration(labelText: "Site"),
onChanged: (val) => site = val,
validator: (val) => val == null || val.isEmpty ? "Required" : null,
),
SizedBox(height: 16),
TextFormField(
decoration: InputDecoration(labelText: "Weather"),
onChanged: (val) => weather = val,
validator: (val) => val == null || val.isEmpty ? "Required" : null,
),
SizedBox(height: 16),
TextFormField(
decoration: InputDecoration(labelText: "Tide Condition"),
onChanged: (val) => tide = val,
validator: (val) => val == null || val.isEmpty ? "Required" : null,
),
SizedBox(height: 24),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Pre-sampling data submitted")),
);
}
},
child: Text("Submit"),
),
],
),
),
),
);
}
}

View File

@ -86,7 +86,10 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return AlertDialog(
title: const Text("Incorrect Image Orientation"), title: const Text("Incorrect Image Orientation"),
content: const Text("Required photos must be taken in a horizontal (landscape) orientation."), // --- START: MODIFICATION 1 ---
// Updated the dialog text to be more general as it now applies to all photos.
content: const Text("All photos must be taken in a horizontal (landscape) orientation."),
// --- END: MODIFICATION 1 ---
actions: [ actions: [
TextButton( TextButton(
child: const Text("OK"), child: const Text("OK"),
@ -98,7 +101,11 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
); );
} }
Future<File?> _pickAndProcessImage(ImageSource source, String imageInfo, {required bool isRequired}) async { // --- START: MODIFICATION 2 ---
// The `isRequired` parameter has been removed. The orientation check will now
// apply to every image processed by this function.
Future<File?> _pickAndProcessImage(ImageSource source, String imageInfo) async {
// --- END: MODIFICATION 2 ---
if (_isPickingImage) return null; if (_isPickingImage) return null;
setState(() => _isPickingImage = true); setState(() => _isPickingImage = true);
@ -117,11 +124,15 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
return null; return null;
} }
if (isRequired && originalImage.height > originalImage.width) { // --- START: MODIFICATION 3 ---
// The `isRequired` check has been removed. Now, ALL photos (required and optional)
// must be in landscape orientation (width > height).
if (originalImage.height > originalImage.width) {
_showOrientationDialog(); _showOrientationDialog();
setState(() => _isPickingImage = false); setState(() => _isPickingImage = false);
return null; return null;
} }
// --- END: MODIFICATION 3 ---
final String watermarkTimestamp = "${widget.data.samplingDate} ${widget.data.samplingTime}"; final String watermarkTimestamp = "${widget.data.samplingDate} ${widget.data.samplingTime}";
final font = img.arial24; final font = img.arial24;
@ -155,19 +166,18 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final filePath = path.join(tempDir.path, newFileName); final filePath = path.join(tempDir.path, newFileName);
// --- START: MODIFICATION TO FIX RACE CONDITION ---
// Changed from asynchronous `writeAsBytes` to synchronous `writeAsBytesSync`.
// This guarantees the file is fully written to disk before the function returns,
// preventing a 0-byte file from being copied during a fast offline save.
final File processedFile = File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); final File processedFile = File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
// --- END: MODIFICATION TO FIX RACE CONDITION ---
setState(() => _isPickingImage = false); setState(() => _isPickingImage = false);
return processedFile; return processedFile;
} }
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async { // --- START: MODIFICATION 4 ---
final file = await _pickAndProcessImage(source, imageInfo, isRequired: isRequired); // The `isRequired` parameter has been removed from the function signature
// to align with the changes in `_pickAndProcessImage`.
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo) async {
final file = await _pickAndProcessImage(source, imageInfo);
// --- END: MODIFICATION 4 ---
if (file != null) { if (file != null) {
setState(() { setState(() {
setImageCallback(file); setImageCallback(file);
@ -200,6 +210,35 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
return; return;
} }
// --- START: MODIFICATION 5 ---
// Added validation to ensure that if an optional image is provided, its
// corresponding remark field is not empty.
if (widget.data.optionalImage1 != null && _remark1Controller.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('A remark is required for Optional Photo 1.'), backgroundColor: Colors.red),
);
return;
}
if (widget.data.optionalImage2 != null && _remark2Controller.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('A remark is required for Optional Photo 2.'), backgroundColor: Colors.red),
);
return;
}
if (widget.data.optionalImage3 != null && _remark3Controller.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('A remark is required for Optional Photo 3.'), backgroundColor: Colors.red),
);
return;
}
if (widget.data.optionalImage4 != null && _remark4Controller.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('A remark is required for Optional Photo 4.'), backgroundColor: Colors.red),
);
return;
}
// --- END: MODIFICATION 5 ---
widget.data.optionalRemark1 = _remark1Controller.text; widget.data.optionalRemark1 = _remark1Controller.text;
widget.data.optionalRemark2 = _remark2Controller.text; widget.data.optionalRemark2 = _remark2Controller.text;
widget.data.optionalRemark3 = _remark3Controller.text; widget.data.optionalRemark3 = _remark3Controller.text;
@ -224,15 +263,13 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall), Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24), const SizedBox(height: 24),
// This dropdown now correctly consumes data from AuthProvider
Consumer<AuthProvider>( Consumer<AuthProvider>(
builder: (context, auth, child) { builder: (context, auth, child) {
final classifications = auth.tarballClassifications; final classifications = auth.tarballClassifications;
// The dropdown is enabled only when the classification list is available from the local cache.
final bool isEnabled = classifications != null; final bool isEnabled = classifications != null;
return DropdownSearch<Map<String, dynamic>>( return DropdownSearch<Map<String, dynamic>>(
items: classifications ?? [], // Use local data from provider items: classifications ?? [],
selectedItem: _selectedClassification, selectedItem: _selectedClassification,
enabled: isEnabled, enabled: isEnabled,
itemAsString: (item) => item['classification_name'] as String, itemAsString: (item) => item['classification_name'] as String,
@ -251,7 +288,6 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_selectedClassification = value; _selectedClassification = value;
// NECESSARY CHANGE: Save both the full object and the ID to the data model.
widget.data.selectedClassification = value; widget.data.selectedClassification = value;
widget.data.classificationId = value?['classification_id']; widget.data.classificationId = value?['classification_id'];
}); });
@ -321,8 +357,11 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")), // --- START: MODIFICATION 6 ---
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")), // The `isRequired` parameter is no longer passed to `_setImage`.
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
// --- END: MODIFICATION 6 ---
], ],
), ),
if (remarkController != null) if (remarkController != null)
@ -332,7 +371,7 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
controller: remarkController, controller: remarkController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Remarks for $title', labelText: 'Remarks for $title',
hintText: 'Add an optional remark...', hintText: 'Add a remark...', // Changed hint text to be more direct
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
), ),

View File

@ -6,10 +6,12 @@ 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';
class InSituStep4Summary extends StatelessWidget { class InSituStep4Summary extends StatefulWidget {
final InSituSamplingData data; final InSituSamplingData data;
final VoidCallback onSubmit; final Future<Map<String, dynamic>> Function()
onSubmit; // Expects a function that returns the submission result
final bool isLoading; final bool isLoading;
const InSituStep4Summary({ const InSituStep4Summary({
@ -19,11 +21,14 @@ class InSituStep4Summary extends StatelessWidget {
required this.isLoading, required this.isLoading,
}); });
// --- START: MODIFICATION FOR HIGHLIGHTING --- @override
// Added helper logic to re-validate parameters on the summary screen. State<InSituStep4Summary> createState() => _InSituStep4SummaryState();
}
/// Maps the app's internal parameter keys to the names used in the database. class _InSituStep4SummaryState extends State<InSituStep4Summary> {
static const Map<String, String> _parameterKeyToLimitName = const { bool _isHandlingSubmit = false;
static const Map<String, String> _parameterKeyToLimitName = {
'oxygenConcentration': 'Oxygen Conc', 'oxygenConcentration': 'Oxygen Conc',
'oxygenSaturation': 'Oxygen Sat', 'oxygenSaturation': 'Oxygen Sat',
'ph': 'pH', 'ph': 'pH',
@ -36,18 +41,23 @@ class InSituStep4Summary extends StatelessWidget {
'batteryVoltage': 'Battery', 'batteryVoltage': 'Battery',
}; };
/// Re-validates the final parameters against the defined limits.
Set<String> _getOutOfBoundsKeys(BuildContext context) { Set<String> _getOutOfBoundsKeys(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final marineLimits = authProvider.marineParameterLimits ?? []; final marineLimits = authProvider.marineParameterLimits ?? [];
final Set<String> invalidKeys = {}; final Set<String> invalidKeys = {};
final int? stationId = data.selectedStation?['station_id']; final int? stationId = widget.data.selectedStation?['station_id'];
final readings = { final readings = {
'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation, 'oxygenConcentration': widget.data.oxygenConcentration,
'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity, 'oxygenSaturation': widget.data.oxygenSaturation,
'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity, 'ph': widget.data.ph,
'tss': data.tss, 'batteryVoltage': data.batteryVoltage, 'salinity': widget.data.salinity,
'electricalConductivity': widget.data.electricalConductivity,
'temperature': widget.data.temperature,
'tds': widget.data.tds,
'turbidity': widget.data.turbidity,
'tss': widget.data.tss,
'batteryVoltage': widget.data.batteryVoltage,
}; };
double? parseLimitValue(dynamic value) { double? parseLimitValue(dynamic value) {
@ -63,22 +73,23 @@ class InSituStep4Summary extends StatelessWidget {
final limitName = _parameterKeyToLimitName[key]; final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return; if (limitName == null) return;
// START MODIFICATION: Only check for station-specific limits
Map<String, dynamic> limitData = {}; Map<String, dynamic> limitData = {};
if (stationId != null) { if (stationId != null) {
limitData = marineLimits.firstWhere( limitData = marineLimits.firstWhere(
(l) => l['param_parameter_list'] == limitName && l['station_id'] == stationId, (l) =>
l['param_parameter_list'] == limitName &&
l['station_id'] == stationId,
orElse: () => {}, orElse: () => {},
); );
} }
// END MODIFICATION
if (limitData.isNotEmpty) { if (limitData.isNotEmpty) {
final lowerLimit = parseLimitValue(limitData['param_lower_limit']); final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
final upperLimit = parseLimitValue(limitData['param_upper_limit']); final upperLimit = parseLimitValue(limitData['param_upper_limit']);
if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) { if ((lowerLimit != null && value < lowerLimit) ||
(upperLimit != null && value > upperLimit)) {
invalidKeys.add(key); invalidKeys.add(key);
} }
} }
@ -86,14 +97,272 @@ class InSituStep4Summary extends StatelessWidget {
return invalidKeys; return invalidKeys;
} }
// --- END: MODIFICATION FOR HIGHLIGHTING ---
/// Checks captured data against NPE limits and returns detailed information for the dialog.
List<Map<String, dynamic>> _getNpeTriggeredParameters(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final npeLimits = authProvider.npeParameterLimits ?? [];
if (npeLimits.isEmpty) return [];
final List<Map<String, dynamic>> triggeredParams = [];
final readings = {
'oxygenConcentration': widget.data.oxygenConcentration,
'oxygenSaturation': widget.data.oxygenSaturation,
'ph': widget.data.ph,
'salinity': widget.data.salinity,
'electricalConductivity': widget.data.electricalConductivity,
'temperature': widget.data.temperature,
'tds': widget.data.tds,
'turbidity': widget.data.turbidity,
'tss': widget.data.tss,
};
double? parseLimitValue(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
readings.forEach((key, value) {
if (value == null || value == -999.0) return;
final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return;
final limitData = npeLimits.firstWhere(
(l) => l['param_parameter_list'] == limitName,
orElse: () => {},
);
if (limitData.isNotEmpty) {
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
final upperLimit = parseLimitValue(limitData['param_upper_limit']);
bool isHit = false;
if (lowerLimit != null && upperLimit != null) {
if (value >= lowerLimit && value <= upperLimit) isHit = true;
} else if (lowerLimit != null && upperLimit == null) {
if (value >= lowerLimit) isHit = true;
} else if (upperLimit != null && lowerLimit == null) {
if (value <= upperLimit) isHit = true;
}
if (isHit) {
triggeredParams.add({
'label': limitName,
'value': value,
'lower_limit': lowerLimit,
'upper_limit': upperLimit,
});
}
}
});
return triggeredParams;
}
/// Displays the enhanced NPE warning dialog with a highlighted table.
Future<bool?> _showNpeDialog(
BuildContext context, List<Map<String, dynamic>> triggeredParams) async {
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text("NPE Parameter Limit Detected"),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'The following parameters have fallen under the Non-Permissible Event (NPE) limit:'),
const SizedBox(height: 16),
Table(
columnWidths: const {
0: FlexColumnWidth(2),
1: FlexColumnWidth(2.5),
2: FlexColumnWidth(1.5),
},
border: TableBorder(
horizontalInside: BorderSide(
width: 0.5,
color: isDarkTheme
? Colors.grey.shade700
: Colors.grey.shade300),
verticalInside: BorderSide(
width: 0.5,
color: isDarkTheme
? Colors.grey.shade700
: Colors.grey.shade300),
top: BorderSide(
width: 1,
color: isDarkTheme
? Colors.grey.shade600
: Colors.grey.shade400),
bottom: BorderSide(
width: 1,
color: isDarkTheme
? Colors.grey.shade600
: Colors.grey.shade400),
),
children: [
TableRow(
decoration: BoxDecoration(
color: isDarkTheme
? Colors.grey.shade800
: Colors.grey.shade200),
children: [
Padding(
padding: const EdgeInsets.all(6.0),
child: Text('Parameter',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context)
.textTheme
.titleSmall
?.color))),
Padding(
padding: const EdgeInsets.all(6.0),
child: Text('NPE Range',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context)
.textTheme
.titleSmall
?.color))),
Padding(
padding: const EdgeInsets.all(6.0),
child: Text('Current',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context)
.textTheme
.titleSmall
?.color))),
],
),
...triggeredParams.map((p) {
final lowerStr =
p['lower_limit']?.toStringAsFixed(5) ?? 'N/A';
final upperStr =
p['upper_limit']?.toStringAsFixed(5) ?? 'N/A';
String range;
if (lowerStr != 'N/A' && upperStr != 'N/A') {
range = '$lowerStr - $upperStr';
} else if (lowerStr != 'N/A') {
range = '>= $lowerStr';
} else {
range = '<= $upperStr';
}
return TableRow(
children: [
Padding(
padding: const EdgeInsets.all(6.0),
child: Text(p['label'])),
Padding(
padding: const EdgeInsets.all(6.0),
child: Text(range)),
Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
p['value'].toStringAsFixed(5),
style: const TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.bold),
),
),
],
);
}).toList(),
],
),
const SizedBox(height: 16),
const Text('Please generate an NPE report.'),
],
),
),
actions: <Widget>[
TextButton(
child: const Text("Continue"),
onPressed: () => Navigator.of(dialogContext).pop(false),
),
ElevatedButton(
child: const Text("Open NPE Report"),
onPressed: () => Navigator.of(dialogContext).pop(true),
),
],
);
},
);
}
/// Handles the complete submission flow: NPE check, submission, and UI feedback/navigation.
Future<void> _handleSubmit(BuildContext context) async {
if (_isHandlingSubmit || widget.isLoading) return;
setState(() => _isHandlingSubmit = true);
bool shouldOpenNpeReport = false;
bool proceedWithSubmission = false;
try {
final npeParameters = _getNpeTriggeredParameters(context);
if (npeParameters.isNotEmpty) {
final userChoice = await _showNpeDialog(context, npeParameters);
if (userChoice == true) {
shouldOpenNpeReport = true;
proceedWithSubmission = true;
} else if (userChoice == false) {
proceedWithSubmission = true;
}
// If userChoice is null (dialog dismissed), we do nothing.
} else {
// No NPE hit, proceed normally.
proceedWithSubmission = true;
}
if (proceedWithSubmission) {
final result = await widget.onSubmit(); // This calls the logic in the parent
if (!mounted) return;
final message = result['message'] ?? 'An unknown error occurred.';
final color = (result['success'] == true) ? Colors.green : Colors.red;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: color,
duration: const Duration(seconds: 4)),
);
if (result['success'] == true) {
if (shouldOpenNpeReport) {
final npeData = widget.data.toNpeReportData();
// This works offline because it's just passing local data
Navigator.of(context).pushNamed(
'/marine/manual/report/npe',
arguments: npeData,
);
} else {
// Submission successful, and no NPE report needed, so go home.
Navigator.of(context).popUntil((route) => route.isFirst);
}
}
}
} finally {
if (mounted) {
setState(() => _isHandlingSubmit = false);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// --- START: MODIFICATION FOR HIGHLIGHTING ---
// Get the set of out-of-bounds keys before building the list.
final outOfBoundsKeys = _getOutOfBoundsKeys(context); final outOfBoundsKeys = _getOutOfBoundsKeys(context);
// --- END: MODIFICATION FOR HIGHLIGHTING ---
return ListView( return ListView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@ -104,94 +373,164 @@ class InSituStep4Summary extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildSectionCard( _buildSectionCard(
context, context,
"Sampling & Station Details", "Sampling & Station Details",
[ [
_buildDetailRow("1st Sampler:", data.firstSamplerName), _buildDetailRow("1st Sampler:", widget.data.firstSamplerName),
_buildDetailRow("2nd Sampler:", data.secondSampler?['first_name']?.toString()), _buildDetailRow(
_buildDetailRow("Sampling Date:", data.samplingDate), "2nd Sampler:", widget.data.secondSampler?['first_name']?.toString()),
_buildDetailRow("Sampling Time:", data.samplingTime), _buildDetailRow("Sampling Date:", widget.data.samplingDate),
_buildDetailRow("Sampling Type:", data.samplingType), _buildDetailRow("Sampling Time:", widget.data.samplingTime),
_buildDetailRow("Sample ID Code:", data.sampleIdCode), _buildDetailRow("Sampling Type:", widget.data.samplingType),
_buildDetailRow("Sample ID Code:", widget.data.sampleIdCode),
const Divider(height: 20), const Divider(height: 20),
_buildDetailRow("State:", data.selectedStateName), _buildDetailRow("State:", widget.data.selectedStateName),
_buildDetailRow("Category:", data.selectedCategoryName), _buildDetailRow("Category:", widget.data.selectedCategoryName),
_buildDetailRow("Station Code:", data.selectedStation?['man_station_code']?.toString()), _buildDetailRow("Station Code:",
_buildDetailRow("Station Name:", data.selectedStation?['man_station_name']?.toString()), widget.data.selectedStation?['man_station_code']?.toString()),
_buildDetailRow("Station Location:", "${data.stationLatitude}, ${data.stationLongitude}"), _buildDetailRow("Station Name:",
widget.data.selectedStation?['man_station_name']?.toString()),
_buildDetailRow("Station Location:",
"${widget.data.stationLatitude}, ${widget.data.stationLongitude}"),
], ],
), ),
_buildSectionCard( _buildSectionCard(
context, context,
"Location & On-Site Info", "Location & On-Site Info",
[ [
_buildDetailRow("Current Location:", "${data.currentLatitude}, ${data.currentLongitude}"), _buildDetailRow("Current Location:",
_buildDetailRow("Distance Difference:", data.distanceDifferenceInKm != null ? "${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters" : "N/A"), "${widget.data.currentLatitude}, ${widget.data.currentLongitude}"),
if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty) _buildDetailRow(
_buildDetailRow("Distance Remarks:", data.distanceDifferenceRemarks), "Distance Difference:",
widget.data.distanceDifferenceInKm != null
? "${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters"
: "N/A"),
if (widget.data.distanceDifferenceRemarks != null &&
widget.data.distanceDifferenceRemarks!.isNotEmpty)
_buildDetailRow(
"Distance Remarks:", widget.data.distanceDifferenceRemarks),
const Divider(height: 20), const Divider(height: 20),
_buildDetailRow("Weather:", data.weather), _buildDetailRow("Weather:", widget.data.weather),
_buildDetailRow("Tide Level:", data.tideLevel), _buildDetailRow("Tide Level:", widget.data.tideLevel),
_buildDetailRow("Sea Condition:", data.seaCondition), _buildDetailRow("Sea Condition:", widget.data.seaCondition),
_buildDetailRow("Event Remarks:", data.eventRemarks), _buildDetailRow("Event Remarks:", widget.data.eventRemarks),
_buildDetailRow("Lab Remarks:", data.labRemarks), _buildDetailRow("Lab Remarks:", widget.data.labRemarks),
], ],
), ),
_buildSectionCard( _buildSectionCard(
context, context,
"Attached Photos", "Attached Photos",
[ [
_buildImageCard("Left Side Land View", data.leftLandViewImage), _buildImageCard("Left Side Land View", widget.data.leftLandViewImage),
_buildImageCard("Right Side Land View", data.rightLandViewImage), _buildImageCard(
_buildImageCard("Filling Water into Bottle", data.waterFillingImage), "Right Side Land View", widget.data.rightLandViewImage),
_buildImageCard("Seawater Color in Bottle", data.seawaterColorImage), _buildImageCard(
_buildImageCard("Examine Preservative (pH paper)", data.phPaperImage), "Filling Water into Bottle", widget.data.waterFillingImage),
_buildImageCard(
"Seawater Color in Bottle", widget.data.seawaterColorImage),
_buildImageCard(
"Examine Preservative (pH paper)", widget.data.phPaperImage),
const Divider(height: 24), const Divider(height: 24),
Text("Optional Photos", style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), Text("Optional Photos",
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildImageCard("Optional Photo 1", data.optionalImage1, remark: data.optionalRemark1), _buildImageCard("Optional Photo 1", widget.data.optionalImage1,
_buildImageCard("Optional Photo 2", data.optionalImage2, remark: data.optionalRemark2), remark: widget.data.optionalRemark1),
_buildImageCard("Optional Photo 3", data.optionalImage3, remark: data.optionalRemark3), _buildImageCard("Optional Photo 2", widget.data.optionalImage2,
_buildImageCard("Optional Photo 4", data.optionalImage4, remark: data.optionalRemark4), remark: widget.data.optionalRemark2),
_buildImageCard("Optional Photo 3", widget.data.optionalImage3,
remark: widget.data.optionalRemark3),
_buildImageCard("Optional Photo 4", widget.data.optionalImage4,
remark: widget.data.optionalRemark4),
], ],
), ),
_buildSectionCard( _buildSectionCard(
context, context,
"Captured Parameters", "Captured Parameters",
[ [
_buildDetailRow("Sonde ID:", data.sondeId), _buildDetailRow("Sonde ID:", widget.data.sondeId),
_buildDetailRow("Capture Time:", "${data.dataCaptureDate} ${data.dataCaptureTime}"), _buildDetailRow("Capture Time:",
"${widget.data.dataCaptureDate} ${widget.data.dataCaptureTime}"),
const Divider(height: 20), const Divider(height: 20),
// --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING --- _buildParameterListItem(context,
_buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration, isOutOfBounds: outOfBoundsKeys.contains('oxygenConcentration')), icon: Icons.air,
_buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation, isOutOfBounds: outOfBoundsKeys.contains('oxygenSaturation')), label: "Oxygen Conc.",
_buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph, isOutOfBounds: outOfBoundsKeys.contains('ph')), unit: "mg/L",
_buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity, isOutOfBounds: outOfBoundsKeys.contains('salinity')), value: widget.data.oxygenConcentration,
_buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity, isOutOfBounds: outOfBoundsKeys.contains('electricalConductivity')), isOutOfBounds:
_buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature, isOutOfBounds: outOfBoundsKeys.contains('temperature')), outOfBoundsKeys.contains('oxygenConcentration')),
_buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds, isOutOfBounds: outOfBoundsKeys.contains('tds')), _buildParameterListItem(context,
_buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity, isOutOfBounds: outOfBoundsKeys.contains('turbidity')), icon: Icons.percent,
_buildParameterListItem(context, icon: Icons.filter_alt_outlined, label: "TSS", unit: "mg/L", value: data.tss, isOutOfBounds: outOfBoundsKeys.contains('tss')), label: "Oxygen Sat.",
_buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage, isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')), unit: "%",
// --- END: MODIFICATION --- value: widget.data.oxygenSaturation,
isOutOfBounds: outOfBoundsKeys.contains('oxygenSaturation')),
_buildParameterListItem(context,
icon: Icons.science_outlined,
label: "pH",
unit: "",
value: widget.data.ph,
isOutOfBounds: outOfBoundsKeys.contains('ph')),
_buildParameterListItem(context,
icon: Icons.waves,
label: "Salinity",
unit: "ppt",
value: widget.data.salinity,
isOutOfBounds: outOfBoundsKeys.contains('salinity')),
_buildParameterListItem(context,
icon: Icons.flash_on,
label: "Conductivity",
unit: "µS/cm",
value: widget.data.electricalConductivity,
isOutOfBounds:
outOfBoundsKeys.contains('electricalConductivity')),
_buildParameterListItem(context,
icon: Icons.thermostat,
label: "Temperature",
unit: "°C",
value: widget.data.temperature,
isOutOfBounds: outOfBoundsKeys.contains('temperature')),
_buildParameterListItem(context,
icon: Icons.grain,
label: "TDS",
unit: "mg/L",
value: widget.data.tds,
isOutOfBounds: outOfBoundsKeys.contains('tds')),
_buildParameterListItem(context,
icon: Icons.opacity,
label: "Turbidity",
unit: "NTU",
value: widget.data.turbidity,
isOutOfBounds: outOfBoundsKeys.contains('turbidity')),
_buildParameterListItem(context,
icon: Icons.filter_alt_outlined,
label: "TSS",
unit: "mg/L",
value: widget.data.tss,
isOutOfBounds: outOfBoundsKeys.contains('tss')),
_buildParameterListItem(context,
icon: Icons.battery_charging_full,
label: "Battery",
unit: "V",
value: widget.data.batteryVoltage,
isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')),
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
isLoading (widget.isLoading || _isHandlingSubmit)
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: ElevatedButton.icon( : ElevatedButton.icon(
onPressed: onSubmit, onPressed: () => _handleSubmit(context),
icon: const Icon(Icons.cloud_upload), icon: const Icon(Icons.cloud_upload),
label: const Text('Confirm & Submit'), label: const Text('Confirm & Submit'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), textStyle: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -199,8 +538,8 @@ class InSituStep4Summary extends StatelessWidget {
); );
} }
/// A reusable widget to create a consistent section card. Widget _buildSectionCard(
Widget _buildSectionCard(BuildContext context, String title, List<Widget> children) { BuildContext context, String title, List<Widget> children) {
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0), margin: const EdgeInsets.symmetric(vertical: 8.0),
elevation: 2, elevation: 2,
@ -226,7 +565,6 @@ class InSituStep4Summary extends StatelessWidget {
); );
} }
/// A reusable widget for a label-value row.
Widget _buildDetailRow(String label, String? value) { Widget _buildDetailRow(String label, String? value) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0), padding: const EdgeInsets.symmetric(vertical: 6.0),
@ -240,26 +578,28 @@ class InSituStep4Summary extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
flex: 3, flex: 3,
child: Text(value != null && value.isNotEmpty ? value : 'N/A', style: const TextStyle(fontSize: 16)), child: Text(value != null && value.isNotEmpty ? value : 'N/A',
style: const TextStyle(fontSize: 16)),
), ),
], ],
), ),
); );
} }
// --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING --- Widget _buildParameterListItem(BuildContext context,
Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required double? value, bool isOutOfBounds = false}) { {required IconData icon,
required String label,
required String unit,
required double? value,
bool isOutOfBounds = false}) {
final bool isMissing = value == null || value == -999.0; final bool isMissing = value == null || value == -999.0;
// Format the value to 5 decimal places if it's a valid number. final String displayValue =
final String displayValue = isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim(); isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim();
final Color? defaultTextColor =
// START CHANGE: Use theme-aware color for normal values Theme.of(context).textTheme.bodyLarge?.color;
// Determine the color for the value based on theme and status.
final Color? defaultTextColor = Theme.of(context).textTheme.bodyLarge?.color;
final Color valueColor = isOutOfBounds final Color valueColor = isOutOfBounds
? Colors.red ? Colors.red
: (isMissing ? Colors.grey : defaultTextColor ?? Colors.black); : (isMissing ? Colors.grey : defaultTextColor ?? Colors.black);
// END CHANGE
return ListTile( return ListTile(
dense: true, dense: true,
@ -275,9 +615,7 @@ class InSituStep4Summary extends StatelessWidget {
), ),
); );
} }
// --- END: MODIFICATION ---
/// A reusable widget to display an attached image or a placeholder.
Widget _buildImageCard(String title, File? image, {String? remark}) { Widget _buildImageCard(String title, File? image, {String? remark}) {
final bool hasRemark = remark != null && remark.isNotEmpty; final bool hasRemark = remark != null && remark.isNotEmpty;
return Padding( return Padding(
@ -285,12 +623,18 @@ class InSituStep4Summary extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), Text(title,
style:
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 8), const SizedBox(height: 8),
if (image != null) if (image != null)
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8.0), borderRadius: BorderRadius.circular(8.0),
child: Image.file(image, key: UniqueKey(), height: 200, width: double.infinity, fit: BoxFit.cover), child: Image.file(image,
key: UniqueKey(),
height: 200,
width: double.infinity,
fit: BoxFit.cover),
) )
else else
Container( Container(
@ -300,12 +644,15 @@ class InSituStep4Summary extends StatelessWidget {
color: Colors.grey[200], color: Colors.grey[200],
borderRadius: BorderRadius.circular(8.0), borderRadius: BorderRadius.circular(8.0),
border: Border.all(color: Colors.grey[300]!)), border: Border.all(color: Colors.grey[300]!)),
child: const Center(child: Text('No Image Attached', style: TextStyle(color: Colors.grey))), child: const Center(
child: Text('No Image Attached',
style: TextStyle(color: Colors.grey))),
), ),
if (hasRemark) if (hasRemark)
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)), child: Text('Remark: $remark',
style: const TextStyle(fontStyle: FontStyle.italic)),
), ),
], ],
), ),

View File

@ -748,7 +748,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
ListTile( ListTile(
leading: const Icon(Icons.info_outline), leading: const Icon(Icons.info_outline),
title: const Text('App Version'), title: const Text('App Version'),
subtitle: const Text('MMS Version 3.2.01'), subtitle: const Text('MMS Version 3.4.01'),
dense: true, dense: true,
), ),
ListTile( ListTile(