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 --> <!-- MMS V4 1.2.08 -->
<application <application
android:label="MMS V4 1.2.08" android:label="MMS V4 1.2.09"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"> 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_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_installation_screen.dart';
import 'package:environment_monitoring_app/screens/air/manual/air_manual_collection_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/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/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_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/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/overview.dart' as airContinuousOverview;
import 'package:environment_monitoring_app/screens/air/continuous/entry.dart' as airContinuousEntry; 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 // River Screens
import 'package:environment_monitoring_app/screens/river/manual/river_manual_info_centre_document.dart'; 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/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/river_manual_data_status_log.dart' as riverManualDataStatusLog;
import 'package:environment_monitoring_app/screens/river/manual/report.dart' as riverManualReport; // 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/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/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/overview.dart' as riverContinuousOverview;
import 'package:environment_monitoring_app/screens/river/continuous/entry.dart' as riverContinuousEntry; 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/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/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/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/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_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/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/marine_continuous_info_centre_document.dart';
import 'package:environment_monitoring_app/screens/marine/continuous/overview.dart' as marineContinuousOverview; 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
'/river/manual/info': (context) => const RiverManualInfoCentreDocument(), '/river/manual/info': (context) => const RiverManualInfoCentreDocument(),
'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(), '/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/report': (context) => riverManualReport.RiverManualReport(),
'/river/manual/triennial': (context) => riverManualTriennialSampling.RiverTriennialSampling(), '/river/manual/triennial': (context) => riverManualTriennialSampling.RiverTriennialSampling(),
'/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(), '/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/pre-sampling': (context) => marineManualPreSampling.MarinePreSampling(),
'/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(), '/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(),
'/marine/manual/tarball': (context) => const TarballSamplingStep1(), '/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/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute
'/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(), '/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(),

View File

@ -68,14 +68,42 @@ class InSituSamplingData {
String? submissionMessage; String? submissionMessage;
String? reportId; 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({ InSituSamplingData({
this.samplingDate, this.samplingDate,
this.samplingTime, this.samplingTime,
}); });
/// Creates an InSituSamplingData object from a JSON map. /// 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) { factory InSituSamplingData.fromJson(Map<String, dynamic> json) {
double? doubleFromJson(dynamic value) { double? doubleFromJson(dynamic value) {
if (value is num) return value.toDouble(); if (value is num) return value.toDouble();
@ -95,13 +123,14 @@ class InSituSamplingData {
final data = 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.firstSamplerName = json['first_sampler_name'];
data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']); data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']);
data.secondSampler = json['secondSampler'] ?? json['second_sampler']; data.secondSampler = json['secondSampler'] ?? json['second_sampler'];
data.samplingDate = json['sampling_date'] ?? json['man_date']; data.samplingDate = json['sampling_date'] ?? json['man_date'];
data.samplingTime = json['sampling_time'] ?? json['man_time']; data.samplingTime = json['sampling_time'] ?? json['man_time'];
data.samplingType = json['sampling_type']; data.samplingType = json['sampling_type'];
// ... (all other existing fields)
data.sampleIdCode = json['sample_id_code']; data.sampleIdCode = json['sample_id_code'];
data.selectedStateName = json['selected_state_name']; data.selectedStateName = json['selected_state_name'];
data.selectedCategoryName = json['selected_category_name']; data.selectedCategoryName = json['selected_category_name'];
@ -138,9 +167,11 @@ class InSituSamplingData {
data.submissionMessage = json['submission_message']; data.submissionMessage = json['submission_message'];
data.reportId = json['report_id']?.toString(); 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.leftLandViewImage = fileFromPath(json['man_left_side_land_view']);
data.rightLandViewImage = fileFromPath(json['man_right_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.waterFillingImage = fileFromPath(json['man_filling_water_into_sample_bottle']);
data.seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle']); data.seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle']);
data.phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper']); data.phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper']);
@ -148,11 +179,88 @@ class InSituSamplingData {
data.optionalImage2 = fileFromPath(json['man_optional_photo_02']); data.optionalImage2 = fileFromPath(json['man_optional_photo_02']);
data.optionalImage3 = fileFromPath(json['man_optional_photo_03']); data.optionalImage3 = fileFromPath(json['man_optional_photo_03']);
data.optionalImage4 = fileFromPath(json['man_optional_photo_04']); 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; 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}) { String generateTelegramAlertMessage({required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = selectedStation?['man_station_name'] ?? 'N/A'; final stationName = selectedStation?['man_station_name'] ?? 'N/A';
@ -256,52 +364,4 @@ class InSituSamplingData {
'man_optional_photo_04': optionalImage4, '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 'dart:io';
import 'package:flutter/material.dart'; 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:flutter/material.dart';
import 'package:image_picker/image_picker.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 { class AirManualImageRequest extends StatefulWidget {
@override @override

View File

@ -1,3 +1,5 @@
// lib/screens/air/manual/air_manual_report.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AirManualReport extends StatelessWidget { 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 'dart:io';
import 'package:flutter/material.dart'; 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.article, label: "Data Log", route: '/marine/manual/data-log'),
SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'), 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( 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 'dart:io';
import 'package:flutter/material.dart'; 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) { SubmissionLogEntry? _createLogEntry(Map<String, dynamic> log) {
final String type = log['samplingType'] ?? 'In-Situ Sampling'; final String type = log['samplingType'] ?? 'In-Situ Sampling';
final String title = log['selectedStation']?['sampling_river'] ?? 'Unknown River'; final String title = log['selectedStation']?['sampling_river'] ?? 'Unknown River';
@ -114,7 +113,6 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
} catch (_) { } catch (_) {
submissionDateTime = DateTime.now(); submissionDateTime = DateTime.now();
} }
// --- END: MODIFIED TO FIX NULL SAFETY ERRORS ---
String? apiStatusRaw; String? apiStatusRaw;
if (log['api_status'] != null) { 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:flutter/material.dart';
import 'package:image_picker/image_picker.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 { class RiverManualImageRequest extends StatefulWidget {

View File

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

View File

@ -36,6 +36,12 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
bool _isAutoReading = false; bool _isAutoReading = false;
StreamSubscription? _dataSubscription; StreamSubscription? _dataSubscription;
// --- START: Added for lockout timer ---
Timer? _lockoutTimer;
int _lockoutSecondsRemaining = 30;
bool _isLockedOut = false;
// --- END: Added for lockout timer ---
late final RiverInSituSamplingService _samplingService; late final RiverInSituSamplingService _samplingService;
// --- START: Added for direct database access --- // --- START: Added for direct database access ---
@ -95,6 +101,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
@override @override
void dispose() { void dispose() {
_dataSubscription?.cancel(); _dataSubscription?.cancel();
_lockoutTimer?.cancel(); // --- MODIFICATION: Cancel timer on dispose ---
if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_samplingService.disconnectFromBluetooth(); _samplingService.disconnectFromBluetooth();
@ -299,12 +306,40 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
return success; 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) { void _toggleAutoReading(String activeType) {
final service = context.read<RiverInSituSamplingService>(); final service = context.read<RiverInSituSamplingService>();
setState(() { setState(() {
_isAutoReading = !_isAutoReading; _isAutoReading = !_isAutoReading;
if (_isAutoReading) { if (_isAutoReading) {
if (activeType == 'bluetooth') service.startBluetoothAutoReading(); else service.startSerialAutoReading(); if (activeType == 'bluetooth') service.startBluetoothAutoReading(); else service.startSerialAutoReading();
_startLockoutTimer(); // --- MODIFICATION: Start countdown
} else { } else {
if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading(); if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading();
} }
@ -320,8 +355,12 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
} }
_dataSubscription?.cancel(); _dataSubscription?.cancel();
_dataSubscription = null; _dataSubscription = null;
_lockoutTimer?.cancel(); // --- MODIFICATION: Cancel timer on disconnect ---
if (mounted) { 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 --- // --- START: MODIFIED VALIDATION FLOW ---
void _validateAndProceed() async { 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) { if (_isAutoReading) {
_showStopReadingDialog(); _showStopReadingDialog();
return; return;
@ -514,86 +560,99 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final activeConnection = _getActiveConnectionDetails(); final activeConnection = _getActiveConnectionDetails();
final String? activeType = activeConnection?['type'] as String?; final String? activeType = activeConnection?['type'] as String?;
return Form( // --- START MODIFICATION: Add WillPopScope to block back navigation ---
key: _formKey, return WillPopScope(
child: ListView( onWillPop: () async {
padding: const EdgeInsets.all(24.0), if (_isLockedOut) {
children: [ _showSnackBar("Please wait for the initial reading period to complete.", isError: true);
Text("Data Capture", style: Theme.of(context).textTheme.headlineSmall), return false; // Prevent back navigation
const SizedBox(height: 16), }
Row( return true; // Allow back navigation
children: [ },
Expanded( child: Form(
child: activeType == 'bluetooth' key: _formKey,
? FilledButton.icon(icon: const Icon(Icons.bluetooth_connected), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth')) child: ListView(
: OutlinedButton.icon(icon: const Icon(Icons.bluetooth), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth')), padding: const EdgeInsets.all(24.0),
), children: [
const SizedBox(width: 16), Text("Data Capture", style: Theme.of(context).textTheme.headlineSmall),
Expanded( const SizedBox(height: 16),
child: activeType == 'serial' Row(
? FilledButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial')) children: [
: OutlinedButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial')), Expanded(
), child: activeType == 'bluetooth'
], ? FilledButton.icon(icon: const Icon(Icons.bluetooth_connected), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth'))
), : OutlinedButton.icon(icon: const Icon(Icons.bluetooth), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth')),
const SizedBox(height: 16), ),
if (activeConnection != null) const SizedBox(width: 16),
_buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']), Expanded(
const SizedBox(height: 24), child: activeType == 'serial'
ValueListenableBuilder<String?>( ? FilledButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial'))
valueListenable: service.sondeId, : OutlinedButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial')),
builder: (context, sondeId, child) { ),
final newSondeId = sondeId ?? ''; ],
WidgetsBinding.instance.addPostFrameCallback((_) { ),
if (mounted && _sondeIdController.text != newSondeId) { const SizedBox(height: 16),
_sondeIdController.text = newSondeId; if (activeConnection != null)
widget.data.sondeId = newSondeId; _buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']),
} const SizedBox(height: 24),
}); ValueListenableBuilder<String?>(
return TextFormField( valueListenable: service.sondeId,
controller: _sondeIdController, builder: (context, sondeId, child) {
decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'), final newSondeId = sondeId ?? '';
validator: (v) => v == null || v.isEmpty ? 'Sonde ID is required' : null, WidgetsBinding.instance.addPostFrameCallback((_) {
onChanged: (value) { widget.data.sondeId = value; }, if (mounted && _sondeIdController.text != newSondeId) {
onSaved: (v) => widget.data.sondeId = v, _sondeIdController.text = newSondeId;
); widget.data.sondeId = newSondeId;
}, }
), });
const SizedBox(height: 16), return TextFormField(
Row( controller: _sondeIdController,
children: [ decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'),
Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Date'))), validator: (v) => v == null || v.isEmpty ? 'Sonde ID is required' : null,
const SizedBox(width: 16), onChanged: (value) { widget.data.sondeId = value; },
Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))), onSaved: (v) => widget.data.sondeId = v,
], );
), },
),
const SizedBox(height: 16),
Row(
children: [
Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Date'))),
const SizedBox(width: 16),
Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))),
],
),
if (_previousReadingsForComparison != null) if (_previousReadingsForComparison != null)
_buildComparisonView(), _buildComparisonView(),
const Divider(height: 32), const Divider(height: 32),
Column( Column(
children: _parameters.map((param) { children: _parameters.map((param) {
return _buildParameterListItem( return _buildParameterListItem(
icon: param['icon'] as IconData, icon: param['icon'] as IconData,
label: param['label'] as String, label: param['label'] as String,
unit: param['unit'] as String, unit: param['unit'] as String,
controller: param['controller'] as TextEditingController, controller: param['controller'] as TextEditingController,
isOutOfBounds: _outOfBoundsKeys.contains(param['key']), isOutOfBounds: _outOfBoundsKeys.contains(param['key']),
); );
}).toList(), }).toList(),
), ),
const Divider(height: 32), const Divider(height: 32),
_buildFlowrateSection(), _buildFlowrateSection(),
const SizedBox(height: 32), const SizedBox(height: 32),
ElevatedButton( // --- START MODIFICATION: Add countdown to Next button ---
onPressed: _validateAndProceed, ElevatedButton(
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), onPressed: _isLockedOut ? null : _validateAndProceed,
child: const Text('Next'), style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
), 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}) { 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( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
// --- START MODIFICATION: Add countdown to Stop Reading button ---
ElevatedButton.icon( ElevatedButton.icon(
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
label: Text(_isAutoReading ? 'Stop Reading' : 'Start Reading'), label: Text(_isAutoReading
onPressed: () => _toggleAutoReading(type), ? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading')
: 'Start Reading'),
onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: _isAutoReading ? Colors.orange : Colors.green, backgroundColor: _isAutoReading
? (_isLockedOut ? Colors.grey.shade600 : Colors.orange)
: Colors.green,
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
), ),
// --- END MODIFICATION ---
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.link_off), icon: const Icon(Icons.link_off),
label: const Text('Disconnect'), label: const Text('Disconnect'),

View File

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

View File

@ -6,8 +6,8 @@ import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
// --- ADDED: Import dio for downloading ---
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:geolocator/geolocator.dart';
import '../models/air_installation_data.dart'; import '../models/air_installation_data.dart';
import '../models/air_collection_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/in_situ_sampling_data.dart';
import '../models/river_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 { class LocalStorageService {
// ======================================================================= // =======================================================================
@ -27,14 +26,11 @@ class LocalStorageService {
return status.isGranted; return status.isGranted;
} }
// --- MODIFIED: This method now accepts a serverName to create a server-specific root directory. ---
Future<Directory?> _getPublicMMSV4Directory({required String serverName}) async { Future<Directory?> _getPublicMMSV4Directory({required String serverName}) async {
if (await _requestPermissions()) { if (await _requestPermissions()) {
final Directory? externalDir = await getExternalStorageDirectory(); final Directory? externalDir = await getExternalStorageDirectory();
if (externalDir != null) { if (externalDir != null) {
final publicRootPath = externalDir.path.split('/Android/')[0]; 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)); final mmsv4Dir = Directory(p.join(publicRootPath, 'MMSV4', serverName));
if (!await mmsv4Dir.exists()) { if (!await mmsv4Dir.exists()) {
await mmsv4Dir.create(recursive: true); await mmsv4Dir.create(recursive: true);
@ -46,7 +42,6 @@ class LocalStorageService {
return null; 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 { Future<Directory?> getLogDirectory({required String serverName, required String module, required String subModule}) async {
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
if (mmsv4Dir == null) return null; if (mmsv4Dir == null) return null;
@ -61,7 +56,6 @@ class LocalStorageService {
// Part 2: Air Manual Sampling Methods (LOGGING RESTORED) // 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 { Future<Directory?> _getAirManualBaseDir({required String serverName}) async {
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
if (mmsv4Dir == null) return null; if (mmsv4Dir == null) return null;
@ -73,8 +67,6 @@ class LocalStorageService {
return airDir; 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 { Future<String?> saveAirSamplingRecord(Map<String, dynamic> data, String refID, {required String serverName}) async {
final baseDir = await _getAirManualBaseDir(serverName: serverName); final baseDir = await _getAirManualBaseDir(serverName: serverName);
if (baseDir == null) { if (baseDir == null) {
@ -88,11 +80,9 @@ class LocalStorageService {
await eventDir.create(recursive: true); await eventDir.create(recursive: true);
} }
// Helper function to copy a file and return its new, permanent path
Future<String?> copyImageToLocal(dynamic imageFile) async { Future<String?> copyImageToLocal(dynamic imageFile) async {
if (imageFile is! File) return null; // Gracefully handle non-File types if (imageFile is! File) return null;
try { try {
// Check if the file is already in the permanent directory to avoid re-copying
if (p.dirname(imageFile.path) == eventDir.path) { if (p.dirname(imageFile.path) == eventDir.path) {
return imageFile.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); final Map<String, dynamic> serializableData = Map.from(data);
// --- MODIFIED: Inject the server name into the data being saved. ---
serializableData['serverConfigName'] = serverName; 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']; final installationImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4'];
// Process top-level (installation) images
for (final key in installationImageKeys) { 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) { if (serializableData.containsKey(key) && serializableData[key] is File) {
final newPath = await copyImageToLocal(serializableData[key]); 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) { if (serializableData['collectionData'] is Map) {
final collectionMap = Map<String, dynamic>.from(serializableData['collectionData']); final collectionMap = Map<String, dynamic>.from(serializableData['collectionData']);
final collectionImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'imageChart', 'imageFilterPaper', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4']; 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); final Map<String, dynamic> finalData = Map.from(serializableData);
// Recursive helper to remove File objects before JSON encoding
void cleanMap(Map<String, dynamic> map) { void cleanMap(Map<String, dynamic> map) {
map.removeWhere((key, value) => value is File); map.removeWhere((key, value) => value is File);
map.forEach((key, value) { map.forEach((key, value) {
@ -149,7 +131,6 @@ class LocalStorageService {
cleanMap(finalData); cleanMap(finalData);
final jsonFile = File(p.join(eventDir.path, 'data.json')); final jsonFile = File(p.join(eventDir.path, 'data.json'));
await jsonFile.writeAsString(jsonEncode(finalData)); await jsonFile.writeAsString(jsonEncode(finalData));
debugPrint("Air sampling log and images saved to: ${eventDir.path}"); 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 { 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 []; if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
final List<Map<String, dynamic>> allLogs = []; final List<Map<String, dynamic>> allLogs = [];
@ -314,7 +294,6 @@ class LocalStorageService {
} }
} }
// ======================================================================= // =======================================================================
// Part 4: Marine In-Situ Specific Methods (LOGGING RESTORED) // Part 4: Marine In-Situ Specific Methods (LOGGING RESTORED)
// ======================================================================= // =======================================================================
@ -347,12 +326,9 @@ class LocalStorageService {
await eventDir.create(recursive: true); 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(); final Map<String, dynamic> jsonData = data.toDbJson();
jsonData['submissionStatus'] = data.submissionStatus; jsonData['submissionStatus'] = data.submissionStatus;
jsonData['submissionMessage'] = data.submissionMessage; jsonData['submissionMessage'] = data.submissionMessage;
// --- END FIX ---
jsonData['serverConfigName'] = serverName; 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) // Part 5: River In-Situ Specific Methods (LOGGING RESTORED)
// ======================================================================= // =======================================================================
@ -475,7 +503,6 @@ class LocalStorageService {
await eventDir.create(recursive: true); await eventDir.create(recursive: true);
} }
// --- START: MODIFIED TO USE toMap() FOR COMPLETE DATA SERIALIZATION ---
final Map<String, dynamic> jsonData = data.toMap(); final Map<String, dynamic> jsonData = data.toMap();
jsonData['serverConfigName'] = serverName; jsonData['serverConfigName'] = serverName;
@ -485,16 +512,13 @@ class LocalStorageService {
if (imageFile != null) { if (imageFile != null) {
final String originalFileName = p.basename(imageFile.path); final String originalFileName = p.basename(imageFile.path);
if (p.dirname(imageFile.path) == eventDir.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; jsonData[entry.key] = imageFile.path;
} else { } else {
// Otherwise, copy it to the permanent directory
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName));
jsonData[entry.key] = newFile.path; jsonData[entry.key] = newFile.path;
} }
} }
} }
// --- END: MODIFIED TO USE toMap() FOR COMPLETE DATA SERIALIZATION ---
final jsonFile = File(p.join(eventDir.path, 'data.json')); final jsonFile = File(p.join(eventDir.path, 'data.json'));
await jsonFile.writeAsString(jsonEncode(jsonData)); await jsonFile.writeAsString(jsonEncode(jsonData));
@ -568,9 +592,7 @@ class LocalStorageService {
final Dio _dio = Dio(); final Dio _dio = Dio();
/// Gets the directory for storing Info Centre documents, creating it if it doesn't exist.
Future<Directory?> _getInfoCentreDocumentsDirectory() async { 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: ''); final mmsv4Dir = await _getPublicMMSV4Directory(serverName: '');
if (mmsv4Dir == null) return null; if (mmsv4Dir == null) return null;
@ -581,7 +603,6 @@ class LocalStorageService {
return docDir; return docDir;
} }
/// Constructs the full local file path for a given document URL.
Future<String?> getLocalDocumentPath(String docUrl) async { Future<String?> getLocalDocumentPath(String docUrl) async {
final docDir = await _getInfoCentreDocumentsDirectory(); final docDir = await _getInfoCentreDocumentsDirectory();
if (docDir == null) return null; if (docDir == null) return null;
@ -590,14 +611,12 @@ class LocalStorageService {
return p.join(docDir.path, fileName); return p.join(docDir.path, fileName);
} }
/// Checks if a document has already been downloaded.
Future<bool> isDocumentDownloaded(String docUrl) async { Future<bool> isDocumentDownloaded(String docUrl) async {
final filePath = await getLocalDocumentPath(docUrl); final filePath = await getLocalDocumentPath(docUrl);
if (filePath == null) return false; if (filePath == null) return false;
return await File(filePath).exists(); 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({ Future<void> downloadDocument({
required String docUrl, required String docUrl,
required Function(double) onReceiveProgress, required Function(double) onReceiveProgress,
@ -618,7 +637,6 @@ class LocalStorageService {
}, },
); );
} catch (e) { } catch (e) {
// If the download fails, delete the partially downloaded file to prevent corruption.
final file = File(filePath); final file = File(filePath);
if (await file.exists()) { if (await file.exists()) {
await file.delete(); await file.delete();