From 882386c522f321ce2d74545e4be994ff0b009c63 Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Sat, 15 Nov 2025 21:12:16 +0800 Subject: [PATCH] repair marine investigative module --- lib/models/in_situ_sampling_data.dart | 104 +++- .../marine_inves_manual_step_2_site_info.dart | 76 ++- ...rine_inves_manual_step_3_data_capture.dart | 56 +- .../marine_inves_manual_step_4_summary.dart | 269 ++++++++- .../marine_manual_report_status_log.dart | 532 +++++++++++------- .../widgets/in_situ_step_3_data_capture.dart | 2 +- .../widgets/in_situ_step_4_summary.dart | 2 +- lib/services/local_storage_service.dart | 6 +- .../marine_in_situ_sampling_service.dart | 58 +- ...marine_investigative_sampling_service.dart | 256 +++++++-- .../river_in_situ_sampling_service.dart | 19 +- 11 files changed, 1064 insertions(+), 316 deletions(-) diff --git a/lib/models/in_situ_sampling_data.dart b/lib/models/in_situ_sampling_data.dart index e0c59aa..180ec45 100644 --- a/lib/models/in_situ_sampling_data.dart +++ b/lib/models/in_situ_sampling_data.dart @@ -32,6 +32,7 @@ class InSituSamplingData { String? weather; String? tideLevel; String? seaCondition; + // String? tarball; // <-- REMOVED THIS PROPERTY String? eventRemarks; String? labRemarks; @@ -201,6 +202,7 @@ class InSituSamplingData { data.weather = json['weather']; data.tideLevel = json['tide_level']; data.seaCondition = json['sea_condition']; + // data.tarball = json['tarball']; // <-- REMOVED DESERIALIZATION data.eventRemarks = json['event_remarks']; data.labRemarks = json['lab_remarks']; data.optionalRemark1 = json['man_optional_photo_01_remarks']; @@ -254,8 +256,8 @@ class InSituSamplingData { return data; } - /// Creates a single JSON object with all submission data for offline storage. - Map toDbJson() { + /// Creates a Map object with all submission data for local logging. + Map toMap() { return { 'first_sampler_name': firstSamplerName, 'first_sampler_user_id': firstSamplerUserId, @@ -276,6 +278,7 @@ class InSituSamplingData { 'weather': weather, 'tide_level': tideLevel, 'sea_condition': seaCondition, + // 'tarball': tarball, // <-- REMOVED 'event_remarks': eventRemarks, 'lab_remarks': labRemarks, 'man_optional_photo_01_remarks': optionalRemark1, @@ -305,8 +308,101 @@ class InSituSamplingData { }; } - // --- REMOVED: generateTelegramAlertMessage method --- - // This logic is now in MarineInSituSamplingService + /// Creates a single JSON object with all submission data, mimicking 'db.json' + String toDbJson() { + // This is a direct conversion of the model's properties to a map, + // with keys matching the expected 'db.json' file format. + final data = { + 'battery_cap': batteryVoltage == -999.0 ? null : batteryVoltage, + 'device_name': sondeId, + 'sampling_type': samplingType, + 'report_id': reportId, + 'sampler_2ndname': secondSampler?['first_name'], + 'sample_state': selectedStateName, + 'station_id': selectedStation?['man_station_code'], + 'tech_id': firstSamplerUserId, + 'tech_phonenum': null, // This field was not in the model + 'tech_name': firstSamplerName, + 'latitude': stationLatitude, + 'longitude': stationLongitude, + 'record_dt': '$samplingDate $samplingTime', + 'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration, + 'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation, + 'ph': ph == -999.0 ? null : ph, + 'salinity': salinity == -999.0 ? null : salinity, + 'tss': tss == -999.0 ? null : tss, + 'temperature': temperature == -999.0 ? null : temperature, + 'turbidity': turbidity == -999.0 ? null : turbidity, + 'tds': tds == -999.0 ? null : tds, + 'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity, + 'sample_id': sampleIdCode, + 'tarball': "", // <-- MODIFIED: Hardcoded to "N/A" + 'weather': weather, + 'tide_lvl': tideLevel, + 'sea_cond': seaCondition, + 'remarks_event': eventRemarks, + 'remarks_lab': labRemarks, + }; + // Remove null values before encoding + data.removeWhere((key, value) => value == null); + return jsonEncode(data); + } + + /// Creates a JSON object for basic form info, mimicking 'marine_insitu_basic_form.json'. + String toBasicFormJson() { + final data = { + 'tech_name': firstSamplerName, + 'sampler_2ndname': secondSampler?['first_name'], // Assuming 'first_name' key + 'sample_date': samplingDate, + 'sample_time': samplingTime, + 'sampling_type': samplingType, + 'sample_state': selectedStateName, + 'station_id': selectedStation?['man_station_code'], // Marine-specific key + 'station_latitude': stationLatitude, + 'station_longitude': stationLongitude, + 'latitude': currentLatitude, + 'longitude': currentLongitude, + 'sample_id': sampleIdCode, + }; + // Remove null values before encoding + data.removeWhere((key, value) => value == null); + return jsonEncode(data); + } + + /// Creates a JSON object for sensor readings, mimicking 'marine_sampling_reading.json'. + String toReadingJson() { + final data = { + 'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration, + 'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation, + 'ph': ph == -999.0 ? null : ph, + 'salinity': salinity == -999.0 ? null : salinity, + 'tds': tds == -999.0 ? null : tds, + 'tss': tss == -999.0 ? null : tss, + 'temperature': temperature == -999.0 ? null : temperature, + 'turbidity': turbidity == -999.0 ? null : turbidity, + 'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity, + 'date_sampling_reading': dataCaptureDate, + 'time_sampling_reading': dataCaptureTime, + }; + // Remove null values before encoding + data.removeWhere((key, value) => value == null); + return jsonEncode(data); + } + + /// Creates a JSON object for manual info, mimicking 'marine_manual_info.json'. + String toManualInfoJson() { + final data = { + 'tarball': "", // <-- MODIFIED: Hardcoded to "N/A" + 'weather': weather, + 'tide_lvl': tideLevel, + 'sea_cond': seaCondition, + 'remarks_event': eventRemarks, + 'remarks_lab': labRemarks, + }; + // Remove null values before encoding + data.removeWhere((key, value) => value == null); + return jsonEncode(data); + } Map toApiFormData() { final Map map = {}; diff --git a/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart b/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart index 3a95a50..d87a784 100644 --- a/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart +++ b/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart @@ -1,6 +1,7 @@ // lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart import 'dart:io'; +import 'dart:typed_data'; // <-- ADDED: Required for Uint8List import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; @@ -26,7 +27,7 @@ class MarineInvesManualStep2SiteInfo extends StatefulWidget { class _MarineInvesManualStep2SiteInfoState extends State { final _formKey = GlobalKey(); - bool _isPickingImage = false; + bool _isPickingImage = false; // <-- ADDED: State variable from in-situ late final TextEditingController _eventRemarksController; late final TextEditingController _labRemarksController; @@ -49,6 +50,7 @@ class _MarineInvesManualStep2SiteInfoState extends State(context, listen: false); - // The service's pickAndProcessImage method will handle file naming - final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: isRequired); + // Always pass `isRequired: true` to the service to enforce landscape check + // and watermarking for ALL photos (required or optional). + // The 'isRequired' param is just for the UI text. + final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: true); if (file != null) { setState(() => setImageCallback(file)); } else if (mounted) { - _showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true); + // Corrected snackbar message + _showSnackBar('Image selection failed. Please ensure all photos are taken in landscape (horizontal) mode.', isError: true); } if (mounted) { setState(() => _isPickingImage = false); } } + // --- END: MODIFIED _setImage function --- /// Validates the form and all required images before proceeding. void _goToNextStep() { @@ -142,7 +148,11 @@ class _MarineInvesManualStep2SiteInfoState extends State widget.data.leftLandViewImage = file, isRequired: true), _buildImagePicker('Right Side Land View', 'RIGHT_LAND_VIEW', widget.data.rightLandViewImage, (file) => widget.data.rightLandViewImage = file, isRequired: true), @@ -196,7 +206,8 @@ class _MarineInvesManualStep2SiteInfoState extends State( + // Use ValueKey to ensure FutureBuilder refetches when the file path changes + key: ValueKey(imageFile.path), + future: imageFile.readAsBytes(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Container( + height: 150, + width: double.infinity, + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ); + } + if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { + return Container( + height: 150, + width: double.infinity, + alignment: Alignment.center, + child: const Icon(Icons.error, color: Colors.red, size: 40), + ); + } + // Display the image from memory + return Image.memory( + snapshot.data!, + height: 150, + width: double.infinity, + fit: BoxFit.cover, + ); + }, + ), + ), + Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle), + child: IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, color: Colors.white, size: 20), + onPressed: () => setState(() => setImageCallback(null)), + ), + ), + ], ) else Row( - // ... (Camera/Gallery buttons - same as original) + 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")), + ], ), ], ), ); } +// --- END: MODIFIED _buildImagePicker --- } \ No newline at end of file diff --git a/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_3_data_capture.dart b/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_3_data_capture.dart index f9778c6..2580371 100644 --- a/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_3_data_capture.dart +++ b/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_3_data_capture.dart @@ -95,29 +95,22 @@ class _MarineInvesManualStep3DataCaptureState extends State readings) { const defaultValue = -999.0; @@ -350,6 +337,7 @@ class _MarineInvesManualStep3DataCaptureState extends State(context, listen: false); - final marineLimits = authProvider.marineParameterLimits ?? []; - final outOfBoundsParams = _validateParameters(currentReadings, marineLimits); + List> outOfBoundsParams = []; - setState(() { - _outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet(); - }); + // --- NEW CONDITIONAL LOGIC --- + // Only check limits if it's a Manual Station + if (widget.data.stationTypeSelection == 'Existing Manual Station') { + final authProvider = Provider.of(context, listen: false); + final marineLimits = authProvider.marineParameterLimits ?? []; + outOfBoundsParams = _validateParameters(currentReadings, marineLimits); + + setState(() { + _outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet(); + }); + } else { + // If not a manual station, ensure any old highlights are cleared + setState(() { + _outOfBoundsKeys.clear(); + }); + } + // --- END NEW CONDITIONAL LOGIC --- if (outOfBoundsParams.isNotEmpty) { _showParameterLimitDialog(outOfBoundsParams, currentReadings); @@ -380,6 +380,7 @@ class _MarineInvesManualStep3DataCaptureState extends State _captureReadingsToMap() { final Map readings = {}; @@ -394,16 +395,14 @@ class _MarineInvesManualStep3DataCaptureState extends State> _validateParameters(Map readings, List> limits) { final List> invalidParams = []; - // --- MODIFIED: Get station ID based on station type --- int? stationId; + // This check is now redundant due to _validateAndProceed, but safe to keep if (widget.data.stationTypeSelection == 'Existing Manual Station') { - stationId = widget.data.selectedStation?['station_id']; + stationId = widget.data.selectedStation?['man_station_id']; } - // Note: Add logic here if Tarball or New Locations have different limits - // For now, we only validate against manual station limits debugPrint("--- Parameter Validation Start (Investigative) ---"); - debugPrint("Selected Station ID: $stationId"); + debugPrint("Selected Station ID: $stationId (from 'man_station_id')"); double? _parseLimitValue(dynamic value) { if (value == null) return null; @@ -824,8 +823,11 @@ class _MarineInvesManualStep3DataCaptureState extends State { bool _isHandlingSubmit = false; - // Keep parameter names for highlighting out-of-bounds station limits static const Map _parameterKeyToLimitName = { 'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', @@ -43,18 +42,15 @@ class _MarineInvesManualStep4SummaryState extends State _getOutOfBoundsKeys(BuildContext context) { final authProvider = Provider.of(context, listen: false); - // Use regular marine limits, not NPE limits final marineLimits = authProvider.marineParameterLimits ?? []; final Set invalidKeys = {}; int? stationId; if (widget.data.stationTypeSelection == 'Existing Manual Station') { - stationId = widget.data.selectedStation?['station_id']; + stationId = widget.data.selectedStation?['man_station_id']; } - // Note: Only checking against manual station limits for now. final readings = { 'oxygenConcentration': widget.data.oxygenConcentration, @@ -88,7 +84,7 @@ class _MarineInvesManualStep4SummaryState extends State l['param_parameter_list'] == limitName && - l['station_id'] == stationId, + l['station_id']?.toString() == stationId.toString(), orElse: () => {}, ); } @@ -107,17 +103,226 @@ class _MarineInvesManualStep4SummaryState extends State> _getNpeTriggeredParameters(BuildContext context) { + final authProvider = Provider.of(context, listen: false); + final npeLimits = authProvider.npeParameterLimits ?? []; + if (npeLimits.isEmpty) return []; - /// Handles the complete submission flow WITHOUT NPE check. + final List> 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; + + // NPE limits are general and NOT station-specific + 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 a SIMPLIFIED NPE warning dialog (no "Open Report" button). + Future _showNpeWarningDialog( + BuildContext context, List> triggeredParams) async { + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + + return showDialog( + 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('Submission will proceed.'), + ], + ), + ), + actions: [ + ElevatedButton( + child: const Text("OK"), + onPressed: () => Navigator.of(dialogContext).pop(), + ), + ], + ); + }, + ); + } + // --- END: ADDED from in-situ module --- + + // --- START: MODIFIED _handleSubmit --- + /// Handles the complete submission flow WITH NPE check. Future _handleSubmit(BuildContext context) async { if (_isHandlingSubmit || widget.isLoading) return; setState(() => _isHandlingSubmit = true); try { - // Directly call the submission function provided by the parent + // --- NPE CHECK ADDED --- + final npeParameters = _getNpeTriggeredParameters(context); + + if (npeParameters.isNotEmpty) { + // Show the warning dialog and wait for the user to press "OK" + await _showNpeWarningDialog(context, npeParameters); + if (!mounted) return; // Check mount status after await + } + // --- END NPE CHECK --- + + // Proceed with submission after dialog is dismissed (or if not needed) final result = await widget.onSubmit(); if (!mounted) return; @@ -133,6 +338,7 @@ class _MarineInvesManualStep4SummaryState extends State route.isFirst); } // If submission failed, the user stays on the summary screen to potentially retry @@ -154,6 +360,7 @@ class _MarineInvesManualStep4SummaryState extends State _buildStationDetails() { @@ -190,7 +397,6 @@ class _MarineInvesManualStep4SummaryState extends State _handleSubmit(context), // Simplified call + onPressed: () => _handleSubmit(context), icon: const Icon(Icons.cloud_upload), label: const Text('Confirm & Submit'), style: ElevatedButton.styleFrom( @@ -420,7 +626,7 @@ class _MarineInvesManualStep4SummaryState extends State( + key: ValueKey(image.path), + future: image.readAsBytes(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Container( + height: 200, + width: double.infinity, + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ); + } + if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { + return Container( + height: 200, + width: double.infinity, + alignment: Alignment.center, + child: const Icon(Icons.error, color: Colors.red, size: 40), + ); + } + return Image.memory( + snapshot.data!, + height: 200, + width: double.infinity, + fit: BoxFit.cover, + ); + }, + ), ) else Container( diff --git a/lib/screens/marine/manual/marine_manual_report_status_log.dart b/lib/screens/marine/manual/marine_manual_report_status_log.dart index 1e8b3cd..5ec7199 100644 --- a/lib/screens/marine/manual/marine_manual_report_status_log.dart +++ b/lib/screens/marine/manual/marine_manual_report_status_log.dart @@ -94,6 +94,52 @@ class _MarineManualReportStatusLogState bool _isLoading = true; final Map _isResubmitting = {}; + // --- START: COPIED FROM SCREEN FILE --- + // This is the "single source of truth" for categories + final Map> _checklistSections = { + 'INTERNAL - IN-SITU SAMPLING': [ // Section title matches PDF + 'Marine manual Standard Operation Procedure (SOP)', // Item text matches PDF + 'Back-up Sampling Sheet & Chain of Custody form', // Item text matches PDF + 'Calibration worksheet', // Item text matches PDF + 'YSI EXO 2 Sonde include sensor (pH/Turbidity/Conductivity/Dissolved Oxygen)', // Item text matches PDF + 'Spare set sensor (pH/Turbidity/Conductivity/Dissolved Oxygen)', // Item text matches PDF + 'YSI serial cable', // Item text matches PDF + 'Van Dorn Sampler (with rope & messenger)', // Item text matches PDF + 'Laptop', // Item text matches PDF + 'Smartphone pre-installed with application (Apps for manual sampling-MMS)', // Item text matches PDF + 'GPS navigation', // Item text matches PDF + 'Calibration standards (pH/Turbidity/Conductivity)', // Item text matches PDF + 'Distilled water (D.I.)', // Item text matches PDF + 'Universal pH indicator paper', // Item text matches PDF + 'Alcohol swab', // Item text matches PDF + 'Personal Floating Devices (PFD)', // Item text matches PDF + 'First aid kits', // Item text matches PDF + 'Disposable gloves', // Item text matches PDF + 'Black plastic bags', // Item text matches PDF + 'Marker pen, pen, clear tapes, brown tapes & scissors', // Item text matches PDF + 'Energizer battery', // Item text matches PDF + 'EXO battery opener and EXO magnet', // Item text matches PDF + 'Laminated white paper', // Item text matches PDF + 'Clear glass bottle (blue сар)', // Item text matches PDF + 'Proper sampling attires & shoes', // Item text matches PDF + 'Raincoat/Poncho', // Item text matches PDF + 'Ice packets', // Item text matches PDF + ], + 'INTERNAL-TARBALL SAMPLING': [ // Section title matches PDF + 'Measuring tape (100 meter)', // Item text matches PDF + 'Steel raking', // Item text matches PDF + 'Aluminum foil', // Item text matches PDF + 'Zipper bags', // Item text matches PDF + ], + 'EXTERNAL - LABORATORY': [ // Section title matches PDF + 'Sufficient sets of cooler box and sampling bottles with label', // Item text matches PDF + 'Field duplicate sampling bottles (if any)', // Item text matches PDF + 'Blank samples sampling bottles (if any)', // Item text matches PDF + 'Preservatives (acid & alkaline)', // Item text matches PDF + ], + }; + // --- END: COPIED FROM SCREEN FILE --- + @override void initState() { super.initState(); @@ -152,9 +198,7 @@ class _MarineManualReportStatusLogState tempPreSampling.add(SubmissionLogEntry( type: 'Pre-Departure Checklist', title: 'Pre-Departure Checklist', - // --- START: REVERTED --- - stationCode: 'N/A', // Reverted - // --- END: REVERTED --- + stationCode: 'N/A', // --- START: MODIFIED --- senderName: (log['reporterName'] as String?) ?? 'Unknown User', // --- END: MODIFIED --- @@ -317,7 +361,6 @@ class _MarineManualReportStatusLogState final data = MarineManualPreDepartureChecklistData(); data.reporterUserId = logData['reporterUserId']; data.submissionDate = logData['submissionDate']; - // data.location = logData['location']; // <-- REVERTED // Reconstruct maps if (logData['checklistItems'] != null) { data.checklistItems = @@ -526,7 +569,7 @@ class _MarineManualReportStatusLogState _preSamplingSearchController, ), _buildCategorySection( - 'Report Log', + 'NPE Report Log', _filteredReportLogs, _reportSearchController, ), @@ -732,6 +775,7 @@ class _MarineManualReportStatusLogState return value.toString(); } + // --- START: ADDED WIDGET-BASED HEADER HELPER --- /// Builds a formatted category header row for the data list. Widget _buildCategoryHeader(BuildContext context, String title, IconData icon) { return Padding( @@ -752,60 +796,92 @@ class _MarineManualReportStatusLogState ), ); } + // --- END: ADDED WIDGET-BASED HEADER HELPER --- - /// Builds a formatted row for the data list. - Widget _buildDataRow(String label, String? value) { + // --- START: RE-INTRODUCED TABLE-BASED HELPERS --- + /// Builds a formatted category header row for the data table. + TableRow _buildCategoryRow(BuildContext context, String title, IconData icon) { + return TableRow( + decoration: BoxDecoration( + color: Colors.grey.shade100, + ), + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 8.0, left: 8.0, right: 8.0), + child: Row( + children: [ + Icon(icon, size: 20, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + const SizedBox.shrink(), // Empty cell for the second column + ], + ); + } + + /// Builds a formatted row for the data dialog. + TableRow _buildDataTableRow(String label, String? value, {Color? valueColor}) { String displayValue = (value == null || value.isEmpty || value == 'null') ? 'N/A' : value; if (displayValue == '-999.0' || displayValue == '-999') { displayValue = 'N/A'; } - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 2, - child: Text( - label, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14.0, - ), + return TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Text( + label, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14.0, ), ), - const SizedBox(width: 8), - Expanded( - flex: 3, - child: Text( - displayValue, - style: const TextStyle(fontSize: 14.0), - ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Text( + displayValue, + style: TextStyle(fontSize: 14.0, color: valueColor), ), - ], - ), + ), + ], ); } - // --- START: NEW HELPER FOR PRE-DEPARTURE --- - /// Determines the category for a given checklist item. - String _getChecklistCategory(String itemName) { - final lowerItem = itemName.toLowerCase(); - if (lowerItem.contains('in-situ') || lowerItem.contains('sonde') || lowerItem.contains('van dorn')) { - return 'Internal - In-Situ Sampling'; + /// Builds a remark row for the data dialog. + TableRow _buildRemarkTableRow(String? remark) { + if (remark == null || remark.isEmpty) { + return const TableRow(children: [SizedBox.shrink(), SizedBox.shrink()]); } - if (lowerItem.contains('tarball')) { - return 'Internal - Tarball Sampling'; - } - if (lowerItem.contains('laboratory') || lowerItem.contains('ice') || lowerItem.contains('bottle')) { - return 'External - Laboratory'; - } - // Add more rules here if needed - return 'General'; + return TableRow( + children: [ + const SizedBox.shrink(), // Empty cell for the label + Padding( + padding: const EdgeInsets.only(bottom: 8.0, left: 8.0, right: 8.0), + child: Text( + "Remark: $remark", + style: TextStyle( + fontSize: 13.0, + color: Colors.grey.shade700, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ); } - // --- END: NEW HELPER FOR PRE-DEPARTURE --- + // --- END: RE-INTRODUCED TABLE-BASED HELPERS --- /// Shows the categorized and formatted data log in a dialog void _showDataDialog(BuildContext context, SubmissionLogEntry log) { @@ -813,118 +889,152 @@ class _MarineManualReportStatusLogState Widget dialogContent; // This will hold either a ListView or a Column if (log.type == 'Pre-Departure Checklist') { - // --- START: Handle Pre-Departure Checklist (uses ListView) --- + // --- START: Handle Pre-Departure Checklist (uses Column/ListView) --- final items = Map.from(data['checklistItems'] ?? {}); final remarks = Map.from(data['remarks'] ?? {}); - // 1. Group items by category - final Map>> categorizedItems = {}; - for (final entry in items.entries) { - final category = _getChecklistCategory(entry.key); - if (!categorizedItems.containsKey(category)) { - categorizedItems[category] = []; - } - categorizedItems[category]!.add(entry); - } - - // 2. Define the order - const categoryOrder = [ - 'Internal - In-Situ Sampling', - 'Internal - Tarball Sampling', - 'External - Laboratory', - 'General' - ]; - - // 3. Build the list of widgets + // 1. Build the list of widgets final List contentWidgets = []; - for (final category in categoryOrder) { - if (categorizedItems.containsKey(category) && categorizedItems[category]!.isNotEmpty) { - // Add the category header - contentWidgets.add(_buildCategoryHeader(context, category, Icons.check_box_outlined)); - // Add the items for that category - contentWidgets.addAll( - categorizedItems[category]!.map((entry) { - final key = entry.key; - final value = entry.value; - final status = value ? 'Yes' : 'No'; - final remark = remarks[key] ?? ''; + // 2. Iterate over the DEFINED categories from the map + for (final categoryEntry in _checklistSections.entries) { + final categoryTitle = categoryEntry.key; + final categoryItems = categoryEntry.value; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Row 1: Item and Status - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Item Name - Expanded( - flex: 3, - child: Text( - key, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14.0, - ), + // Add the category header + contentWidgets.add(_buildCategoryHeader(context, categoryTitle, Icons.check_box_outlined)); + + // 3. Add the items for that category + contentWidgets.addAll( + categoryItems.map((itemName) { + // Find the item's status and remark from the log data + final bool value = items[itemName] ?? false; // Default to 'No' + final String remark = remarks[itemName] ?? ''; + final String status = value ? 'Yes' : 'No'; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row 1: Item and Status + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Item Name + Expanded( + flex: 3, + child: Text( + itemName, // Use the name from the category list + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14.0, ), ), - const SizedBox(width: 8), - // Status - Expanded( - flex: 1, - child: Text( - status, + ), + const SizedBox(width: 8), + // Status + Expanded( + flex: 1, + child: Text( + status, + style: TextStyle( + fontSize: 14.0, + color: value ? Colors.green.shade700 : Colors.red.shade700, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.end, + ), + ), + ], + ), + // Row 2: Remark (only if it exists) + if (remark.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6.0, left: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Remark: ", style: TextStyle( - fontSize: 14.0, - color: value ? Colors.green.shade700 : Colors.red.shade700, - fontWeight: FontWeight.bold, + fontSize: 13.0, + color: Colors.grey.shade700, + fontStyle: FontStyle.italic, ), - textAlign: TextAlign.end, ), - ), - ], - ), - // Row 2: Remark (only if it exists) - if (remark.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 6.0, left: 8.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Remark: ", + Expanded( + child: Text( + remark, style: TextStyle( fontSize: 13.0, color: Colors.grey.shade700, fontStyle: FontStyle.italic, ), ), - Expanded( - child: Text( - remark, - style: TextStyle( - fontSize: 13.0, - color: Colors.grey.shade700, - fontStyle: FontStyle.italic, - ), - ), - ), - ], - ), + ), + ], ), - ], - ), - ); - }).toList() - ); + ), + ], + ), + ); + }).toList() + ); - // Add a divider after the category - contentWidgets.add(const Divider(height: 16)); + // Add a divider after the category + contentWidgets.add(const Divider(height: 16)); + } + + // 4. Handle any items that were in the log but NOT in the category map + final Set allCategorizedItems = _checklistSections.values.expand((list) => list).toSet(); + final List otherItems = []; + + for (final itemEntry in items.entries) { + if (!allCategorizedItems.contains(itemEntry.key)) { + // This item was not in our hard-coded map + final key = itemEntry.key; + final value = itemEntry.value; + final status = value ? 'Yes' : 'No'; + final remark = remarks[key] ?? ''; + + otherItems.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 3, child: Text(key, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14.0))), + const SizedBox(width: 8), + Expanded(flex: 1, child: Text(status, style: TextStyle(fontSize: 14.0, color: value ? Colors.green.shade700 : Colors.red.shade700, fontWeight: FontWeight.bold), textAlign: TextAlign.end)), + ], + ), + if (remark.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6.0, left: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Remark: ", style: TextStyle(fontSize: 13.0, color: Colors.grey.shade700, fontStyle: FontStyle.italic)), + Expanded(child: Text(remark, style: TextStyle(fontSize: 13.0, color: Colors.grey.shade700, fontStyle: FontStyle.italic))), + ], + ), + ), + ], + ), + ) + ); } } + if (otherItems.isNotEmpty) { + contentWidgets.add(_buildCategoryHeader(context, "Other Items", Icons.help_outline)); + contentWidgets.addAll(otherItems); + } + if (contentWidgets.isEmpty) { dialogContent = const Center(child: Text('No checklist items found.')); } else { @@ -937,25 +1047,20 @@ class _MarineManualReportStatusLogState // --- END: Handle Pre-Departure Checklist --- } else { - // --- START: Handle ALL OTHER Log Types (uses Column) --- - final List contentWidgets = []; + // --- START: Handle ALL OTHER Log Types (uses Table) --- + final List tableRows = []; // --- Helper for nested maps --- void addNestedMapRows(Map map) { map.forEach((key, value) { if (value is Map) { // Handle nested maps (e.g., ysiSensorChecks) - contentWidgets.add(_buildDataRow(key, '')); + tableRows.add(_buildDataTableRow(key, '')); value.forEach((subKey, subValue) { - contentWidgets.add( - Padding( - padding: const EdgeInsets.only(left: 16.0), - child: _buildDataRow(subKey, subValue?.toString() ?? 'N/A'), - ) - ); + tableRows.add(_buildDataTableRow(' $subKey', subValue?.toString() ?? 'N/A')); }); } else { - contentWidgets.add(_buildDataRow(key, value?.toString() ?? 'N/A')); + tableRows.add(_buildDataTableRow(key, value?.toString() ?? 'N/A')); } }); } @@ -963,119 +1068,128 @@ class _MarineManualReportStatusLogState switch (log.type) { case 'Sonde Calibration': - contentWidgets.add(_buildCategoryHeader(context, 'Sonde Info', Icons.info_outline)); - contentWidgets.add(_buildDataRow('Sonde Serial #', _getString(data, 'sondeSerialNumber'))); - contentWidgets.add(_buildDataRow('Firmware Version', _getString(data, 'firmwareVersion'))); - contentWidgets.add(_buildDataRow('KOR Version', _getString(data, 'korVersion'))); - contentWidgets.add(_buildDataRow('Location', _getString(data, 'location'))); - contentWidgets.add(_buildDataRow('Start Time', _getString(data, 'startDateTime'))); - contentWidgets.add(_buildDataRow('End Time', _getString(data, 'endDateTime'))); - contentWidgets.add(_buildDataRow('Status', _getString(data, 'calibration_status'))); - contentWidgets.add(_buildDataRow('Remarks', _getString(data, 'remarks'))); + tableRows.add(_buildCategoryRow(context, 'Sonde Info', Icons.info_outline)); + tableRows.add(_buildDataTableRow('Sonde Serial #', _getString(data, 'sondeSerialNumber'))); + tableRows.add(_buildDataTableRow('Firmware Version', _getString(data, 'firmwareVersion'))); + tableRows.add(_buildDataTableRow('KOR Version', _getString(data, 'korVersion'))); + tableRows.add(_buildDataTableRow('Location', _getString(data, 'location'))); + tableRows.add(_buildDataTableRow('Start Time', _getString(data, 'startDateTime'))); + tableRows.add(_buildDataTableRow('End Time', _getString(data, 'endDateTime'))); + tableRows.add(_buildDataTableRow('Status', _getString(data, 'calibration_status'))); + tableRows.add(_buildDataTableRow('Remarks', _getString(data, 'remarks'))); - contentWidgets.add(_buildCategoryHeader(context, 'pH 7.0', Icons.science_outlined)); - contentWidgets.add(_buildDataRow('MV', _getString(data, 'ph_7_mv'))); - contentWidgets.add(_buildDataRow('Before', _getString(data, 'ph_7_before'))); - contentWidgets.add(_buildDataRow('After', _getString(data, 'ph_7_after'))); + tableRows.add(_buildCategoryRow(context, 'pH 7.0', Icons.science_outlined)); + tableRows.add(_buildDataTableRow('MV', _getString(data, 'ph_7_mv'))); + tableRows.add(_buildDataTableRow('Before', _getString(data, 'ph_7_before'))); + tableRows.add(_buildDataTableRow('After', _getString(data, 'ph_7_after'))); - contentWidgets.add(_buildCategoryHeader(context, 'pH 10.0', Icons.science_outlined)); - contentWidgets.add(_buildDataRow('MV', _getString(data, 'ph_10_mv'))); - contentWidgets.add(_buildDataRow('Before', _getString(data, 'ph_10_before'))); - contentWidgets.add(_buildDataRow('After', _getString(data, 'ph_10_after'))); + tableRows.add(_buildCategoryRow(context, 'pH 10.0', Icons.science_outlined)); + tableRows.add(_buildDataTableRow('MV', _getString(data, 'ph_10_mv'))); + tableRows.add(_buildDataTableRow('Before', _getString(data, 'ph_10_before'))); + tableRows.add(_buildDataTableRow('After', _getString(data, 'ph_10_after'))); - contentWidgets.add(_buildCategoryHeader(context, 'Conductivity', Icons.thermostat)); - contentWidgets.add(_buildDataRow('Before', _getString(data, 'cond_before'))); - contentWidgets.add(_buildDataRow('After', _getString(data, 'cond_after'))); + tableRows.add(_buildCategoryRow(context, 'Conductivity', Icons.thermostat)); + tableRows.add(_buildDataTableRow('Before', _getString(data, 'cond_before'))); + tableRows.add(_buildDataTableRow('After', _getString(data, 'cond_after'))); - contentWidgets.add(_buildCategoryHeader(context, 'Dissolved Oxygen', Icons.air)); - contentWidgets.add(_buildDataRow('Before', _getString(data, 'do_before'))); - contentWidgets.add(_buildDataRow('After', _getString(data, 'do_after'))); + tableRows.add(_buildCategoryRow(context, 'Dissolved Oxygen', Icons.air)); + tableRows.add(_buildDataTableRow('Before', _getString(data, 'do_before'))); + tableRows.add(_buildDataTableRow('After', _getString(data, 'do_after'))); - contentWidgets.add(_buildCategoryHeader(context, 'Turbidity', Icons.waves)); - contentWidgets.add(_buildDataRow('0 NTU Before', _getString(data, 'turbidity_0_before'))); - contentWidgets.add(_buildDataRow('0 NTU After', _getString(data, 'turbidity_0_after'))); - contentWidgets.add(_buildDataRow('124 NTU Before', _getString(data, 'turbidity_124_before'))); - contentWidgets.add(_buildDataRow('124 NTU After', _getString(data, 'turbidity_124_after'))); + tableRows.add(_buildCategoryRow(context, 'Turbidity', Icons.waves)); + tableRows.add(_buildDataTableRow('0 NTU Before', _getString(data, 'turbidity_0_before'))); + tableRows.add(_buildDataTableRow('0 NTU After', _getString(data, 'turbidity_0_after'))); + tableRows.add(_buildDataTableRow('124 NTU Before', _getString(data, 'turbidity_124_before'))); + tableRows.add(_buildDataTableRow('124 NTU After', _getString(data, 'turbidity_124_after'))); break; case 'Equipment Maintenance': - contentWidgets.add(_buildCategoryHeader(context, 'YSI Sonde Checks', Icons.build_circle_outlined)); + tableRows.add(_buildCategoryRow(context, 'YSI Sonde Checks', Icons.build_circle_outlined)); if (data['ysiSondeChecks'] != null) { addNestedMapRows(Map.from(data['ysiSondeChecks'])); } - contentWidgets.add(_buildDataRow('Comments', _getString(data, 'ysiSondeComments'))); + tableRows.add(_buildDataTableRow('Comments', _getString(data, 'ysiSondeComments'))); - contentWidgets.add(_buildCategoryHeader(context, 'YSI Sensor Checks', Icons.sensors)); + tableRows.add(_buildCategoryRow(context, 'YSI Sensor Checks', Icons.sensors)); if (data['ysiSensorChecks'] != null) { addNestedMapRows(Map.from(data['ysiSensorChecks'])); } - contentWidgets.add(_buildDataRow('Comments', _getString(data, 'ysiSensorComments'))); + tableRows.add(_buildDataTableRow('Comments', _getString(data, 'ysiSensorComments'))); - contentWidgets.add(_buildCategoryHeader(context, 'YSI Replacements', Icons.published_with_changes)); + tableRows.add(_buildCategoryRow(context, 'YSI Replacements', Icons.published_with_changes)); if (data['ysiReplacements'] != null) { addNestedMapRows(Map.from(data['ysiReplacements'])); } - contentWidgets.add(_buildCategoryHeader(context, 'Van Dorn Checks', Icons.opacity)); + tableRows.add(_buildCategoryRow(context, 'Van Dorn Checks', Icons.opacity)); if (data['vanDornChecks'] != null) { addNestedMapRows(Map.from(data['vanDornChecks'])); } - contentWidgets.add(_buildDataRow('Comments', _getString(data, 'vanDornComments'))); - contentWidgets.add(_buildDataRow('Current Serial', _getString(data, 'vanDornCurrentSerial'))); - contentWidgets.add(_buildDataRow('New Serial', _getString(data, 'vanDornNewSerial'))); + tableRows.add(_buildDataTableRow('Comments', _getString(data, 'vanDornComments'))); + tableRows.add(_buildDataTableRow('Current Serial', _getString(data, 'vanDornCurrentSerial'))); + tableRows.add(_buildDataTableRow('New Serial', _getString(data, 'vanDornNewSerial'))); - contentWidgets.add(_buildCategoryHeader(context, 'Van Dorn Replacements', Icons.published_with_changes)); + tableRows.add(_buildCategoryRow(context, 'Van Dorn Replacements', Icons.published_with_changes)); if (data['vanDornReplacements'] != null) { addNestedMapRows(Map.from(data['vanDornReplacements'])); } break; case 'NPE Report': - contentWidgets.add(_buildCategoryHeader(context, 'Event Info', Icons.calendar_today)); - contentWidgets.add(_buildDataRow('Date', _getString(data, 'eventDate'))); - contentWidgets.add(_buildDataRow('Time', _getString(data, 'eventTime'))); - contentWidgets.add(_buildDataRow('Sampler', _getString(data, 'firstSamplerName'))); + tableRows.add(_buildCategoryRow(context, 'Event Info', Icons.calendar_today)); + tableRows.add(_buildDataTableRow('Date', _getString(data, 'eventDate'))); + tableRows.add(_buildDataTableRow('Time', _getString(data, 'eventTime'))); + tableRows.add(_buildDataTableRow('Sampler', _getString(data, 'firstSamplerName'))); - contentWidgets.add(_buildCategoryHeader(context, 'Location', Icons.location_on_outlined)); + tableRows.add(_buildCategoryRow(context, 'Location', Icons.location_on_outlined)); if (data['selectedStation'] != null) { - contentWidgets.add(_buildDataRow('Station', _getString(data['selectedStation'], 'man_station_name') ?? _getString(data['selectedStation'], 'tbl_station_name'))); + tableRows.add(_buildDataTableRow('Station', _getString(data['selectedStation'], 'man_station_name') ?? _getString(data['selectedStation'], 'tbl_station_name'))); } else { - contentWidgets.add(_buildDataRow('Location', _getString(data, 'locationDescription'))); - contentWidgets.add(_buildDataRow('State', _getString(data, 'stateName'))); + tableRows.add(_buildDataTableRow('Location', _getString(data, 'locationDescription'))); + tableRows.add(_buildDataTableRow('State', _getString(data, 'stateName'))); } - contentWidgets.add(_buildDataRow('Latitude', _getString(data, 'latitude'))); - contentWidgets.add(_buildDataRow('Longitude', _getString(data, 'longitude'))); + tableRows.add(_buildDataTableRow('Latitude', _getString(data, 'latitude'))); + tableRows.add(_buildDataTableRow('Longitude', _getString(data, 'longitude'))); - contentWidgets.add(_buildCategoryHeader(context, 'Parameters', Icons.bar_chart)); - contentWidgets.add(_buildDataRow('Oxygen Conc (mg/L)', _getString(data, 'oxygenConcentration'))); - contentWidgets.add(_buildDataRow('Oxygen Sat (%)', _getString(data, 'oxygenSaturation'))); - contentWidgets.add(_buildDataRow('pH', _getString(data, 'ph'))); - contentWidgets.add(_buildDataRow('Conductivity (µS/cm)', _getString(data, 'electricalConductivity'))); - contentWidgets.add(_buildDataRow('Temperature (°C)', _getString(data, 'temperature'))); - contentWidgets.add(_buildDataRow('Turbidity (NTU)', _getString(data, 'turbidity'))); + tableRows.add(_buildCategoryRow(context, 'Parameters', Icons.bar_chart)); + tableRows.add(_buildDataTableRow('Oxygen Conc (mg/L)', _getString(data, 'oxygenConcentration'))); + tableRows.add(_buildDataTableRow('Oxygen Sat (%)', _getString(data, 'oxygenSaturation'))); + tableRows.add(_buildDataTableRow('pH', _getString(data, 'ph'))); + tableRows.add(_buildDataTableRow('Conductivity (µS/cm)', _getString(data, 'electricalConductivity'))); + tableRows.add(_buildDataTableRow('Temperature (°C)', _getString(data, 'temperature'))); + tableRows.add(_buildDataTableRow('Turbidity (NTU)', _getString(data, 'turbidity'))); - contentWidgets.add(_buildCategoryHeader(context, 'Observations', Icons.warning_amber_rounded)); + tableRows.add(_buildCategoryRow(context, 'Observations', Icons.warning_amber_rounded)); if (data['fieldObservations'] != null) { final observations = Map.from(data['fieldObservations']); observations.forEach((key, value) { - if(value) contentWidgets.add(_buildDataRow(key, 'Checked')); + if(value) tableRows.add(_buildDataTableRow(key, 'Checked')); }); } - contentWidgets.add(_buildDataRow('Other Remarks', _getString(data, 'othersObservationRemark'))); - contentWidgets.add(_buildDataRow('Possible Source', _getString(data, 'possibleSource'))); + tableRows.add(_buildDataTableRow('Other Remarks', _getString(data, 'othersObservationRemark'))); + tableRows.add(_buildDataTableRow('Possible Source', _getString(data, 'possibleSource'))); if (data['selectedTarballClassification'] != null) { - contentWidgets.add(_buildDataRow('Tarball Class', _getString(data['selectedTarballClassification'], 'classification_name'))); + tableRows.add(_buildDataTableRow('Tarball Class', _getString(data['selectedTarballClassification'], 'classification_name'))); } break; default: - contentWidgets.add(_buildDataRow('Error', 'No data view configured for log type: ${log.type}')); + tableRows.add(_buildDataTableRow('Error', 'No data view configured for log type: ${log.type}')); } - // Assign the Column as the content for the 'else' block - dialogContent = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: contentWidgets, + // Assign the Table as the content for the 'else' block + dialogContent = Table( + columnWidths: const { + 0: IntrinsicColumnWidth(), // Label column + 1: FlexColumnWidth(), // Value column + }, + border: TableBorder( + horizontalInside: BorderSide( + color: Colors.grey.shade300, + width: 0.5, + ), + ), + children: tableRows, ); // --- END: Handle ALL OTHER Log Types --- } diff --git a/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart b/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart index 32fca9e..32da99c 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart @@ -409,7 +409,7 @@ class _InSituStep3DataCaptureState extends State with Wi List> _validateParameters(Map readings, List> limits) { final List> invalidParams = []; - final int? stationId = widget.data.selectedStation?['station_id']; + final int? stationId = widget.data.selectedStation?['man_station_id']; debugPrint("--- Parameter Validation Start ---"); debugPrint("Selected Station ID: $stationId"); diff --git a/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart b/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart index 402ccea..5afff19 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart @@ -47,7 +47,7 @@ class _InSituStep4SummaryState extends State { final authProvider = Provider.of(context, listen: false); final marineLimits = authProvider.marineParameterLimits ?? []; final Set invalidKeys = {}; - final int? stationId = widget.data.selectedStation?['station_id']; + final int? stationId = widget.data.selectedStation?['man_station_id']; final readings = { 'oxygenConcentration': widget.data.oxygenConcentration, diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index b3abc52..a3d3c1d 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -338,7 +338,11 @@ class LocalStorageService { await eventDir.create(recursive: true); } - final Map jsonData = data.toDbJson(); + // --- START: MODIFICATION (FIXED ERROR) --- + // Changed data.toDbJson() to data.toMap() to get a Map, not a String. + final Map jsonData = data.toMap(); + // --- END: MODIFICATION (FIXED ERROR) --- + jsonData['submissionStatus'] = data.submissionStatus; jsonData['submissionMessage'] = data.submissionMessage; diff --git a/lib/services/marine_in_situ_sampling_service.dart b/lib/services/marine_in_situ_sampling_service.dart index e4ce69a..51fbb49 100644 --- a/lib/services/marine_in_situ_sampling_service.dart +++ b/lib/services/marine_in_situ_sampling_service.dart @@ -291,11 +291,19 @@ class MarineInSituSamplingService { // --- START FIX: Add ftpConfigId when queuing --- final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? []; + // --- MODIFIED: Use new data model methods for multi-json zip --- final dataZip = await _zippingService.createDataZip( - jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, + jsonDataMap: { + 'db.json': data.toDbJson(), + 'marine_insitu_basic_form.json': data.toBasicFormJson(), + 'marine_sampling_reading.json': data.toReadingJson(), + 'marine_manual_info.json': data.toManualInfoJson(), + }, baseFileName: baseFileNameForQueue, destinationDir: null, // Use temp dir ); + // --- END MODIFIED --- + if (dataZip != null) { // Queue for each config separately for (final config in ftpConfigs) { @@ -401,7 +409,10 @@ class MarineInSituSamplingService { // Save/Update local log first if (savedLogPath != null && savedLogPath.isNotEmpty) { // Need to reconstruct the map with file paths for updating - Map logUpdateData = data.toDbJson(); + // --- START: MODIFICATION (FIXED ERROR) --- + // Changed data.toDbJson() to data.toMap() to get a Map, not a String. + Map logUpdateData = data.toMap(); + // --- END: MODIFICATION (FIXED ERROR) --- final imageFiles = data.toApiImageFiles(); imageFiles.forEach((key, file) { logUpdateData[key] = file?.path; // Add paths back @@ -439,12 +450,21 @@ class MarineInSituSamplingService { return {'success': true, 'message': successMessage, 'reportId': null}; // No report ID yet } + // --- START: MODIFIED _generateBaseFileName --- /// Helper to generate the base filename for ZIP files. String _generateBaseFileName(InSituSamplingData data) { final stationCode = data.selectedStation?['man_station_code'] ?? 'NA'; - final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); - return '${stationCode}_$fileTimestamp'; + + // Check if reportId (timestamp) is available. + if (data.reportId != null && data.reportId!.isNotEmpty) { + return '${stationCode}_${data.reportId}'; + } else { + // Fallback to old method if reportId is not available (e.g., offline queue) + final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); + return '${stationCode}_$fileTimestamp'; + } } + // --- END: MODIFIED _generateBaseFileName --- /// Generates data and image ZIP files and uploads them using SubmissionFtpService. Future> _generateAndUploadFtpFiles(InSituSamplingData data, Map imageFiles, String serverName, String moduleName) async { @@ -455,19 +475,32 @@ class MarineInSituSamplingService { module: 'marine', subModule: 'marine_in_situ_sampling', // Correct sub-module path ); - final folderName = data.reportId ?? baseFileName; + + // --- START: MODIFIED folderName --- + // Use baseFileName for the folder name to match [stationCode]_[reportId] + final folderName = baseFileName; + // --- END: MODIFIED folderName --- + final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null; if (localSubmissionDir != null && !await localSubmissionDir.exists()) { await localSubmissionDir.create(recursive: true); } - // Create and upload data ZIP + // --- START: MODIFIED createDataZip CALL --- + // Create and upload data ZIP (with multiple JSON files as per new requirement) final dataZip = await _zippingService.createDataZip( - jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, // Use toDbJson for FTP + jsonDataMap: { + 'db.json': data.toDbJson(), + 'marine_insitu_basic_form.json': data.toBasicFormJson(), + 'marine_sampling_reading.json': data.toReadingJson(), + 'marine_manual_info.json': data.toManualInfoJson(), + }, baseFileName: baseFileName, destinationDir: localSubmissionDir, ); + // --- END: MODIFIED createDataZip CALL --- + Map ftpDataResult = {'success': true, 'statuses': []}; if (dataZip != null) { ftpDataResult = await _submissionFtpService.submit( @@ -516,7 +549,10 @@ class MarineInSituSamplingService { final baseFileName = _generateBaseFileName(data); // Use helper // Prepare log data map including file paths - Map logMapData = data.toDbJson(); + // --- START: MODIFICATION (FIXED ERROR) --- + // Changed data.toDbJson() to data.toMap() to get a Map, not a String. + Map logMapData = data.toMap(); + // --- END: MODIFICATION (FIXED ERROR) --- final imageFileMap = data.toApiImageFiles(); imageFileMap.forEach((key, file) { logMapData[key] = file?.path; // Store path or null @@ -650,7 +686,9 @@ class MarineInSituSamplingService { final allLimits = await _dbHelper.loadMarineParameterLimits() ?? []; if (allLimits.isEmpty) return ""; - final int? stationId = data.selectedStation?['station_id']; + // --- START FIX: Use correct key 'man_station_id' --- + final dynamic stationId = data.selectedStation?['man_station_id']; + // --- END FIX --- if (stationId == null) return ""; // Cannot check limits without a station ID final readings = { @@ -771,7 +809,7 @@ class MarineInSituSamplingService { if (isHit) { final valueStr = value.toStringAsFixed(5); final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A'; - final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A'; + final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/á'; String limitStr; if (lowerStr != 'N/A' && upperStr != 'N/A') { limitStr = '$lowerStr - $upperStr'; diff --git a/lib/services/marine_investigative_sampling_service.dart b/lib/services/marine_investigative_sampling_service.dart index 5f03dba..f790a7b 100644 --- a/lib/services/marine_investigative_sampling_service.dart +++ b/lib/services/marine_investigative_sampling_service.dart @@ -14,6 +14,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:usb_serial/usb_serial.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:intl/intl.dart'; // Import intl import '../auth_provider.dart'; import 'location_service.dart'; @@ -71,9 +72,10 @@ class MarineInvestigativeSamplingService { img.Image? originalImage = img.decodeImage(bytes); if (originalImage == null) return null; + // --- MODIFIED: Enforce landscape check logic from in-situ --- if (isRequired && originalImage.height > originalImage.width) { debugPrint("Image rejected: Must be in landscape orientation."); - return null; + return null; // Return null if landscape is required and check fails } final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}"; @@ -223,6 +225,10 @@ class MarineInvestigativeSamplingService { required AuthProvider authProvider, String? logDirectory, }) async { + // --- START FIX: Capture the status before attempting submission --- + final String? previousStatus = data.submissionStatus; + // --- END FIX --- + final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; final imageFilesWithNulls = data.toApiImageFiles(); imageFilesWithNulls.removeWhere((key, value) => value == null); @@ -287,7 +293,6 @@ class MarineInvestigativeSamplingService { debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks."); final baseFileNameForQueue = _generateBaseFileName(data); - // --- START FIX: Add ftpConfigId when queuing --- final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? []; final dataZip = await _zippingService.createDataZip( @@ -296,14 +301,13 @@ class MarineInvestigativeSamplingService { destinationDir: null, // Use temp dir ); if (dataZip != null) { - // Queue for each config separately for (final config in ftpConfigs) { final configId = config['ftp_config_id']; if (configId != null) { await _retryService.addFtpToQueue( localFilePath: dataZip.path, remotePath: '/${p.basename(dataZip.path)}', - ftpConfigId: configId // Provide the specific config ID + ftpConfigId: configId ); } } @@ -316,33 +320,29 @@ class MarineInvestigativeSamplingService { destinationDir: null, // Use temp dir ); if (imageZip != null) { - // Queue for each config separately for (final config in ftpConfigs) { final configId = config['ftp_config_id']; if (configId != null) { await _retryService.addFtpToQueue( localFilePath: imageZip.path, remotePath: '/${p.basename(imageZip.path)}', - ftpConfigId: configId // Provide the specific config ID + ftpConfigId: configId ); } } } } - // --- END FIX --- ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]}; anyFtpSuccess = false; } else { // Session is OK, proceed with normal FTP attempt try { - // _generateAndUploadFtpFiles already uses the generic SubmissionFtpService ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName); - // Check if *any* configured FTP target succeeded (excluding 'Not Configured') anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured'); } catch (e) { debugPrint("Unexpected FTP submission error: $e"); - anyFtpSuccess = false; // FTP failures are auto-queued by SubmissionFtpService + anyFtpSuccess = false; } } @@ -376,7 +376,10 @@ class MarineInvestigativeSamplingService { ); // 6. Send Alert - if (overallSuccess) { + // --- START FIX: Check if log was already successful before sending alert --- + final bool wasAlreadySuccessful = previousStatus == 'S4' || previousStatus == 'S3' || previousStatus == 'L4'; + if (overallSuccess && !wasAlreadySuccessful) { + // --- END FIX --- _handleInvestigativeSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); } @@ -397,9 +400,7 @@ class MarineInvestigativeSamplingService { String? savedLogPath = logDirectory; // Use existing path if provided - // Save/Update local log first if (savedLogPath != null && savedLogPath.isNotEmpty) { - // Prepare map with file paths for update Map logUpdateData = data.toDbJson(); final imageFiles = data.toApiImageFiles(); imageFiles.forEach((key, file) { @@ -429,9 +430,6 @@ class MarineInvestigativeSamplingService { ); const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored."; - // Log final queued state to central DB - // await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: savedLogPath); - return {'success': true, 'message': successMessage, 'reportId': null}; // No report ID yet } @@ -444,10 +442,16 @@ class MarineInvestigativeSamplingService { } else if (data.stationTypeSelection == 'New Location') { stationCode = data.newStationCode ?? 'NEW_NA'; } - final datePart = data.samplingDate ?? 'NODATE'; - final timePart = (data.samplingTime ?? 'NOTIME').replaceAll(':', '-'); - final fileTimestamp = "${datePart}_${timePart}".replaceAll(' ', '_'); - return '${stationCode}_$fileTimestamp'; + + // --- START: MODIFIED (from in-situ) --- + // Use reportId if available, otherwise fall back to timestamp + if (data.reportId != null && data.reportId!.isNotEmpty) { + return '${stationCode}_${data.reportId}'; + } else { + final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); + return '${stationCode}_$fileTimestamp'; + } + // --- END: MODIFIED --- } @@ -459,7 +463,12 @@ class MarineInvestigativeSamplingService { module: 'marine', subModule: 'marine_investigative_sampling', ); - final folderName = data.reportId ?? baseFileName; + + // --- START: MODIFIED folderName (from in-situ) --- + // Use baseFileName for the folder name to match [stationCode]_[reportId] + final folderName = baseFileName; + // --- END: MODIFIED folderName --- + final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null; if (localSubmissionDir != null && !await localSubmissionDir.exists()) { @@ -470,11 +479,15 @@ class MarineInvestigativeSamplingService { } } + // --- START: MODIFIED createDataZip call (from in-situ) --- + // This module does not have the extra JSON files, so we keep the single db.json final dataZip = await _zippingService.createDataZip( jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, baseFileName: baseFileName, destinationDir: localSubmissionDir, ); + // --- END: MODIFIED createDataZip call --- + Map ftpDataResult = {'success': true, 'statuses': []}; if (dataZip != null) { ftpDataResult = await _submissionFtpService.submit( @@ -525,13 +538,11 @@ class MarineInvestigativeSamplingService { data.submissionMessage = message; final baseFileName = _generateBaseFileName(data); - // Prepare log data map including file paths Map logMapData = data.toDbJson(); final imageFileMap = data.toApiImageFiles(); imageFileMap.forEach((key, file) { logMapData[key] = file?.path; // Store path or null }); - // Add submission metadata logMapData['submissionStatus'] = status; logMapData['submissionMessage'] = message; logMapData['reportId'] = data.reportId; @@ -568,10 +579,11 @@ class MarineInvestigativeSamplingService { } } - + // --- START: MODIFIED ALERT HANDLER --- Future _handleInvestigativeSuccessAlert(MarineInvesManualSamplingData data, List>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { - String generateInvestigativeTelegramAlertMessage(MarineInvesManualSamplingData data, {required bool isDataOnly}) { + // This internal function generates the main message + Future generateInvestigativeTelegramAlertMessage(MarineInvesManualSamplingData data, {required bool isDataOnly}) async { final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; String stationName = 'N/A'; @@ -588,31 +600,54 @@ class MarineInvestigativeSamplingService { stationCode = data.newStationCode ?? 'NEW'; } + final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); + final submissionTime = data.samplingTime ?? DateFormat('HH:mm:ss').format(DateTime.now()); + final submitter = data.firstSamplerName ?? 'N/A'; + final buffer = StringBuffer() ..writeln('🕵️ *Marine Investigative Sample $submissionType Submitted:*') ..writeln() ..writeln('*Station Name & Code:* $stationName ($stationCode)') - ..writeln('*Date of Submitted:* ${data.samplingDate}') - ..writeln('*Submitted by User:* ${data.firstSamplerName}') + ..writeln('*Date & Time of Submission:* $submissionDate $submissionTime') + ..writeln('*Submitted by User:* $submitter') ..writeln('*Sonde ID:* ${data.sondeId ?? "N/A"}') ..writeln('*Status of Submission:* Successful'); - if (data.distanceDifferenceInKm != null && data.distanceDifferenceInKm! * 1000 > 50) { + final distanceKm = data.distanceDifferenceInKm ?? 0; + final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); + final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A'; + if (distanceKm * 1000 > 50 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { buffer ..writeln() ..writeln('🔔 *Distance Alert:*') - ..writeln('*Distance from station:* ${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters'); + ..writeln('*Distance from station:* $distanceMeters meters (${distanceKm.toStringAsFixed(3)} KM)'); - if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty) { - buffer.writeln('*Remarks for distance:* ${data.distanceDifferenceRemarks}'); + if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { + buffer.writeln('*Remarks for distance:* $distanceRemarks'); } } + // --- NEW: Add parameter limit checks to message --- + // 1. Add station parameter limit check section + final outOfBoundsAlert = await _getOutOfBoundsAlertSection(data); + if (outOfBoundsAlert.isNotEmpty) { + buffer.write(outOfBoundsAlert); + } + + // 2. Add NPE parameter limit check section + final npeAlert = await _getNpeAlertSection(data); + if (npeAlert.isNotEmpty) { + buffer.write(npeAlert); + } + // --- END NEW --- + return buffer.toString(); } + // --- End internal function --- try { - final message = generateInvestigativeTelegramAlertMessage(data, isDataOnly: isDataOnly); + // Call the internal function to build the message + final message = await generateInvestigativeTelegramAlertMessage(data, isDataOnly: isDataOnly); final alertKey = 'marine_investigative'; if (isSessionExpired) { @@ -628,4 +663,161 @@ class MarineInvestigativeSamplingService { debugPrint("Failed to handle Investigative Telegram alert: $e"); } } + + // --- NEW: Added from in-situ service --- + /// Helper to generate the station-specific parameter limit alert section. + Future _getOutOfBoundsAlertSection(MarineInvesManualSamplingData data) async { + // Only check limits if it's a Manual Station + if (data.stationTypeSelection != 'Existing Manual Station') { + return ""; + } + + const Map _parameterKeyToLimitName = { + 'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', 'ph': 'pH', + 'salinity': 'Salinity', 'electricalConductivity': 'Conductivity', 'temperature': 'Temperature', + 'tds': 'TDS', 'turbidity': 'Turbidity', 'tss': 'TSS', 'batteryVoltage': 'Battery', + }; + + final allLimits = await _dbHelper.loadMarineParameterLimits() ?? []; + if (allLimits.isEmpty) return ""; + + final dynamic stationId = data.selectedStation?['man_station_id']; + if (stationId == null) return ""; // Cannot check limits + + 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, + }; + + final List outOfBoundsMessages = []; + + 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 = allLimits.firstWhere( + (l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(), + orElse: () => {}, + ); + + 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)) { + final valueStr = value.toStringAsFixed(5); + final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A'; + final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A'; + outOfBoundsMessages.add('- *$limitName*: `$valueStr` (Station Limit: `$lowerStr` - `$upperStr`)'); + } + } + }); + + if (outOfBoundsMessages.isEmpty) { + return ""; + } + + final buffer = StringBuffer() + ..writeln() + ..writeln('⚠️ *Station Parameter Limit Alert:*') + ..writeln('The following parameters were outside their defined station limits:'); + buffer.writeAll(outOfBoundsMessages, '\n'); + + return buffer.toString(); + } + + // --- NEW: Added from in-situ service --- + /// Helper to generate the NPE parameter limit alert section. + Future _getNpeAlertSection(MarineInvesManualSamplingData data) async { + const Map _parameterKeyToLimitName = { + 'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', 'ph': 'pH', + 'salinity': 'Salinity', 'electricalConductivity': 'Conductivity', 'temperature': 'Temperature', + 'tds': 'TDS', 'turbidity': 'Turbidity', 'tss': 'TSS', + }; + + final npeLimits = await _dbHelper.loadNpeParameterLimits() ?? []; + if (npeLimits.isEmpty) return ""; + + 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, + }; + + final List npeMessages = []; + + 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) { + final valueStr = value.toStringAsFixed(5); + final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A'; + final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/á'; + String limitStr; + if (lowerStr != 'N/A' && upperStr != 'N/A') { + limitStr = '$lowerStr - $upperStr'; + } else if (lowerStr != 'N/A') { + limitStr = '>= $lowerStr'; + } else { + limitStr = '<= $upperStr'; + } + npeMessages.add('- *$limitName*: `$valueStr` (NPE Limit: `$limitStr`)'); + } + } + }); + + if (npeMessages.isEmpty) { + return ""; + } + + final buffer = StringBuffer() + ..writeln() + ..writeln(' ') + ..writeln('🚨 *NPE Parameter Limit Detected:*') + ..writeln('The following parameters triggered an NPE alert:'); + buffer.writeAll(npeMessages, '\n'); + + return buffer.toString(); + } +// --- END: MODIFIED ALERT HANDLER & HELPERS --- } \ No newline at end of file diff --git a/lib/services/river_in_situ_sampling_service.dart b/lib/services/river_in_situ_sampling_service.dart index 33e4b09..fb4444d 100644 --- a/lib/services/river_in_situ_sampling_service.dart +++ b/lib/services/river_in_situ_sampling_service.dart @@ -437,12 +437,21 @@ class RiverInSituSamplingService { return {'status': 'Queued', 'success': true, 'message': successMessage, 'reportId': null}; } + // --- START: MODIFIED _generateBaseFileName --- /// Helper to generate the base filename for ZIP files. String _generateBaseFileName(RiverInSituSamplingData data) { final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN'; - final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); - return "${stationCode}_$fileTimestamp"; + + // Check if reportId (timestamp) is available. + if (data.reportId != null && data.reportId!.isNotEmpty) { + return '${stationCode}_${data.reportId}'; + } else { + // Fallback to old method if reportId is not available (e.g., offline queue) + final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); + return "${stationCode}_$fileTimestamp"; + } } + // --- END: MODIFIED _generateBaseFileName --- /// Generates data and image ZIP files and uploads them using SubmissionFtpService. Future> _generateAndUploadFtpFiles(RiverInSituSamplingData data, Map imageFiles, String serverName, String moduleName) async { @@ -450,7 +459,11 @@ class RiverInSituSamplingService { final Directory? logDirectory = await _localStorageService.getRiverInSituBaseDir(data.samplingType, serverName: serverName); // Use correct base dir getter - final folderName = data.reportId ?? baseFileName; + // --- START: MODIFIED folderName --- + // Use baseFileName for the folder name to match [stationCode]_[reportId] + final folderName = baseFileName; + // --- END: MODIFIED folderName --- + final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null; if (localSubmissionDir != null && !await localSubmissionDir.exists()) { await localSubmissionDir.create(recursive: true);