repair marine investigative module

This commit is contained in:
ALim Aidrus 2025-11-15 21:12:16 +08:00
parent fa5f0361de
commit 882386c522
11 changed files with 1064 additions and 316 deletions

View File

@ -32,6 +32,7 @@ class InSituSamplingData {
String? weather; String? weather;
String? tideLevel; String? tideLevel;
String? seaCondition; String? seaCondition;
// String? tarball; // <-- REMOVED THIS PROPERTY
String? eventRemarks; String? eventRemarks;
String? labRemarks; String? labRemarks;
@ -201,6 +202,7 @@ class InSituSamplingData {
data.weather = json['weather']; data.weather = json['weather'];
data.tideLevel = json['tide_level']; data.tideLevel = json['tide_level'];
data.seaCondition = json['sea_condition']; data.seaCondition = json['sea_condition'];
// data.tarball = json['tarball']; // <-- REMOVED DESERIALIZATION
data.eventRemarks = json['event_remarks']; data.eventRemarks = json['event_remarks'];
data.labRemarks = json['lab_remarks']; data.labRemarks = json['lab_remarks'];
data.optionalRemark1 = json['man_optional_photo_01_remarks']; data.optionalRemark1 = json['man_optional_photo_01_remarks'];
@ -254,8 +256,8 @@ class InSituSamplingData {
return data; return data;
} }
/// Creates a single JSON object with all submission data for offline storage. /// Creates a Map object with all submission data for local logging.
Map<String, dynamic> toDbJson() { Map<String, dynamic> toMap() {
return { return {
'first_sampler_name': firstSamplerName, 'first_sampler_name': firstSamplerName,
'first_sampler_user_id': firstSamplerUserId, 'first_sampler_user_id': firstSamplerUserId,
@ -276,6 +278,7 @@ class InSituSamplingData {
'weather': weather, 'weather': weather,
'tide_level': tideLevel, 'tide_level': tideLevel,
'sea_condition': seaCondition, 'sea_condition': seaCondition,
// 'tarball': tarball, // <-- REMOVED
'event_remarks': eventRemarks, 'event_remarks': eventRemarks,
'lab_remarks': labRemarks, 'lab_remarks': labRemarks,
'man_optional_photo_01_remarks': optionalRemark1, 'man_optional_photo_01_remarks': optionalRemark1,
@ -305,8 +308,101 @@ class InSituSamplingData {
}; };
} }
// --- REMOVED: generateTelegramAlertMessage method --- /// Creates a single JSON object with all submission data, mimicking 'db.json'
// This logic is now in MarineInSituSamplingService 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<String, String> toApiFormData() { Map<String, String> toApiFormData() {
final Map<String, String> map = {}; final Map<String, String> map = {};

View File

@ -1,6 +1,7 @@
// lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart // lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; // <-- ADDED: Required for Uint8List
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -26,7 +27,7 @@ class MarineInvesManualStep2SiteInfo extends StatefulWidget {
class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2SiteInfo> { class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2SiteInfo> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false; bool _isPickingImage = false; // <-- ADDED: State variable from in-situ
late final TextEditingController _eventRemarksController; late final TextEditingController _eventRemarksController;
late final TextEditingController _labRemarksController; late final TextEditingController _labRemarksController;
@ -49,6 +50,7 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
super.dispose(); super.dispose();
} }
// --- START: MODIFIED _setImage function (from in-situ) ---
/// Handles picking and processing an image using the dedicated service. /// Handles picking and processing an image using the dedicated service.
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async { void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return; if (_isPickingImage) return;
@ -56,19 +58,23 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false); final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
// The service's pickAndProcessImage method will handle file naming // Always pass `isRequired: true` to the service to enforce landscape check
final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: isRequired); // 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) { if (file != null) {
setState(() => setImageCallback(file)); setState(() => setImageCallback(file));
} else if (mounted) { } 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) { if (mounted) {
setState(() => _isPickingImage = false); setState(() => _isPickingImage = false);
} }
} }
// --- END: MODIFIED _setImage function ---
/// Validates the form and all required images before proceeding. /// Validates the form and all required images before proceeding.
void _goToNextStep() { void _goToNextStep() {
@ -142,7 +148,11 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
// --- Section: Required Photos --- // --- Section: Required Photos ---
Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge), Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge),
const Text("All photos must be taken in landscape (horizontal) orientation.", style: TextStyle(color: Colors.grey)), // MODIFIED: Matched in-situ text
const Text(
"All photos must be in landscape (horizontal) orientation. A watermark will be applied automatically.",
style: TextStyle(color: Colors.grey)
),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildImagePicker('Left Side Land View', 'LEFT_LAND_VIEW', widget.data.leftLandViewImage, (file) => widget.data.leftLandViewImage = file, isRequired: true), _buildImagePicker('Left Side Land View', 'LEFT_LAND_VIEW', widget.data.leftLandViewImage, (file) => widget.data.leftLandViewImage = file, isRequired: true),
_buildImagePicker('Right Side Land View', 'RIGHT_LAND_VIEW', widget.data.rightLandViewImage, (file) => widget.data.rightLandViewImage = 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<MarineInvesManualStep2S
); );
} }
/// A reusable widget for picking and displaying an image // --- START: MODIFIED _buildImagePicker (from in-situ) ---
/// A reusable widget for picking and displaying an image.
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {bool isRequired = false}) { Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {bool isRequired = false}) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
@ -207,14 +218,63 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
const SizedBox(height: 8), const SizedBox(height: 8),
if (imageFile != null) if (imageFile != null)
Stack( Stack(
// ... (Image preview stack - same as original) alignment: Alignment.topRight,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: FutureBuilder<Uint8List>(
// 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 else
Row( 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 ---
} }

View File

@ -95,29 +95,22 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
super.dispose(); super.dispose();
} }
// --- START: MODIFIED LIFECYCLE METHOD ---
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
if (mounted) { if (mounted) {
// Use the member variable, not context
final service = _samplingService; final service = _samplingService;
final btConnecting = service.bluetoothConnectionState.value == BluetoothConnectionState.connecting; final btConnecting = service.bluetoothConnectionState.value == BluetoothConnectionState.connecting;
final serialConnecting = service.serialConnectionState.value == SerialConnectionState.connecting; final serialConnecting = service.serialConnectionState.value == SerialConnectionState.connecting;
// If the widget's local state is loading OR the service's state is stuck connecting
if (_isLoading || btConnecting || serialConnecting) { if (_isLoading || btConnecting || serialConnecting) {
// Force-call disconnect to reset both the service's state
// and the local _isLoading flag (inside _disconnect).
_disconnectFromAll(); _disconnectFromAll();
} else { } else {
// If not stuck, just a normal refresh
setState(() {}); setState(() {});
} }
} }
} }
} }
// --- END: MODIFIED LIFECYCLE METHOD ---
void _initializeControllers() { void _initializeControllers() {
widget.data.dataCaptureDate = widget.data.samplingDate; widget.data.dataCaptureDate = widget.data.samplingDate;
@ -299,9 +292,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
}); });
} }
// --- START: MODIFIED DISCONNECT METHOD ---
void _disconnect(String type) { void _disconnect(String type) {
// Use the member variable, not context, for lifecycle safety
final service = _samplingService; final service = _samplingService;
if (type == 'bluetooth') { if (type == 'bluetooth') {
service.disconnectFromBluetooth(); service.disconnectFromBluetooth();
@ -315,15 +306,12 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
setState(() { setState(() {
_isAutoReading = false; _isAutoReading = false;
_isLockedOut = false; _isLockedOut = false;
_isLoading = false; // <-- CRITICAL: Also reset the loading flag _isLoading = false;
}); });
} }
} }
// --- END: MODIFIED DISCONNECT METHOD ---
// --- START: MODIFIED DISCONNECT_ALL METHOD ---
void _disconnectFromAll() { void _disconnectFromAll() {
// Use the member variable, not context, for lifecycle safety
final service = _samplingService; final service = _samplingService;
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_disconnect('bluetooth'); _disconnect('bluetooth');
@ -332,7 +320,6 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
_disconnect('serial'); _disconnect('serial');
} }
} }
// --- END: MODIFIED DISCONNECT_ALL METHOD ---
void _updateTextFields(Map<String, double> readings) { void _updateTextFields(Map<String, double> readings) {
const defaultValue = -999.0; const defaultValue = -999.0;
@ -350,6 +337,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
}); });
} }
// --- START: MODIFIED _validateAndProceed ---
void _validateAndProceed() { void _validateAndProceed() {
if (_isLockedOut) { if (_isLockedOut) {
_showSnackBar("Please wait for the initial reading period to complete.", isError: true); _showSnackBar("Please wait for the initial reading period to complete.", isError: true);
@ -366,13 +354,25 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
_formKey.currentState!.save(); _formKey.currentState!.save();
final currentReadings = _captureReadingsToMap(); final currentReadings = _captureReadingsToMap();
List<Map<String, dynamic>> outOfBoundsParams = [];
// --- NEW CONDITIONAL LOGIC ---
// Only check limits if it's a Manual Station
if (widget.data.stationTypeSelection == 'Existing Manual Station') {
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final marineLimits = authProvider.marineParameterLimits ?? []; final marineLimits = authProvider.marineParameterLimits ?? [];
final outOfBoundsParams = _validateParameters(currentReadings, marineLimits); outOfBoundsParams = _validateParameters(currentReadings, marineLimits);
setState(() { setState(() {
_outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet(); _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) { if (outOfBoundsParams.isNotEmpty) {
_showParameterLimitDialog(outOfBoundsParams, currentReadings); _showParameterLimitDialog(outOfBoundsParams, currentReadings);
@ -380,6 +380,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
_saveDataAndMoveOn(currentReadings); _saveDataAndMoveOn(currentReadings);
} }
} }
// --- END: MODIFIED _validateAndProceed ---
Map<String, double> _captureReadingsToMap() { Map<String, double> _captureReadingsToMap() {
final Map<String, double> readings = {}; final Map<String, double> readings = {};
@ -394,16 +395,14 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) { List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) {
final List<Map<String, dynamic>> invalidParams = []; final List<Map<String, dynamic>> invalidParams = [];
// --- MODIFIED: Get station ID based on station type ---
int? stationId; int? stationId;
// This check is now redundant due to _validateAndProceed, but safe to keep
if (widget.data.stationTypeSelection == 'Existing Manual Station') { 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("--- Parameter Validation Start (Investigative) ---");
debugPrint("Selected Station ID: $stationId"); debugPrint("Selected Station ID: $stationId (from 'man_station_id')");
double? _parseLimitValue(dynamic value) { double? _parseLimitValue(dynamic value) {
if (value == null) return null; if (value == null) return null;
@ -824,8 +823,11 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
if (isConnecting || _isLoading) if (isConnecting || _isLoading)
const CircularProgressIndicator() const CircularProgressIndicator()
else if (isConnected) else if (isConnected)
Row( Wrap(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, alignment: WrapAlignment.spaceEvenly,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8.0,
runSpacing: 4.0,
children: [ children: [
ElevatedButton.icon( ElevatedButton.icon(
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),

View File

@ -1,13 +1,13 @@
// lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_4_summary.dart // lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_4_summary.dart
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; // <-- Required for Uint8List
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../../../auth_provider.dart'; import '../../../../auth_provider.dart';
import '../../../../models/marine_inves_manual_sampling_data.dart'; import '../../../../models/marine_inves_manual_sampling_data.dart';
// REMOVED: Import for NPE Report Screen is no longer needed // REMOVED: Import for NPE Report Screen is no longer needed
// import '../reports/npe_report_from_investigative.dart';
class MarineInvesManualStep4Summary extends StatefulWidget { class MarineInvesManualStep4Summary extends StatefulWidget {
final MarineInvesManualSamplingData data; final MarineInvesManualSamplingData data;
@ -29,7 +29,6 @@ class MarineInvesManualStep4Summary extends StatefulWidget {
class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Summary> { class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Summary> {
bool _isHandlingSubmit = false; bool _isHandlingSubmit = false;
// Keep parameter names for highlighting out-of-bounds station limits
static const Map<String, String> _parameterKeyToLimitName = { static const Map<String, String> _parameterKeyToLimitName = {
'oxygenConcentration': 'Oxygen Conc', 'oxygenConcentration': 'Oxygen Conc',
'oxygenSaturation': 'Oxygen Sat', 'oxygenSaturation': 'Oxygen Sat',
@ -43,18 +42,15 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
'batteryVoltage': 'Battery', 'batteryVoltage': 'Battery',
}; };
// Keep this function to highlight parameters outside *station* limits
Set<String> _getOutOfBoundsKeys(BuildContext context) { Set<String> _getOutOfBoundsKeys(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
// Use regular marine limits, not NPE limits
final marineLimits = authProvider.marineParameterLimits ?? []; final marineLimits = authProvider.marineParameterLimits ?? [];
final Set<String> invalidKeys = {}; final Set<String> invalidKeys = {};
int? stationId; int? stationId;
if (widget.data.stationTypeSelection == 'Existing Manual Station') { 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 = { final readings = {
'oxygenConcentration': widget.data.oxygenConcentration, 'oxygenConcentration': widget.data.oxygenConcentration,
@ -88,7 +84,7 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
limitData = marineLimits.firstWhere( limitData = marineLimits.firstWhere(
(l) => (l) =>
l['param_parameter_list'] == limitName && l['param_parameter_list'] == limitName &&
l['station_id'] == stationId, l['station_id']?.toString() == stationId.toString(),
orElse: () => {}, orElse: () => {},
); );
} }
@ -107,17 +103,226 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
return invalidKeys; return invalidKeys;
} }
// REMOVED: _getNpeTriggeredParameters method // --- START: ADDED from in-situ module ---
// REMOVED: _showNpeDialog method /// Checks captured data against NPE limits and returns detailed information.
List<Map<String, dynamic>> _getNpeTriggeredParameters(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final npeLimits = authProvider.npeParameterLimits ?? [];
if (npeLimits.isEmpty) return [];
/// Handles the complete submission flow WITHOUT NPE check. 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;
// 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<void> _showNpeWarningDialog(
BuildContext context, List<Map<String, dynamic>> triggeredParams) async {
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return showDialog<void>(
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: <Widget>[
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<void> _handleSubmit(BuildContext context) async { Future<void> _handleSubmit(BuildContext context) async {
if (_isHandlingSubmit || widget.isLoading) return; if (_isHandlingSubmit || widget.isLoading) return;
setState(() => _isHandlingSubmit = true); setState(() => _isHandlingSubmit = true);
try { 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(); final result = await widget.onSubmit();
if (!mounted) return; if (!mounted) return;
@ -133,6 +338,7 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
// If submission was successful, navigate back to the home screen // If submission was successful, navigate back to the home screen
if (result['success'] == true) { if (result['success'] == true) {
// No conditional navigation, just go home
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
} }
// If submission failed, the user stays on the summary screen to potentially retry // If submission failed, the user stays on the summary screen to potentially retry
@ -154,6 +360,7 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
} }
} }
} }
// --- END: MODIFIED _handleSubmit ---
// Helper to build station details dynamically // Helper to build station details dynamically
List<Widget> _buildStationDetails() { List<Widget> _buildStationDetails() {
@ -190,7 +397,6 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Still get out-of-bounds keys for station limits to highlight them
final outOfBoundsKeys = _getOutOfBoundsKeys(context); final outOfBoundsKeys = _getOutOfBoundsKeys(context);
return ListView( return ListView(
@ -214,7 +420,7 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
_buildDetailRow("Sampling Type:", widget.data.samplingType), _buildDetailRow("Sampling Type:", widget.data.samplingType),
_buildDetailRow("Sample ID Code:", widget.data.sampleIdCode), _buildDetailRow("Sample ID Code:", widget.data.sampleIdCode),
const Divider(height: 20), const Divider(height: 20),
..._buildStationDetails(), // Use dynamic station details ..._buildStationDetails(),
], ],
), ),
_buildSectionCard( _buildSectionCard(
@ -346,7 +552,7 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
(widget.isLoading || _isHandlingSubmit) (widget.isLoading || _isHandlingSubmit)
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: ElevatedButton.icon( : ElevatedButton.icon(
onPressed: () => _handleSubmit(context), // Simplified call onPressed: () => _handleSubmit(context),
icon: const Icon(Icons.cloud_upload), icon: const Icon(Icons.cloud_upload),
label: const Text('Confirm & Submit'), label: const Text('Confirm & Submit'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@ -420,7 +626,7 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim(); isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim();
final Color? defaultTextColor = final Color? defaultTextColor =
Theme.of(context).textTheme.bodyLarge?.color; Theme.of(context).textTheme.bodyLarge?.color;
final Color valueColor = isOutOfBounds // Still highlight if outside station limits final Color valueColor = isOutOfBounds
? Colors.red ? Colors.red
: (isMissing ? Colors.grey : defaultTextColor ?? Colors.black); : (isMissing ? Colors.grey : defaultTextColor ?? Colors.black);
@ -453,11 +659,34 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
if (image != null) if (image != null)
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8.0), borderRadius: BorderRadius.circular(8.0),
child: Image.file(image, child: FutureBuilder<Uint8List>(
key: UniqueKey(), key: ValueKey(image.path),
future: image.readAsBytes(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
height: 200, height: 200,
width: double.infinity, width: double.infinity,
fit: BoxFit.cover), 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 else
Container( Container(

View File

@ -94,6 +94,52 @@ class _MarineManualReportStatusLogState
bool _isLoading = true; bool _isLoading = true;
final Map<String, bool> _isResubmitting = {}; final Map<String, bool> _isResubmitting = {};
// --- START: COPIED FROM SCREEN FILE ---
// This is the "single source of truth" for categories
final Map<String, List<String>> _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 @override
void initState() { void initState() {
super.initState(); super.initState();
@ -152,9 +198,7 @@ class _MarineManualReportStatusLogState
tempPreSampling.add(SubmissionLogEntry( tempPreSampling.add(SubmissionLogEntry(
type: 'Pre-Departure Checklist', type: 'Pre-Departure Checklist',
title: 'Pre-Departure Checklist', title: 'Pre-Departure Checklist',
// --- START: REVERTED --- stationCode: 'N/A',
stationCode: 'N/A', // Reverted
// --- END: REVERTED ---
// --- START: MODIFIED --- // --- START: MODIFIED ---
senderName: (log['reporterName'] as String?) ?? 'Unknown User', senderName: (log['reporterName'] as String?) ?? 'Unknown User',
// --- END: MODIFIED --- // --- END: MODIFIED ---
@ -317,7 +361,6 @@ class _MarineManualReportStatusLogState
final data = MarineManualPreDepartureChecklistData(); final data = MarineManualPreDepartureChecklistData();
data.reporterUserId = logData['reporterUserId']; data.reporterUserId = logData['reporterUserId'];
data.submissionDate = logData['submissionDate']; data.submissionDate = logData['submissionDate'];
// data.location = logData['location']; // <-- REVERTED
// Reconstruct maps // Reconstruct maps
if (logData['checklistItems'] != null) { if (logData['checklistItems'] != null) {
data.checklistItems = data.checklistItems =
@ -526,7 +569,7 @@ class _MarineManualReportStatusLogState
_preSamplingSearchController, _preSamplingSearchController,
), ),
_buildCategorySection( _buildCategorySection(
'Report Log', 'NPE Report Log',
_filteredReportLogs, _filteredReportLogs,
_reportSearchController, _reportSearchController,
), ),
@ -732,6 +775,7 @@ class _MarineManualReportStatusLogState
return value.toString(); return value.toString();
} }
// --- START: ADDED WIDGET-BASED HEADER HELPER ---
/// Builds a formatted category header row for the data list. /// Builds a formatted category header row for the data list.
Widget _buildCategoryHeader(BuildContext context, String title, IconData icon) { Widget _buildCategoryHeader(BuildContext context, String title, IconData icon) {
return Padding( return Padding(
@ -752,22 +796,50 @@ class _MarineManualReportStatusLogState
), ),
); );
} }
// --- END: ADDED WIDGET-BASED HEADER HELPER ---
/// Builds a formatted row for the data list. // --- START: RE-INTRODUCED TABLE-BASED HELPERS ---
Widget _buildDataRow(String label, String? value) { /// 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; String displayValue = (value == null || value.isEmpty || value == 'null') ? 'N/A' : value;
if (displayValue == '-999.0' || displayValue == '-999') { if (displayValue == '-999.0' || displayValue == '-999') {
displayValue = 'N/A'; displayValue = 'N/A';
} }
return Padding( return TableRow(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Padding(
flex: 2, padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
child: Text( child: Text(
label, label,
style: const TextStyle( style: const TextStyle(
@ -776,36 +848,40 @@ class _MarineManualReportStatusLogState
), ),
), ),
), ),
const SizedBox(width: 8), Padding(
Expanded( padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
flex: 3,
child: Text( child: Text(
displayValue, displayValue,
style: const TextStyle(fontSize: 14.0), style: TextStyle(fontSize: 14.0, color: valueColor),
), ),
), ),
], ],
),
); );
} }
// --- START: NEW HELPER FOR PRE-DEPARTURE --- /// Builds a remark row for the data dialog.
/// Determines the category for a given checklist item. TableRow _buildRemarkTableRow(String? remark) {
String _getChecklistCategory(String itemName) { if (remark == null || remark.isEmpty) {
final lowerItem = itemName.toLowerCase(); return const TableRow(children: [SizedBox.shrink(), SizedBox.shrink()]);
if (lowerItem.contains('in-situ') || lowerItem.contains('sonde') || lowerItem.contains('van dorn')) {
return 'Internal - In-Situ Sampling';
} }
if (lowerItem.contains('tarball')) { return TableRow(
return 'Internal - Tarball Sampling'; 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,
),
),
),
],
);
} }
if (lowerItem.contains('laboratory') || lowerItem.contains('ice') || lowerItem.contains('bottle')) { // --- END: RE-INTRODUCED TABLE-BASED HELPERS ---
return 'External - Laboratory';
}
// Add more rules here if needed
return 'General';
}
// --- END: NEW HELPER FOR PRE-DEPARTURE ---
/// Shows the categorized and formatted data log in a dialog /// Shows the categorized and formatted data log in a dialog
void _showDataDialog(BuildContext context, SubmissionLogEntry log) { void _showDataDialog(BuildContext context, SubmissionLogEntry log) {
@ -813,45 +889,31 @@ class _MarineManualReportStatusLogState
Widget dialogContent; // This will hold either a ListView or a Column Widget dialogContent; // This will hold either a ListView or a Column
if (log.type == 'Pre-Departure Checklist') { if (log.type == 'Pre-Departure Checklist') {
// --- START: Handle Pre-Departure Checklist (uses ListView) --- // --- START: Handle Pre-Departure Checklist (uses Column/ListView) ---
final items = Map<String, bool>.from(data['checklistItems'] ?? {}); final items = Map<String, bool>.from(data['checklistItems'] ?? {});
final remarks = Map<String, String>.from(data['remarks'] ?? {}); final remarks = Map<String, String>.from(data['remarks'] ?? {});
// 1. Group items by category // 1. Build the list of widgets
final Map<String, List<MapEntry<String, bool>>> 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
final List<Widget> contentWidgets = []; final List<Widget> 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 // 2. Iterate over the DEFINED categories from the map
for (final categoryEntry in _checklistSections.entries) {
final categoryTitle = categoryEntry.key;
final categoryItems = categoryEntry.value;
// Add the category header
contentWidgets.add(_buildCategoryHeader(context, categoryTitle, Icons.check_box_outlined));
// 3. Add the items for that category
contentWidgets.addAll( contentWidgets.addAll(
categorizedItems[category]!.map((entry) { categoryItems.map((itemName) {
final key = entry.key; // Find the item's status and remark from the log data
final value = entry.value; final bool value = items[itemName] ?? false; // Default to 'No'
final status = value ? 'Yes' : 'No'; final String remark = remarks[itemName] ?? '';
final remark = remarks[key] ?? ''; final String status = value ? 'Yes' : 'No';
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -863,7 +925,7 @@ class _MarineManualReportStatusLogState
Expanded( Expanded(
flex: 3, flex: 3,
child: Text( child: Text(
key, itemName, // Use the name from the category list
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14.0, fontSize: 14.0,
@ -923,6 +985,54 @@ class _MarineManualReportStatusLogState
// Add a divider after the category // Add a divider after the category
contentWidgets.add(const Divider(height: 16)); contentWidgets.add(const Divider(height: 16));
} }
// 4. Handle any items that were in the log but NOT in the category map
final Set<String> allCategorizedItems = _checklistSections.values.expand((list) => list).toSet();
final List<Widget> 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) { if (contentWidgets.isEmpty) {
@ -937,25 +1047,20 @@ class _MarineManualReportStatusLogState
// --- END: Handle Pre-Departure Checklist --- // --- END: Handle Pre-Departure Checklist ---
} else { } else {
// --- START: Handle ALL OTHER Log Types (uses Column) --- // --- START: Handle ALL OTHER Log Types (uses Table) ---
final List<Widget> contentWidgets = []; final List<TableRow> tableRows = [];
// --- Helper for nested maps --- // --- Helper for nested maps ---
void addNestedMapRows(Map<String, dynamic> map) { void addNestedMapRows(Map<String, dynamic> map) {
map.forEach((key, value) { map.forEach((key, value) {
if (value is Map) { if (value is Map) {
// Handle nested maps (e.g., ysiSensorChecks) // Handle nested maps (e.g., ysiSensorChecks)
contentWidgets.add(_buildDataRow(key, '')); tableRows.add(_buildDataTableRow(key, ''));
value.forEach((subKey, subValue) { value.forEach((subKey, subValue) {
contentWidgets.add( tableRows.add(_buildDataTableRow(' $subKey', subValue?.toString() ?? 'N/A'));
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: _buildDataRow(subKey, subValue?.toString() ?? 'N/A'),
)
);
}); });
} else { } 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) { switch (log.type) {
case 'Sonde Calibration': case 'Sonde Calibration':
contentWidgets.add(_buildCategoryHeader(context, 'Sonde Info', Icons.info_outline)); tableRows.add(_buildCategoryRow(context, 'Sonde Info', Icons.info_outline));
contentWidgets.add(_buildDataRow('Sonde Serial #', _getString(data, 'sondeSerialNumber'))); tableRows.add(_buildDataTableRow('Sonde Serial #', _getString(data, 'sondeSerialNumber')));
contentWidgets.add(_buildDataRow('Firmware Version', _getString(data, 'firmwareVersion'))); tableRows.add(_buildDataTableRow('Firmware Version', _getString(data, 'firmwareVersion')));
contentWidgets.add(_buildDataRow('KOR Version', _getString(data, 'korVersion'))); tableRows.add(_buildDataTableRow('KOR Version', _getString(data, 'korVersion')));
contentWidgets.add(_buildDataRow('Location', _getString(data, 'location'))); tableRows.add(_buildDataTableRow('Location', _getString(data, 'location')));
contentWidgets.add(_buildDataRow('Start Time', _getString(data, 'startDateTime'))); tableRows.add(_buildDataTableRow('Start Time', _getString(data, 'startDateTime')));
contentWidgets.add(_buildDataRow('End Time', _getString(data, 'endDateTime'))); tableRows.add(_buildDataTableRow('End Time', _getString(data, 'endDateTime')));
contentWidgets.add(_buildDataRow('Status', _getString(data, 'calibration_status'))); tableRows.add(_buildDataTableRow('Status', _getString(data, 'calibration_status')));
contentWidgets.add(_buildDataRow('Remarks', _getString(data, 'remarks'))); tableRows.add(_buildDataTableRow('Remarks', _getString(data, 'remarks')));
contentWidgets.add(_buildCategoryHeader(context, 'pH 7.0', Icons.science_outlined)); tableRows.add(_buildCategoryRow(context, 'pH 7.0', Icons.science_outlined));
contentWidgets.add(_buildDataRow('MV', _getString(data, 'ph_7_mv'))); tableRows.add(_buildDataTableRow('MV', _getString(data, 'ph_7_mv')));
contentWidgets.add(_buildDataRow('Before', _getString(data, 'ph_7_before'))); tableRows.add(_buildDataTableRow('Before', _getString(data, 'ph_7_before')));
contentWidgets.add(_buildDataRow('After', _getString(data, 'ph_7_after'))); tableRows.add(_buildDataTableRow('After', _getString(data, 'ph_7_after')));
contentWidgets.add(_buildCategoryHeader(context, 'pH 10.0', Icons.science_outlined)); tableRows.add(_buildCategoryRow(context, 'pH 10.0', Icons.science_outlined));
contentWidgets.add(_buildDataRow('MV', _getString(data, 'ph_10_mv'))); tableRows.add(_buildDataTableRow('MV', _getString(data, 'ph_10_mv')));
contentWidgets.add(_buildDataRow('Before', _getString(data, 'ph_10_before'))); tableRows.add(_buildDataTableRow('Before', _getString(data, 'ph_10_before')));
contentWidgets.add(_buildDataRow('After', _getString(data, 'ph_10_after'))); tableRows.add(_buildDataTableRow('After', _getString(data, 'ph_10_after')));
contentWidgets.add(_buildCategoryHeader(context, 'Conductivity', Icons.thermostat)); tableRows.add(_buildCategoryRow(context, 'Conductivity', Icons.thermostat));
contentWidgets.add(_buildDataRow('Before', _getString(data, 'cond_before'))); tableRows.add(_buildDataTableRow('Before', _getString(data, 'cond_before')));
contentWidgets.add(_buildDataRow('After', _getString(data, 'cond_after'))); tableRows.add(_buildDataTableRow('After', _getString(data, 'cond_after')));
contentWidgets.add(_buildCategoryHeader(context, 'Dissolved Oxygen', Icons.air)); tableRows.add(_buildCategoryRow(context, 'Dissolved Oxygen', Icons.air));
contentWidgets.add(_buildDataRow('Before', _getString(data, 'do_before'))); tableRows.add(_buildDataTableRow('Before', _getString(data, 'do_before')));
contentWidgets.add(_buildDataRow('After', _getString(data, 'do_after'))); tableRows.add(_buildDataTableRow('After', _getString(data, 'do_after')));
contentWidgets.add(_buildCategoryHeader(context, 'Turbidity', Icons.waves)); tableRows.add(_buildCategoryRow(context, 'Turbidity', Icons.waves));
contentWidgets.add(_buildDataRow('0 NTU Before', _getString(data, 'turbidity_0_before'))); tableRows.add(_buildDataTableRow('0 NTU Before', _getString(data, 'turbidity_0_before')));
contentWidgets.add(_buildDataRow('0 NTU After', _getString(data, 'turbidity_0_after'))); tableRows.add(_buildDataTableRow('0 NTU After', _getString(data, 'turbidity_0_after')));
contentWidgets.add(_buildDataRow('124 NTU Before', _getString(data, 'turbidity_124_before'))); tableRows.add(_buildDataTableRow('124 NTU Before', _getString(data, 'turbidity_124_before')));
contentWidgets.add(_buildDataRow('124 NTU After', _getString(data, 'turbidity_124_after'))); tableRows.add(_buildDataTableRow('124 NTU After', _getString(data, 'turbidity_124_after')));
break; break;
case 'Equipment Maintenance': 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) { if (data['ysiSondeChecks'] != null) {
addNestedMapRows(Map<String, dynamic>.from(data['ysiSondeChecks'])); addNestedMapRows(Map<String, dynamic>.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) { if (data['ysiSensorChecks'] != null) {
addNestedMapRows(Map<String, dynamic>.from(data['ysiSensorChecks'])); addNestedMapRows(Map<String, dynamic>.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) { if (data['ysiReplacements'] != null) {
addNestedMapRows(Map<String, dynamic>.from(data['ysiReplacements'])); addNestedMapRows(Map<String, dynamic>.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) { if (data['vanDornChecks'] != null) {
addNestedMapRows(Map<String, dynamic>.from(data['vanDornChecks'])); addNestedMapRows(Map<String, dynamic>.from(data['vanDornChecks']));
} }
contentWidgets.add(_buildDataRow('Comments', _getString(data, 'vanDornComments'))); tableRows.add(_buildDataTableRow('Comments', _getString(data, 'vanDornComments')));
contentWidgets.add(_buildDataRow('Current Serial', _getString(data, 'vanDornCurrentSerial'))); tableRows.add(_buildDataTableRow('Current Serial', _getString(data, 'vanDornCurrentSerial')));
contentWidgets.add(_buildDataRow('New Serial', _getString(data, 'vanDornNewSerial'))); 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) { if (data['vanDornReplacements'] != null) {
addNestedMapRows(Map<String, dynamic>.from(data['vanDornReplacements'])); addNestedMapRows(Map<String, dynamic>.from(data['vanDornReplacements']));
} }
break; break;
case 'NPE Report': case 'NPE Report':
contentWidgets.add(_buildCategoryHeader(context, 'Event Info', Icons.calendar_today)); tableRows.add(_buildCategoryRow(context, 'Event Info', Icons.calendar_today));
contentWidgets.add(_buildDataRow('Date', _getString(data, 'eventDate'))); tableRows.add(_buildDataTableRow('Date', _getString(data, 'eventDate')));
contentWidgets.add(_buildDataRow('Time', _getString(data, 'eventTime'))); tableRows.add(_buildDataTableRow('Time', _getString(data, 'eventTime')));
contentWidgets.add(_buildDataRow('Sampler', _getString(data, 'firstSamplerName'))); 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) { 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 { } else {
contentWidgets.add(_buildDataRow('Location', _getString(data, 'locationDescription'))); tableRows.add(_buildDataTableRow('Location', _getString(data, 'locationDescription')));
contentWidgets.add(_buildDataRow('State', _getString(data, 'stateName'))); tableRows.add(_buildDataTableRow('State', _getString(data, 'stateName')));
} }
contentWidgets.add(_buildDataRow('Latitude', _getString(data, 'latitude'))); tableRows.add(_buildDataTableRow('Latitude', _getString(data, 'latitude')));
contentWidgets.add(_buildDataRow('Longitude', _getString(data, 'longitude'))); tableRows.add(_buildDataTableRow('Longitude', _getString(data, 'longitude')));
contentWidgets.add(_buildCategoryHeader(context, 'Parameters', Icons.bar_chart)); tableRows.add(_buildCategoryRow(context, 'Parameters', Icons.bar_chart));
contentWidgets.add(_buildDataRow('Oxygen Conc (mg/L)', _getString(data, 'oxygenConcentration'))); tableRows.add(_buildDataTableRow('Oxygen Conc (mg/L)', _getString(data, 'oxygenConcentration')));
contentWidgets.add(_buildDataRow('Oxygen Sat (%)', _getString(data, 'oxygenSaturation'))); tableRows.add(_buildDataTableRow('Oxygen Sat (%)', _getString(data, 'oxygenSaturation')));
contentWidgets.add(_buildDataRow('pH', _getString(data, 'ph'))); tableRows.add(_buildDataTableRow('pH', _getString(data, 'ph')));
contentWidgets.add(_buildDataRow('Conductivity (µS/cm)', _getString(data, 'electricalConductivity'))); tableRows.add(_buildDataTableRow('Conductivity (µS/cm)', _getString(data, 'electricalConductivity')));
contentWidgets.add(_buildDataRow('Temperature (°C)', _getString(data, 'temperature'))); tableRows.add(_buildDataTableRow('Temperature (°C)', _getString(data, 'temperature')));
contentWidgets.add(_buildDataRow('Turbidity (NTU)', _getString(data, 'turbidity'))); 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) { if (data['fieldObservations'] != null) {
final observations = Map<String, bool>.from(data['fieldObservations']); final observations = Map<String, bool>.from(data['fieldObservations']);
observations.forEach((key, value) { 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'))); tableRows.add(_buildDataTableRow('Other Remarks', _getString(data, 'othersObservationRemark')));
contentWidgets.add(_buildDataRow('Possible Source', _getString(data, 'possibleSource'))); tableRows.add(_buildDataTableRow('Possible Source', _getString(data, 'possibleSource')));
if (data['selectedTarballClassification'] != null) { 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; break;
default: 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 // Assign the Table as the content for the 'else' block
dialogContent = Column( dialogContent = Table(
crossAxisAlignment: CrossAxisAlignment.start, columnWidths: const {
children: contentWidgets, 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 --- // --- END: Handle ALL OTHER Log Types ---
} }

View File

@ -409,7 +409,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) { List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) {
final List<Map<String, dynamic>> invalidParams = []; final List<Map<String, dynamic>> invalidParams = [];
final int? stationId = widget.data.selectedStation?['station_id']; final int? stationId = widget.data.selectedStation?['man_station_id'];
debugPrint("--- Parameter Validation Start ---"); debugPrint("--- Parameter Validation Start ---");
debugPrint("Selected Station ID: $stationId"); debugPrint("Selected Station ID: $stationId");

View File

@ -47,7 +47,7 @@ class _InSituStep4SummaryState extends State<InSituStep4Summary> {
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final marineLimits = authProvider.marineParameterLimits ?? []; final marineLimits = authProvider.marineParameterLimits ?? [];
final Set<String> invalidKeys = {}; final Set<String> invalidKeys = {};
final int? stationId = widget.data.selectedStation?['station_id']; final int? stationId = widget.data.selectedStation?['man_station_id'];
final readings = { final readings = {
'oxygenConcentration': widget.data.oxygenConcentration, 'oxygenConcentration': widget.data.oxygenConcentration,

View File

@ -338,7 +338,11 @@ class LocalStorageService {
await eventDir.create(recursive: true); await eventDir.create(recursive: true);
} }
final Map<String, dynamic> jsonData = data.toDbJson(); // --- START: MODIFICATION (FIXED ERROR) ---
// Changed data.toDbJson() to data.toMap() to get a Map, not a String.
final Map<String, dynamic> jsonData = data.toMap();
// --- END: MODIFICATION (FIXED ERROR) ---
jsonData['submissionStatus'] = data.submissionStatus; jsonData['submissionStatus'] = data.submissionStatus;
jsonData['submissionMessage'] = data.submissionMessage; jsonData['submissionMessage'] = data.submissionMessage;

View File

@ -291,11 +291,19 @@ class MarineInSituSamplingService {
// --- START FIX: Add ftpConfigId when queuing --- // --- START FIX: Add ftpConfigId when queuing ---
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? []; final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
// --- MODIFIED: Use new data model methods for multi-json zip ---
final dataZip = await _zippingService.createDataZip( 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, baseFileName: baseFileNameForQueue,
destinationDir: null, // Use temp dir destinationDir: null, // Use temp dir
); );
// --- END MODIFIED ---
if (dataZip != null) { if (dataZip != null) {
// Queue for each config separately // Queue for each config separately
for (final config in ftpConfigs) { for (final config in ftpConfigs) {
@ -401,7 +409,10 @@ class MarineInSituSamplingService {
// Save/Update local log first // Save/Update local log first
if (savedLogPath != null && savedLogPath.isNotEmpty) { if (savedLogPath != null && savedLogPath.isNotEmpty) {
// Need to reconstruct the map with file paths for updating // Need to reconstruct the map with file paths for updating
Map<String, dynamic> logUpdateData = data.toDbJson(); // --- START: MODIFICATION (FIXED ERROR) ---
// Changed data.toDbJson() to data.toMap() to get a Map, not a String.
Map<String, dynamic> logUpdateData = data.toMap();
// --- END: MODIFICATION (FIXED ERROR) ---
final imageFiles = data.toApiImageFiles(); final imageFiles = data.toApiImageFiles();
imageFiles.forEach((key, file) { imageFiles.forEach((key, file) {
logUpdateData[key] = file?.path; // Add paths back logUpdateData[key] = file?.path; // Add paths back
@ -439,12 +450,21 @@ class MarineInSituSamplingService {
return {'success': true, 'message': successMessage, 'reportId': null}; // No report ID yet return {'success': true, 'message': successMessage, 'reportId': null}; // No report ID yet
} }
// --- START: MODIFIED _generateBaseFileName ---
/// Helper to generate the base filename for ZIP files. /// Helper to generate the base filename for ZIP files.
String _generateBaseFileName(InSituSamplingData data) { String _generateBaseFileName(InSituSamplingData data) {
final stationCode = data.selectedStation?['man_station_code'] ?? 'NA'; final stationCode = data.selectedStation?['man_station_code'] ?? 'NA';
// 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(' ', '_'); final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
return '${stationCode}_$fileTimestamp'; return '${stationCode}_$fileTimestamp';
} }
}
// --- END: MODIFIED _generateBaseFileName ---
/// Generates data and image ZIP files and uploads them using SubmissionFtpService. /// Generates data and image ZIP files and uploads them using SubmissionFtpService.
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(InSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async { Future<Map<String, dynamic>> _generateAndUploadFtpFiles(InSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
@ -455,19 +475,32 @@ class MarineInSituSamplingService {
module: 'marine', module: 'marine',
subModule: 'marine_in_situ_sampling', // Correct sub-module path 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; final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) { if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true); 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( 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, baseFileName: baseFileName,
destinationDir: localSubmissionDir, destinationDir: localSubmissionDir,
); );
// --- END: MODIFIED createDataZip CALL ---
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []}; Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) { if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit( ftpDataResult = await _submissionFtpService.submit(
@ -516,7 +549,10 @@ class MarineInSituSamplingService {
final baseFileName = _generateBaseFileName(data); // Use helper final baseFileName = _generateBaseFileName(data); // Use helper
// Prepare log data map including file paths // Prepare log data map including file paths
Map<String, dynamic> logMapData = data.toDbJson(); // --- START: MODIFICATION (FIXED ERROR) ---
// Changed data.toDbJson() to data.toMap() to get a Map, not a String.
Map<String, dynamic> logMapData = data.toMap();
// --- END: MODIFICATION (FIXED ERROR) ---
final imageFileMap = data.toApiImageFiles(); final imageFileMap = data.toApiImageFiles();
imageFileMap.forEach((key, file) { imageFileMap.forEach((key, file) {
logMapData[key] = file?.path; // Store path or null logMapData[key] = file?.path; // Store path or null
@ -650,7 +686,9 @@ class MarineInSituSamplingService {
final allLimits = await _dbHelper.loadMarineParameterLimits() ?? []; final allLimits = await _dbHelper.loadMarineParameterLimits() ?? [];
if (allLimits.isEmpty) return ""; 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 if (stationId == null) return ""; // Cannot check limits without a station ID
final readings = { final readings = {
@ -771,7 +809,7 @@ class MarineInSituSamplingService {
if (isHit) { if (isHit) {
final valueStr = value.toStringAsFixed(5); final valueStr = value.toStringAsFixed(5);
final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A'; final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A';
final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A'; final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/á';
String limitStr; String limitStr;
if (lowerStr != 'N/A' && upperStr != 'N/A') { if (lowerStr != 'N/A' && upperStr != 'N/A') {
limitStr = '$lowerStr - $upperStr'; limitStr = '$lowerStr - $upperStr';

View File

@ -14,6 +14,7 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart'; import 'package:usb_serial/usb_serial.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:intl/intl.dart'; // Import intl
import '../auth_provider.dart'; import '../auth_provider.dart';
import 'location_service.dart'; import 'location_service.dart';
@ -71,9 +72,10 @@ class MarineInvestigativeSamplingService {
img.Image? originalImage = img.decodeImage(bytes); img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) return null; if (originalImage == null) return null;
// --- MODIFIED: Enforce landscape check logic from in-situ ---
if (isRequired && originalImage.height > originalImage.width) { if (isRequired && originalImage.height > originalImage.width) {
debugPrint("Image rejected: Must be in landscape orientation."); 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}"; final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}";
@ -223,6 +225,10 @@ class MarineInvestigativeSamplingService {
required AuthProvider authProvider, required AuthProvider authProvider,
String? logDirectory, String? logDirectory,
}) async { }) 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 serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
final imageFilesWithNulls = data.toApiImageFiles(); final imageFilesWithNulls = data.toApiImageFiles();
imageFilesWithNulls.removeWhere((key, value) => value == null); 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."); debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
final baseFileNameForQueue = _generateBaseFileName(data); final baseFileNameForQueue = _generateBaseFileName(data);
// --- START FIX: Add ftpConfigId when queuing ---
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? []; final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
final dataZip = await _zippingService.createDataZip( final dataZip = await _zippingService.createDataZip(
@ -296,14 +301,13 @@ class MarineInvestigativeSamplingService {
destinationDir: null, // Use temp dir destinationDir: null, // Use temp dir
); );
if (dataZip != null) { if (dataZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) { for (final config in ftpConfigs) {
final configId = config['ftp_config_id']; final configId = config['ftp_config_id'];
if (configId != null) { if (configId != null) {
await _retryService.addFtpToQueue( await _retryService.addFtpToQueue(
localFilePath: dataZip.path, localFilePath: dataZip.path,
remotePath: '/${p.basename(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 destinationDir: null, // Use temp dir
); );
if (imageZip != null) { if (imageZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) { for (final config in ftpConfigs) {
final configId = config['ftp_config_id']; final configId = config['ftp_config_id'];
if (configId != null) { if (configId != null) {
await _retryService.addFtpToQueue( await _retryService.addFtpToQueue(
localFilePath: imageZip.path, localFilePath: imageZip.path,
remotePath: '/${p.basename(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}]}; ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
anyFtpSuccess = false; anyFtpSuccess = false;
} else { } else {
// Session is OK, proceed with normal FTP attempt // Session is OK, proceed with normal FTP attempt
try { try {
// _generateAndUploadFtpFiles already uses the generic SubmissionFtpService
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName); 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'); anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) { } catch (e) {
debugPrint("Unexpected FTP submission error: $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 // 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); _handleInvestigativeSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
} }
@ -397,9 +400,7 @@ class MarineInvestigativeSamplingService {
String? savedLogPath = logDirectory; // Use existing path if provided String? savedLogPath = logDirectory; // Use existing path if provided
// Save/Update local log first
if (savedLogPath != null && savedLogPath.isNotEmpty) { if (savedLogPath != null && savedLogPath.isNotEmpty) {
// Prepare map with file paths for update
Map<String, dynamic> logUpdateData = data.toDbJson(); Map<String, dynamic> logUpdateData = data.toDbJson();
final imageFiles = data.toApiImageFiles(); final imageFiles = data.toApiImageFiles();
imageFiles.forEach((key, file) { 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."; 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 return {'success': true, 'message': successMessage, 'reportId': null}; // No report ID yet
} }
@ -444,11 +442,17 @@ class MarineInvestigativeSamplingService {
} else if (data.stationTypeSelection == 'New Location') { } else if (data.stationTypeSelection == 'New Location') {
stationCode = data.newStationCode ?? 'NEW_NA'; stationCode = data.newStationCode ?? 'NEW_NA';
} }
final datePart = data.samplingDate ?? 'NODATE';
final timePart = (data.samplingTime ?? 'NOTIME').replaceAll(':', '-'); // --- START: MODIFIED (from in-situ) ---
final fileTimestamp = "${datePart}_${timePart}".replaceAll(' ', '_'); // 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'; return '${stationCode}_$fileTimestamp';
} }
// --- END: MODIFIED ---
}
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(MarineInvesManualSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async { Future<Map<String, dynamic>> _generateAndUploadFtpFiles(MarineInvesManualSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
@ -459,7 +463,12 @@ class MarineInvestigativeSamplingService {
module: 'marine', module: 'marine',
subModule: 'marine_investigative_sampling', 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; final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) { 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( final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir, destinationDir: localSubmissionDir,
); );
// --- END: MODIFIED createDataZip call ---
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []}; Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) { if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit( ftpDataResult = await _submissionFtpService.submit(
@ -525,13 +538,11 @@ class MarineInvestigativeSamplingService {
data.submissionMessage = message; data.submissionMessage = message;
final baseFileName = _generateBaseFileName(data); final baseFileName = _generateBaseFileName(data);
// Prepare log data map including file paths
Map<String, dynamic> logMapData = data.toDbJson(); Map<String, dynamic> logMapData = data.toDbJson();
final imageFileMap = data.toApiImageFiles(); final imageFileMap = data.toApiImageFiles();
imageFileMap.forEach((key, file) { imageFileMap.forEach((key, file) {
logMapData[key] = file?.path; // Store path or null logMapData[key] = file?.path; // Store path or null
}); });
// Add submission metadata
logMapData['submissionStatus'] = status; logMapData['submissionStatus'] = status;
logMapData['submissionMessage'] = message; logMapData['submissionMessage'] = message;
logMapData['reportId'] = data.reportId; logMapData['reportId'] = data.reportId;
@ -568,10 +579,11 @@ class MarineInvestigativeSamplingService {
} }
} }
// --- START: MODIFIED ALERT HANDLER ---
Future<void> _handleInvestigativeSuccessAlert(MarineInvesManualSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { Future<void> _handleInvestigativeSuccessAlert(MarineInvesManualSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
String generateInvestigativeTelegramAlertMessage(MarineInvesManualSamplingData data, {required bool isDataOnly}) { // This internal function generates the main message
Future<String> generateInvestigativeTelegramAlertMessage(MarineInvesManualSamplingData data, {required bool isDataOnly}) async {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
String stationName = 'N/A'; String stationName = 'N/A';
@ -588,31 +600,54 @@ class MarineInvestigativeSamplingService {
stationCode = data.newStationCode ?? 'NEW'; 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() final buffer = StringBuffer()
..writeln('🕵️ *Marine Investigative Sample $submissionType Submitted:*') ..writeln('🕵️ *Marine Investigative Sample $submissionType Submitted:*')
..writeln() ..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)') ..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submitted:* ${data.samplingDate}') ..writeln('*Date & Time of Submission:* $submissionDate $submissionTime')
..writeln('*Submitted by User:* ${data.firstSamplerName}') ..writeln('*Submitted by User:* $submitter')
..writeln('*Sonde ID:* ${data.sondeId ?? "N/A"}') ..writeln('*Sonde ID:* ${data.sondeId ?? "N/A"}')
..writeln('*Status of Submission:* Successful'); ..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 buffer
..writeln() ..writeln()
..writeln('🔔 *Distance Alert:*') ..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) { if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
buffer.writeln('*Remarks for distance:* ${data.distanceDifferenceRemarks}'); 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(); return buffer.toString();
} }
// --- End internal function ---
try { 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'; final alertKey = 'marine_investigative';
if (isSessionExpired) { if (isSessionExpired) {
@ -628,4 +663,161 @@ class MarineInvestigativeSamplingService {
debugPrint("Failed to handle Investigative Telegram alert: $e"); 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<String> _getOutOfBoundsAlertSection(MarineInvesManualSamplingData data) async {
// Only check limits if it's a Manual Station
if (data.stationTypeSelection != 'Existing Manual Station') {
return "";
}
const Map<String, String> _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<String> 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: () => <String, dynamic>{},
);
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<String> _getNpeAlertSection(MarineInvesManualSamplingData data) async {
const Map<String, String> _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<String> 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: () => <String, dynamic>{},
);
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 ---
} }

View File

@ -437,12 +437,21 @@ class RiverInSituSamplingService {
return {'status': 'Queued', 'success': true, 'message': successMessage, 'reportId': null}; return {'status': 'Queued', 'success': true, 'message': successMessage, 'reportId': null};
} }
// --- START: MODIFIED _generateBaseFileName ---
/// Helper to generate the base filename for ZIP files. /// Helper to generate the base filename for ZIP files.
String _generateBaseFileName(RiverInSituSamplingData data) { String _generateBaseFileName(RiverInSituSamplingData data) {
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN'; final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN';
// 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(' ', '_'); final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
return "${stationCode}_$fileTimestamp"; return "${stationCode}_$fileTimestamp";
} }
}
// --- END: MODIFIED _generateBaseFileName ---
/// Generates data and image ZIP files and uploads them using SubmissionFtpService. /// Generates data and image ZIP files and uploads them using SubmissionFtpService.
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverInSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async { Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverInSituSamplingData data, Map<String, File> 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 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; final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) { if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true); await localSubmissionDir.create(recursive: true);