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: [
IconButton(
icon: const Icon(Icons.person),

View File

@ -72,13 +72,13 @@ import 'package:environment_monitoring_app/screens/river/investigative/report.da
// Marine Screens
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/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_pre_departure_checklist_screen.dart';
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_equipment_maintenance_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' 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/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/continuous/marine_continuous_info_centre_document.dart';
@ -326,14 +326,14 @@ class _RootAppState extends State<RootApp> {
// Marine Manual
'/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/tarball': (context) => const TarballSamplingStep1(),
'/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(),
'/marine/manual/report/npe': (context) => const marineManualNPEReport.MarineManualNPEReport(),
'/marine/manual/report/pre-departure': (context) => const MarineManualPreDepartureChecklistScreen(),
'/marine/manual/report/calibration': (context) => const MarineManualSondeCalibrationScreen(),
'/marine/manual/report/maintenance': (context) => const MarineManualEquipmentMaintenanceScreen(),
'/marine/manual/report/pre-departure': (context) => const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(),
'/marine/manual/report/calibration': (context) => const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(),
'/marine/manual/report/maintenance': (context) => const marineManualEquipmentMaintenance.MarineManualEquipmentMaintenanceScreen(),
//'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute
'/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(),

View File

@ -3,6 +3,8 @@
import 'dart:io';
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
/// In-Situ Sampling form.
class InSituSamplingData {
@ -103,6 +105,61 @@ class InSituSamplingData {
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.
factory InSituSamplingData.fromJson(Map<String, dynamic> json) {
double? doubleFromJson(dynamic value) {
@ -181,7 +238,7 @@ class InSituSamplingData {
data.optionalImage4 = fileFromPath(json['man_optional_photo_04']);
// --- START: Deserialization for NPE Fields ---
// --- Deserialization for NPE Fields ---
if (json['npe_field_observations'] is Map) {
data.npeFieldObservations = Map<String, bool>.from(json['npe_field_observations']);
}
@ -193,17 +250,10 @@ class InSituSamplingData {
data.npeImage2 = fileFromPath(json['npe_image_2']);
data.npeImage3 = fileFromPath(json['npe_image_3']);
data.npeImage4 = fileFromPath(json['npe_image_4']);
// --- END: Deserialization for NPE Fields ---
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.
Map<String, dynamic> toDbJson() {
return {
@ -212,7 +262,6 @@ class InSituSamplingData {
'secondSampler': secondSampler,
'sampling_date': samplingDate,
'sampling_time': samplingTime,
// ... (all other existing fields)
'sampling_type': samplingType,
'sample_id_code': sampleIdCode,
'selected_state_name': selectedStateName,
@ -249,18 +298,12 @@ class InSituSamplingData {
'submission_status': submissionStatus,
'submission_message': submissionMessage,
'report_id': reportId,
// --- START: Serialization for NPE Fields ---
'npe_field_observations': npeFieldObservations,
'npe_others_observation_remark': npeOthersObservationRemark,
'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}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = selectedStation?['man_station_name'] ?? 'N/A';

View File

@ -45,12 +45,6 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
@override
void 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();
}
@ -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);
final authProvider = Provider.of<AuthProvider>(context, listen: false);
@ -85,17 +81,11 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
authProvider: authProvider,
);
if (!mounted) return;
if (mounted) {
setState(() => _isLoading = false);
}
setState(() => _isLoading = false);
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)),
);
Navigator.of(context).popUntil((route) => route.isFirst);
return result;
}
@ -125,7 +115,7 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
InSituStep1SamplingInfo(data: _data, onNext: _nextPage),
InSituStep2SiteInfo(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) {
return AlertDialog(
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: [
TextButton(
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;
setState(() => _isPickingImage = true);
@ -117,11 +124,15 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
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();
setState(() => _isPickingImage = false);
return null;
}
// --- END: MODIFICATION 3 ---
final String watermarkTimestamp = "${widget.data.samplingDate} ${widget.data.samplingTime}";
final font = img.arial24;
@ -155,19 +166,18 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
final tempDir = await getTemporaryDirectory();
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));
// --- END: MODIFICATION TO FIX RACE CONDITION ---
setState(() => _isPickingImage = false);
return processedFile;
}
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
final file = await _pickAndProcessImage(source, imageInfo, isRequired: isRequired);
// --- START: MODIFICATION 4 ---
// 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) {
setState(() {
setImageCallback(file);
@ -200,6 +210,35 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
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.optionalRemark2 = _remark2Controller.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),
const SizedBox(height: 24),
// This dropdown now correctly consumes data from AuthProvider
Consumer<AuthProvider>(
builder: (context, auth, child) {
final classifications = auth.tarballClassifications;
// The dropdown is enabled only when the classification list is available from the local cache.
final bool isEnabled = classifications != null;
return DropdownSearch<Map<String, dynamic>>(
items: classifications ?? [], // Use local data from provider
items: classifications ?? [],
selectedItem: _selectedClassification,
enabled: isEnabled,
itemAsString: (item) => item['classification_name'] as String,
@ -251,7 +288,6 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
onChanged: (value) {
setState(() {
_selectedClassification = value;
// NECESSARY CHANGE: Save both the full object and the ID to the data model.
widget.data.selectedClassification = value;
widget.data.classificationId = value?['classification_id'];
});
@ -321,8 +357,11 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
// --- START: MODIFICATION 6 ---
// 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)
@ -332,7 +371,7 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
controller: remarkController,
decoration: InputDecoration(
labelText: 'Remarks for $title',
hintText: 'Add an optional remark...',
hintText: 'Add a remark...', // Changed hint text to be more direct
border: const OutlineInputBorder(),
),
),

View File

@ -6,10 +6,12 @@ import 'package:provider/provider.dart';
import '../../../../auth_provider.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 VoidCallback onSubmit;
final Future<Map<String, dynamic>> Function()
onSubmit; // Expects a function that returns the submission result
final bool isLoading;
const InSituStep4Summary({
@ -19,11 +21,14 @@ class InSituStep4Summary extends StatelessWidget {
required this.isLoading,
});
// --- START: MODIFICATION FOR HIGHLIGHTING ---
// Added helper logic to re-validate parameters on the summary screen.
@override
State<InSituStep4Summary> createState() => _InSituStep4SummaryState();
}
/// Maps the app's internal parameter keys to the names used in the database.
static const Map<String, String> _parameterKeyToLimitName = const {
class _InSituStep4SummaryState extends State<InSituStep4Summary> {
bool _isHandlingSubmit = false;
static const Map<String, String> _parameterKeyToLimitName = {
'oxygenConcentration': 'Oxygen Conc',
'oxygenSaturation': 'Oxygen Sat',
'ph': 'pH',
@ -36,18 +41,23 @@ class InSituStep4Summary extends StatelessWidget {
'batteryVoltage': 'Battery',
};
/// Re-validates the final parameters against the defined limits.
Set<String> _getOutOfBoundsKeys(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final marineLimits = authProvider.marineParameterLimits ?? [];
final Set<String> invalidKeys = {};
final int? stationId = data.selectedStation?['station_id'];
final int? stationId = widget.data.selectedStation?['station_id'];
final readings = {
'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation,
'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity,
'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity,
'tss': data.tss, 'batteryVoltage': data.batteryVoltage,
'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,
'batteryVoltage': widget.data.batteryVoltage,
};
double? parseLimitValue(dynamic value) {
@ -63,22 +73,23 @@ class InSituStep4Summary extends StatelessWidget {
final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return;
// START MODIFICATION: Only check for station-specific limits
Map<String, dynamic> limitData = {};
if (stationId != null) {
limitData = marineLimits.firstWhere(
(l) => l['param_parameter_list'] == limitName && l['station_id'] == stationId,
(l) =>
l['param_parameter_list'] == limitName &&
l['station_id'] == stationId,
orElse: () => {},
);
}
// END MODIFICATION
if (limitData.isNotEmpty) {
final lowerLimit = parseLimitValue(limitData['param_lower_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);
}
}
@ -86,14 +97,272 @@ class InSituStep4Summary extends StatelessWidget {
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
Widget build(BuildContext context) {
// --- START: MODIFICATION FOR HIGHLIGHTING ---
// Get the set of out-of-bounds keys before building the list.
final outOfBoundsKeys = _getOutOfBoundsKeys(context);
// --- END: MODIFICATION FOR HIGHLIGHTING ---
return ListView(
padding: const EdgeInsets.all(16.0),
@ -104,94 +373,164 @@ class InSituStep4Summary extends StatelessWidget {
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
_buildSectionCard(
context,
"Sampling & Station Details",
[
_buildDetailRow("1st Sampler:", data.firstSamplerName),
_buildDetailRow("2nd Sampler:", data.secondSampler?['first_name']?.toString()),
_buildDetailRow("Sampling Date:", data.samplingDate),
_buildDetailRow("Sampling Time:", data.samplingTime),
_buildDetailRow("Sampling Type:", data.samplingType),
_buildDetailRow("Sample ID Code:", data.sampleIdCode),
_buildDetailRow("1st Sampler:", widget.data.firstSamplerName),
_buildDetailRow(
"2nd Sampler:", widget.data.secondSampler?['first_name']?.toString()),
_buildDetailRow("Sampling Date:", widget.data.samplingDate),
_buildDetailRow("Sampling Time:", widget.data.samplingTime),
_buildDetailRow("Sampling Type:", widget.data.samplingType),
_buildDetailRow("Sample ID Code:", widget.data.sampleIdCode),
const Divider(height: 20),
_buildDetailRow("State:", data.selectedStateName),
_buildDetailRow("Category:", data.selectedCategoryName),
_buildDetailRow("Station Code:", data.selectedStation?['man_station_code']?.toString()),
_buildDetailRow("Station Name:", data.selectedStation?['man_station_name']?.toString()),
_buildDetailRow("Station Location:", "${data.stationLatitude}, ${data.stationLongitude}"),
_buildDetailRow("State:", widget.data.selectedStateName),
_buildDetailRow("Category:", widget.data.selectedCategoryName),
_buildDetailRow("Station Code:",
widget.data.selectedStation?['man_station_code']?.toString()),
_buildDetailRow("Station Name:",
widget.data.selectedStation?['man_station_name']?.toString()),
_buildDetailRow("Station Location:",
"${widget.data.stationLatitude}, ${widget.data.stationLongitude}"),
],
),
_buildSectionCard(
context,
"Location & On-Site Info",
[
_buildDetailRow("Current Location:", "${data.currentLatitude}, ${data.currentLongitude}"),
_buildDetailRow("Distance Difference:", data.distanceDifferenceInKm != null ? "${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters" : "N/A"),
if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty)
_buildDetailRow("Distance Remarks:", data.distanceDifferenceRemarks),
_buildDetailRow("Current Location:",
"${widget.data.currentLatitude}, ${widget.data.currentLongitude}"),
_buildDetailRow(
"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),
_buildDetailRow("Weather:", data.weather),
_buildDetailRow("Tide Level:", data.tideLevel),
_buildDetailRow("Sea Condition:", data.seaCondition),
_buildDetailRow("Event Remarks:", data.eventRemarks),
_buildDetailRow("Lab Remarks:", data.labRemarks),
_buildDetailRow("Weather:", widget.data.weather),
_buildDetailRow("Tide Level:", widget.data.tideLevel),
_buildDetailRow("Sea Condition:", widget.data.seaCondition),
_buildDetailRow("Event Remarks:", widget.data.eventRemarks),
_buildDetailRow("Lab Remarks:", widget.data.labRemarks),
],
),
_buildSectionCard(
context,
"Attached Photos",
[
_buildImageCard("Left Side Land View", data.leftLandViewImage),
_buildImageCard("Right Side Land View", data.rightLandViewImage),
_buildImageCard("Filling Water into Bottle", data.waterFillingImage),
_buildImageCard("Seawater Color in Bottle", data.seawaterColorImage),
_buildImageCard("Examine Preservative (pH paper)", data.phPaperImage),
_buildImageCard("Left Side Land View", widget.data.leftLandViewImage),
_buildImageCard(
"Right Side Land View", widget.data.rightLandViewImage),
_buildImageCard(
"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),
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),
_buildImageCard("Optional Photo 1", data.optionalImage1, remark: data.optionalRemark1),
_buildImageCard("Optional Photo 2", data.optionalImage2, remark: data.optionalRemark2),
_buildImageCard("Optional Photo 3", data.optionalImage3, remark: data.optionalRemark3),
_buildImageCard("Optional Photo 4", data.optionalImage4, remark: data.optionalRemark4),
_buildImageCard("Optional Photo 1", widget.data.optionalImage1,
remark: widget.data.optionalRemark1),
_buildImageCard("Optional Photo 2", widget.data.optionalImage2,
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(
context,
"Captured Parameters",
[
_buildDetailRow("Sonde ID:", data.sondeId),
_buildDetailRow("Capture Time:", "${data.dataCaptureDate} ${data.dataCaptureTime}"),
_buildDetailRow("Sonde ID:", widget.data.sondeId),
_buildDetailRow("Capture Time:",
"${widget.data.dataCaptureDate} ${widget.data.dataCaptureTime}"),
const Divider(height: 20),
// --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING ---
_buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration, isOutOfBounds: outOfBoundsKeys.contains('oxygenConcentration')),
_buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation, isOutOfBounds: outOfBoundsKeys.contains('oxygenSaturation')),
_buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph, isOutOfBounds: outOfBoundsKeys.contains('ph')),
_buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity, isOutOfBounds: outOfBoundsKeys.contains('salinity')),
_buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity, isOutOfBounds: outOfBoundsKeys.contains('electricalConductivity')),
_buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature, isOutOfBounds: outOfBoundsKeys.contains('temperature')),
_buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds, isOutOfBounds: outOfBoundsKeys.contains('tds')),
_buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity, isOutOfBounds: outOfBoundsKeys.contains('turbidity')),
_buildParameterListItem(context, icon: Icons.filter_alt_outlined, label: "TSS", unit: "mg/L", value: data.tss, isOutOfBounds: outOfBoundsKeys.contains('tss')),
_buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage, isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')),
// --- END: MODIFICATION ---
_buildParameterListItem(context,
icon: Icons.air,
label: "Oxygen Conc.",
unit: "mg/L",
value: widget.data.oxygenConcentration,
isOutOfBounds:
outOfBoundsKeys.contains('oxygenConcentration')),
_buildParameterListItem(context,
icon: Icons.percent,
label: "Oxygen Sat.",
unit: "%",
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),
isLoading
(widget.isLoading || _isHandlingSubmit)
? const Center(child: CircularProgressIndicator())
: ElevatedButton.icon(
onPressed: onSubmit,
onPressed: () => _handleSubmit(context),
icon: const Icon(Icons.cloud_upload),
label: const Text('Confirm & Submit'),
style: ElevatedButton.styleFrom(
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),
@ -199,8 +538,8 @@ class InSituStep4Summary extends StatelessWidget {
);
}
/// A reusable widget to create a consistent section card.
Widget _buildSectionCard(BuildContext context, String title, List<Widget> children) {
Widget _buildSectionCard(
BuildContext context, String title, List<Widget> children) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0),
elevation: 2,
@ -226,7 +565,6 @@ class InSituStep4Summary extends StatelessWidget {
);
}
/// A reusable widget for a label-value row.
Widget _buildDetailRow(String label, String? value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
@ -240,26 +578,28 @@ class InSituStep4Summary extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
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, {required IconData icon, required String label, required String unit, required double? value, bool isOutOfBounds = false}) {
Widget _buildParameterListItem(BuildContext context,
{required IconData icon,
required String label,
required String unit,
required double? value,
bool isOutOfBounds = false}) {
final bool isMissing = value == null || value == -999.0;
// Format the value to 5 decimal places if it's a valid number.
final String displayValue = isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim();
// START CHANGE: Use theme-aware color for normal values
// Determine the color for the value based on theme and status.
final Color? defaultTextColor = Theme.of(context).textTheme.bodyLarge?.color;
final String displayValue =
isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim();
final Color? defaultTextColor =
Theme.of(context).textTheme.bodyLarge?.color;
final Color valueColor = isOutOfBounds
? Colors.red
: (isMissing ? Colors.grey : defaultTextColor ?? Colors.black);
// END CHANGE
return ListTile(
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}) {
final bool hasRemark = remark != null && remark.isNotEmpty;
return Padding(
@ -285,12 +623,18 @@ class InSituStep4Summary extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
if (image != null)
ClipRRect(
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
Container(
@ -300,12 +644,15 @@ class InSituStep4Summary extends StatelessWidget {
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8.0),
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)
Padding(
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(
leading: const Icon(Icons.info_outline),
title: const Text('App Version'),
subtitle: const Text('MMS Version 3.2.01'),
subtitle: const Text('MMS Version 3.4.01'),
dense: true,
),
ListTile(