add in pre-sampling hub screen
This commit is contained in:
parent
8245ba0820
commit
dff653883a
@ -29,7 +29,7 @@ class _HomePageState extends State<HomePage> {
|
||||
});
|
||||
},
|
||||
),
|
||||
title: const Text("MMS Version 3.2.01"),
|
||||
title: const Text("MMS Version 3.4.01"),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person),
|
||||
|
||||
@ -72,13 +72,13 @@ import 'package:environment_monitoring_app/screens/river/investigative/report.da
|
||||
|
||||
// Marine Screens
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/info_centre_document.dart' as marineManualInfoCentreDocument;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/pre_sampling.dart' as marineManualPreSampling;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_pre_sampling.dart' as marineManualPreSampling;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/in_situ_sampling.dart' as marineManualInSituSampling;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_report.dart' as marineManualReport;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_npe_report.dart' as marineManualNPEReport;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart';
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart';
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart';
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart' as marineManualPreDepartureChecklist;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart' as marineManualSondeCalibration;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart' as marineManualEquipmentMaintenance;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_data_status_log.dart' as marineManualDataStatusLog;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_image_request.dart' as marineManualImageRequest;
|
||||
import 'package:environment_monitoring_app/screens/marine/continuous/marine_continuous_info_centre_document.dart';
|
||||
@ -326,14 +326,14 @@ class _RootAppState extends State<RootApp> {
|
||||
|
||||
// Marine Manual
|
||||
'/marine/manual/info': (context) => marineManualInfoCentreDocument.MarineInfoCentreDocument(),
|
||||
'/marine/manual/pre-sampling': (context) => marineManualPreSampling.MarinePreSampling(),
|
||||
'/marine/manual/pre-sampling': (context) => marineManualPreSampling.MarineManualPreSampling(),
|
||||
'/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(),
|
||||
'/marine/manual/tarball': (context) => const TarballSamplingStep1(),
|
||||
'/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(),
|
||||
'/marine/manual/report/npe': (context) => const marineManualNPEReport.MarineManualNPEReport(),
|
||||
'/marine/manual/report/pre-departure': (context) => const MarineManualPreDepartureChecklistScreen(),
|
||||
'/marine/manual/report/calibration': (context) => const MarineManualSondeCalibrationScreen(),
|
||||
'/marine/manual/report/maintenance': (context) => const MarineManualEquipmentMaintenanceScreen(),
|
||||
'/marine/manual/report/pre-departure': (context) => const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(),
|
||||
'/marine/manual/report/calibration': (context) => const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(),
|
||||
'/marine/manual/report/maintenance': (context) => const marineManualEquipmentMaintenance.MarineManualEquipmentMaintenanceScreen(),
|
||||
//'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute
|
||||
'/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(),
|
||||
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert'; // Added for jsonEncode
|
||||
|
||||
import 'package:environment_monitoring_app/models/marine_manual_npe_report_data.dart';
|
||||
|
||||
/// A data model class to hold all information for the multi-step
|
||||
/// In-Situ Sampling form.
|
||||
class InSituSamplingData {
|
||||
@ -103,6 +105,61 @@ class InSituSamplingData {
|
||||
this.samplingTime,
|
||||
});
|
||||
|
||||
/// Creates a pre-populated NPE Report object from the current In-Situ data.
|
||||
MarineManualNpeReportData toNpeReportData() {
|
||||
final npeData = MarineManualNpeReportData();
|
||||
|
||||
// Transfer Reporter & Event Info
|
||||
npeData.firstSamplerName = firstSamplerName;
|
||||
npeData.firstSamplerUserId = firstSamplerUserId;
|
||||
npeData.eventDate = samplingDate;
|
||||
npeData.eventTime = samplingTime;
|
||||
|
||||
// Transfer Location Info
|
||||
npeData.selectedStation = selectedStation;
|
||||
npeData.latitude = currentLatitude;
|
||||
npeData.longitude = currentLongitude;
|
||||
|
||||
// Transfer In-Situ Measurements relevant to NPE
|
||||
npeData.oxygenSaturation = oxygenSaturation;
|
||||
npeData.electricalConductivity = electricalConductivity;
|
||||
npeData.oxygenConcentration = oxygenConcentration;
|
||||
npeData.turbidity = turbidity;
|
||||
npeData.ph = ph;
|
||||
npeData.temperature = temperature;
|
||||
|
||||
// Pre-populate possible source with event remarks as a starting point for the user
|
||||
npeData.possibleSource = eventRemarks;
|
||||
|
||||
// Pre-populate some common observations based on data
|
||||
if ((turbidity ?? 0) > 50) { // Example threshold, adjust as needed
|
||||
npeData.fieldObservations['Silt plume'] = true;
|
||||
}
|
||||
if ((oxygenConcentration ?? 999) < 4) { // Example threshold for low oxygen
|
||||
npeData.fieldObservations['Foul smell'] = true;
|
||||
}
|
||||
|
||||
// Transfer up to 4 available images
|
||||
final availableImages = [
|
||||
leftLandViewImage,
|
||||
rightLandViewImage,
|
||||
waterFillingImage,
|
||||
seawaterColorImage,
|
||||
phPaperImage,
|
||||
optionalImage1,
|
||||
optionalImage2,
|
||||
optionalImage3,
|
||||
optionalImage4,
|
||||
].where((img) => img != null).cast<File>().toList();
|
||||
|
||||
if (availableImages.isNotEmpty) npeData.image1 = availableImages[0];
|
||||
if (availableImages.length > 1) npeData.image2 = availableImages[1];
|
||||
if (availableImages.length > 2) npeData.image3 = availableImages[2];
|
||||
if (availableImages.length > 3) npeData.image4 = availableImages[3];
|
||||
|
||||
return npeData;
|
||||
}
|
||||
|
||||
/// Creates an InSituSamplingData object from a JSON map.
|
||||
factory InSituSamplingData.fromJson(Map<String, dynamic> json) {
|
||||
double? doubleFromJson(dynamic value) {
|
||||
@ -181,7 +238,7 @@ class InSituSamplingData {
|
||||
data.optionalImage4 = fileFromPath(json['man_optional_photo_04']);
|
||||
|
||||
|
||||
// --- START: Deserialization for NPE Fields ---
|
||||
// --- Deserialization for NPE Fields ---
|
||||
if (json['npe_field_observations'] is Map) {
|
||||
data.npeFieldObservations = Map<String, bool>.from(json['npe_field_observations']);
|
||||
}
|
||||
@ -193,17 +250,10 @@ class InSituSamplingData {
|
||||
data.npeImage2 = fileFromPath(json['npe_image_2']);
|
||||
data.npeImage3 = fileFromPath(json['npe_image_3']);
|
||||
data.npeImage4 = fileFromPath(json['npe_image_4']);
|
||||
// --- END: Deserialization for NPE Fields ---
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ... (generateTelegramAlertMessage method remains unchanged) ...
|
||||
|
||||
// ... (toApiFormData method remains unchanged) ...
|
||||
|
||||
// ... (toApiImageFiles method remains unchanged) ...
|
||||
|
||||
/// Creates a single JSON object with all submission data for offline storage.
|
||||
Map<String, dynamic> toDbJson() {
|
||||
return {
|
||||
@ -212,7 +262,6 @@ class InSituSamplingData {
|
||||
'secondSampler': secondSampler,
|
||||
'sampling_date': samplingDate,
|
||||
'sampling_time': samplingTime,
|
||||
// ... (all other existing fields)
|
||||
'sampling_type': samplingType,
|
||||
'sample_id_code': sampleIdCode,
|
||||
'selected_state_name': selectedStateName,
|
||||
@ -249,18 +298,12 @@ class InSituSamplingData {
|
||||
'submission_status': submissionStatus,
|
||||
'submission_message': submissionMessage,
|
||||
'report_id': reportId,
|
||||
|
||||
// --- START: Serialization for NPE Fields ---
|
||||
'npe_field_observations': npeFieldObservations,
|
||||
'npe_others_observation_remark': npeOthersObservationRemark,
|
||||
'npe_possible_source': npePossibleSource,
|
||||
// Note: Image file paths are handled separately by the LocalStorageService
|
||||
// and are not part of this JSON object directly.
|
||||
// --- END: Serialization for NPE Fields ---
|
||||
};
|
||||
}
|
||||
|
||||
// --- Methods from the original file ---
|
||||
String generateTelegramAlertMessage({required bool isDataOnly}) {
|
||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||
final stationName = selectedStation?['man_station_name'] ?? 'N/A';
|
||||
|
||||
@ -45,12 +45,6 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
// START FIX: Do not dispose the service here.
|
||||
// The service is managed by the Provider at a higher level in the app. Disposing it
|
||||
// here can cause the "deactivated widget's ancestor" error if other widgets
|
||||
// are still listening to it during screen transitions.
|
||||
// _samplingService.dispose();
|
||||
// END FIX
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -72,7 +66,9 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitForm() async {
|
||||
/// This function contains the core logic for submitting data and returns the result.
|
||||
/// UI actions like SnackBars and navigation are handled by the caller (summary screen).
|
||||
Future<Map<String, dynamic>> _submitFormLogic() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
@ -85,17 +81,11 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
|
||||
authProvider: authProvider,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
final message = result['message'] ?? 'An unknown error occurred.';
|
||||
final color = (result['success'] == true) ? Colors.green : Colors.red;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
|
||||
);
|
||||
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@ -125,7 +115,7 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
|
||||
InSituStep1SamplingInfo(data: _data, onNext: _nextPage),
|
||||
InSituStep2SiteInfo(data: _data, onNext: _nextPage),
|
||||
InSituStep3DataCapture(data: _data, onNext: _nextPage),
|
||||
InSituStep4Summary(data: _data, onSubmit: _submitForm, isLoading: _isLoading),
|
||||
InSituStep4Summary(data: _data, onSubmit: _submitFormLogic, isLoading: _isLoading),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
108
lib/screens/marine/manual/marine_manual_pre_sampling.dart
Normal file
108
lib/screens/marine/manual/marine_manual_pre_sampling.dart
Normal file
@ -0,0 +1,108 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_report.dart';
|
||||
|
||||
class MarineManualPreSampling extends StatelessWidget {
|
||||
const MarineManualPreSampling({super.key});
|
||||
|
||||
// This list includes the pre-sampling items, excluding the NPE report.
|
||||
// The routes point to the same screens used by the report module.
|
||||
final List<ReportItem> _preSamplingItems = const [
|
||||
ReportItem(
|
||||
icon: Icons.biotech_rounded,
|
||||
label: "Sonde Calibration",
|
||||
formCode: "F-MM02",
|
||||
route: '/marine/manual/report/calibration',
|
||||
),
|
||||
ReportItem(
|
||||
icon: Icons.checklist_rtl_rounded,
|
||||
label: "Pre-Departure & Safety Checklist",
|
||||
formCode: "F-MM03",
|
||||
route: '/marine/manual/report/pre-departure',
|
||||
),
|
||||
ReportItem(
|
||||
icon: Icons.build_circle_outlined,
|
||||
label: "Equipment Maintenance",
|
||||
formCode: "F-MM01",
|
||||
route: '/marine/manual/report/maintenance',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Marine Pre-Sampling"),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Select a Pre-Sampling Form",
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16.0,
|
||||
mainAxisSpacing: 16.0,
|
||||
childAspectRatio: 1.2, // Adjusted for better text fit
|
||||
),
|
||||
itemCount: _preSamplingItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _preSamplingItems[index];
|
||||
return _buildReportCard(context, item);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// This widget is copied from MarineManualReportHomePage for visual consistency.
|
||||
Widget _buildReportCard(BuildContext context, ReportItem item) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, item.route);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: Colors.white24, width: 1),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(item.icon, size: 40, color: Theme.of(context).colorScheme.secondary),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
item.label,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.formCode, // Displaying the form code
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[400]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MarinePreSampling extends StatefulWidget {
|
||||
@override
|
||||
State<MarinePreSampling> createState() => _MarinePreSamplingState();
|
||||
}
|
||||
|
||||
class _MarinePreSamplingState extends State<MarinePreSampling> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
String site = '';
|
||||
String weather = '';
|
||||
String tide = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text("Marine Pre-Sampling")),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
children: [
|
||||
Text("Enter Pre-Sampling Conditions", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 24),
|
||||
TextFormField(
|
||||
decoration: InputDecoration(labelText: "Site"),
|
||||
onChanged: (val) => site = val,
|
||||
validator: (val) => val == null || val.isEmpty ? "Required" : null,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
TextFormField(
|
||||
decoration: InputDecoration(labelText: "Weather"),
|
||||
onChanged: (val) => weather = val,
|
||||
validator: (val) => val == null || val.isEmpty ? "Required" : null,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
TextFormField(
|
||||
decoration: InputDecoration(labelText: "Tide Condition"),
|
||||
onChanged: (val) => tide = val,
|
||||
validator: (val) => val == null || val.isEmpty ? "Required" : null,
|
||||
),
|
||||
SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Pre-sampling data submitted")),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text("Submit"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -86,7 +86,10 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text("Incorrect Image Orientation"),
|
||||
content: const Text("Required photos must be taken in a horizontal (landscape) orientation."),
|
||||
// --- START: MODIFICATION 1 ---
|
||||
// Updated the dialog text to be more general as it now applies to all photos.
|
||||
content: const Text("All photos must be taken in a horizontal (landscape) orientation."),
|
||||
// --- END: MODIFICATION 1 ---
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text("OK"),
|
||||
@ -98,7 +101,11 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<File?> _pickAndProcessImage(ImageSource source, String imageInfo, {required bool isRequired}) async {
|
||||
// --- START: MODIFICATION 2 ---
|
||||
// The `isRequired` parameter has been removed. The orientation check will now
|
||||
// apply to every image processed by this function.
|
||||
Future<File?> _pickAndProcessImage(ImageSource source, String imageInfo) async {
|
||||
// --- END: MODIFICATION 2 ---
|
||||
if (_isPickingImage) return null;
|
||||
setState(() => _isPickingImage = true);
|
||||
|
||||
@ -117,11 +124,15 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRequired && originalImage.height > originalImage.width) {
|
||||
// --- START: MODIFICATION 3 ---
|
||||
// The `isRequired` check has been removed. Now, ALL photos (required and optional)
|
||||
// must be in landscape orientation (width > height).
|
||||
if (originalImage.height > originalImage.width) {
|
||||
_showOrientationDialog();
|
||||
setState(() => _isPickingImage = false);
|
||||
return null;
|
||||
}
|
||||
// --- END: MODIFICATION 3 ---
|
||||
|
||||
final String watermarkTimestamp = "${widget.data.samplingDate} ${widget.data.samplingTime}";
|
||||
final font = img.arial24;
|
||||
@ -155,19 +166,18 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final filePath = path.join(tempDir.path, newFileName);
|
||||
|
||||
// --- START: MODIFICATION TO FIX RACE CONDITION ---
|
||||
// Changed from asynchronous `writeAsBytes` to synchronous `writeAsBytesSync`.
|
||||
// This guarantees the file is fully written to disk before the function returns,
|
||||
// preventing a 0-byte file from being copied during a fast offline save.
|
||||
final File processedFile = File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
|
||||
// --- END: MODIFICATION TO FIX RACE CONDITION ---
|
||||
|
||||
setState(() => _isPickingImage = false);
|
||||
return processedFile;
|
||||
}
|
||||
|
||||
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
|
||||
final file = await _pickAndProcessImage(source, imageInfo, isRequired: isRequired);
|
||||
// --- START: MODIFICATION 4 ---
|
||||
// The `isRequired` parameter has been removed from the function signature
|
||||
// to align with the changes in `_pickAndProcessImage`.
|
||||
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo) async {
|
||||
final file = await _pickAndProcessImage(source, imageInfo);
|
||||
// --- END: MODIFICATION 4 ---
|
||||
if (file != null) {
|
||||
setState(() {
|
||||
setImageCallback(file);
|
||||
@ -200,6 +210,35 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- START: MODIFICATION 5 ---
|
||||
// Added validation to ensure that if an optional image is provided, its
|
||||
// corresponding remark field is not empty.
|
||||
if (widget.data.optionalImage1 != null && _remark1Controller.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('A remark is required for Optional Photo 1.'), backgroundColor: Colors.red),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (widget.data.optionalImage2 != null && _remark2Controller.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('A remark is required for Optional Photo 2.'), backgroundColor: Colors.red),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (widget.data.optionalImage3 != null && _remark3Controller.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('A remark is required for Optional Photo 3.'), backgroundColor: Colors.red),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (widget.data.optionalImage4 != null && _remark4Controller.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('A remark is required for Optional Photo 4.'), backgroundColor: Colors.red),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// --- END: MODIFICATION 5 ---
|
||||
|
||||
widget.data.optionalRemark1 = _remark1Controller.text;
|
||||
widget.data.optionalRemark2 = _remark2Controller.text;
|
||||
widget.data.optionalRemark3 = _remark3Controller.text;
|
||||
@ -224,15 +263,13 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
|
||||
Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// This dropdown now correctly consumes data from AuthProvider
|
||||
Consumer<AuthProvider>(
|
||||
builder: (context, auth, child) {
|
||||
final classifications = auth.tarballClassifications;
|
||||
// The dropdown is enabled only when the classification list is available from the local cache.
|
||||
final bool isEnabled = classifications != null;
|
||||
|
||||
return DropdownSearch<Map<String, dynamic>>(
|
||||
items: classifications ?? [], // Use local data from provider
|
||||
items: classifications ?? [],
|
||||
selectedItem: _selectedClassification,
|
||||
enabled: isEnabled,
|
||||
itemAsString: (item) => item['classification_name'] as String,
|
||||
@ -251,7 +288,6 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedClassification = value;
|
||||
// NECESSARY CHANGE: Save both the full object and the ID to the data model.
|
||||
widget.data.selectedClassification = value;
|
||||
widget.data.classificationId = value?['classification_id'];
|
||||
});
|
||||
@ -321,8 +357,11 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
|
||||
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
|
||||
// --- START: MODIFICATION 6 ---
|
||||
// The `isRequired` parameter is no longer passed to `_setImage`.
|
||||
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
|
||||
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
|
||||
// --- END: MODIFICATION 6 ---
|
||||
],
|
||||
),
|
||||
if (remarkController != null)
|
||||
@ -332,7 +371,7 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
|
||||
controller: remarkController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Remarks for $title',
|
||||
hintText: 'Add an optional remark...',
|
||||
hintText: 'Add a remark...', // Changed hint text to be more direct
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
|
||||
@ -6,10 +6,12 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../../auth_provider.dart';
|
||||
import '../../../../models/in_situ_sampling_data.dart';
|
||||
import '../../../../models/marine_manual_npe_report_data.dart';
|
||||
|
||||
class InSituStep4Summary extends StatelessWidget {
|
||||
class InSituStep4Summary extends StatefulWidget {
|
||||
final InSituSamplingData data;
|
||||
final VoidCallback onSubmit;
|
||||
final Future<Map<String, dynamic>> Function()
|
||||
onSubmit; // Expects a function that returns the submission result
|
||||
final bool isLoading;
|
||||
|
||||
const InSituStep4Summary({
|
||||
@ -19,11 +21,14 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
required this.isLoading,
|
||||
});
|
||||
|
||||
// --- START: MODIFICATION FOR HIGHLIGHTING ---
|
||||
// Added helper logic to re-validate parameters on the summary screen.
|
||||
@override
|
||||
State<InSituStep4Summary> createState() => _InSituStep4SummaryState();
|
||||
}
|
||||
|
||||
/// Maps the app's internal parameter keys to the names used in the database.
|
||||
static const Map<String, String> _parameterKeyToLimitName = const {
|
||||
class _InSituStep4SummaryState extends State<InSituStep4Summary> {
|
||||
bool _isHandlingSubmit = false;
|
||||
|
||||
static const Map<String, String> _parameterKeyToLimitName = {
|
||||
'oxygenConcentration': 'Oxygen Conc',
|
||||
'oxygenSaturation': 'Oxygen Sat',
|
||||
'ph': 'pH',
|
||||
@ -36,18 +41,23 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
'batteryVoltage': 'Battery',
|
||||
};
|
||||
|
||||
/// Re-validates the final parameters against the defined limits.
|
||||
Set<String> _getOutOfBoundsKeys(BuildContext context) {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final marineLimits = authProvider.marineParameterLimits ?? [];
|
||||
final Set<String> invalidKeys = {};
|
||||
final int? stationId = data.selectedStation?['station_id'];
|
||||
final int? stationId = widget.data.selectedStation?['station_id'];
|
||||
|
||||
final readings = {
|
||||
'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation,
|
||||
'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity,
|
||||
'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity,
|
||||
'tss': data.tss, 'batteryVoltage': data.batteryVoltage,
|
||||
'oxygenConcentration': widget.data.oxygenConcentration,
|
||||
'oxygenSaturation': widget.data.oxygenSaturation,
|
||||
'ph': widget.data.ph,
|
||||
'salinity': widget.data.salinity,
|
||||
'electricalConductivity': widget.data.electricalConductivity,
|
||||
'temperature': widget.data.temperature,
|
||||
'tds': widget.data.tds,
|
||||
'turbidity': widget.data.turbidity,
|
||||
'tss': widget.data.tss,
|
||||
'batteryVoltage': widget.data.batteryVoltage,
|
||||
};
|
||||
|
||||
double? parseLimitValue(dynamic value) {
|
||||
@ -63,22 +73,23 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
final limitName = _parameterKeyToLimitName[key];
|
||||
if (limitName == null) return;
|
||||
|
||||
// START MODIFICATION: Only check for station-specific limits
|
||||
Map<String, dynamic> limitData = {};
|
||||
|
||||
if (stationId != null) {
|
||||
limitData = marineLimits.firstWhere(
|
||||
(l) => l['param_parameter_list'] == limitName && l['station_id'] == stationId,
|
||||
(l) =>
|
||||
l['param_parameter_list'] == limitName &&
|
||||
l['station_id'] == stationId,
|
||||
orElse: () => {},
|
||||
);
|
||||
}
|
||||
// END MODIFICATION
|
||||
|
||||
if (limitData.isNotEmpty) {
|
||||
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
|
||||
final upperLimit = parseLimitValue(limitData['param_upper_limit']);
|
||||
|
||||
if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) {
|
||||
if ((lowerLimit != null && value < lowerLimit) ||
|
||||
(upperLimit != null && value > upperLimit)) {
|
||||
invalidKeys.add(key);
|
||||
}
|
||||
}
|
||||
@ -86,14 +97,272 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
|
||||
return invalidKeys;
|
||||
}
|
||||
// --- END: MODIFICATION FOR HIGHLIGHTING ---
|
||||
|
||||
/// Checks captured data against NPE limits and returns detailed information for the dialog.
|
||||
List<Map<String, dynamic>> _getNpeTriggeredParameters(BuildContext context) {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final npeLimits = authProvider.npeParameterLimits ?? [];
|
||||
if (npeLimits.isEmpty) return [];
|
||||
|
||||
final List<Map<String, dynamic>> triggeredParams = [];
|
||||
|
||||
final readings = {
|
||||
'oxygenConcentration': widget.data.oxygenConcentration,
|
||||
'oxygenSaturation': widget.data.oxygenSaturation,
|
||||
'ph': widget.data.ph,
|
||||
'salinity': widget.data.salinity,
|
||||
'electricalConductivity': widget.data.electricalConductivity,
|
||||
'temperature': widget.data.temperature,
|
||||
'tds': widget.data.tds,
|
||||
'turbidity': widget.data.turbidity,
|
||||
'tss': widget.data.tss,
|
||||
};
|
||||
|
||||
double? parseLimitValue(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
readings.forEach((key, value) {
|
||||
if (value == null || value == -999.0) return;
|
||||
final limitName = _parameterKeyToLimitName[key];
|
||||
if (limitName == null) return;
|
||||
|
||||
final limitData = npeLimits.firstWhere(
|
||||
(l) => l['param_parameter_list'] == limitName,
|
||||
orElse: () => {},
|
||||
);
|
||||
|
||||
if (limitData.isNotEmpty) {
|
||||
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
|
||||
final upperLimit = parseLimitValue(limitData['param_upper_limit']);
|
||||
bool isHit = false;
|
||||
|
||||
if (lowerLimit != null && upperLimit != null) {
|
||||
if (value >= lowerLimit && value <= upperLimit) isHit = true;
|
||||
} else if (lowerLimit != null && upperLimit == null) {
|
||||
if (value >= lowerLimit) isHit = true;
|
||||
} else if (upperLimit != null && lowerLimit == null) {
|
||||
if (value <= upperLimit) isHit = true;
|
||||
}
|
||||
|
||||
if (isHit) {
|
||||
triggeredParams.add({
|
||||
'label': limitName,
|
||||
'value': value,
|
||||
'lower_limit': lowerLimit,
|
||||
'upper_limit': upperLimit,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return triggeredParams;
|
||||
}
|
||||
|
||||
/// Displays the enhanced NPE warning dialog with a highlighted table.
|
||||
Future<bool?> _showNpeDialog(
|
||||
BuildContext context, List<Map<String, dynamic>> triggeredParams) async {
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text("NPE Parameter Limit Detected"),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'The following parameters have fallen under the Non-Permissible Event (NPE) limit:'),
|
||||
const SizedBox(height: 16),
|
||||
Table(
|
||||
columnWidths: const {
|
||||
0: FlexColumnWidth(2),
|
||||
1: FlexColumnWidth(2.5),
|
||||
2: FlexColumnWidth(1.5),
|
||||
},
|
||||
border: TableBorder(
|
||||
horizontalInside: BorderSide(
|
||||
width: 0.5,
|
||||
color: isDarkTheme
|
||||
? Colors.grey.shade700
|
||||
: Colors.grey.shade300),
|
||||
verticalInside: BorderSide(
|
||||
width: 0.5,
|
||||
color: isDarkTheme
|
||||
? Colors.grey.shade700
|
||||
: Colors.grey.shade300),
|
||||
top: BorderSide(
|
||||
width: 1,
|
||||
color: isDarkTheme
|
||||
? Colors.grey.shade600
|
||||
: Colors.grey.shade400),
|
||||
bottom: BorderSide(
|
||||
width: 1,
|
||||
color: isDarkTheme
|
||||
? Colors.grey.shade600
|
||||
: Colors.grey.shade400),
|
||||
),
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkTheme
|
||||
? Colors.grey.shade800
|
||||
: Colors.grey.shade200),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: Text('Parameter',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.color))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: Text('NPE Range',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.color))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: Text('Current',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.color))),
|
||||
],
|
||||
),
|
||||
...triggeredParams.map((p) {
|
||||
final lowerStr =
|
||||
p['lower_limit']?.toStringAsFixed(5) ?? 'N/A';
|
||||
final upperStr =
|
||||
p['upper_limit']?.toStringAsFixed(5) ?? 'N/A';
|
||||
String range;
|
||||
if (lowerStr != 'N/A' && upperStr != 'N/A') {
|
||||
range = '$lowerStr - $upperStr';
|
||||
} else if (lowerStr != 'N/A') {
|
||||
range = '>= $lowerStr';
|
||||
} else {
|
||||
range = '<= $upperStr';
|
||||
}
|
||||
|
||||
return TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: Text(p['label'])),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: Text(range)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: Text(
|
||||
p['value'].toStringAsFixed(5),
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Please generate an NPE report.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: const Text("Continue"),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
),
|
||||
ElevatedButton(
|
||||
child: const Text("Open NPE Report"),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles the complete submission flow: NPE check, submission, and UI feedback/navigation.
|
||||
Future<void> _handleSubmit(BuildContext context) async {
|
||||
if (_isHandlingSubmit || widget.isLoading) return;
|
||||
|
||||
setState(() => _isHandlingSubmit = true);
|
||||
|
||||
bool shouldOpenNpeReport = false;
|
||||
bool proceedWithSubmission = false;
|
||||
|
||||
try {
|
||||
final npeParameters = _getNpeTriggeredParameters(context);
|
||||
|
||||
if (npeParameters.isNotEmpty) {
|
||||
final userChoice = await _showNpeDialog(context, npeParameters);
|
||||
if (userChoice == true) {
|
||||
shouldOpenNpeReport = true;
|
||||
proceedWithSubmission = true;
|
||||
} else if (userChoice == false) {
|
||||
proceedWithSubmission = true;
|
||||
}
|
||||
// If userChoice is null (dialog dismissed), we do nothing.
|
||||
} else {
|
||||
// No NPE hit, proceed normally.
|
||||
proceedWithSubmission = true;
|
||||
}
|
||||
|
||||
if (proceedWithSubmission) {
|
||||
final result = await widget.onSubmit(); // This calls the logic in the parent
|
||||
if (!mounted) return;
|
||||
|
||||
final message = result['message'] ?? 'An unknown error occurred.';
|
||||
final color = (result['success'] == true) ? Colors.green : Colors.red;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: color,
|
||||
duration: const Duration(seconds: 4)),
|
||||
);
|
||||
|
||||
if (result['success'] == true) {
|
||||
if (shouldOpenNpeReport) {
|
||||
final npeData = widget.data.toNpeReportData();
|
||||
// This works offline because it's just passing local data
|
||||
Navigator.of(context).pushNamed(
|
||||
'/marine/manual/report/npe',
|
||||
arguments: npeData,
|
||||
);
|
||||
} else {
|
||||
// Submission successful, and no NPE report needed, so go home.
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isHandlingSubmit = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// --- START: MODIFICATION FOR HIGHLIGHTING ---
|
||||
// Get the set of out-of-bounds keys before building the list.
|
||||
final outOfBoundsKeys = _getOutOfBoundsKeys(context);
|
||||
// --- END: MODIFICATION FOR HIGHLIGHTING ---
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
@ -104,94 +373,164 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildSectionCard(
|
||||
context,
|
||||
"Sampling & Station Details",
|
||||
[
|
||||
_buildDetailRow("1st Sampler:", data.firstSamplerName),
|
||||
_buildDetailRow("2nd Sampler:", data.secondSampler?['first_name']?.toString()),
|
||||
_buildDetailRow("Sampling Date:", data.samplingDate),
|
||||
_buildDetailRow("Sampling Time:", data.samplingTime),
|
||||
_buildDetailRow("Sampling Type:", data.samplingType),
|
||||
_buildDetailRow("Sample ID Code:", data.sampleIdCode),
|
||||
_buildDetailRow("1st Sampler:", widget.data.firstSamplerName),
|
||||
_buildDetailRow(
|
||||
"2nd Sampler:", widget.data.secondSampler?['first_name']?.toString()),
|
||||
_buildDetailRow("Sampling Date:", widget.data.samplingDate),
|
||||
_buildDetailRow("Sampling Time:", widget.data.samplingTime),
|
||||
_buildDetailRow("Sampling Type:", widget.data.samplingType),
|
||||
_buildDetailRow("Sample ID Code:", widget.data.sampleIdCode),
|
||||
const Divider(height: 20),
|
||||
_buildDetailRow("State:", data.selectedStateName),
|
||||
_buildDetailRow("Category:", data.selectedCategoryName),
|
||||
_buildDetailRow("Station Code:", data.selectedStation?['man_station_code']?.toString()),
|
||||
_buildDetailRow("Station Name:", data.selectedStation?['man_station_name']?.toString()),
|
||||
_buildDetailRow("Station Location:", "${data.stationLatitude}, ${data.stationLongitude}"),
|
||||
_buildDetailRow("State:", widget.data.selectedStateName),
|
||||
_buildDetailRow("Category:", widget.data.selectedCategoryName),
|
||||
_buildDetailRow("Station Code:",
|
||||
widget.data.selectedStation?['man_station_code']?.toString()),
|
||||
_buildDetailRow("Station Name:",
|
||||
widget.data.selectedStation?['man_station_name']?.toString()),
|
||||
_buildDetailRow("Station Location:",
|
||||
"${widget.data.stationLatitude}, ${widget.data.stationLongitude}"),
|
||||
],
|
||||
),
|
||||
|
||||
_buildSectionCard(
|
||||
context,
|
||||
"Location & On-Site Info",
|
||||
[
|
||||
_buildDetailRow("Current Location:", "${data.currentLatitude}, ${data.currentLongitude}"),
|
||||
_buildDetailRow("Distance Difference:", data.distanceDifferenceInKm != null ? "${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters" : "N/A"),
|
||||
if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty)
|
||||
_buildDetailRow("Distance Remarks:", data.distanceDifferenceRemarks),
|
||||
_buildDetailRow("Current Location:",
|
||||
"${widget.data.currentLatitude}, ${widget.data.currentLongitude}"),
|
||||
_buildDetailRow(
|
||||
"Distance Difference:",
|
||||
widget.data.distanceDifferenceInKm != null
|
||||
? "${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters"
|
||||
: "N/A"),
|
||||
if (widget.data.distanceDifferenceRemarks != null &&
|
||||
widget.data.distanceDifferenceRemarks!.isNotEmpty)
|
||||
_buildDetailRow(
|
||||
"Distance Remarks:", widget.data.distanceDifferenceRemarks),
|
||||
const Divider(height: 20),
|
||||
_buildDetailRow("Weather:", data.weather),
|
||||
_buildDetailRow("Tide Level:", data.tideLevel),
|
||||
_buildDetailRow("Sea Condition:", data.seaCondition),
|
||||
_buildDetailRow("Event Remarks:", data.eventRemarks),
|
||||
_buildDetailRow("Lab Remarks:", data.labRemarks),
|
||||
_buildDetailRow("Weather:", widget.data.weather),
|
||||
_buildDetailRow("Tide Level:", widget.data.tideLevel),
|
||||
_buildDetailRow("Sea Condition:", widget.data.seaCondition),
|
||||
_buildDetailRow("Event Remarks:", widget.data.eventRemarks),
|
||||
_buildDetailRow("Lab Remarks:", widget.data.labRemarks),
|
||||
],
|
||||
),
|
||||
|
||||
_buildSectionCard(
|
||||
context,
|
||||
"Attached Photos",
|
||||
[
|
||||
_buildImageCard("Left Side Land View", data.leftLandViewImage),
|
||||
_buildImageCard("Right Side Land View", data.rightLandViewImage),
|
||||
_buildImageCard("Filling Water into Bottle", data.waterFillingImage),
|
||||
_buildImageCard("Seawater Color in Bottle", data.seawaterColorImage),
|
||||
_buildImageCard("Examine Preservative (pH paper)", data.phPaperImage),
|
||||
_buildImageCard("Left Side Land View", widget.data.leftLandViewImage),
|
||||
_buildImageCard(
|
||||
"Right Side Land View", widget.data.rightLandViewImage),
|
||||
_buildImageCard(
|
||||
"Filling Water into Bottle", widget.data.waterFillingImage),
|
||||
_buildImageCard(
|
||||
"Seawater Color in Bottle", widget.data.seawaterColorImage),
|
||||
_buildImageCard(
|
||||
"Examine Preservative (pH paper)", widget.data.phPaperImage),
|
||||
const Divider(height: 24),
|
||||
Text("Optional Photos", style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
Text("Optional Photos",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
_buildImageCard("Optional Photo 1", data.optionalImage1, remark: data.optionalRemark1),
|
||||
_buildImageCard("Optional Photo 2", data.optionalImage2, remark: data.optionalRemark2),
|
||||
_buildImageCard("Optional Photo 3", data.optionalImage3, remark: data.optionalRemark3),
|
||||
_buildImageCard("Optional Photo 4", data.optionalImage4, remark: data.optionalRemark4),
|
||||
_buildImageCard("Optional Photo 1", widget.data.optionalImage1,
|
||||
remark: widget.data.optionalRemark1),
|
||||
_buildImageCard("Optional Photo 2", widget.data.optionalImage2,
|
||||
remark: widget.data.optionalRemark2),
|
||||
_buildImageCard("Optional Photo 3", widget.data.optionalImage3,
|
||||
remark: widget.data.optionalRemark3),
|
||||
_buildImageCard("Optional Photo 4", widget.data.optionalImage4,
|
||||
remark: widget.data.optionalRemark4),
|
||||
],
|
||||
),
|
||||
|
||||
_buildSectionCard(
|
||||
context,
|
||||
"Captured Parameters",
|
||||
[
|
||||
_buildDetailRow("Sonde ID:", data.sondeId),
|
||||
_buildDetailRow("Capture Time:", "${data.dataCaptureDate} ${data.dataCaptureTime}"),
|
||||
_buildDetailRow("Sonde ID:", widget.data.sondeId),
|
||||
_buildDetailRow("Capture Time:",
|
||||
"${widget.data.dataCaptureDate} ${widget.data.dataCaptureTime}"),
|
||||
const Divider(height: 20),
|
||||
// --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING ---
|
||||
_buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration, isOutOfBounds: outOfBoundsKeys.contains('oxygenConcentration')),
|
||||
_buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation, isOutOfBounds: outOfBoundsKeys.contains('oxygenSaturation')),
|
||||
_buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph, isOutOfBounds: outOfBoundsKeys.contains('ph')),
|
||||
_buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity, isOutOfBounds: outOfBoundsKeys.contains('salinity')),
|
||||
_buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity, isOutOfBounds: outOfBoundsKeys.contains('electricalConductivity')),
|
||||
_buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature, isOutOfBounds: outOfBoundsKeys.contains('temperature')),
|
||||
_buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds, isOutOfBounds: outOfBoundsKeys.contains('tds')),
|
||||
_buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity, isOutOfBounds: outOfBoundsKeys.contains('turbidity')),
|
||||
_buildParameterListItem(context, icon: Icons.filter_alt_outlined, label: "TSS", unit: "mg/L", value: data.tss, isOutOfBounds: outOfBoundsKeys.contains('tss')),
|
||||
_buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage, isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')),
|
||||
// --- END: MODIFICATION ---
|
||||
_buildParameterListItem(context,
|
||||
icon: Icons.air,
|
||||
label: "Oxygen Conc.",
|
||||
unit: "mg/L",
|
||||
value: widget.data.oxygenConcentration,
|
||||
isOutOfBounds:
|
||||
outOfBoundsKeys.contains('oxygenConcentration')),
|
||||
_buildParameterListItem(context,
|
||||
icon: Icons.percent,
|
||||
label: "Oxygen Sat.",
|
||||
unit: "%",
|
||||
value: widget.data.oxygenSaturation,
|
||||
isOutOfBounds: outOfBoundsKeys.contains('oxygenSaturation')),
|
||||
_buildParameterListItem(context,
|
||||
icon: Icons.science_outlined,
|
||||
label: "pH",
|
||||
unit: "",
|
||||
value: widget.data.ph,
|
||||
isOutOfBounds: outOfBoundsKeys.contains('ph')),
|
||||
_buildParameterListItem(context,
|
||||
icon: Icons.waves,
|
||||
label: "Salinity",
|
||||
unit: "ppt",
|
||||
value: widget.data.salinity,
|
||||
isOutOfBounds: outOfBoundsKeys.contains('salinity')),
|
||||
_buildParameterListItem(context,
|
||||
icon: Icons.flash_on,
|
||||
label: "Conductivity",
|
||||
unit: "µS/cm",
|
||||
value: widget.data.electricalConductivity,
|
||||
isOutOfBounds:
|
||||
outOfBoundsKeys.contains('electricalConductivity')),
|
||||
_buildParameterListItem(context,
|
||||
icon: Icons.thermostat,
|
||||
label: "Temperature",
|
||||
unit: "°C",
|
||||
value: widget.data.temperature,
|
||||
isOutOfBounds: outOfBoundsKeys.contains('temperature')),
|
||||
_buildParameterListItem(context,
|
||||
icon: Icons.grain,
|
||||
label: "TDS",
|
||||
unit: "mg/L",
|
||||
value: widget.data.tds,
|
||||
isOutOfBounds: outOfBoundsKeys.contains('tds')),
|
||||
_buildParameterListItem(context,
|
||||
icon: Icons.opacity,
|
||||
label: "Turbidity",
|
||||
unit: "NTU",
|
||||
value: widget.data.turbidity,
|
||||
isOutOfBounds: outOfBoundsKeys.contains('turbidity')),
|
||||
_buildParameterListItem(context,
|
||||
icon: Icons.filter_alt_outlined,
|
||||
label: "TSS",
|
||||
unit: "mg/L",
|
||||
value: widget.data.tss,
|
||||
isOutOfBounds: outOfBoundsKeys.contains('tss')),
|
||||
_buildParameterListItem(context,
|
||||
icon: Icons.battery_charging_full,
|
||||
label: "Battery",
|
||||
unit: "V",
|
||||
value: widget.data.batteryVoltage,
|
||||
isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
isLoading
|
||||
(widget.isLoading || _isHandlingSubmit)
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ElevatedButton.icon(
|
||||
onPressed: onSubmit,
|
||||
onPressed: () => _handleSubmit(context),
|
||||
icon: const Icon(Icons.cloud_upload),
|
||||
label: const Text('Confirm & Submit'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@ -199,8 +538,8 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// A reusable widget to create a consistent section card.
|
||||
Widget _buildSectionCard(BuildContext context, String title, List<Widget> children) {
|
||||
Widget _buildSectionCard(
|
||||
BuildContext context, String title, List<Widget> children) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
elevation: 2,
|
||||
@ -226,7 +565,6 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// A reusable widget for a label-value row.
|
||||
Widget _buildDetailRow(String label, String? value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
@ -240,26 +578,28 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(value != null && value.isNotEmpty ? value : 'N/A', style: const TextStyle(fontSize: 16)),
|
||||
child: Text(value != null && value.isNotEmpty ? value : 'N/A',
|
||||
style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING ---
|
||||
Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required double? value, bool isOutOfBounds = false}) {
|
||||
Widget _buildParameterListItem(BuildContext context,
|
||||
{required IconData icon,
|
||||
required String label,
|
||||
required String unit,
|
||||
required double? value,
|
||||
bool isOutOfBounds = false}) {
|
||||
final bool isMissing = value == null || value == -999.0;
|
||||
// Format the value to 5 decimal places if it's a valid number.
|
||||
final String displayValue = isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim();
|
||||
|
||||
// START CHANGE: Use theme-aware color for normal values
|
||||
// Determine the color for the value based on theme and status.
|
||||
final Color? defaultTextColor = Theme.of(context).textTheme.bodyLarge?.color;
|
||||
final String displayValue =
|
||||
isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim();
|
||||
final Color? defaultTextColor =
|
||||
Theme.of(context).textTheme.bodyLarge?.color;
|
||||
final Color valueColor = isOutOfBounds
|
||||
? Colors.red
|
||||
: (isMissing ? Colors.grey : defaultTextColor ?? Colors.black);
|
||||
// END CHANGE
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
@ -275,9 +615,7 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
// --- END: MODIFICATION ---
|
||||
|
||||
/// A reusable widget to display an attached image or a placeholder.
|
||||
Widget _buildImageCard(String title, File? image, {String? remark}) {
|
||||
final bool hasRemark = remark != null && remark.isNotEmpty;
|
||||
return Padding(
|
||||
@ -285,12 +623,18 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
Text(title,
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
const SizedBox(height: 8),
|
||||
if (image != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
child: Image.file(image, key: UniqueKey(), height: 200, width: double.infinity, fit: BoxFit.cover),
|
||||
child: Image.file(image,
|
||||
key: UniqueKey(),
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
@ -300,12 +644,15 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
border: Border.all(color: Colors.grey[300]!)),
|
||||
child: const Center(child: Text('No Image Attached', style: TextStyle(color: Colors.grey))),
|
||||
child: const Center(
|
||||
child: Text('No Image Attached',
|
||||
style: TextStyle(color: Colors.grey))),
|
||||
),
|
||||
if (hasRemark)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic)),
|
||||
child: Text('Remark: $remark',
|
||||
style: const TextStyle(fontStyle: FontStyle.italic)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -748,7 +748,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('App Version'),
|
||||
subtitle: const Text('MMS Version 3.2.01'),
|
||||
subtitle: const Text('MMS Version 3.4.01'),
|
||||
dense: true,
|
||||
),
|
||||
ListTile(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user