repair file naming and start the npe screen for marine
This commit is contained in:
parent
8931ed9297
commit
37874a1eab
@ -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">
|
||||
|
||||
@ -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(),
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
@ -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
|
||||
@ -1,3 +1,5 @@
|
||||
// lib/screens/air/manual/air_manual_report.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AirManualReport extends StatelessWidget {
|
||||
@ -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';
|
||||
1073
lib/screens/marine/manual/marine_manual_npe_report.dart
Normal file
1073
lib/screens/marine/manual/marine_manual_npe_report.dart
Normal file
File diff suppressed because it is too large
Load Diff
108
lib/screens/marine/manual/marine_manual_report.dart
Normal file
108
lib/screens/marine/manual/marine_manual_report.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
@ -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 {
|
||||
@ -1,3 +1,5 @@
|
||||
// lib/screens/river/manual/river_manual_report.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class RiverManualReport extends StatelessWidget {
|
||||
@ -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'),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user