repair file naming and start the npe screen for marine

This commit is contained in:
ALim Aidrus 2025-10-08 14:44:20 +08:00
parent 8931ed9297
commit 37874a1eab
17 changed files with 1525 additions and 231 deletions

View File

@ -29,7 +29,7 @@
<!-- MMS V4 1.2.08 -->
<application
android:label="MMS V4 1.2.08"
android:label="MMS V4 1.2.09"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true">

View File

@ -36,9 +36,9 @@ import 'package:environment_monitoring_app/screens/marine/marine_home_page.dart'
import 'package:environment_monitoring_app/screens/air/manual/air_manual_info_centre_document.dart';
import 'package:environment_monitoring_app/screens/air/manual/air_manual_installation_screen.dart';
import 'package:environment_monitoring_app/screens/air/manual/air_manual_collection_screen.dart';
import 'package:environment_monitoring_app/screens/air/manual/report.dart' as airManualReport;
import 'package:environment_monitoring_app/screens/air/manual/data_status_log.dart' as airManualDataStatusLog;
import 'package:environment_monitoring_app/screens/air/manual/image_request.dart' as airManualImageRequest;
import 'package:environment_monitoring_app/screens/air/manual/air_manual_report.dart' as airManualReport;
import 'package:environment_monitoring_app/screens/air/manual/air_manual_data_status_log.dart' as airManualDataStatusLog;
import 'package:environment_monitoring_app/screens/air/manual/air_manual_image_request.dart' as airManualImageRequest;
import 'package:environment_monitoring_app/screens/air/continuous/air_continuous_info_centre_document.dart';
import 'package:environment_monitoring_app/screens/air/continuous/overview.dart' as airContinuousOverview;
import 'package:environment_monitoring_app/screens/air/continuous/entry.dart' as airContinuousEntry;
@ -51,10 +51,11 @@ import 'package:environment_monitoring_app/screens/air/investigative/report.dart
// River Screens
import 'package:environment_monitoring_app/screens/river/manual/river_manual_info_centre_document.dart';
import 'package:environment_monitoring_app/screens/river/manual/in_situ_sampling.dart' as riverManualInSituSampling;
import 'package:environment_monitoring_app/screens/river/manual/data_status_log.dart' as riverManualDataStatusLog;
import 'package:environment_monitoring_app/screens/river/manual/report.dart' as riverManualReport;
import 'package:environment_monitoring_app/screens/river/manual/river_manual_data_status_log.dart' as riverManualDataStatusLog;
// MODIFIED: Import paths updated to new filenames
import 'package:environment_monitoring_app/screens/river/manual/river_manual_report.dart' as riverManualReport;
import 'package:environment_monitoring_app/screens/river/manual/triennial_sampling.dart' as riverManualTriennialSampling;
import 'package:environment_monitoring_app/screens/river/manual/image_request.dart' as riverManualImageRequest;
import 'package:environment_monitoring_app/screens/river/manual/river_manual_image_request.dart' as riverManualImageRequest;
import 'package:environment_monitoring_app/screens/river/continuous/river_continuous_info_centre_document.dart';
import 'package:environment_monitoring_app/screens/river/continuous/overview.dart' as riverContinuousOverview;
import 'package:environment_monitoring_app/screens/river/continuous/entry.dart' as riverContinuousEntry;
@ -68,8 +69,9 @@ import 'package:environment_monitoring_app/screens/river/investigative/report.da
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/in_situ_sampling.dart' as marineManualInSituSampling;
import 'package:environment_monitoring_app/screens/marine/manual/report.dart' as marineManualReport;
import 'package:environment_monitoring_app/screens/marine/manual/data_status_log.dart' as marineManualDataStatusLog;
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_report.dart' as marineManualReport;
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_npe_report.dart' as marineManualNPEReport;
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';
import 'package:environment_monitoring_app/screens/marine/continuous/overview.dart' as marineContinuousOverview;
@ -257,6 +259,7 @@ class _RootAppState extends State<RootApp> {
// River Manual
'/river/manual/info': (context) => const RiverManualInfoCentreDocument(),
'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(),
// MODIFIED: Routes updated to use new class names from aliased imports
'/river/manual/report': (context) => riverManualReport.RiverManualReport(),
'/river/manual/triennial': (context) => riverManualTriennialSampling.RiverTriennialSampling(),
'/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(),
@ -279,7 +282,8 @@ class _RootAppState extends State<RootApp> {
'/marine/manual/pre-sampling': (context) => marineManualPreSampling.MarinePreSampling(),
'/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(),
'/marine/manual/tarball': (context) => const TarballSamplingStep1(),
'/marine/manual/report': (context) => marineManualReport.MarineManualReport(),
'/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(),
'/marine/manual/report/npe': (context) => const marineManualNPEReport.MarineManualNPEReport(),
//'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute
'/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(),

View File

@ -68,14 +68,42 @@ class InSituSamplingData {
String? submissionMessage;
String? reportId;
// --- START: NPE Report Compatibility Fields ---
/// Fields to hold data that can be transferred to an NPE Report.
/// This makes the model compatible for auto-generating NPE reports in the future.
// Corresponds to the checkboxes in the NPE form
Map<String, bool> npeFieldObservations = {
'Oil slick on the water surface/ Oil spill': false,
'Discoloration of the sea water': false,
'Formation of foam on the surface': false,
'Coral bleaching or dead corals': false,
'Observation of tar balls': false,
'Excessive debris': false,
'Red tides or algae blooms': false,
'Silt plume': false,
'Foul smell': false,
'Others': false,
};
// Corresponds to the "Others" text field in NPE observations
String? npeOthersObservationRemark;
// Corresponds to the "Possible Source" field in the NPE form
String? npePossibleSource;
// Holds the images to be attached to the NPE report
File? npeImage1;
File? npeImage2;
File? npeImage3;
File? npeImage4;
// --- END: NPE Report Compatibility Fields ---
InSituSamplingData({
this.samplingDate,
this.samplingTime,
});
/// Creates an InSituSamplingData object from a JSON map.
/// This is critical for the offline retry mechanism. The keys used here MUST perfectly
/// match the keys used in the `toDbJson()` method to ensure data integrity.
factory InSituSamplingData.fromJson(Map<String, dynamic> json) {
double? doubleFromJson(dynamic value) {
if (value is num) return value.toDouble();
@ -95,13 +123,14 @@ class InSituSamplingData {
final data = InSituSamplingData();
// START FIX: Aligned all keys to perfectly match the toDbJson() method and added backward compatibility.
// Standard In-Situ Fields
data.firstSamplerName = json['first_sampler_name'];
data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']);
data.secondSampler = json['secondSampler'] ?? json['second_sampler'];
data.samplingDate = json['sampling_date'] ?? json['man_date'];
data.samplingTime = json['sampling_time'] ?? json['man_time'];
data.samplingType = json['sampling_type'];
// ... (all other existing fields)
data.sampleIdCode = json['sample_id_code'];
data.selectedStateName = json['selected_state_name'];
data.selectedCategoryName = json['selected_category_name'];
@ -138,9 +167,11 @@ class InSituSamplingData {
data.submissionMessage = json['submission_message'];
data.reportId = json['report_id']?.toString();
// Image paths are added by LocalStorageService, not toDbJson, so they are read separately.
// Image paths (handled by LocalStorageService)
data.leftLandViewImage = fileFromPath(json['man_left_side_land_view']);
data.rightLandViewImage = fileFromPath(json['man_right_side_land_view']);
// ... (all other existing images)
data.waterFillingImage = fileFromPath(json['man_filling_water_into_sample_bottle']);
data.seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle']);
data.phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper']);
@ -148,11 +179,88 @@ class InSituSamplingData {
data.optionalImage2 = fileFromPath(json['man_optional_photo_02']);
data.optionalImage3 = fileFromPath(json['man_optional_photo_03']);
data.optionalImage4 = fileFromPath(json['man_optional_photo_04']);
// END FIX
// --- START: Deserialization for NPE Fields ---
if (json['npe_field_observations'] is Map) {
data.npeFieldObservations = Map<String, bool>.from(json['npe_field_observations']);
}
data.npeOthersObservationRemark = json['npe_others_observation_remark'];
data.npePossibleSource = json['npe_possible_source'];
// NPE image paths
data.npeImage1 = fileFromPath(json['npe_image_1']);
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 {
'first_sampler_name': firstSamplerName,
'first_sampler_user_id': firstSamplerUserId,
'secondSampler': secondSampler,
'sampling_date': samplingDate,
'sampling_time': samplingTime,
// ... (all other existing fields)
'sampling_type': samplingType,
'sample_id_code': sampleIdCode,
'selected_state_name': selectedStateName,
'selected_category_name': selectedCategoryName,
'selectedStation': selectedStation,
'station_latitude': stationLatitude,
'station_longitude': stationLongitude,
'current_latitude': currentLatitude,
'current_longitude': currentLongitude,
'distance_difference_in_km': distanceDifferenceInKm,
'distance_difference_remarks': distanceDifferenceRemarks,
'weather': weather,
'tide_level': tideLevel,
'sea_condition': seaCondition,
'event_remarks': eventRemarks,
'lab_remarks': labRemarks,
'man_optional_photo_01_remarks': optionalRemark1,
'man_optional_photo_02_remarks': optionalRemark2,
'man_optional_photo_03_remarks': optionalRemark3,
'man_optional_photo_04_remarks': optionalRemark4,
'sonde_id': sondeId,
'data_capture_date': dataCaptureDate,
'data_capture_time': dataCaptureTime,
'oxygen_concentration': oxygenConcentration,
'oxygen_saturation': oxygenSaturation,
'ph': ph,
'salinity': salinity,
'electrical_conductivity': electricalConductivity,
'temperature': temperature,
'tds': tds,
'turbidity': turbidity,
'tss': tss,
'battery_voltage': batteryVoltage,
'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';
@ -256,52 +364,4 @@ class InSituSamplingData {
'man_optional_photo_04': optionalImage4,
};
}
/// Creates a single JSON object with all submission data for offline storage.
/// The keys here are the single source of truth for the offline data format.
Map<String, dynamic> toDbJson() {
return {
'first_sampler_name': firstSamplerName,
'first_sampler_user_id': firstSamplerUserId,
'secondSampler': secondSampler,
'sampling_date': samplingDate,
'sampling_time': samplingTime,
'sampling_type': samplingType,
'sample_id_code': sampleIdCode,
'selected_state_name': selectedStateName,
'selected_category_name': selectedCategoryName,
'selectedStation': selectedStation,
'station_latitude': stationLatitude,
'station_longitude': stationLongitude,
'current_latitude': currentLatitude,
'current_longitude': currentLongitude,
'distance_difference_in_km': distanceDifferenceInKm,
'distance_difference_remarks': distanceDifferenceRemarks,
'weather': weather,
'tide_level': tideLevel,
'sea_condition': seaCondition,
'event_remarks': eventRemarks,
'lab_remarks': labRemarks,
'man_optional_photo_01_remarks': optionalRemark1,
'man_optional_photo_02_remarks': optionalRemark2,
'man_optional_photo_03_remarks': optionalRemark3,
'man_optional_photo_04_remarks': optionalRemark4,
'sonde_id': sondeId,
'data_capture_date': dataCaptureDate,
'data_capture_time': dataCaptureTime,
'oxygen_concentration': oxygenConcentration,
'oxygen_saturation': oxygenSaturation,
'ph': ph,
'salinity': salinity,
'electrical_conductivity': electricalConductivity,
'temperature': temperature,
'tds': tds,
'turbidity': turbidity,
'tss': tss,
'battery_voltage': batteryVoltage,
'submission_status': submissionStatus,
'submission_message': submissionMessage,
'report_id': reportId,
};
}
}

View File

@ -1,4 +1,4 @@
// lib/screens/air/manual/data_status_log.dart
// lib/screens/air/manual/air_manual_data_status_log.dart
import 'dart:io';
import 'package:flutter/material.dart';

View File

@ -1,7 +1,8 @@
// lib/screens/air/manual/air_manual_image_request.dart
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io'; // Add this line at the top of these files
import 'dart:io';
class AirManualImageRequest extends StatefulWidget {
@override

View File

@ -1,3 +1,5 @@
// lib/screens/air/manual/air_manual_report.dart
import 'package:flutter/material.dart';
class AirManualReport extends StatelessWidget {

View File

@ -1,4 +1,4 @@
// lib/screens/marine/manual/data_status_log.dart
// lib/screens/marine/manual/marine_manual_data_status_log.dart
import 'dart:io';
import 'package:flutter/material.dart';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
// A simple data class for report items
class ReportItem {
final IconData icon;
final String label;
final String route;
const ReportItem({
required this.icon,
required this.label,
required this.route,
});
}
class MarineManualReportHomePage extends StatelessWidget {
const MarineManualReportHomePage({super.key});
// Define the list of available reports
final List<ReportItem> _reports = const [
ReportItem(
icon: Icons.science_outlined,
label: "NPE Report",
route: '/marine/manual/report/npe',
),
// You can add other future reports here. For example:
// ReportItem(
// icon: Icons.assessment_outlined,
// label: "Quarterly Summary",
// route: '/marine/manual/report/quarterly',
// ),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Marine Manual Reports"),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Select a Report to Generate",
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
// Using a GridView for the report items for a clean layout
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
childAspectRatio: 1.5, // Adjust for a card-like appearance
),
itemCount: _reports.length,
itemBuilder: (context, index) {
final report = _reports[index];
return _buildReportCard(context, report);
},
),
],
),
),
);
}
// Method to build a clickable card for each report type
Widget _buildReportCard(BuildContext context, ReportItem report) {
return InkWell(
onTap: () {
Navigator.pushNamed(context, report.route);
},
borderRadius: BorderRadius.circular(12),
child: Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.white24, width: 1),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(report.icon, size: 40, color: Theme.of(context).colorScheme.secondary),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
report.label,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}

View File

@ -1,39 +0,0 @@
import 'package:flutter/material.dart';
class MarineManualReport extends StatelessWidget {
final List<Map<String, String>> sampleData = [
{"Station": "Marine Site A", "Parameter": "Salinity", "Value": "34 PSU"},
{"Station": "Marine Site B", "Parameter": "Turbidity", "Value": "5 NTU"},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Marine Manual Report")),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Manual Sampling Report", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 16),
DataTable(
columns: [
DataColumn(label: Text("Station")),
DataColumn(label: Text("Parameter")),
DataColumn(label: Text("Value")),
],
rows: sampleData.map((data) {
return DataRow(cells: [
DataCell(Text(data["Station"]!)),
DataCell(Text(data["Parameter"]!)),
DataCell(Text(data["Value"]!)),
]);
}).toList(),
),
],
),
),
);
}
}

View File

@ -38,7 +38,7 @@ class MarineHomePage extends StatelessWidget {
SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'),
SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'),
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/manual/report'),
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/manual/report'),
],
),
SidebarItem(

View File

@ -1,4 +1,4 @@
// lib/screens/river/manual/data_status_log.dart
// lib/screens/river/manual/river_manual_data_status_log.dart
import 'dart:io';
import 'package:flutter/material.dart';
@ -97,7 +97,6 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
}
}
// --- START: MODIFIED TO FIX NULL SAFETY ERRORS ---
SubmissionLogEntry? _createLogEntry(Map<String, dynamic> log) {
final String type = log['samplingType'] ?? 'In-Situ Sampling';
final String title = log['selectedStation']?['sampling_river'] ?? 'Unknown River';
@ -114,7 +113,6 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
} catch (_) {
submissionDateTime = DateTime.now();
}
// --- END: MODIFIED TO FIX NULL SAFETY ERRORS ---
String? apiStatusRaw;
if (log['api_status'] != null) {

View File

@ -1,6 +1,8 @@
// lib/screens/river/manual/river_manual_image_request.dart
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io'; // Add this line at the top of these files
import 'dart:io';
class RiverManualImageRequest extends StatefulWidget {

View File

@ -1,3 +1,5 @@
// lib/screens/river/manual/river_manual_report.dart
import 'package:flutter/material.dart';
class RiverManualReport extends StatelessWidget {

View File

@ -36,6 +36,12 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
bool _isAutoReading = false;
StreamSubscription? _dataSubscription;
// --- START: Added for lockout timer ---
Timer? _lockoutTimer;
int _lockoutSecondsRemaining = 30;
bool _isLockedOut = false;
// --- END: Added for lockout timer ---
late final RiverInSituSamplingService _samplingService;
// --- START: Added for direct database access ---
@ -95,6 +101,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
@override
void dispose() {
_dataSubscription?.cancel();
_lockoutTimer?.cancel(); // --- MODIFICATION: Cancel timer on dispose ---
if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_samplingService.disconnectFromBluetooth();
@ -299,12 +306,40 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
return success;
}
// --- START MODIFICATION: Countdown Timer Logic ---
void _startLockoutTimer() {
_lockoutTimer?.cancel();
setState(() {
_isLockedOut = true;
_lockoutSecondsRemaining = 30;
});
_lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_lockoutSecondsRemaining > 0) {
if (mounted) {
setState(() {
_lockoutSecondsRemaining--;
});
}
} else {
timer.cancel();
if (mounted) {
setState(() {
_isLockedOut = false;
});
}
}
});
}
// --- END MODIFICATION ---
void _toggleAutoReading(String activeType) {
final service = context.read<RiverInSituSamplingService>();
setState(() {
_isAutoReading = !_isAutoReading;
if (_isAutoReading) {
if (activeType == 'bluetooth') service.startBluetoothAutoReading(); else service.startSerialAutoReading();
_startLockoutTimer(); // --- MODIFICATION: Start countdown
} else {
if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading();
}
@ -320,8 +355,12 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
}
_dataSubscription?.cancel();
_dataSubscription = null;
_lockoutTimer?.cancel(); // --- MODIFICATION: Cancel timer on disconnect ---
if (mounted) {
setState(() => _isAutoReading = false);
setState(() {
_isAutoReading = false;
_isLockedOut = false; // --- MODIFICATION: Reset lockout state ---
});
}
}
@ -353,6 +392,13 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
// --- START: MODIFIED VALIDATION FLOW ---
void _validateAndProceed() async {
// --- START MODIFICATION: Add lockout check ---
if (_isLockedOut) {
_showSnackBar("Please wait for the initial reading period to complete.", isError: true);
return;
}
// --- END MODIFICATION ---
if (_isAutoReading) {
_showStopReadingDialog();
return;
@ -514,7 +560,16 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final activeConnection = _getActiveConnectionDetails();
final String? activeType = activeConnection?['type'] as String?;
return Form(
// --- START MODIFICATION: Add WillPopScope to block back navigation ---
return WillPopScope(
onWillPop: () async {
if (_isLockedOut) {
_showSnackBar("Please wait for the initial reading period to complete.", isError: true);
return false; // Prevent back navigation
}
return true; // Allow back navigation
},
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
@ -586,14 +641,18 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
const Divider(height: 32),
_buildFlowrateSection(),
const SizedBox(height: 32),
// --- START MODIFICATION: Add countdown to Next button ---
ElevatedButton(
onPressed: _validateAndProceed,
onPressed: _isLockedOut ? null : _validateAndProceed,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Next'),
child: Text(_isLockedOut ? 'Next ($_lockoutSecondsRemaining\s)' : 'Next'),
),
// --- END MODIFICATION ---
],
),
),
);
// --- END MODIFICATION ---
}
Widget _buildParameterListItem({ required IconData icon, required String label, required String unit, required TextEditingController controller, bool isOutOfBounds = false}) {
@ -643,15 +702,21 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// --- START MODIFICATION: Add countdown to Stop Reading button ---
ElevatedButton.icon(
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
label: Text(_isAutoReading ? 'Stop Reading' : 'Start Reading'),
onPressed: () => _toggleAutoReading(type),
label: Text(_isAutoReading
? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading')
: 'Start Reading'),
onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type),
style: ElevatedButton.styleFrom(
backgroundColor: _isAutoReading ? Colors.orange : Colors.green,
backgroundColor: _isAutoReading
? (_isLockedOut ? Colors.grey.shade600 : Colors.orange)
: Colors.green,
foregroundColor: Colors.white,
),
),
// --- END MODIFICATION ---
TextButton.icon(
icon: const Icon(Icons.link_off),
label: const Text('Disconnect'),

View File

@ -744,7 +744,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('App Version'),
subtitle: const Text('MMS V4 1.2.08'),
subtitle: const Text('MMS V4 1.2.09'),
dense: true,
),
ListTile(

View File

@ -6,8 +6,8 @@ import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:path/path.dart' as p;
// --- ADDED: Import dio for downloading ---
import 'package:dio/dio.dart';
import 'package:geolocator/geolocator.dart';
import '../models/air_installation_data.dart';
import '../models/air_collection_data.dart';
@ -15,7 +15,6 @@ import '../models/tarball_data.dart';
import '../models/in_situ_sampling_data.dart';
import '../models/river_in_situ_sampling_data.dart';
/// A comprehensive service for handling all local data storage for offline submissions.
class LocalStorageService {
// =======================================================================
@ -27,14 +26,11 @@ class LocalStorageService {
return status.isGranted;
}
// --- MODIFIED: This method now accepts a serverName to create a server-specific root directory. ---
Future<Directory?> _getPublicMMSV4Directory({required String serverName}) async {
if (await _requestPermissions()) {
final Directory? externalDir = await getExternalStorageDirectory();
if (externalDir != null) {
final publicRootPath = externalDir.path.split('/Android/')[0];
// Create a subdirectory for the specific server configuration.
// If serverName is empty, it returns the root MMSV4 folder.
final mmsv4Dir = Directory(p.join(publicRootPath, 'MMSV4', serverName));
if (!await mmsv4Dir.exists()) {
await mmsv4Dir.create(recursive: true);
@ -46,7 +42,6 @@ class LocalStorageService {
return null;
}
// --- ADDED: A public method to retrieve the root log directory. ---
Future<Directory?> getLogDirectory({required String serverName, required String module, required String subModule}) async {
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
if (mmsv4Dir == null) return null;
@ -61,7 +56,6 @@ class LocalStorageService {
// Part 2: Air Manual Sampling Methods (LOGGING RESTORED)
// =======================================================================
// --- MODIFIED: Method now requires serverName to get the correct base directory. ---
Future<Directory?> _getAirManualBaseDir({required String serverName}) async {
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
if (mmsv4Dir == null) return null;
@ -73,8 +67,6 @@ class LocalStorageService {
return airDir;
}
/// Saves or updates an air sampling record, including copying all associated images to permanent local storage.
// --- MODIFIED: Method now requires serverName. ---
Future<String?> saveAirSamplingRecord(Map<String, dynamic> data, String refID, {required String serverName}) async {
final baseDir = await _getAirManualBaseDir(serverName: serverName);
if (baseDir == null) {
@ -88,11 +80,9 @@ class LocalStorageService {
await eventDir.create(recursive: true);
}
// Helper function to copy a file and return its new, permanent path
Future<String?> copyImageToLocal(dynamic imageFile) async {
if (imageFile is! File) return null; // Gracefully handle non-File types
if (imageFile is! File) return null;
try {
// Check if the file is already in the permanent directory to avoid re-copying
if (p.dirname(imageFile.path) == eventDir.path) {
return imageFile.path;
}
@ -105,25 +95,18 @@ class LocalStorageService {
}
}
// Create a mutable copy of the data map to avoid modifying the original
final Map<String, dynamic> serializableData = Map.from(data);
// --- MODIFIED: Inject the server name into the data being saved. ---
serializableData['serverConfigName'] = serverName;
// Define the keys for installation images to look for in the map
final installationImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4'];
// Process top-level (installation) images
for (final key in installationImageKeys) {
// Check if the key exists and the value is a File object
if (serializableData.containsKey(key) && serializableData[key] is File) {
final newPath = await copyImageToLocal(serializableData[key]);
serializableData['${key}Path'] = newPath; // Creates 'imageFrontPath', etc.
serializableData['${key}Path'] = newPath;
}
}
// Process nested collection images, if they exist
if (serializableData['collectionData'] is Map) {
final collectionMap = Map<String, dynamic>.from(serializableData['collectionData']);
final collectionImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'imageChart', 'imageFilterPaper', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4'];
@ -139,7 +122,6 @@ class LocalStorageService {
final Map<String, dynamic> finalData = Map.from(serializableData);
// Recursive helper to remove File objects before JSON encoding
void cleanMap(Map<String, dynamic> map) {
map.removeWhere((key, value) => value is File);
map.forEach((key, value) {
@ -149,7 +131,6 @@ class LocalStorageService {
cleanMap(finalData);
final jsonFile = File(p.join(eventDir.path, 'data.json'));
await jsonFile.writeAsString(jsonEncode(finalData));
debugPrint("Air sampling log and images saved to: ${eventDir.path}");
@ -163,9 +144,8 @@ class LocalStorageService {
}
}
// --- MODIFIED: This method now scans all server subdirectories to find all logs. ---
Future<List<Map<String, dynamic>>> getAllAirSamplingLogs() async {
final mmsv4Root = await _getPublicMMSV4Directory(serverName: ''); // Get root MMSV4 without a server subfolder
final mmsv4Root = await _getPublicMMSV4Directory(serverName: '');
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
final List<Map<String, dynamic>> allLogs = [];
@ -314,7 +294,6 @@ class LocalStorageService {
}
}
// =======================================================================
// Part 4: Marine In-Situ Specific Methods (LOGGING RESTORED)
// =======================================================================
@ -347,12 +326,9 @@ class LocalStorageService {
await eventDir.create(recursive: true);
}
// --- START FIX: Explicitly include the final status and message ---
// This ensures the status calculated in the service layer is saved correctly.
final Map<String, dynamic> jsonData = data.toDbJson();
jsonData['submissionStatus'] = data.submissionStatus;
jsonData['submissionMessage'] = data.submissionMessage;
// --- END FIX ---
jsonData['serverConfigName'] = serverName;
@ -436,6 +412,58 @@ class LocalStorageService {
}
}
Future<List<InSituSamplingData>> getRecentNearbySamples({
required double latitude,
required double longitude,
required double radiusKm,
required int withinHours,
}) async {
final allLogs = await getAllInSituLogs();
final List<InSituSamplingData> recentNearbySamples = [];
final cutoffDateTime = DateTime.now().subtract(Duration(hours: withinHours));
final double radiusInMeters = radiusKm * 1000;
for (var log in allLogs) {
try {
final sampleData = InSituSamplingData.fromJson(log);
if (sampleData.samplingDate == null || sampleData.samplingTime == null) {
continue;
}
final sampleDateTime = DateTime.tryParse('${sampleData.samplingDate} ${sampleData.samplingTime}');
if (sampleDateTime == null || sampleDateTime.isBefore(cutoffDateTime)) {
continue;
}
final sampleLat = double.tryParse(sampleData.currentLatitude ?? '');
final sampleLon = double.tryParse(sampleData.currentLongitude ?? '');
if (sampleLat == null || sampleLon == null) {
continue;
}
final distanceInMeters = Geolocator.distanceBetween(
latitude,
longitude,
sampleLat,
sampleLon,
);
if (distanceInMeters <= radiusInMeters) {
recentNearbySamples.add(sampleData);
}
} catch (e) {
debugPrint("Error processing in-situ log for nearby search: $e");
}
}
recentNearbySamples.sort((a, b) {
final dtA = DateTime.tryParse('${a.samplingDate} ${a.samplingTime}');
final dtB = DateTime.tryParse('${b.samplingDate} ${b.samplingTime}');
if (dtA == null || dtB == null) return 0;
return dtB.compareTo(dtA);
});
return recentNearbySamples;
}
// =======================================================================
// Part 5: River In-Situ Specific Methods (LOGGING RESTORED)
// =======================================================================
@ -475,7 +503,6 @@ class LocalStorageService {
await eventDir.create(recursive: true);
}
// --- START: MODIFIED TO USE toMap() FOR COMPLETE DATA SERIALIZATION ---
final Map<String, dynamic> jsonData = data.toMap();
jsonData['serverConfigName'] = serverName;
@ -485,16 +512,13 @@ class LocalStorageService {
if (imageFile != null) {
final String originalFileName = p.basename(imageFile.path);
if (p.dirname(imageFile.path) == eventDir.path) {
// If file is already in the correct directory, just store the path
jsonData[entry.key] = imageFile.path;
} else {
// Otherwise, copy it to the permanent directory
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName));
jsonData[entry.key] = newFile.path;
}
}
}
// --- END: MODIFIED TO USE toMap() FOR COMPLETE DATA SERIALIZATION ---
final jsonFile = File(p.join(eventDir.path, 'data.json'));
await jsonFile.writeAsString(jsonEncode(jsonData));
@ -568,9 +592,7 @@ class LocalStorageService {
final Dio _dio = Dio();
/// Gets the directory for storing Info Centre documents, creating it if it doesn't exist.
Future<Directory?> _getInfoCentreDocumentsDirectory() async {
// We use serverName: '' to ensure documents are stored in a common root MMSV4 folder, not server-specific ones.
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: '');
if (mmsv4Dir == null) return null;
@ -581,7 +603,6 @@ class LocalStorageService {
return docDir;
}
/// Constructs the full local file path for a given document URL.
Future<String?> getLocalDocumentPath(String docUrl) async {
final docDir = await _getInfoCentreDocumentsDirectory();
if (docDir == null) return null;
@ -590,14 +611,12 @@ class LocalStorageService {
return p.join(docDir.path, fileName);
}
/// Checks if a document has already been downloaded.
Future<bool> isDocumentDownloaded(String docUrl) async {
final filePath = await getLocalDocumentPath(docUrl);
if (filePath == null) return false;
return await File(filePath).exists();
}
/// Downloads a document from a URL and saves it to the local `MMSV4/info_centre_documents` folder.
Future<void> downloadDocument({
required String docUrl,
required Function(double) onReceiveProgress,
@ -618,7 +637,6 @@ class LocalStorageService {
},
);
} catch (e) {
// If the download fails, delete the partially downloaded file to prevent corruption.
final file = File(filePath);
if (await file.exists()) {
await file.delete();