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? tideLevel;
String? seaCondition;
// String? tarball; // <-- REMOVED THIS PROPERTY
String? eventRemarks;
String? labRemarks;
@ -201,6 +202,7 @@ class InSituSamplingData {
data.weather = json['weather'];
data.tideLevel = json['tide_level'];
data.seaCondition = json['sea_condition'];
// data.tarball = json['tarball']; // <-- REMOVED DESERIALIZATION
data.eventRemarks = json['event_remarks'];
data.labRemarks = json['lab_remarks'];
data.optionalRemark1 = json['man_optional_photo_01_remarks'];
@ -254,8 +256,8 @@ class InSituSamplingData {
return data;
}
/// Creates a single JSON object with all submission data for offline storage.
Map<String, dynamic> toDbJson() {
/// Creates a Map object with all submission data for local logging.
Map<String, dynamic> toMap() {
return {
'first_sampler_name': firstSamplerName,
'first_sampler_user_id': firstSamplerUserId,
@ -276,6 +278,7 @@ class InSituSamplingData {
'weather': weather,
'tide_level': tideLevel,
'sea_condition': seaCondition,
// 'tarball': tarball, // <-- REMOVED
'event_remarks': eventRemarks,
'lab_remarks': labRemarks,
'man_optional_photo_01_remarks': optionalRemark1,
@ -305,8 +308,101 @@ class InSituSamplingData {
};
}
// --- REMOVED: generateTelegramAlertMessage method ---
// This logic is now in MarineInSituSamplingService
/// Creates a single JSON object with all submission data, mimicking 'db.json'
String toDbJson() {
// This is a direct conversion of the model's properties to a map,
// with keys matching the expected 'db.json' file format.
final data = {
'battery_cap': batteryVoltage == -999.0 ? null : batteryVoltage,
'device_name': sondeId,
'sampling_type': samplingType,
'report_id': reportId,
'sampler_2ndname': secondSampler?['first_name'],
'sample_state': selectedStateName,
'station_id': selectedStation?['man_station_code'],
'tech_id': firstSamplerUserId,
'tech_phonenum': null, // This field was not in the model
'tech_name': firstSamplerName,
'latitude': stationLatitude,
'longitude': stationLongitude,
'record_dt': '$samplingDate $samplingTime',
'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration,
'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation,
'ph': ph == -999.0 ? null : ph,
'salinity': salinity == -999.0 ? null : salinity,
'tss': tss == -999.0 ? null : tss,
'temperature': temperature == -999.0 ? null : temperature,
'turbidity': turbidity == -999.0 ? null : turbidity,
'tds': tds == -999.0 ? null : tds,
'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity,
'sample_id': sampleIdCode,
'tarball': "", // <-- MODIFIED: Hardcoded to "N/A"
'weather': weather,
'tide_lvl': tideLevel,
'sea_cond': seaCondition,
'remarks_event': eventRemarks,
'remarks_lab': labRemarks,
};
// Remove null values before encoding
data.removeWhere((key, value) => value == null);
return jsonEncode(data);
}
/// Creates a JSON object for basic form info, mimicking 'marine_insitu_basic_form.json'.
String toBasicFormJson() {
final data = {
'tech_name': firstSamplerName,
'sampler_2ndname': secondSampler?['first_name'], // Assuming 'first_name' key
'sample_date': samplingDate,
'sample_time': samplingTime,
'sampling_type': samplingType,
'sample_state': selectedStateName,
'station_id': selectedStation?['man_station_code'], // Marine-specific key
'station_latitude': stationLatitude,
'station_longitude': stationLongitude,
'latitude': currentLatitude,
'longitude': currentLongitude,
'sample_id': sampleIdCode,
};
// Remove null values before encoding
data.removeWhere((key, value) => value == null);
return jsonEncode(data);
}
/// Creates a JSON object for sensor readings, mimicking 'marine_sampling_reading.json'.
String toReadingJson() {
final data = {
'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration,
'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation,
'ph': ph == -999.0 ? null : ph,
'salinity': salinity == -999.0 ? null : salinity,
'tds': tds == -999.0 ? null : tds,
'tss': tss == -999.0 ? null : tss,
'temperature': temperature == -999.0 ? null : temperature,
'turbidity': turbidity == -999.0 ? null : turbidity,
'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity,
'date_sampling_reading': dataCaptureDate,
'time_sampling_reading': dataCaptureTime,
};
// Remove null values before encoding
data.removeWhere((key, value) => value == null);
return jsonEncode(data);
}
/// Creates a JSON object for manual info, mimicking 'marine_manual_info.json'.
String toManualInfoJson() {
final data = {
'tarball': "", // <-- MODIFIED: Hardcoded to "N/A"
'weather': weather,
'tide_lvl': tideLevel,
'sea_cond': seaCondition,
'remarks_event': eventRemarks,
'remarks_lab': labRemarks,
};
// Remove null values before encoding
data.removeWhere((key, value) => value == null);
return jsonEncode(data);
}
Map<String, String> toApiFormData() {
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
import 'dart:io';
import 'dart:typed_data'; // <-- ADDED: Required for Uint8List
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
@ -26,7 +27,7 @@ class MarineInvesManualStep2SiteInfo extends StatefulWidget {
class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2SiteInfo> {
final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false;
bool _isPickingImage = false; // <-- ADDED: State variable from in-situ
late final TextEditingController _eventRemarksController;
late final TextEditingController _labRemarksController;
@ -49,6 +50,7 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
super.dispose();
}
// --- START: MODIFIED _setImage function (from in-situ) ---
/// Handles picking and processing an image using the dedicated service.
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return;
@ -56,19 +58,23 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
// The service's pickAndProcessImage method will handle file naming
final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: isRequired);
// Always pass `isRequired: true` to the service to enforce landscape check
// and watermarking for ALL photos (required or optional).
// The 'isRequired' param is just for the UI text.
final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: true);
if (file != null) {
setState(() => setImageCallback(file));
} else if (mounted) {
_showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true);
// Corrected snackbar message
_showSnackBar('Image selection failed. Please ensure all photos are taken in landscape (horizontal) mode.', isError: true);
}
if (mounted) {
setState(() => _isPickingImage = false);
}
}
// --- END: MODIFIED _setImage function ---
/// Validates the form and all required images before proceeding.
void _goToNextStep() {
@ -142,7 +148,11 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
// --- Section: Required Photos ---
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),
_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),
@ -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}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
@ -207,14 +218,63 @@ class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2S
const SizedBox(height: 8),
if (imageFile != null)
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
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();
}
// --- START: MODIFIED LIFECYCLE METHOD ---
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (mounted) {
// Use the member variable, not context
final service = _samplingService;
final btConnecting = service.bluetoothConnectionState.value == BluetoothConnectionState.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) {
// Force-call disconnect to reset both the service's state
// and the local _isLoading flag (inside _disconnect).
_disconnectFromAll();
} else {
// If not stuck, just a normal refresh
setState(() {});
}
}
}
}
// --- END: MODIFIED LIFECYCLE METHOD ---
void _initializeControllers() {
widget.data.dataCaptureDate = widget.data.samplingDate;
@ -299,9 +292,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
});
}
// --- START: MODIFIED DISCONNECT METHOD ---
void _disconnect(String type) {
// Use the member variable, not context, for lifecycle safety
final service = _samplingService;
if (type == 'bluetooth') {
service.disconnectFromBluetooth();
@ -315,15 +306,12 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
setState(() {
_isAutoReading = false;
_isLockedOut = false;
_isLoading = false; // <-- CRITICAL: Also reset the loading flag
_isLoading = false;
});
}
}
// --- END: MODIFIED DISCONNECT METHOD ---
// --- START: MODIFIED DISCONNECT_ALL METHOD ---
void _disconnectFromAll() {
// Use the member variable, not context, for lifecycle safety
final service = _samplingService;
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_disconnect('bluetooth');
@ -332,7 +320,6 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
_disconnect('serial');
}
}
// --- END: MODIFIED DISCONNECT_ALL METHOD ---
void _updateTextFields(Map<String, double> readings) {
const defaultValue = -999.0;
@ -350,6 +337,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
});
}
// --- START: MODIFIED _validateAndProceed ---
void _validateAndProceed() {
if (_isLockedOut) {
_showSnackBar("Please wait for the initial reading period to complete.", isError: true);
@ -366,13 +354,25 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
_formKey.currentState!.save();
final currentReadings = _captureReadingsToMap();
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final marineLimits = authProvider.marineParameterLimits ?? [];
final outOfBoundsParams = _validateParameters(currentReadings, marineLimits);
List<Map<String, dynamic>> outOfBoundsParams = [];
setState(() {
_outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet();
});
// --- NEW CONDITIONAL LOGIC ---
// Only check limits if it's a Manual Station
if (widget.data.stationTypeSelection == 'Existing Manual Station') {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final marineLimits = authProvider.marineParameterLimits ?? [];
outOfBoundsParams = _validateParameters(currentReadings, marineLimits);
setState(() {
_outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet();
});
} else {
// If not a manual station, ensure any old highlights are cleared
setState(() {
_outOfBoundsKeys.clear();
});
}
// --- END NEW CONDITIONAL LOGIC ---
if (outOfBoundsParams.isNotEmpty) {
_showParameterLimitDialog(outOfBoundsParams, currentReadings);
@ -380,6 +380,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
_saveDataAndMoveOn(currentReadings);
}
}
// --- END: MODIFIED _validateAndProceed ---
Map<String, double> _captureReadingsToMap() {
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) {
final List<Map<String, dynamic>> invalidParams = [];
// --- MODIFIED: Get station ID based on station type ---
int? stationId;
// This check is now redundant due to _validateAndProceed, but safe to keep
if (widget.data.stationTypeSelection == 'Existing Manual Station') {
stationId = widget.data.selectedStation?['station_id'];
stationId = widget.data.selectedStation?['man_station_id'];
}
// Note: Add logic here if Tarball or New Locations have different limits
// For now, we only validate against manual station limits
debugPrint("--- Parameter Validation Start (Investigative) ---");
debugPrint("Selected Station ID: $stationId");
debugPrint("Selected Station ID: $stationId (from 'man_station_id')");
double? _parseLimitValue(dynamic value) {
if (value == null) return null;
@ -824,8 +823,11 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
if (isConnecting || _isLoading)
const CircularProgressIndicator()
else if (isConnected)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
Wrap(
alignment: WrapAlignment.spaceEvenly,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8.0,
runSpacing: 4.0,
children: [
ElevatedButton.icon(
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
import 'dart:io';
import 'dart:typed_data'; // <-- Required for Uint8List
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../../auth_provider.dart';
import '../../../../models/marine_inves_manual_sampling_data.dart';
// REMOVED: Import for NPE Report Screen is no longer needed
// import '../reports/npe_report_from_investigative.dart';
class MarineInvesManualStep4Summary extends StatefulWidget {
final MarineInvesManualSamplingData data;
@ -29,7 +29,6 @@ class MarineInvesManualStep4Summary extends StatefulWidget {
class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Summary> {
bool _isHandlingSubmit = false;
// Keep parameter names for highlighting out-of-bounds station limits
static const Map<String, String> _parameterKeyToLimitName = {
'oxygenConcentration': 'Oxygen Conc',
'oxygenSaturation': 'Oxygen Sat',
@ -43,18 +42,15 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
'batteryVoltage': 'Battery',
};
// Keep this function to highlight parameters outside *station* limits
Set<String> _getOutOfBoundsKeys(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
// Use regular marine limits, not NPE limits
final marineLimits = authProvider.marineParameterLimits ?? [];
final Set<String> invalidKeys = {};
int? stationId;
if (widget.data.stationTypeSelection == 'Existing Manual Station') {
stationId = widget.data.selectedStation?['station_id'];
stationId = widget.data.selectedStation?['man_station_id'];
}
// Note: Only checking against manual station limits for now.
final readings = {
'oxygenConcentration': widget.data.oxygenConcentration,
@ -88,7 +84,7 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
limitData = marineLimits.firstWhere(
(l) =>
l['param_parameter_list'] == limitName &&
l['station_id'] == stationId,
l['station_id']?.toString() == stationId.toString(),
orElse: () => {},
);
}
@ -107,17 +103,226 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
return invalidKeys;
}
// REMOVED: _getNpeTriggeredParameters method
// REMOVED: _showNpeDialog method
// --- START: ADDED from in-situ module ---
/// 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 {
if (_isHandlingSubmit || widget.isLoading) return;
setState(() => _isHandlingSubmit = true);
try {
// Directly call the submission function provided by the parent
// --- NPE CHECK ADDED ---
final npeParameters = _getNpeTriggeredParameters(context);
if (npeParameters.isNotEmpty) {
// Show the warning dialog and wait for the user to press "OK"
await _showNpeWarningDialog(context, npeParameters);
if (!mounted) return; // Check mount status after await
}
// --- END NPE CHECK ---
// Proceed with submission after dialog is dismissed (or if not needed)
final result = await widget.onSubmit();
if (!mounted) return;
@ -133,6 +338,7 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
// If submission was successful, navigate back to the home screen
if (result['success'] == true) {
// No conditional navigation, just go home
Navigator.of(context).popUntil((route) => route.isFirst);
}
// 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
List<Widget> _buildStationDetails() {
@ -190,7 +397,6 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
@override
Widget build(BuildContext context) {
// Still get out-of-bounds keys for station limits to highlight them
final outOfBoundsKeys = _getOutOfBoundsKeys(context);
return ListView(
@ -214,7 +420,7 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
_buildDetailRow("Sampling Type:", widget.data.samplingType),
_buildDetailRow("Sample ID Code:", widget.data.sampleIdCode),
const Divider(height: 20),
..._buildStationDetails(), // Use dynamic station details
..._buildStationDetails(),
],
),
_buildSectionCard(
@ -346,7 +552,7 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
(widget.isLoading || _isHandlingSubmit)
? const Center(child: CircularProgressIndicator())
: ElevatedButton.icon(
onPressed: () => _handleSubmit(context), // Simplified call
onPressed: () => _handleSubmit(context),
icon: const Icon(Icons.cloud_upload),
label: const Text('Confirm & Submit'),
style: ElevatedButton.styleFrom(
@ -420,7 +626,7 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim();
final Color? defaultTextColor =
Theme.of(context).textTheme.bodyLarge?.color;
final Color valueColor = isOutOfBounds // Still highlight if outside station limits
final Color valueColor = isOutOfBounds
? Colors.red
: (isMissing ? Colors.grey : defaultTextColor ?? Colors.black);
@ -453,11 +659,34 @@ class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Su
if (image != null)
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.file(image,
key: UniqueKey(),
height: 200,
width: double.infinity,
fit: BoxFit.cover),
child: FutureBuilder<Uint8List>(
key: ValueKey(image.path),
future: image.readAsBytes(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
height: 200,
width: double.infinity,
alignment: Alignment.center,
child: const CircularProgressIndicator(),
);
}
if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) {
return Container(
height: 200,
width: double.infinity,
alignment: Alignment.center,
child: const Icon(Icons.error, color: Colors.red, size: 40),
);
}
return Image.memory(
snapshot.data!,
height: 200,
width: double.infinity,
fit: BoxFit.cover,
);
},
),
)
else
Container(

View File

@ -94,6 +94,52 @@ class _MarineManualReportStatusLogState
bool _isLoading = true;
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
void initState() {
super.initState();
@ -152,9 +198,7 @@ class _MarineManualReportStatusLogState
tempPreSampling.add(SubmissionLogEntry(
type: 'Pre-Departure Checklist',
title: 'Pre-Departure Checklist',
// --- START: REVERTED ---
stationCode: 'N/A', // Reverted
// --- END: REVERTED ---
stationCode: 'N/A',
// --- START: MODIFIED ---
senderName: (log['reporterName'] as String?) ?? 'Unknown User',
// --- END: MODIFIED ---
@ -317,7 +361,6 @@ class _MarineManualReportStatusLogState
final data = MarineManualPreDepartureChecklistData();
data.reporterUserId = logData['reporterUserId'];
data.submissionDate = logData['submissionDate'];
// data.location = logData['location']; // <-- REVERTED
// Reconstruct maps
if (logData['checklistItems'] != null) {
data.checklistItems =
@ -526,7 +569,7 @@ class _MarineManualReportStatusLogState
_preSamplingSearchController,
),
_buildCategorySection(
'Report Log',
'NPE Report Log',
_filteredReportLogs,
_reportSearchController,
),
@ -732,6 +775,7 @@ class _MarineManualReportStatusLogState
return value.toString();
}
// --- START: ADDED WIDGET-BASED HEADER HELPER ---
/// Builds a formatted category header row for the data list.
Widget _buildCategoryHeader(BuildContext context, String title, IconData icon) {
return Padding(
@ -752,60 +796,92 @@ class _MarineManualReportStatusLogState
),
);
}
// --- END: ADDED WIDGET-BASED HEADER HELPER ---
/// Builds a formatted row for the data list.
Widget _buildDataRow(String label, String? value) {
// --- START: RE-INTRODUCED TABLE-BASED HELPERS ---
/// Builds a formatted category header row for the data table.
TableRow _buildCategoryRow(BuildContext context, String title, IconData icon) {
return TableRow(
decoration: BoxDecoration(
color: Colors.grey.shade100,
),
children: [
Padding(
padding: const EdgeInsets.only(top: 16.0, bottom: 8.0, left: 8.0, right: 8.0),
child: Row(
children: [
Icon(icon, size: 20, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Theme.of(context).primaryColor,
),
),
],
),
),
const SizedBox.shrink(), // Empty cell for the second column
],
);
}
/// Builds a formatted row for the data dialog.
TableRow _buildDataTableRow(String label, String? value, {Color? valueColor}) {
String displayValue = (value == null || value.isEmpty || value == 'null') ? 'N/A' : value;
if (displayValue == '-999.0' || displayValue == '-999') {
displayValue = 'N/A';
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Text(
label,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
),
return TableRow(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
child: Text(
label,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
),
),
const SizedBox(width: 8),
Expanded(
flex: 3,
child: Text(
displayValue,
style: const TextStyle(fontSize: 14.0),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
child: Text(
displayValue,
style: TextStyle(fontSize: 14.0, color: valueColor),
),
],
),
),
],
);
}
// --- START: NEW HELPER FOR PRE-DEPARTURE ---
/// Determines the category for a given checklist item.
String _getChecklistCategory(String itemName) {
final lowerItem = itemName.toLowerCase();
if (lowerItem.contains('in-situ') || lowerItem.contains('sonde') || lowerItem.contains('van dorn')) {
return 'Internal - In-Situ Sampling';
/// Builds a remark row for the data dialog.
TableRow _buildRemarkTableRow(String? remark) {
if (remark == null || remark.isEmpty) {
return const TableRow(children: [SizedBox.shrink(), SizedBox.shrink()]);
}
if (lowerItem.contains('tarball')) {
return 'Internal - Tarball Sampling';
}
if (lowerItem.contains('laboratory') || lowerItem.contains('ice') || lowerItem.contains('bottle')) {
return 'External - Laboratory';
}
// Add more rules here if needed
return 'General';
return TableRow(
children: [
const SizedBox.shrink(), // Empty cell for the label
Padding(
padding: const EdgeInsets.only(bottom: 8.0, left: 8.0, right: 8.0),
child: Text(
"Remark: $remark",
style: TextStyle(
fontSize: 13.0,
color: Colors.grey.shade700,
fontStyle: FontStyle.italic,
),
),
),
],
);
}
// --- END: NEW HELPER FOR PRE-DEPARTURE ---
// --- END: RE-INTRODUCED TABLE-BASED HELPERS ---
/// Shows the categorized and formatted data log in a dialog
void _showDataDialog(BuildContext context, SubmissionLogEntry log) {
@ -813,118 +889,152 @@ class _MarineManualReportStatusLogState
Widget dialogContent; // This will hold either a ListView or a Column
if (log.type == 'Pre-Departure Checklist') {
// --- START: Handle Pre-Departure Checklist (uses ListView) ---
// --- START: Handle Pre-Departure Checklist (uses Column/ListView) ---
final items = Map<String, bool>.from(data['checklistItems'] ?? {});
final remarks = Map<String, String>.from(data['remarks'] ?? {});
// 1. Group items by category
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
// 1. Build the list of widgets
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
contentWidgets.addAll(
categorizedItems[category]!.map((entry) {
final key = entry.key;
final value = entry.value;
final status = value ? 'Yes' : 'No';
final remark = remarks[key] ?? '';
// 2. Iterate over the DEFINED categories from the map
for (final categoryEntry in _checklistSections.entries) {
final categoryTitle = categoryEntry.key;
final categoryItems = categoryEntry.value;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: Item and Status
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Item Name
Expanded(
flex: 3,
child: Text(
key,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
),
// Add the category header
contentWidgets.add(_buildCategoryHeader(context, categoryTitle, Icons.check_box_outlined));
// 3. Add the items for that category
contentWidgets.addAll(
categoryItems.map((itemName) {
// Find the item's status and remark from the log data
final bool value = items[itemName] ?? false; // Default to 'No'
final String remark = remarks[itemName] ?? '';
final String status = value ? 'Yes' : 'No';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: Item and Status
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Item Name
Expanded(
flex: 3,
child: Text(
itemName, // Use the name from the category list
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
),
),
const SizedBox(width: 8),
// Status
Expanded(
flex: 1,
child: Text(
status,
),
const SizedBox(width: 8),
// Status
Expanded(
flex: 1,
child: Text(
status,
style: TextStyle(
fontSize: 14.0,
color: value ? Colors.green.shade700 : Colors.red.shade700,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.end,
),
),
],
),
// Row 2: Remark (only if it exists)
if (remark.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6.0, left: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Remark: ",
style: TextStyle(
fontSize: 14.0,
color: value ? Colors.green.shade700 : Colors.red.shade700,
fontWeight: FontWeight.bold,
fontSize: 13.0,
color: Colors.grey.shade700,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.end,
),
),
],
),
// Row 2: Remark (only if it exists)
if (remark.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6.0, left: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Remark: ",
Expanded(
child: Text(
remark,
style: TextStyle(
fontSize: 13.0,
color: Colors.grey.shade700,
fontStyle: FontStyle.italic,
),
),
Expanded(
child: Text(
remark,
style: TextStyle(
fontSize: 13.0,
color: Colors.grey.shade700,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
],
),
],
),
);
}).toList()
);
),
],
),
);
}).toList()
);
// Add a divider after the category
contentWidgets.add(const Divider(height: 16));
// Add a divider after the category
contentWidgets.add(const Divider(height: 16));
}
// 4. Handle any items that were in the log but NOT in the category map
final Set<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) {
dialogContent = const Center(child: Text('No checklist items found.'));
} else {
@ -937,25 +1047,20 @@ class _MarineManualReportStatusLogState
// --- END: Handle Pre-Departure Checklist ---
} else {
// --- START: Handle ALL OTHER Log Types (uses Column) ---
final List<Widget> contentWidgets = [];
// --- START: Handle ALL OTHER Log Types (uses Table) ---
final List<TableRow> tableRows = [];
// --- Helper for nested maps ---
void addNestedMapRows(Map<String, dynamic> map) {
map.forEach((key, value) {
if (value is Map) {
// Handle nested maps (e.g., ysiSensorChecks)
contentWidgets.add(_buildDataRow(key, ''));
tableRows.add(_buildDataTableRow(key, ''));
value.forEach((subKey, subValue) {
contentWidgets.add(
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: _buildDataRow(subKey, subValue?.toString() ?? 'N/A'),
)
);
tableRows.add(_buildDataTableRow(' $subKey', subValue?.toString() ?? 'N/A'));
});
} else {
contentWidgets.add(_buildDataRow(key, value?.toString() ?? 'N/A'));
tableRows.add(_buildDataTableRow(key, value?.toString() ?? 'N/A'));
}
});
}
@ -963,119 +1068,128 @@ class _MarineManualReportStatusLogState
switch (log.type) {
case 'Sonde Calibration':
contentWidgets.add(_buildCategoryHeader(context, 'Sonde Info', Icons.info_outline));
contentWidgets.add(_buildDataRow('Sonde Serial #', _getString(data, 'sondeSerialNumber')));
contentWidgets.add(_buildDataRow('Firmware Version', _getString(data, 'firmwareVersion')));
contentWidgets.add(_buildDataRow('KOR Version', _getString(data, 'korVersion')));
contentWidgets.add(_buildDataRow('Location', _getString(data, 'location')));
contentWidgets.add(_buildDataRow('Start Time', _getString(data, 'startDateTime')));
contentWidgets.add(_buildDataRow('End Time', _getString(data, 'endDateTime')));
contentWidgets.add(_buildDataRow('Status', _getString(data, 'calibration_status')));
contentWidgets.add(_buildDataRow('Remarks', _getString(data, 'remarks')));
tableRows.add(_buildCategoryRow(context, 'Sonde Info', Icons.info_outline));
tableRows.add(_buildDataTableRow('Sonde Serial #', _getString(data, 'sondeSerialNumber')));
tableRows.add(_buildDataTableRow('Firmware Version', _getString(data, 'firmwareVersion')));
tableRows.add(_buildDataTableRow('KOR Version', _getString(data, 'korVersion')));
tableRows.add(_buildDataTableRow('Location', _getString(data, 'location')));
tableRows.add(_buildDataTableRow('Start Time', _getString(data, 'startDateTime')));
tableRows.add(_buildDataTableRow('End Time', _getString(data, 'endDateTime')));
tableRows.add(_buildDataTableRow('Status', _getString(data, 'calibration_status')));
tableRows.add(_buildDataTableRow('Remarks', _getString(data, 'remarks')));
contentWidgets.add(_buildCategoryHeader(context, 'pH 7.0', Icons.science_outlined));
contentWidgets.add(_buildDataRow('MV', _getString(data, 'ph_7_mv')));
contentWidgets.add(_buildDataRow('Before', _getString(data, 'ph_7_before')));
contentWidgets.add(_buildDataRow('After', _getString(data, 'ph_7_after')));
tableRows.add(_buildCategoryRow(context, 'pH 7.0', Icons.science_outlined));
tableRows.add(_buildDataTableRow('MV', _getString(data, 'ph_7_mv')));
tableRows.add(_buildDataTableRow('Before', _getString(data, 'ph_7_before')));
tableRows.add(_buildDataTableRow('After', _getString(data, 'ph_7_after')));
contentWidgets.add(_buildCategoryHeader(context, 'pH 10.0', Icons.science_outlined));
contentWidgets.add(_buildDataRow('MV', _getString(data, 'ph_10_mv')));
contentWidgets.add(_buildDataRow('Before', _getString(data, 'ph_10_before')));
contentWidgets.add(_buildDataRow('After', _getString(data, 'ph_10_after')));
tableRows.add(_buildCategoryRow(context, 'pH 10.0', Icons.science_outlined));
tableRows.add(_buildDataTableRow('MV', _getString(data, 'ph_10_mv')));
tableRows.add(_buildDataTableRow('Before', _getString(data, 'ph_10_before')));
tableRows.add(_buildDataTableRow('After', _getString(data, 'ph_10_after')));
contentWidgets.add(_buildCategoryHeader(context, 'Conductivity', Icons.thermostat));
contentWidgets.add(_buildDataRow('Before', _getString(data, 'cond_before')));
contentWidgets.add(_buildDataRow('After', _getString(data, 'cond_after')));
tableRows.add(_buildCategoryRow(context, 'Conductivity', Icons.thermostat));
tableRows.add(_buildDataTableRow('Before', _getString(data, 'cond_before')));
tableRows.add(_buildDataTableRow('After', _getString(data, 'cond_after')));
contentWidgets.add(_buildCategoryHeader(context, 'Dissolved Oxygen', Icons.air));
contentWidgets.add(_buildDataRow('Before', _getString(data, 'do_before')));
contentWidgets.add(_buildDataRow('After', _getString(data, 'do_after')));
tableRows.add(_buildCategoryRow(context, 'Dissolved Oxygen', Icons.air));
tableRows.add(_buildDataTableRow('Before', _getString(data, 'do_before')));
tableRows.add(_buildDataTableRow('After', _getString(data, 'do_after')));
contentWidgets.add(_buildCategoryHeader(context, 'Turbidity', Icons.waves));
contentWidgets.add(_buildDataRow('0 NTU Before', _getString(data, 'turbidity_0_before')));
contentWidgets.add(_buildDataRow('0 NTU After', _getString(data, 'turbidity_0_after')));
contentWidgets.add(_buildDataRow('124 NTU Before', _getString(data, 'turbidity_124_before')));
contentWidgets.add(_buildDataRow('124 NTU After', _getString(data, 'turbidity_124_after')));
tableRows.add(_buildCategoryRow(context, 'Turbidity', Icons.waves));
tableRows.add(_buildDataTableRow('0 NTU Before', _getString(data, 'turbidity_0_before')));
tableRows.add(_buildDataTableRow('0 NTU After', _getString(data, 'turbidity_0_after')));
tableRows.add(_buildDataTableRow('124 NTU Before', _getString(data, 'turbidity_124_before')));
tableRows.add(_buildDataTableRow('124 NTU After', _getString(data, 'turbidity_124_after')));
break;
case 'Equipment Maintenance':
contentWidgets.add(_buildCategoryHeader(context, 'YSI Sonde Checks', Icons.build_circle_outlined));
tableRows.add(_buildCategoryRow(context, 'YSI Sonde Checks', Icons.build_circle_outlined));
if (data['ysiSondeChecks'] != null) {
addNestedMapRows(Map<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) {
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) {
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) {
addNestedMapRows(Map<String, dynamic>.from(data['vanDornChecks']));
}
contentWidgets.add(_buildDataRow('Comments', _getString(data, 'vanDornComments')));
contentWidgets.add(_buildDataRow('Current Serial', _getString(data, 'vanDornCurrentSerial')));
contentWidgets.add(_buildDataRow('New Serial', _getString(data, 'vanDornNewSerial')));
tableRows.add(_buildDataTableRow('Comments', _getString(data, 'vanDornComments')));
tableRows.add(_buildDataTableRow('Current Serial', _getString(data, 'vanDornCurrentSerial')));
tableRows.add(_buildDataTableRow('New Serial', _getString(data, 'vanDornNewSerial')));
contentWidgets.add(_buildCategoryHeader(context, 'Van Dorn Replacements', Icons.published_with_changes));
tableRows.add(_buildCategoryRow(context, 'Van Dorn Replacements', Icons.published_with_changes));
if (data['vanDornReplacements'] != null) {
addNestedMapRows(Map<String, dynamic>.from(data['vanDornReplacements']));
}
break;
case 'NPE Report':
contentWidgets.add(_buildCategoryHeader(context, 'Event Info', Icons.calendar_today));
contentWidgets.add(_buildDataRow('Date', _getString(data, 'eventDate')));
contentWidgets.add(_buildDataRow('Time', _getString(data, 'eventTime')));
contentWidgets.add(_buildDataRow('Sampler', _getString(data, 'firstSamplerName')));
tableRows.add(_buildCategoryRow(context, 'Event Info', Icons.calendar_today));
tableRows.add(_buildDataTableRow('Date', _getString(data, 'eventDate')));
tableRows.add(_buildDataTableRow('Time', _getString(data, 'eventTime')));
tableRows.add(_buildDataTableRow('Sampler', _getString(data, 'firstSamplerName')));
contentWidgets.add(_buildCategoryHeader(context, 'Location', Icons.location_on_outlined));
tableRows.add(_buildCategoryRow(context, 'Location', Icons.location_on_outlined));
if (data['selectedStation'] != null) {
contentWidgets.add(_buildDataRow('Station', _getString(data['selectedStation'], 'man_station_name') ?? _getString(data['selectedStation'], 'tbl_station_name')));
tableRows.add(_buildDataTableRow('Station', _getString(data['selectedStation'], 'man_station_name') ?? _getString(data['selectedStation'], 'tbl_station_name')));
} else {
contentWidgets.add(_buildDataRow('Location', _getString(data, 'locationDescription')));
contentWidgets.add(_buildDataRow('State', _getString(data, 'stateName')));
tableRows.add(_buildDataTableRow('Location', _getString(data, 'locationDescription')));
tableRows.add(_buildDataTableRow('State', _getString(data, 'stateName')));
}
contentWidgets.add(_buildDataRow('Latitude', _getString(data, 'latitude')));
contentWidgets.add(_buildDataRow('Longitude', _getString(data, 'longitude')));
tableRows.add(_buildDataTableRow('Latitude', _getString(data, 'latitude')));
tableRows.add(_buildDataTableRow('Longitude', _getString(data, 'longitude')));
contentWidgets.add(_buildCategoryHeader(context, 'Parameters', Icons.bar_chart));
contentWidgets.add(_buildDataRow('Oxygen Conc (mg/L)', _getString(data, 'oxygenConcentration')));
contentWidgets.add(_buildDataRow('Oxygen Sat (%)', _getString(data, 'oxygenSaturation')));
contentWidgets.add(_buildDataRow('pH', _getString(data, 'ph')));
contentWidgets.add(_buildDataRow('Conductivity (µS/cm)', _getString(data, 'electricalConductivity')));
contentWidgets.add(_buildDataRow('Temperature (°C)', _getString(data, 'temperature')));
contentWidgets.add(_buildDataRow('Turbidity (NTU)', _getString(data, 'turbidity')));
tableRows.add(_buildCategoryRow(context, 'Parameters', Icons.bar_chart));
tableRows.add(_buildDataTableRow('Oxygen Conc (mg/L)', _getString(data, 'oxygenConcentration')));
tableRows.add(_buildDataTableRow('Oxygen Sat (%)', _getString(data, 'oxygenSaturation')));
tableRows.add(_buildDataTableRow('pH', _getString(data, 'ph')));
tableRows.add(_buildDataTableRow('Conductivity (µS/cm)', _getString(data, 'electricalConductivity')));
tableRows.add(_buildDataTableRow('Temperature (°C)', _getString(data, 'temperature')));
tableRows.add(_buildDataTableRow('Turbidity (NTU)', _getString(data, 'turbidity')));
contentWidgets.add(_buildCategoryHeader(context, 'Observations', Icons.warning_amber_rounded));
tableRows.add(_buildCategoryRow(context, 'Observations', Icons.warning_amber_rounded));
if (data['fieldObservations'] != null) {
final observations = Map<String, bool>.from(data['fieldObservations']);
observations.forEach((key, value) {
if(value) contentWidgets.add(_buildDataRow(key, 'Checked'));
if(value) tableRows.add(_buildDataTableRow(key, 'Checked'));
});
}
contentWidgets.add(_buildDataRow('Other Remarks', _getString(data, 'othersObservationRemark')));
contentWidgets.add(_buildDataRow('Possible Source', _getString(data, 'possibleSource')));
tableRows.add(_buildDataTableRow('Other Remarks', _getString(data, 'othersObservationRemark')));
tableRows.add(_buildDataTableRow('Possible Source', _getString(data, 'possibleSource')));
if (data['selectedTarballClassification'] != null) {
contentWidgets.add(_buildDataRow('Tarball Class', _getString(data['selectedTarballClassification'], 'classification_name')));
tableRows.add(_buildDataTableRow('Tarball Class', _getString(data['selectedTarballClassification'], 'classification_name')));
}
break;
default:
contentWidgets.add(_buildDataRow('Error', 'No data view configured for log type: ${log.type}'));
tableRows.add(_buildDataTableRow('Error', 'No data view configured for log type: ${log.type}'));
}
// Assign the Column as the content for the 'else' block
dialogContent = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: contentWidgets,
// Assign the Table as the content for the 'else' block
dialogContent = Table(
columnWidths: const {
0: IntrinsicColumnWidth(), // Label column
1: FlexColumnWidth(), // Value column
},
border: TableBorder(
horizontalInside: BorderSide(
color: Colors.grey.shade300,
width: 0.5,
),
),
children: tableRows,
);
// --- END: Handle ALL OTHER Log Types ---
}

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) {
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("Selected Station ID: $stationId");

View File

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

View File

@ -338,7 +338,11 @@ class LocalStorageService {
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['submissionMessage'] = data.submissionMessage;

View File

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

View File

@ -14,6 +14,7 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:intl/intl.dart'; // Import intl
import '../auth_provider.dart';
import 'location_service.dart';
@ -71,9 +72,10 @@ class MarineInvestigativeSamplingService {
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) return null;
// --- MODIFIED: Enforce landscape check logic from in-situ ---
if (isRequired && originalImage.height > originalImage.width) {
debugPrint("Image rejected: Must be in landscape orientation.");
return null;
return null; // Return null if landscape is required and check fails
}
final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}";
@ -223,6 +225,10 @@ class MarineInvestigativeSamplingService {
required AuthProvider authProvider,
String? logDirectory,
}) async {
// --- START FIX: Capture the status before attempting submission ---
final String? previousStatus = data.submissionStatus;
// --- END FIX ---
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
final imageFilesWithNulls = data.toApiImageFiles();
imageFilesWithNulls.removeWhere((key, value) => value == null);
@ -287,7 +293,6 @@ class MarineInvestigativeSamplingService {
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
final baseFileNameForQueue = _generateBaseFileName(data);
// --- START FIX: Add ftpConfigId when queuing ---
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
final dataZip = await _zippingService.createDataZip(
@ -296,14 +301,13 @@ class MarineInvestigativeSamplingService {
destinationDir: null, // Use temp dir
);
if (dataZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/${p.basename(dataZip.path)}',
ftpConfigId: configId // Provide the specific config ID
ftpConfigId: configId
);
}
}
@ -316,33 +320,29 @@ class MarineInvestigativeSamplingService {
destinationDir: null, // Use temp dir
);
if (imageZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/${p.basename(imageZip.path)}',
ftpConfigId: configId // Provide the specific config ID
ftpConfigId: configId
);
}
}
}
}
// --- END FIX ---
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
anyFtpSuccess = false;
} else {
// Session is OK, proceed with normal FTP attempt
try {
// _generateAndUploadFtpFiles already uses the generic SubmissionFtpService
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
// Check if *any* configured FTP target succeeded (excluding 'Not Configured')
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) {
debugPrint("Unexpected FTP submission error: $e");
anyFtpSuccess = false; // FTP failures are auto-queued by SubmissionFtpService
anyFtpSuccess = false;
}
}
@ -376,7 +376,10 @@ class MarineInvestigativeSamplingService {
);
// 6. Send Alert
if (overallSuccess) {
// --- START FIX: Check if log was already successful before sending alert ---
final bool wasAlreadySuccessful = previousStatus == 'S4' || previousStatus == 'S3' || previousStatus == 'L4';
if (overallSuccess && !wasAlreadySuccessful) {
// --- END FIX ---
_handleInvestigativeSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
}
@ -397,9 +400,7 @@ class MarineInvestigativeSamplingService {
String? savedLogPath = logDirectory; // Use existing path if provided
// Save/Update local log first
if (savedLogPath != null && savedLogPath.isNotEmpty) {
// Prepare map with file paths for update
Map<String, dynamic> logUpdateData = data.toDbJson();
final imageFiles = data.toApiImageFiles();
imageFiles.forEach((key, file) {
@ -429,9 +430,6 @@ class MarineInvestigativeSamplingService {
);
const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored.";
// Log final queued state to central DB
// await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: savedLogPath);
return {'success': true, 'message': successMessage, 'reportId': null}; // No report ID yet
}
@ -444,10 +442,16 @@ class MarineInvestigativeSamplingService {
} else if (data.stationTypeSelection == 'New Location') {
stationCode = data.newStationCode ?? 'NEW_NA';
}
final datePart = data.samplingDate ?? 'NODATE';
final timePart = (data.samplingTime ?? 'NOTIME').replaceAll(':', '-');
final fileTimestamp = "${datePart}_${timePart}".replaceAll(' ', '_');
return '${stationCode}_$fileTimestamp';
// --- START: MODIFIED (from in-situ) ---
// Use reportId if available, otherwise fall back to timestamp
if (data.reportId != null && data.reportId!.isNotEmpty) {
return '${stationCode}_${data.reportId}';
} else {
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
return '${stationCode}_$fileTimestamp';
}
// --- END: MODIFIED ---
}
@ -459,7 +463,12 @@ class MarineInvestigativeSamplingService {
module: 'marine',
subModule: 'marine_investigative_sampling',
);
final folderName = data.reportId ?? baseFileName;
// --- START: MODIFIED folderName (from in-situ) ---
// Use baseFileName for the folder name to match [stationCode]_[reportId]
final folderName = baseFileName;
// --- END: MODIFIED folderName ---
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
@ -470,11 +479,15 @@ class MarineInvestigativeSamplingService {
}
}
// --- START: MODIFIED createDataZip call (from in-situ) ---
// This module does not have the extra JSON files, so we keep the single db.json
final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
baseFileName: baseFileName,
destinationDir: localSubmissionDir,
);
// --- END: MODIFIED createDataZip call ---
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(
@ -525,13 +538,11 @@ class MarineInvestigativeSamplingService {
data.submissionMessage = message;
final baseFileName = _generateBaseFileName(data);
// Prepare log data map including file paths
Map<String, dynamic> logMapData = data.toDbJson();
final imageFileMap = data.toApiImageFiles();
imageFileMap.forEach((key, file) {
logMapData[key] = file?.path; // Store path or null
});
// Add submission metadata
logMapData['submissionStatus'] = status;
logMapData['submissionMessage'] = message;
logMapData['reportId'] = data.reportId;
@ -568,10 +579,11 @@ class MarineInvestigativeSamplingService {
}
}
// --- START: MODIFIED ALERT HANDLER ---
Future<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)";
String stationName = 'N/A';
@ -588,31 +600,54 @@ class MarineInvestigativeSamplingService {
stationCode = data.newStationCode ?? 'NEW';
}
final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
final submissionTime = data.samplingTime ?? DateFormat('HH:mm:ss').format(DateTime.now());
final submitter = data.firstSamplerName ?? 'N/A';
final buffer = StringBuffer()
..writeln('🕵️ *Marine Investigative Sample $submissionType Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submitted:* ${data.samplingDate}')
..writeln('*Submitted by User:* ${data.firstSamplerName}')
..writeln('*Date & Time of Submission:* $submissionDate $submissionTime')
..writeln('*Submitted by User:* $submitter')
..writeln('*Sonde ID:* ${data.sondeId ?? "N/A"}')
..writeln('*Status of Submission:* Successful');
if (data.distanceDifferenceInKm != null && data.distanceDifferenceInKm! * 1000 > 50) {
final distanceKm = data.distanceDifferenceInKm ?? 0;
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A';
if (distanceKm * 1000 > 50 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
buffer
..writeln()
..writeln('🔔 *Distance Alert:*')
..writeln('*Distance from station:* ${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters');
..writeln('*Distance from station:* $distanceMeters meters (${distanceKm.toStringAsFixed(3)} KM)');
if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty) {
buffer.writeln('*Remarks for distance:* ${data.distanceDifferenceRemarks}');
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
}
// --- NEW: Add parameter limit checks to message ---
// 1. Add station parameter limit check section
final outOfBoundsAlert = await _getOutOfBoundsAlertSection(data);
if (outOfBoundsAlert.isNotEmpty) {
buffer.write(outOfBoundsAlert);
}
// 2. Add NPE parameter limit check section
final npeAlert = await _getNpeAlertSection(data);
if (npeAlert.isNotEmpty) {
buffer.write(npeAlert);
}
// --- END NEW ---
return buffer.toString();
}
// --- End internal function ---
try {
final message = generateInvestigativeTelegramAlertMessage(data, isDataOnly: isDataOnly);
// Call the internal function to build the message
final message = await generateInvestigativeTelegramAlertMessage(data, isDataOnly: isDataOnly);
final alertKey = 'marine_investigative';
if (isSessionExpired) {
@ -628,4 +663,161 @@ class MarineInvestigativeSamplingService {
debugPrint("Failed to handle Investigative Telegram alert: $e");
}
}
// --- NEW: Added from in-situ service ---
/// Helper to generate the station-specific parameter limit alert section.
Future<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};
}
// --- START: MODIFIED _generateBaseFileName ---
/// Helper to generate the base filename for ZIP files.
String _generateBaseFileName(RiverInSituSamplingData data) {
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN';
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
return "${stationCode}_$fileTimestamp";
// Check if reportId (timestamp) is available.
if (data.reportId != null && data.reportId!.isNotEmpty) {
return '${stationCode}_${data.reportId}';
} else {
// Fallback to old method if reportId is not available (e.g., offline queue)
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
return "${stationCode}_$fileTimestamp";
}
}
// --- END: MODIFIED _generateBaseFileName ---
/// Generates data and image ZIP files and uploads them using SubmissionFtpService.
Future<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 folderName = data.reportId ?? baseFileName;
// --- START: MODIFIED folderName ---
// Use baseFileName for the folder name to match [stationCode]_[reportId]
final folderName = baseFileName;
// --- END: MODIFIED folderName ---
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true);