add in report module for marine department

This commit is contained in:
ALim Aidrus 2025-10-12 22:00:13 +08:00
parent 077efa745d
commit 768047ad18
28 changed files with 5014 additions and 47 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.09" android:label="MMS V4 1.2.11"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"> android:requestLegacyExternalStorage="true">

View File

@ -8,12 +8,17 @@ import 'package:provider/single_child_widget.dart';
import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/local_storage_service.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart';
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart'; import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
import 'package:environment_monitoring_app/services/river_manual_triennial_sampling_service.dart';
import 'package:environment_monitoring_app/services/air_sampling_service.dart'; import 'package:environment_monitoring_app/services/air_sampling_service.dart';
import 'package:environment_monitoring_app/services/telegram_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart';
import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart';
import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart'; import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart';
import 'package:environment_monitoring_app/services/marine_npe_report_service.dart';
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart'; import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart';
import 'package:environment_monitoring_app/services/marine_manual_pre_departure_service.dart';
import 'package:environment_monitoring_app/services/marine_manual_sonde_calibration_service.dart';
import 'package:environment_monitoring_app/services/marine_manual_equipment_maintenance_service.dart';
import 'package:environment_monitoring_app/theme.dart'; import 'package:environment_monitoring_app/theme.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart';
@ -52,9 +57,8 @@ import 'package:environment_monitoring_app/screens/air/investigative/report.dart
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/river_manual_data_status_log.dart' as riverManualDataStatusLog; 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/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/river_manual_triennial_sampling.dart' as riverManualTriennialSampling;
import 'package:environment_monitoring_app/screens/river/manual/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;
@ -70,7 +74,10 @@ import 'package:environment_monitoring_app/screens/marine/manual/info_centre_doc
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/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/marine_manual_npe_report.dart' as marineManualNPEReport; import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_npe_report.dart' as marineManualNPEReport;
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart';
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart';
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart';
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_data_status_log.dart' as marineManualDataStatusLog; 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';
@ -116,9 +123,14 @@ void main() async {
Provider(create: (_) => LocalStorageService()), Provider(create: (_) => LocalStorageService()),
Provider(create: (context) => RiverInSituSamplingService(telegramService)), Provider(create: (context) => RiverInSituSamplingService(telegramService)),
Provider(create: (context) => RiverManualTriennialSamplingService(telegramService)),
Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)), Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)),
Provider(create: (context) => MarineInSituSamplingService(telegramService)), Provider(create: (context) => MarineInSituSamplingService(telegramService)),
Provider(create: (context) => MarineTarballSamplingService(telegramService)), Provider(create: (context) => MarineTarballSamplingService(telegramService)),
Provider(create: (context) => MarineNpeReportService(Provider.of<TelegramService>(context, listen: false))),
Provider(create: (context) => MarineManualPreDepartureService()),
Provider(create: (context) => MarineManualSondeCalibrationService()),
Provider(create: (context) => MarineManualEquipmentMaintenanceService()),
], ],
child: const RootApp(), child: const RootApp(),
), ),
@ -259,9 +271,8 @@ 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.RiverManualTriennialSamplingScreen(),
'/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(), '/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(),
'/river/manual/image-request': (context) => riverManualImageRequest.RiverManualImageRequest(), '/river/manual/image-request': (context) => riverManualImageRequest.RiverManualImageRequest(),
@ -284,6 +295,9 @@ class _RootAppState extends State<RootApp> {
'/marine/manual/tarball': (context) => const TarballSamplingStep1(), '/marine/manual/tarball': (context) => const TarballSamplingStep1(),
'/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(), '/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(),
'/marine/manual/report/npe': (context) => const marineManualNPEReport.MarineManualNPEReport(), '/marine/manual/report/npe': (context) => const marineManualNPEReport.MarineManualNPEReport(),
'/marine/manual/report/pre-departure': (context) => const MarineManualPreDepartureChecklistScreen(),
'/marine/manual/report/calibration': (context) => const MarineManualSondeCalibrationScreen(),
'/marine/manual/report/maintenance': (context) => const MarineManualEquipmentMaintenanceScreen(),
//'/marine/manual/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

@ -0,0 +1,23 @@
class MarineManualEquipmentMaintenanceData {
int? performedByUserId;
String? equipmentName;
String? maintenanceDate;
String? maintenanceType;
String? workDescription;
String? partsReplaced;
String? status;
String? remarks;
Map<String, dynamic> toApiFormData() {
return {
'performed_by_user_id': performedByUserId.toString(),
'equipment_name': equipmentName,
'maintenance_date': maintenanceDate,
'maintenance_type': maintenanceType,
'work_description': workDescription,
'parts_replaced': partsReplaced,
'status': status,
'remarks': remarks,
};
}
}

View File

@ -0,0 +1,172 @@
import 'dart:io';
import 'dart:convert';
/// A dedicated data model for the Marine Manual Notification of Pollution Event (NPE) report.
class MarineManualNpeReportData {
// --- Reporter & Event Info ---
String? firstSamplerName;
int? firstSamplerUserId;
String? eventDate;
String? eventTime;
// --- Location Info ---
String? locationDescription; // For new locations
String? stateName; // For new locations
Map<String, dynamic>? selectedStation; // For existing stations
String? latitude;
String? longitude;
// --- In-Situ Measurements ---
double? oxygenSaturation;
double? electricalConductivity;
double? oxygenConcentration;
double? turbidity;
double? ph;
double? temperature;
// --- NPE Observations ---
Map<String, bool> fieldObservations = {
'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,
};
String? othersObservationRemark;
String? possibleSource;
// --- Attachments ---
File? image1;
File? image2;
File? image3;
File? image4;
// --- Submission Status ---
String? submissionStatus;
String? submissionMessage;
String? reportId;
MarineManualNpeReportData(); // Constructor
/// Creates a JSON object for offline database storage.
Map<String, dynamic> toDbJson() {
return {
'firstSamplerName': firstSamplerName,
'firstSamplerUserId': firstSamplerUserId,
'eventDate': eventDate,
'eventTime': eventTime,
'locationDescription': locationDescription,
'stateName': stateName,
'selectedStation': selectedStation,
'latitude': latitude,
'longitude': longitude,
'oxygenSaturation': oxygenSaturation,
'electricalConductivity': electricalConductivity,
'oxygenConcentration': oxygenConcentration,
'turbidity': turbidity,
'ph': ph,
'temperature': temperature,
'fieldObservations': fieldObservations,
'othersObservationRemark': othersObservationRemark,
'possibleSource': possibleSource,
'submissionStatus': submissionStatus,
'submissionMessage': submissionMessage,
'reportId': reportId,
};
}
/// Formats the data for the API POST request body.
Map<String, String> toApiFormData() {
final Map<String, String> map = {};
void add(String key, dynamic value) {
if (value != null && value.toString().isNotEmpty) {
map[key] = value.toString();
}
}
add('npe_date', eventDate);
add('npe_time', eventTime);
add('first_sampler_user_id', firstSamplerUserId);
if (selectedStation != null) {
add('station_id', selectedStation?['station_id'] ?? selectedStation?['tbl_station_id']);
add('station_code', selectedStation?['man_station_code'] ?? selectedStation?['tbl_station_code']);
add('station_name', selectedStation?['man_station_name'] ?? selectedStation?['tbl_station_name']);
} else {
add('station_name', locationDescription);
add('state_name', stateName);
}
add('latitude', latitude);
add('longitude', longitude);
add('npe_oxygen_sat', oxygenSaturation);
add('npe_conductivity', electricalConductivity);
add('npe_oxygen_conc', oxygenConcentration);
add('npe_turbidity', turbidity);
add('npe_ph', ph);
add('npe_temperature', temperature);
final selectedObs = fieldObservations.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toList();
add('npe_observations', jsonEncode(selectedObs));
if (fieldObservations['Others'] == true) {
add('npe_others_remarks', othersObservationRemark);
}
add('npe_possible_source', possibleSource);
return map;
}
/// Gathers all image files for the multipart API request.
Map<String, File?> toApiImageFiles() {
return {
'npe_image_1': image1,
'npe_image_2': image2,
'npe_image_3': image3,
'npe_image_4': image4,
};
}
/// Generates the Telegram alert message for this NPE report.
String generateTelegramAlertMessage() {
final locationDesc = selectedStation != null
? '${selectedStation!['man_station_name'] ?? selectedStation!['tbl_station_name']}'
: locationDescription ?? 'A custom location';
final buffer = StringBuffer()
..writeln('🚨 *Notification of Pollution Event (NPE) Submitted:*')
..writeln()
..writeln('*Location:* $locationDesc')
..writeln('*Event Date:* $eventDate $eventTime')
..writeln('*Submitted by:* $firstSamplerName')
..writeln('*Status of Submission:* Successful');
final observations = fieldObservations.entries
.where((e) => e.value)
.map((e) => '- ${e.key}')
.toList();
if (observations.isNotEmpty) {
buffer
..writeln()
..writeln('📋 *Observations:*');
buffer.writeAll(observations, '\n');
if (fieldObservations['Others'] == true && othersObservationRemark != null && othersObservationRemark!.isNotEmpty) {
buffer.writeln('\n - Remarks: $othersObservationRemark');
}
}
if (possibleSource != null && possibleSource!.isNotEmpty) {
buffer
..writeln()
..writeln('*Possible Source:* $possibleSource');
}
return buffer.toString();
}
}

View File

@ -0,0 +1,24 @@
import 'dart:convert';
class MarineManualPreDepartureChecklistData {
String? reporterName;
int? reporterUserId;
String? submissionDate;
// Key: Item description, Value: true if 'Yes', false if 'No'
Map<String, bool> checklistItems = {};
// Key: Item description, Value: Remarks text
Map<String, String> remarks = {};
MarineManualPreDepartureChecklistData();
Map<String, dynamic> toApiFormData() {
return {
'reporter_user_id': reporterUserId.toString(),
'submission_date': submissionDate,
'checklist_items': jsonEncode(checklistItems),
'remarks': jsonEncode(remarks),
};
}
}

View File

@ -0,0 +1,46 @@
class MarineManualSondeCalibrationData {
int? calibratedByUserId;
String? sondeId;
String? calibrationDateTime;
// pH values
double? ph4Initial;
double? ph4Calibrated;
double? ph7Initial;
double? ph7Calibrated;
double? ph10Initial;
double? ph10Calibrated;
// Other parameters
double? condInitial;
double? condCalibrated;
double? doInitial;
double? doCalibrated;
double? turbidityInitial;
double? turbidityCalibrated;
String? calibrationStatus;
String? remarks;
Map<String, dynamic> toApiFormData() {
return {
'calibrated_by_user_id': calibratedByUserId.toString(),
'sonde_id': sondeId,
'calibration_datetime': calibrationDateTime,
'ph_4_initial': ph4Initial?.toString(),
'ph_4_calibrated': ph4Calibrated?.toString(),
'ph_7_initial': ph7Initial?.toString(),
'ph_7_calibrated': ph7Calibrated?.toString(),
'ph_10_initial': ph10Initial?.toString(),
'ph_10_calibrated': ph10Calibrated?.toString(),
'cond_initial': condInitial?.toString(),
'cond_calibrated': condCalibrated?.toString(),
'do_initial': doInitial?.toString(),
'do_calibrated': doCalibrated?.toString(),
'turbidity_initial': turbidityInitial?.toString(),
'turbidity_calibrated': turbidityCalibrated?.toString(),
'calibration_status': calibrationStatus,
'remarks': remarks,
};
}
}

View File

@ -0,0 +1,205 @@
// lib/models/river_manual_triennial_sampling_data.dart
import 'dart:io';
import 'dart:convert';
class RiverManualTriennialSamplingData {
// --- Step 1: Sampling & Station Info ---
String? firstSamplerName;
int? firstSamplerUserId;
Map<String, dynamic>? secondSampler;
String? samplingDate;
String? samplingTime;
String? samplingType;
String? sampleIdCode;
String? selectedStateName;
String? selectedCategoryName;
Map<String, dynamic>? selectedStation;
String? stationLatitude;
String? stationLongitude;
String? currentLatitude;
String? currentLongitude;
double? distanceDifferenceInKm;
String? distanceDifferenceRemarks;
// --- Step 2: Site Info & Photos ---
String? weather;
String? eventRemarks;
String? labRemarks;
File? backgroundStationImage;
File? upstreamRiverImage;
File? downstreamRiverImage;
// --- Step 4: Additional Photos ---
File? sampleTurbidityImage;
File? optionalImage1;
String? optionalRemark1;
File? optionalImage2;
String? optionalRemark2;
File? optionalImage3;
String? optionalRemark3;
File? optionalImage4;
String? optionalRemark4;
// --- Step 3: Data Capture ---
String? sondeId;
String? dataCaptureDate;
String? dataCaptureTime;
double? oxygenConcentration;
double? oxygenSaturation;
double? ph;
double? salinity;
double? electricalConductivity;
double? temperature;
double? tds;
double? turbidity;
double? ammonia;
double? batteryVoltage;
// --- ADDED: Missing flowrate properties ---
String? flowrateMethod;
double? flowrateSurfaceDrifterHeight;
double? flowrateSurfaceDrifterDistance;
String? flowrateSurfaceDrifterTimeFirst;
String? flowrateSurfaceDrifterTimeLast;
double? flowrateValue;
// --- Post-Submission Status ---
String? submissionStatus;
String? submissionMessage;
String? reportId;
RiverManualTriennialSamplingData({
this.samplingDate,
this.samplingTime,
});
Map<String, String> toApiFormData() {
final Map<String, String> map = {};
void add(String key, dynamic value) {
if (value != null) {
map[key] = value.toString();
}
}
add('first_sampler_user_id', firstSamplerUserId);
add('r_tri_second_sampler_id', secondSampler?['user_id']);
add('r_tri_date', samplingDate);
add('r_tri_time', samplingTime);
add('r_tri_type', samplingType);
add('r_tri_sample_id_code', sampleIdCode);
add('station_id', selectedStation?['station_id']);
add('r_tri_current_latitude', currentLatitude);
add('r_tri_current_longitude', currentLongitude);
add('r_tri_distance_difference', distanceDifferenceInKm);
add('r_tri_distance_difference_remarks', distanceDifferenceRemarks);
add('r_tri_weather', weather);
add('r_tri_event_remark', eventRemarks);
add('r_tri_lab_remark', labRemarks);
add('r_tri_optional_photo_01_remarks', optionalRemark1);
add('r_tri_optional_photo_02_remarks', optionalRemark2);
add('r_tri_optional_photo_03_remarks', optionalRemark3);
add('r_tri_optional_photo_04_remarks', optionalRemark4);
add('r_tri_sondeID', sondeId);
add('data_capture_date', dataCaptureDate);
add('data_capture_time', dataCaptureTime);
add('r_tri_oxygen_conc', oxygenConcentration);
add('r_tri_oxygen_sat', oxygenSaturation);
add('r_tri_ph', ph);
add('r_tri_salinity', salinity);
add('r_tri_conductivity', electricalConductivity);
add('r_tri_temperature', temperature);
add('r_tri_tds', tds);
add('r_tri_turbidity', turbidity);
add('r_tri_ammonia', ammonia);
add('r_tri_battery_volt', batteryVoltage);
add('r_tri_flowrate_method', flowrateMethod);
add('r_tri_flowrate_sd_height', flowrateSurfaceDrifterHeight);
add('r_tri_flowrate_sd_distance', flowrateSurfaceDrifterDistance);
add('r_tri_flowrate_sd_time_first', flowrateSurfaceDrifterTimeFirst);
add('r_tri_flowrate_sd_time_last', flowrateSurfaceDrifterTimeLast);
add('r_tri_flowrate_value', flowrateValue);
add('first_sampler_name', firstSamplerName);
add('r_tri_station_code', selectedStation?['sampling_station_code']);
add('r_tri_station_name', selectedStation?['sampling_river']);
return map;
}
Map<String, File?> toApiImageFiles() {
return {
'r_tri_background_station': backgroundStationImage,
'r_tri_upstream_river': upstreamRiverImage,
'r_tri_downstream_river': downstreamRiverImage,
'r_tri_sample_turbidity': sampleTurbidityImage,
'r_tri_optional_photo_01': optionalImage1,
'r_tri_optional_photo_02': optionalImage2,
'r_tri_optional_photo_03': optionalImage3,
'r_tri_optional_photo_04': optionalImage4,
};
}
// --- ADDED: Missing toMap() method ---
Map<String, dynamic> toMap() {
return {
'firstSamplerName': firstSamplerName,
'firstSamplerUserId': firstSamplerUserId,
'secondSampler': secondSampler,
'samplingDate': samplingDate,
'samplingTime': samplingTime,
'samplingType': samplingType,
'sampleIdCode': sampleIdCode,
'selectedStateName': selectedStateName,
'selectedCategoryName': selectedCategoryName,
'selectedStation': selectedStation,
'stationLatitude': stationLatitude,
'stationLongitude': stationLongitude,
'currentLatitude': currentLatitude,
'currentLongitude': currentLongitude,
'distanceDifferenceInKm': distanceDifferenceInKm,
'distanceDifferenceRemarks': distanceDifferenceRemarks,
'weather': weather,
'eventRemarks': eventRemarks,
'labRemarks': labRemarks,
'backgroundStationImage': backgroundStationImage?.path,
'upstreamRiverImage': upstreamRiverImage?.path,
'downstreamRiverImage': downstreamRiverImage?.path,
'sampleTurbidityImage': sampleTurbidityImage?.path,
'optionalImage1': optionalImage1?.path,
'optionalRemark1': optionalRemark1,
'optionalImage2': optionalImage2?.path,
'optionalRemark2': optionalRemark2,
'optionalImage3': optionalImage3?.path,
'optionalRemark3': optionalRemark3,
'optionalImage4': optionalImage4?.path,
'optionalRemark4': optionalRemark4,
'sondeId': sondeId,
'dataCaptureDate': dataCaptureDate,
'dataCaptureTime': dataCaptureTime,
'oxygenConcentration': oxygenConcentration,
'oxygenSaturation': oxygenSaturation,
'ph': ph,
'salinity': salinity,
'electricalConductivity': electricalConductivity,
'temperature': temperature,
'tds': tds,
'turbidity': turbidity,
'ammonia': ammonia,
'batteryVoltage': batteryVoltage,
'flowrateMethod': flowrateMethod,
'flowrateSurfaceDrifterHeight': flowrateSurfaceDrifterHeight,
'flowrateSurfaceDrifterDistance': flowrateSurfaceDrifterDistance,
'flowrateSurfaceDrifterTimeFirst': flowrateSurfaceDrifterTimeFirst,
'flowrateSurfaceDrifterTimeLast': flowrateSurfaceDrifterTimeLast,
'flowrateValue': flowrateValue,
'submissionStatus': submissionStatus,
'submissionMessage': submissionMessage,
'reportId': reportId,
};
}
}

View File

@ -5,30 +5,45 @@ class ReportItem {
final IconData icon; final IconData icon;
final String label; final String label;
final String route; final String route;
final String formCode; // Added for clarity in the UI
const ReportItem({ const ReportItem({
required this.icon, required this.icon,
required this.label, required this.label,
required this.route, required this.route,
required this.formCode,
}); });
} }
class MarineManualReportHomePage extends StatelessWidget { class MarineManualReportHomePage extends StatelessWidget {
const MarineManualReportHomePage({super.key}); const MarineManualReportHomePage({super.key});
// Define the list of available reports // Updated list to include all reports
final List<ReportItem> _reports = const [ final List<ReportItem> _reports = const [
ReportItem( ReportItem(
icon: Icons.science_outlined, icon: Icons.warning_amber_rounded,
label: "NPE Report", label: "Notification of Pollution Event",
formCode: "F-MM06",
route: '/marine/manual/report/npe', route: '/marine/manual/report/npe',
), ),
// You can add other future reports here. For example: ReportItem(
// ReportItem( icon: Icons.biotech_rounded,
// icon: Icons.assessment_outlined, label: "Sonde Calibration",
// label: "Quarterly Summary", formCode: "F-MM02",
// route: '/marine/manual/report/quarterly', route: '/marine/manual/report/calibration',
// ), ),
ReportItem(
icon: Icons.checklist_rtl_rounded,
label: "Pre-Departure & Safety Checklist",
formCode: "F-MM03",
route: '/marine/manual/report/pre-departure',
),
ReportItem(
icon: Icons.build_circle_outlined,
label: "Equipment Maintenance",
formCode: "F-MM01",
route: '/marine/manual/report/maintenance',
),
]; ];
@override @override
@ -43,13 +58,12 @@ class MarineManualReportHomePage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
"Select a Report to Generate", "Select a Report to Create",
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Using a GridView for the report items for a clean layout
GridView.builder( GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@ -57,7 +71,7 @@ class MarineManualReportHomePage extends StatelessWidget {
crossAxisCount: 2, crossAxisCount: 2,
crossAxisSpacing: 16.0, crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0, mainAxisSpacing: 16.0,
childAspectRatio: 1.5, // Adjust for a card-like appearance childAspectRatio: 1.2, // Adjusted for better text fit
), ),
itemCount: _reports.length, itemCount: _reports.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@ -71,7 +85,6 @@ class MarineManualReportHomePage extends StatelessWidget {
); );
} }
// Method to build a clickable card for each report type
Widget _buildReportCard(BuildContext context, ReportItem report) { Widget _buildReportCard(BuildContext context, ReportItem report) {
return InkWell( return InkWell(
onTap: () { onTap: () {
@ -82,7 +95,7 @@ class MarineManualReportHomePage extends StatelessWidget {
elevation: 4, elevation: 4,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.white24, width: 1), side: const BorderSide(color: Colors.white24, width: 1),
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -100,6 +113,11 @@ class MarineManualReportHomePage extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
const SizedBox(height: 4),
Text(
report.formCode, // Displaying the form code
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[400]),
),
], ],
), ),
), ),

View File

@ -0,0 +1,246 @@
import 'dart:async';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../../../../../auth_provider.dart';
import '../../../../models/marine_manual_equipment_maintenance_data.dart';
import '../../../../services/marine_manual_equipment_maintenance_service.dart';
class MarineManualEquipmentMaintenanceScreen extends StatefulWidget {
const MarineManualEquipmentMaintenanceScreen({super.key});
@override
State<MarineManualEquipmentMaintenanceScreen> createState() =>
_MarineManualEquipmentMaintenanceScreenState();
}
class _MarineManualEquipmentMaintenanceScreenState
extends State<MarineManualEquipmentMaintenanceScreen> {
final _formKey = GlobalKey<FormState>();
final _data = MarineManualEquipmentMaintenanceData();
bool _isLoading = false;
bool _isOnline = true;
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
final _dateController = TextEditingController();
final _performedByController = TextEditingController();
@override
void initState() {
super.initState();
final auth = Provider.of<AuthProvider>(context, listen: false);
_dateController.text = DateFormat('yyyy-MM-dd').format(DateTime.now());
_performedByController.text = auth.profileData?['username'] ?? 'Unknown User';
_checkInitialConnectivity();
_connectivitySubscription =
Connectivity().onConnectivityChanged.listen(_updateConnectionStatus);
}
@override
void dispose() {
_connectivitySubscription.cancel();
_dateController.dispose();
_performedByController.dispose();
super.dispose();
}
Future<void> _checkInitialConnectivity() async {
final connectivityResult = await Connectivity().checkConnectivity();
_updateConnectionStatus(connectivityResult);
}
void _updateConnectionStatus(List<ConnectivityResult> result) {
final bool currentlyOnline = !result.contains(ConnectivityResult.none);
if (_isOnline != currentlyOnline) {
setState(() {
_isOnline = currentlyOnline;
});
if (currentlyOnline && mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text("You are back online."),
backgroundColor: Colors.green,
));
}
}
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save();
setState(() => _isLoading = true);
try {
final auth = Provider.of<AuthProvider>(context, listen: false);
final service = Provider.of<MarineManualEquipmentMaintenanceService>(context, listen: false);
_data.performedByUserId = auth.profileData?['user_id'];
_data.maintenanceDate = _dateController.text;
final result = await service.submitMaintenanceReport(data: _data, authProvider: auth);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(result['message']),
backgroundColor: result['success'] == true ? Colors.green : Colors.red,
));
if (result['success'] == true) Navigator.of(context).pop();
}
} on SocketException {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text("Submission failed. Please check your network connection."),
backgroundColor: Colors.red,
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text("An unexpected error occurred: $e"),
backgroundColor: Colors.red,
));
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Equipment Maintenance (F-MM01)'),
),
body: Column(
children: [
if (!_isOnline)
Container(
width: double.infinity,
color: Colors.red,
padding: const EdgeInsets.all(8.0),
child: const Text(
'No Internet Connection. You cannot submit the report.',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
Expanded(
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Report Details', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: 'Equipment Name / ID *', border: OutlineInputBorder()),
validator: (val) => val == null || val.isEmpty ? 'This field is required' : null,
onSaved: (val) => _data.equipmentName = val,
),
const SizedBox(height: 16),
TextFormField(
controller: _dateController,
readOnly: true,
decoration: const InputDecoration(labelText: 'Maintenance Date', border: OutlineInputBorder()),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: 'Maintenance Type *', border: OutlineInputBorder()),
items: ['Routine', 'Repair', 'Inspection', 'Replacement'].map((String value) {
return DropdownMenuItem<String>(value: value, child: Text(value));
}).toList(),
onChanged: (val) => _data.maintenanceType = val,
validator: (val) => val == null ? 'Please select a type' : null,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: 'Final Status *', border: OutlineInputBorder()),
items: ['Completed', 'Pending Parts', 'In Progress', 'Requires Follow-up'].map((String value) {
return DropdownMenuItem<String>(value: value, child: Text(value));
}).toList(),
onChanged: (val) => _data.status = val,
validator: (val) => val == null ? 'Please select a status' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _performedByController,
readOnly: true,
decoration: const InputDecoration(labelText: 'Performed By', border: OutlineInputBorder()),
),
],
),
),
),
const SizedBox(height: 16),
Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Description & Remarks', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: 'Description of Work Done', border: OutlineInputBorder(), alignLabelWithHint: true),
maxLines: 4,
onSaved: (val) => _data.workDescription = val,
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: 'Parts Replaced (if any)', border: OutlineInputBorder(), alignLabelWithHint: true),
maxLines: 3,
onSaved: (val) => _data.partsReplaced = val,
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: 'General Remarks', border: OutlineInputBorder(), alignLabelWithHint: true),
maxLines: 3,
onSaved: (val) => _data.remarks = val,
),
],
),
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'))),
const SizedBox(width: 10.0),
Expanded(
child: ElevatedButton(
onPressed: _isLoading || !_isOnline ? null : _submit,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('Submit'),
),
)
],
),
],
),
),
),
),
],
),
);
}
}

View File

@ -8,14 +8,16 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:dropdown_search/dropdown_search.dart'; import 'package:dropdown_search/dropdown_search.dart';
import '../../../auth_provider.dart'; import '../../../../auth_provider.dart';
import '../../../models/in_situ_sampling_data.dart'; import '../../../../models/in_situ_sampling_data.dart';
import '../../../services/marine_in_situ_sampling_service.dart'; import '../../../../models/marine_manual_npe_report_data.dart';
import '../../../services/local_storage_service.dart'; import '../../../../services/marine_in_situ_sampling_service.dart';
import '../../../bluetooth/bluetooth_manager.dart'; import '../../../../services/marine_npe_report_service.dart';
import '../../../serial/serial_manager.dart'; import '../../../../services/local_storage_service.dart';
import '../../../bluetooth/widgets/bluetooth_device_list_dialog.dart'; import '../../../../bluetooth/bluetooth_manager.dart';
import '../../../serial/widget/serial_port_list_dialog.dart'; import '../../../../serial/serial_manager.dart';
import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart';
import '../../../../serial/widget/serial_port_list_dialog.dart';
class MarineManualNPEReport extends StatefulWidget { class MarineManualNPEReport extends StatefulWidget {
final InSituSamplingData? initialData; final InSituSamplingData? initialData;
@ -29,7 +31,6 @@ class MarineManualNPEReport extends StatefulWidget {
class _MarineManualNPEReportState extends State<MarineManualNPEReport> with WidgetsBindingObserver { class _MarineManualNPEReportState extends State<MarineManualNPEReport> with WidgetsBindingObserver {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
// Dropdown and Location State
String? _locationDataSourceOption; String? _locationDataSourceOption;
static const String optionUseExisting = 'Use existing sampling information and data from manual station'; static const String optionUseExisting = 'Use existing sampling information and data from manual station';
static const String optionNewLocation = 'new location pollution event observe'; static const String optionNewLocation = 'new location pollution event observe';
@ -43,12 +44,10 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
bool _areLocationFieldsLocked = false; bool _areLocationFieldsLocked = false;
bool _isFetchingLocation = false; bool _isFetchingLocation = false;
// Data for Option 1
bool _isLoadingRecentSamples = false; bool _isLoadingRecentSamples = false;
List<InSituSamplingData> _recentNearbySamples = []; List<InSituSamplingData> _recentNearbySamples = [];
InSituSamplingData? _selectedRecentSample; InSituSamplingData? _selectedRecentSample;
// Data for Dropdown Selections
String? _selectedState; String? _selectedState;
String? _selectedCategory; String? _selectedCategory;
Map<String, dynamic>? _selectedStationMap; Map<String, dynamic>? _selectedStationMap;
@ -56,14 +55,12 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
List<String> _categoriesForState = []; List<String> _categoriesForState = [];
List<Map<String, dynamic>> _stationsForCategory = []; List<Map<String, dynamic>> _stationsForCategory = [];
// Image State
File? _image1; File? _image1;
File? _image2; File? _image2;
File? _image3; File? _image3;
File? _image4; File? _image4;
bool _isPickingImage = false; bool _isPickingImage = false;
// In-Situ Measurement State & Logic
bool _isLoading = false; bool _isLoading = false;
bool _isAutoReading = false; bool _isAutoReading = false;
StreamSubscription? _dataSubscription; StreamSubscription? _dataSubscription;
@ -73,7 +70,6 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
late final MarineInSituSamplingService _samplingService; late final MarineInSituSamplingService _samplingService;
final List<Map<String, dynamic>> _npeParameters = []; final List<Map<String, dynamic>> _npeParameters = [];
// Controllers
final _latController = TextEditingController(); final _latController = TextEditingController();
final _longController = TextEditingController(); final _longController = TextEditingController();
final _stationIdController = TextEditingController(); final _stationIdController = TextEditingController();
@ -88,7 +84,6 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
final _othersObservationController = TextEditingController(); final _othersObservationController = TextEditingController();
final _possibleSourceController = TextEditingController(); final _possibleSourceController = TextEditingController();
// Checkbox states
final Map<String, bool> _observations = { final Map<String, bool> _observations = {
'Oil slick on the water surface/ Oil spill': false, 'Oil slick on the water surface/ Oil spill': false,
'Discoloration of the sea water': false, 'Discoloration of the sea water': false,
@ -263,7 +258,6 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
void _handleLocationOptionChange(String? value) { void _handleLocationOptionChange(String? value) {
setState(() { setState(() {
// Clear all fields and selection states
_locationDataSourceOption = value; _locationDataSourceOption = value;
_clearLocationFields(); _clearLocationFields();
_clearInSituFields(); _clearInSituFields();
@ -327,7 +321,6 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
Future<void> _processAndSetImage(ImageSource source, int imageNumber) async { Future<void> _processAndSetImage(ImageSource source, int imageNumber) async {
if (_isPickingImage) return; if (_isPickingImage) return;
setState(() => _isPickingImage = true); setState(() => _isPickingImage = true);
final watermarkData = InSituSamplingData() final watermarkData = InSituSamplingData()
@ -362,6 +355,56 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
} }
} }
Future<void> _submitNpeReport() async {
if (!_formKey.currentState!.validate()) {
_showSnackBar('Please fill in all required fields.', isError: true);
return;
}
setState(() => _isLoading = true);
final auth = Provider.of<AuthProvider>(context, listen: false);
final service = Provider.of<MarineNpeReportService>(context, listen: false);
final MarineManualNpeReportData data = MarineManualNpeReportData()
..firstSamplerName = auth.profileData?['user_name']
..firstSamplerUserId = auth.profileData?['user_id']
..eventDate = _eventDateTimeController.text.split(' ')[0]
..eventTime = _eventDateTimeController.text.split(' ').length > 1 ? _eventDateTimeController.text.split(' ')[1] : ''
..latitude = _latController.text
..longitude = _longController.text
..selectedStation = _selectedStationMap ?? _selectedRecentSample?.selectedStation
..locationDescription = _locationController.text
..stateName = _selectedState
..oxygenSaturation = double.tryParse(_doPercentController.text)
..electricalConductivity = double.tryParse(_condController.text)
..oxygenConcentration = double.tryParse(_doMgLController.text)
..turbidity = double.tryParse(_turbController.text)
..ph = double.tryParse(_phController.text)
..temperature = double.tryParse(_tempController.text)
..fieldObservations = _observations
..othersObservationRemark = _othersObservationController.text
..possibleSource = _possibleSourceController.text
..image1 = _image1
..image2 = _image2
..image3 = _image3
..image4 = _image4;
final result = await service.submitNpeReport(
data: data,
authProvider: auth,
);
setState(() => _isLoading = false);
if (mounted) {
_showSnackBar(result['message'], isError: result['success'] != true);
if (result['success'] == true) {
Navigator.of(context).pop();
}
}
}
void _showSnackBar(String message, {bool isError = false}) { void _showSnackBar(String message, {bool isError = false}) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( ScaffoldMessenger.of(context).showSnackBar(SnackBar(
@ -570,13 +613,10 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 40, vertical: 15)), horizontal: 40, vertical: 15)),
onPressed: () { onPressed: _isLoading ? null : _submitNpeReport,
if (_formKey.currentState!.validate()) { child: _isLoading
ScaffoldMessenger.of(context).showSnackBar( ? const CircularProgressIndicator(color: Colors.white)
const SnackBar(content: Text('Submitting NPE Report...'))); : const Text("Submit Report"),
}
},
child: const Text("Submit Report"),
), ),
), ),
], ],
@ -1063,7 +1103,8 @@ class _MarineManualNPEReportState extends State<MarineManualNPEReport> with Widg
onTap: onTap, onTap: onTap,
keyboardType: keyboardType, keyboardType: keyboardType,
validator: (value) { validator: (value) {
if (!readOnly && (value == null || value.isEmpty)) { if (!label.contains('*')) return null;
if (!readOnly && (value == null || value.trim().isEmpty)) {
return 'This field cannot be empty'; return 'This field cannot be empty';
} }
return null; return null;

View File

@ -0,0 +1,291 @@
import 'dart:async';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:toggle_switch/toggle_switch.dart';
import '../../../../../auth_provider.dart';
import '../../../../models/marine_manual_pre_departure_checklist_data.dart';
import '../../../../services/marine_manual_pre_departure_service.dart';
class MarineManualPreDepartureChecklistScreen extends StatefulWidget {
const MarineManualPreDepartureChecklistScreen({super.key});
@override
_MarineManualPreDepartureChecklistScreenState createState() =>
_MarineManualPreDepartureChecklistScreenState();
}
class _MarineManualPreDepartureChecklistScreenState
extends State<MarineManualPreDepartureChecklistScreen> {
final _data = MarineManualPreDepartureChecklistData();
bool _isLoading = false;
final Map<String, bool> _remarksVisibility = {};
// NEW: State variables for connectivity
bool _isOnline = true;
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
final List<String> _checklistItemsText = [
'MMWQM Standard Operation Procedure (SOP)',
'Back-up Sampling Sheet and Chain of Custody form',
'YSI EXO2 Sonde Include Sensor (pH/Turbidity/Conductivity/Dissolved Oxygen)',
'Varn Dorn Sampler with Rope and Messenger',
'Laptop',
'Smart pre-installed with application (apps for manual sampling - MMS)',
'GPS Navigation',
'Calibration standards (pH/Turbidity/Conductivity)',
'Distilled water (D.I)',
'Universal pH Indicator paper',
'Personal Floating Devices (PFD)',
'First aid kits',
'Sampling Shoes',
'Sufficient set of cooler box and sampling bottles',
'Ice packets',
'Disposable gloves',
'Black plastic bags',
'Maker pen, pen and brown tapes',
'Zipper Bags',
'Aluminium Foil',
];
@override
void initState() {
super.initState();
for (var item in _checklistItemsText) {
_data.checklistItems[item] = false;
_data.remarks[item] = '';
_remarksVisibility[item] = false;
}
// NEW: Check initial connection and start listening for changes
_checkInitialConnectivity();
_connectivitySubscription =
Connectivity().onConnectivityChanged.listen(_updateConnectionStatus);
}
// NEW: Dispose the connectivity listener to prevent memory leaks
@override
void dispose() {
_connectivitySubscription.cancel();
super.dispose();
}
// NEW: Method to check the first time the screen loads
Future<void> _checkInitialConnectivity() async {
final connectivityResult = await Connectivity().checkConnectivity();
_updateConnectionStatus(connectivityResult);
}
// NEW: Callback method to update UI based on connectivity changes
void _updateConnectionStatus(List<ConnectivityResult> result) {
final bool currentlyOnline = !result.contains(ConnectivityResult.none);
if (_isOnline != currentlyOnline) {
setState(() {
_isOnline = currentlyOnline;
});
if (currentlyOnline && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("You are back online."),
backgroundColor: Colors.green,
),
);
}
}
}
Future<void> _submit() async {
setState(() => _isLoading = true);
try {
final auth = Provider.of<AuthProvider>(context, listen: false);
final service = Provider.of<MarineManualPreDepartureService>(context, listen: false);
_data.reporterUserId = auth.profileData?['user_id'];
_data.reporterName = auth.profileData?['user_name'];
_data.submissionDate = DateTime.now().toIso8601String().split('T').first;
final result = await service.submitChecklist(data: _data, authProvider: auth);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message']),
backgroundColor: result['success'] == true ? Colors.green : Colors.red,
),
);
if (result['success'] == true) Navigator.of(context).pop();
}
} on SocketException {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Submission failed. Please check your network connection."),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("An unexpected error occurred: $e"),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Pre-Departure Checklist (F-MM03)'),
),
body: Column(
children: [
// NEW: Offline banner that shows when there's no internet
if (!_isOnline)
Container(
width: double.infinity,
color: Colors.red,
padding: const EdgeInsets.all(8.0),
child: const Text(
'No Internet Connection. You cannot submit the report.',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: _checklistItemsText.length,
itemBuilder: (context, index) {
final item = _checklistItemsText[index];
return _buildChecklistItem(item);
},
separatorBuilder: (context, index) => const SizedBox(height: 8),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
),
const SizedBox(width: 10.0),
Expanded(
child: ElevatedButton(
// NEW: Submit button is disabled when loading OR offline
onPressed: _isLoading || !_isOnline ? null : _submit,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('Submit'),
),
)
],
),
],
),
),
),
],
),
);
}
Widget _buildChecklistItem(String title) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(width: 16),
ToggleSwitch(
minWidth: 70.0,
cornerRadius: 20.0,
activeBgColor: [Theme.of(context).colorScheme.primary],
activeFgColor: Colors.white,
inactiveBgColor: Colors.grey[700],
inactiveFgColor: Colors.white,
initialLabelIndex: _data.checklistItems[title]! ? 0 : 1,
totalSwitches: 2,
labels: const ['Yes', 'No'],
radiusStyle: true,
onToggle: (index) {
setState(() {
_data.checklistItems[title] = index == 0;
});
},
),
],
),
const SizedBox(height: 8),
if (_remarksVisibility[title]!)
TextFormField(
initialValue: _data.remarks[title],
decoration: const InputDecoration(
labelText: 'Remarks',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(),
),
onChanged: (value) {
_data.remarks[title] = value;
},
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () {
setState(() {
_remarksVisibility[title] = !_remarksVisibility[title]!;
});
},
icon: Icon(
_remarksVisibility[title]!
? Icons.remove_circle_outline
: Icons.add_comment_outlined,
size: 16,
),
label: Text(
_data.remarks[title]!.isNotEmpty
? 'Edit Remarks'
: 'Add Remarks',
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,318 @@
import 'dart:async';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../../../../../auth_provider.dart';
import '../../../../models/marine_manual_sonde_calibration_data.dart';
import '../../../../services/marine_manual_sonde_calibration_service.dart';
class MarineManualSondeCalibrationScreen extends StatefulWidget {
const MarineManualSondeCalibrationScreen({super.key});
@override
State<MarineManualSondeCalibrationScreen> createState() =>
_MarineManualSondeCalibrationScreenState();
}
class _MarineManualSondeCalibrationScreenState
extends State<MarineManualSondeCalibrationScreen> {
final _formKey = GlobalKey<FormState>();
final _data = MarineManualSondeCalibrationData();
bool _isLoading = false;
// State for connectivity
bool _isOnline = true;
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
// Text Controllers
final _sondeIdController = TextEditingController();
final _dateTimeController = TextEditingController();
final _remarksController = TextEditingController();
@override
void initState() {
super.initState();
_dateTimeController.text = DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now());
_checkInitialConnectivity();
_connectivitySubscription =
Connectivity().onConnectivityChanged.listen(_updateConnectionStatus);
}
@override
void dispose() {
_connectivitySubscription.cancel();
_sondeIdController.dispose();
_dateTimeController.dispose();
_remarksController.dispose();
super.dispose();
}
Future<void> _checkInitialConnectivity() async {
final connectivityResult = await Connectivity().checkConnectivity();
_updateConnectionStatus(connectivityResult);
}
void _updateConnectionStatus(List<ConnectivityResult> result) {
final bool currentlyOnline = !result.contains(ConnectivityResult.none);
if (_isOnline != currentlyOnline) {
setState(() {
_isOnline = currentlyOnline;
});
if (currentlyOnline && mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text("You are back online."),
backgroundColor: Colors.green,
));
}
}
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text("Please fill in all required fields."),
backgroundColor: Colors.red,
));
return;
}
_formKey.currentState!.save();
setState(() => _isLoading = true);
try {
final auth = Provider.of<AuthProvider>(context, listen: false);
final service = Provider.of<MarineManualSondeCalibrationService>(context, listen: false);
_data.calibratedByUserId = auth.profileData?['user_id'];
_data.sondeId = _sondeIdController.text;
_data.calibrationDateTime = _dateTimeController.text;
_data.remarks = _remarksController.text;
final result = await service.submitCalibration(data: _data, authProvider: auth);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(result['message']),
backgroundColor: result['success'] == true ? Colors.green : Colors.red,
));
if (result['success'] == true) Navigator.of(context).pop();
}
} on SocketException {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text("Submission failed. Please check your network connection."),
backgroundColor: Colors.red,
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text("An unexpected error occurred: $e"),
backgroundColor: Colors.red,
));
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Sonde Calibration Form (F-MM02)'),
),
body: Column(
children: [
if (!_isOnline)
Container(
width: double.infinity,
color: Colors.red,
padding: const EdgeInsets.all(8.0),
child: const Text(
'No Internet Connection. You cannot submit the report.',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
Expanded(
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
_buildGeneralInfoCard(),
const SizedBox(height: 16),
_buildCalibrationCard(
title: 'pH Calibration',
parameters: ['pH 4', 'pH 7', 'pH 10'],
onSave: (param, type, value) {
if (value == null) return;
if (param == 'pH 4' && type == 'Initial') _data.ph4Initial = value;
if (param == 'pH 4' && type == 'Calibrated') _data.ph4Calibrated = value;
if (param == 'pH 7' && type == 'Initial') _data.ph7Initial = value;
if (param == 'pH 7' && type == 'Calibrated') _data.ph7Calibrated = value;
if (param == 'pH 10' && type == 'Initial') _data.ph10Initial = value;
if (param == 'pH 10' && type == 'Calibrated') _data.ph10Calibrated = value;
},
),
const SizedBox(height: 16),
_buildCalibrationCard(
title: 'Other Parameters',
parameters: ['Conductivity', 'Dissolved Oxygen', 'Turbidity'],
onSave: (param, type, value) {
if (value == null) return;
if (param == 'Conductivity' && type == 'Initial') _data.condInitial = value;
if (param == 'Conductivity' && type == 'Calibrated') _data.condCalibrated = value;
if (param == 'Dissolved Oxygen' && type == 'Initial') _data.doInitial = value;
if (param == 'Dissolved Oxygen' && type == 'Calibrated') _data.doCalibrated = value;
if (param == 'Turbidity' && type == 'Initial') _data.turbidityInitial = value;
if (param == 'Turbidity' && type == 'Calibrated') _data.turbidityCalibrated = value;
},
),
const SizedBox(height: 16),
_buildSummaryCard(),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'))),
const SizedBox(width: 10.0),
Expanded(
child: ElevatedButton(
onPressed: _isLoading || !_isOnline ? null : _submit,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('Submit'),
),
)
],
),
],
),
),
),
),
],
),
);
}
Widget _buildGeneralInfoCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('General Information', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
TextFormField(
controller: _sondeIdController,
decoration: const InputDecoration(labelText: 'Sonde ID *', border: OutlineInputBorder()),
validator: (val) => val == null || val.isEmpty ? 'Sonde ID is required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _dateTimeController,
readOnly: true,
decoration: const InputDecoration(labelText: 'Calibration Date & Time', border: OutlineInputBorder()),
),
],
),
),
);
}
Widget _buildCalibrationCard({
required String title,
required List<String> parameters,
required Function(String, String, double?) onSave,
}) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
...parameters.map((param) => _buildParameterRow(param, onSave)).toList(),
],
),
),
);
}
Widget _buildParameterRow(String label, Function(String, String, double?) onSave) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Expanded(flex: 2, child: Text(label, style: Theme.of(context).textTheme.titleMedium)),
Expanded(
flex: 3,
child: Row(
children: [
Expanded(child: TextFormField(
decoration: const InputDecoration(labelText: 'Initial', border: OutlineInputBorder()),
keyboardType: TextInputType.number,
onSaved: (val) => onSave(label, 'Initial', double.tryParse(val ?? '')),
)),
const SizedBox(width: 8),
Expanded(child: TextFormField(
decoration: const InputDecoration(labelText: 'Calibrated', border: OutlineInputBorder()),
keyboardType: TextInputType.number,
onSaved: (val) => onSave(label, 'Calibrated', double.tryParse(val ?? '')),
)),
],
),
),
],
),
);
}
Widget _buildSummaryCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Summary', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: 'Overall Status *', border: OutlineInputBorder()),
items: ['Pass', 'Fail', 'Pass with Issues'].map((String value) {
return DropdownMenuItem<String>(value: value, child: Text(value));
}).toList(),
onChanged: (val) {
_data.calibrationStatus = val;
},
validator: (val) => val == null || val.isEmpty ? 'Status is required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _remarksController,
decoration: const InputDecoration(labelText: 'Remarks', border: OutlineInputBorder()),
maxLines: 3,
),
],
),
),
);
}
}

View File

@ -0,0 +1,115 @@
// lib/screens/river/manual/triennial/river_manual_triennial_sampling.dart
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:environment_monitoring_app/auth_provider.dart';
import '../../../../models/river_manual_triennial_sampling_data.dart';
import '../../../../services/river_manual_triennial_sampling_service.dart';
// --- ADDED: Missing imports for step widgets ---
import 'widgets/river_manual_triennial_step_1_sampling_info.dart';
import 'widgets/river_manual_triennial_step_2_site_info.dart';
import 'widgets/river_manual_triennial_step_3_data_capture.dart';
import 'widgets/river_manual_triennial_step_4_additional_info.dart';
import 'widgets/river_manual_triennial_step_5_summary.dart';
class RiverManualTriennialSamplingScreen extends StatefulWidget {
const RiverManualTriennialSamplingScreen({super.key});
@override
State<RiverManualTriennialSamplingScreen> createState() => _RiverManualTriennialSamplingScreenState();
}
class _RiverManualTriennialSamplingScreenState extends State<RiverManualTriennialSamplingScreen> {
final PageController _pageController = PageController();
late RiverManualTriennialSamplingData _data;
int _currentPage = 0;
bool _isLoading = false;
@override
void initState() {
super.initState();
_data = RiverManualTriennialSamplingData(
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()),
);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _nextPage() {
if (_currentPage < 4) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
void _previousPage() {
if (_currentPage > 0) {
_pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
Future<void> _submitForm() async {
setState(() => _isLoading = true);
final samplingService = Provider.of<RiverManualTriennialSamplingService>(context, listen: false);
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
final result = await samplingService.submitData(
data: _data,
appSettings: appSettings,
authProvider: authProvider
);
if (!mounted) return;
setState(() => _isLoading = false);
final bool isSuccess = result['success'] ?? false;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? "An unknown error occurred."),
backgroundColor: isSuccess ? Colors.green : Colors.red,
),
);
Navigator.of(context).popUntil((route) => route.isFirst);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Triennial Sampling (${_currentPage + 1}/5)'),
leading: _currentPage > 0
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _previousPage,
)
: null,
),
body: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (page) => setState(() => _currentPage = page),
children: [
RiverManualTriennialStep1SamplingInfo(data: _data, onNext: _nextPage),
RiverManualTriennialStep2SiteInfo(data: _data, onNext: _nextPage),
RiverManualTriennialStep3DataCapture(data: _data, onNext: _nextPage),
RiverManualTriennialStep4AdditionalInfo(data: _data, onNext: _nextPage),
RiverManualTriennialStep5Summary(data: _data, onSubmit: _submitForm, isLoading: _isLoading),
],
),
);
}
}

View File

@ -0,0 +1,532 @@
// lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_1_sampling_info.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:dropdown_search/dropdown_search.dart';
import 'package:intl/intl.dart';
import 'package:simple_barcode_scanner/simple_barcode_scanner.dart';
import '../../../../../auth_provider.dart';
import '../../../../../models/river_manual_triennial_sampling_data.dart';
import '../../../../../services/river_in_situ_sampling_service.dart';
class RiverManualTriennialStep1SamplingInfo extends StatefulWidget {
final RiverManualTriennialSamplingData data;
final VoidCallback onNext;
const RiverManualTriennialStep1SamplingInfo({
super.key,
required this.data,
required this.onNext,
});
@override
State<RiverManualTriennialStep1SamplingInfo> createState() => _RiverManualTriennialStep1SamplingInfoState();
}
class _RiverManualTriennialStep1SamplingInfoState extends State<RiverManualTriennialStep1SamplingInfo> {
final _formKey = GlobalKey<FormState>();
bool _isLoadingLocation = false;
late final TextEditingController _firstSamplerController;
late final TextEditingController _dateController;
late final TextEditingController _timeController;
late final TextEditingController _barcodeController;
late final TextEditingController _stationLatController;
late final TextEditingController _stationLonController;
late final TextEditingController _currentLatController;
late final TextEditingController _currentLonController;
List<String> _statesList = [];
List<Map<String, dynamic>> _stationsForState = [];
final List<String> _samplingTypes = ['Schedule', 'Triennial'];
@override
void initState() {
super.initState();
_initializeControllers();
_initializeForm();
}
@override
void dispose() {
_firstSamplerController.dispose();
_dateController.dispose();
_timeController.dispose();
_barcodeController.dispose();
_stationLatController.dispose();
_stationLonController.dispose();
_currentLatController.dispose();
_currentLonController.dispose();
super.dispose();
}
void _initializeControllers() {
_firstSamplerController = TextEditingController();
_dateController = TextEditingController();
_timeController = TextEditingController();
_barcodeController = TextEditingController(text: widget.data.sampleIdCode);
_stationLatController = TextEditingController(text: widget.data.stationLatitude);
_stationLonController = TextEditingController(text: widget.data.stationLongitude);
_currentLatController = TextEditingController(text: widget.data.currentLatitude);
_currentLonController = TextEditingController(text: widget.data.currentLongitude);
}
void _initializeForm() {
final auth = Provider.of<AuthProvider>(context, listen: false);
widget.data.firstSamplerName = auth.profileData?['first_name'] ?? 'Current User';
widget.data.firstSamplerUserId = auth.profileData?['user_id'];
_firstSamplerController.text = widget.data.firstSamplerName!;
final now = DateTime.now();
if (widget.data.samplingDate == null || widget.data.samplingDate!.isEmpty) {
widget.data.samplingDate = DateFormat('yyyy-MM-dd').format(now);
widget.data.samplingTime = DateFormat('HH:mm:ss').format(now);
}
_dateController.text = widget.data.samplingDate!;
_timeController.text = widget.data.samplingTime!;
if (widget.data.samplingType == null) {
widget.data.samplingType = 'Triennial';
}
final allStations = auth.riverManualStations ?? [];
if (allStations.isNotEmpty) {
final states = allStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList();
states.sort();
if (widget.data.selectedStateName != null) {
_stationsForState = allStations
.where((s) => s['state_name'] == widget.data.selectedStateName)
.toList()
..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? ''));
}
setState(() {
_statesList = states;
});
}
}
Future<void> _getCurrentLocation() async {
setState(() => _isLoadingLocation = true);
final service = Provider.of<RiverInSituSamplingService>(context, listen: false);
try {
final position = await service.getCurrentLocation();
if (mounted) {
setState(() {
widget.data.currentLatitude = position.latitude.toString();
widget.data.currentLongitude = position.longitude.toString();
_currentLatController.text = widget.data.currentLatitude!;
_currentLonController.text = widget.data.currentLongitude!;
_calculateDistance();
});
}
} catch (e) {
if(mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e')));
}
} finally {
if (mounted) {
setState(() => _isLoadingLocation = false);
}
}
}
void _calculateDistance() {
final lat1Str = widget.data.stationLatitude;
final lon1Str = widget.data.stationLongitude;
final lat2Str = widget.data.currentLatitude;
final lon2Str = widget.data.currentLongitude;
if (lat1Str != null && lon1Str != null && lat2Str != null && lon2Str != null) {
final service = Provider.of<RiverInSituSamplingService>(context, listen: false);
final lat1 = double.tryParse(lat1Str);
final lon1 = double.tryParse(lon1Str);
final lat2 = double.tryParse(lat2Str);
final lon2 = double.tryParse(lon2Str);
if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) {
final distance = service.calculateDistance(lat1, lon1, lat2, lon2);
setState(() {
widget.data.distanceDifferenceInKm = distance;
});
}
}
}
Future<void> _scanBarcode() async {
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SimpleBarcodeScannerPage()),
);
if (result is String && result != '-1' && mounted) {
setState(() {
widget.data.sampleIdCode = result;
_barcodeController.text = result;
});
}
}
Future<void> _findAndShowNearbyStations() async {
if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
await _getCurrentLocation();
if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
return;
}
}
final service = Provider.of<RiverInSituSamplingService>(context, listen: false);
final auth = Provider.of<AuthProvider>(context, listen: false);
final currentLat = double.parse(widget.data.currentLatitude!);
final currentLon = double.parse(widget.data.currentLongitude!);
final allStations = auth.riverManualStations ?? [];
final List<Map<String, dynamic>> nearbyStations = [];
for (var station in allStations) {
final stationLat = station['sampling_lat'];
final stationLon = station['sampling_long'];
if (stationLat is num && stationLon is num) {
final distance = service.calculateDistance(currentLat, currentLon, stationLat.toDouble(), stationLon.toDouble());
if (distance <= 3.0) {
nearbyStations.add({'station': station, 'distance': distance});
}
}
}
nearbyStations.sort((a, b) => a['distance'].compareTo(b['distance']));
if (!mounted) return;
final selectedStation = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations),
);
if (selectedStation != null) {
_updateFormWithSelectedStation(selectedStation);
}
}
void _updateFormWithSelectedStation(Map<String, dynamic> station) {
final allStations = Provider.of<AuthProvider>(context, listen: false).riverManualStations ?? [];
setState(() {
widget.data.selectedStateName = station['state_name'];
_stationsForState = allStations
.where((s) => s['state_name'] == widget.data.selectedStateName)
.toList()
..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? ''));
widget.data.selectedStation = station;
widget.data.stationLatitude = station['sampling_lat']?.toString();
widget.data.stationLongitude = station['sampling_long']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? '';
_calculateDistance();
});
}
void _goToNextStep() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000;
if (distanceInMeters > 50) {
_showDistanceRemarkDialog();
} else {
widget.data.distanceDifferenceRemarks = null;
widget.onNext();
}
}
}
Future<void> _showDistanceRemarkDialog() async {
final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks);
final dialogFormKey = GlobalKey<FormState>();
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Distance Warning'),
content: SingleChildScrollView(
child: Form(
key: dialogFormKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Your current location is more than 50m away from the station.'),
const SizedBox(height: 16),
TextFormField(
controller: remarkController,
decoration: const InputDecoration(
labelText: 'Remarks *',
hintText: 'Please provide a reason...',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Remarks are required to continue.';
}
return null;
},
maxLines: 3,
),
],
),
),
),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(context).pop();
},
),
FilledButton(
child: const Text('Confirm'),
onPressed: () {
if (dialogFormKey.currentState!.validate()) {
setState(() {
widget.data.distanceDifferenceRemarks = remarkController.text;
});
Navigator.of(context).pop();
widget.onNext();
}
},
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthProvider>(context, listen: false);
final allStations = auth.riverManualStations ?? [];
final allUsers = auth.allUsers ?? [];
final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList()
..sort((a, b) => (a['first_name'] ?? '').compareTo(b['first_name'] ?? ''));
return Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
Text("Sampling Information", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24),
TextFormField(controller: _firstSamplerController, readOnly: true, decoration: const InputDecoration(labelText: '1st Sampler')),
const SizedBox(height: 16),
DropdownSearch<Map<String, dynamic>>(
items: secondSamplersList,
selectedItem: widget.data.secondSampler,
itemAsString: (sampler) => "${sampler['first_name']} ${sampler['last_name']}",
onChanged: (sampler) => widget.data.secondSampler = sampler,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Sampler..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: '2nd Sampler (Optional)')),
),
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'))),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: widget.data.samplingType,
items: _samplingTypes.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(),
onChanged: (value) => setState(() => widget.data.samplingType = value),
decoration: const InputDecoration(labelText: 'Sampling Type *'),
validator: (value) => value == null ? 'Please select a type' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _barcodeController,
decoration: InputDecoration(
labelText: 'Sample ID Code *',
suffixIcon: IconButton(
icon: const Icon(Icons.qr_code_scanner),
onPressed: _scanBarcode,
),
),
validator: (val) => val == null || val.isEmpty ? "Sample ID is required" : null,
onSaved: (val) => widget.data.sampleIdCode = val,
onChanged: (val) => widget.data.sampleIdCode = val,
),
const SizedBox(height: 24),
DropdownSearch<String>(
items: _statesList,
selectedItem: widget.data.selectedStateName,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")),
onChanged: (state) {
setState(() {
widget.data.selectedStateName = state;
widget.data.selectedStation = null;
_stationLatController.clear();
_stationLonController.clear();
widget.data.distanceDifferenceInKm = null;
_stationsForState = state != null
? (allStations
.where((s) => s['state_name'] == state)
.toList()
..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? '')))
: [];
});
},
validator: (val) => val == null ? "State is required" : null,
),
const SizedBox(height: 16),
DropdownSearch<Map<String, dynamic>>(
items: _stationsForState,
selectedItem: widget.data.selectedStation,
enabled: widget.data.selectedStateName != null,
itemAsString: (station) =>
"${station['sampling_station_code']} | ${station['sampling_river']} | ${station['sampling_basin']}",
popupProps: const PopupProps.menu(
showSearchBox: true,
searchFieldProps: TextFieldProps(
decoration: InputDecoration(hintText: "Search Station..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(
dropdownSearchDecoration: InputDecoration(
labelText: "Select Station *")),
onChanged: (station) => setState(() {
widget.data.selectedStation = station;
widget.data.stationLatitude = station?['sampling_lat']?.toString();
widget.data.stationLongitude = station?['sampling_long']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? '';
_calculateDistance();
}),
validator: (val) => widget.data.selectedStateName != null && val == null ? "Station is required" : null,
),
const SizedBox(height: 16),
TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')),
const SizedBox(height: 16),
TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.explore_outlined),
label: const Text("NEARBY STATION"),
onPressed: _isLoadingLocation ? null : _findAndShowNearbyStations,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
const SizedBox(height: 24),
Text("Location Verification", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')),
const SizedBox(height: 16),
TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')),
if (widget.data.distanceDifferenceInKm != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green),
),
child: RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: Theme.of(context).textTheme.bodyLarge,
children: <TextSpan>[
const TextSpan(text: 'Distance from Station: '),
TextSpan(
text: '${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters',
style: TextStyle(
fontWeight: FontWeight.bold,
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green
),
),
],
),
),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _isLoadingLocation ? null : _getCurrentLocation,
icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching),
label: const Text("Get Current Location"),
),
const SizedBox(height: 16),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _goToNextStep,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Next'),
),
],
),
);
}
}
class _NearbyStationsDialog extends StatelessWidget {
final List<Map<String, dynamic>> nearbyStations;
const _NearbyStationsDialog({required this.nearbyStations});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Nearby Stations (within 3km)'),
content: SizedBox(
width: double.maxFinite,
child: nearbyStations.isEmpty
? const Center(child: Text('No stations found.'))
: ListView.builder(
shrinkWrap: true,
itemCount: nearbyStations.length,
itemBuilder: (context, index) {
final item = nearbyStations[index];
final station = item['station'] as Map<String, dynamic>;
final distanceInMeters = (item['distance'] as double) * 1000;
return Card(
child: ListTile(
title: Text("${station['sampling_station_code'] ?? 'N/A'}"),
subtitle: Text("${station['sampling_river'] ?? 'N/A'}"),
trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"),
onTap: () {
Navigator.of(context).pop(station);
},
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
],
);
}
}

View File

@ -0,0 +1,202 @@
// lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_2_site_info.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../../../../../models/river_manual_triennial_sampling_data.dart';
import '../../../../../services/river_manual_triennial_sampling_service.dart';
class RiverManualTriennialStep2SiteInfo extends StatefulWidget {
final RiverManualTriennialSamplingData data;
final VoidCallback onNext;
const RiverManualTriennialStep2SiteInfo({
super.key,
required this.data,
required this.onNext,
});
@override
State<RiverManualTriennialStep2SiteInfo> createState() => _RiverManualTriennialStep2SiteInfoState();
}
class _RiverManualTriennialStep2SiteInfoState extends State<RiverManualTriennialStep2SiteInfo> {
final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false;
late final TextEditingController _eventRemarksController;
late final TextEditingController _labRemarksController;
final List<String> _weatherOptions = ['Cloudy', 'Drizzle', 'Rainy', 'Sunny', 'Windy'];
@override
void initState() {
super.initState();
_eventRemarksController = TextEditingController(text: widget.data.eventRemarks);
_labRemarksController = TextEditingController(text: widget.data.labRemarks);
}
@override
void dispose() {
_eventRemarksController.dispose();
_labRemarksController.dispose();
super.dispose();
}
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return;
setState(() => _isPickingImage = true);
final service = Provider.of<RiverManualTriennialSamplingService>(context, listen: false);
final String? stationCode = widget.data.selectedStation?['sampling_station_code'];
final file = await service.pickAndProcessImage(
source,
data: widget.data,
imageInfo: imageInfo,
isRequired: isRequired,
stationCode: stationCode,
);
if (file != null) {
setState(() => setImageCallback(file));
} else if (mounted) {
_showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true);
}
if (mounted) {
setState(() => _isPickingImage = false);
}
}
void _goToNextStep() {
if (!_formKey.currentState!.validate()) {
return;
}
_formKey.currentState!.save();
if (widget.data.backgroundStationImage == null ||
widget.data.upstreamRiverImage == null ||
widget.data.downstreamRiverImage == null) {
_showSnackBar('Please attach all 3 required photos before proceeding.', isError: true);
return;
}
widget.onNext();
}
void _showSnackBar(String message, {bool isError = false}) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : null,
));
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
Text("On-Site Information", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: widget.data.weather,
items: _weatherOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(),
onChanged: (value) => setState(() => widget.data.weather = value),
decoration: const InputDecoration(labelText: 'Weather *'),
validator: (value) => value == null ? 'Weather is required' : null,
onSaved: (value) => widget.data.weather = value,
),
const SizedBox(height: 16),
TextFormField(
controller: _eventRemarksController,
decoration: const InputDecoration(labelText: 'Event Remarks (Optional)', hintText: 'e.g., unusual smells, colors, etc.'),
onSaved: (value) => widget.data.eventRemarks = value,
maxLines: 3,
),
const SizedBox(height: 16),
TextFormField(
controller: _labRemarksController,
decoration: const InputDecoration(labelText: 'Lab Remarks (Optional)'),
onSaved: (value) => widget.data.labRemarks = value,
maxLines: 3,
),
const Divider(height: 32),
Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge),
const Text("All photos must be taken in landscape (horizontal) orientation.", style: TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
_buildImagePicker('Background Station', 'BACKGROUND_STATION', widget.data.backgroundStationImage, (file) => widget.data.backgroundStationImage = file, isRequired: true),
_buildImagePicker('Upstream River', 'UPSTREAM_RIVER', widget.data.upstreamRiverImage, (file) => widget.data.upstreamRiverImage = file, isRequired: true),
_buildImagePicker('Downstream River', 'DOWNSTREAM_RIVER', widget.data.downstreamRiverImage, (file) => widget.data.downstreamRiverImage = file, isRequired: true),
const SizedBox(height: 24),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _goToNextStep,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Next'),
),
],
),
);
}
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
if (imageFile != null)
Stack(
alignment: Alignment.topRight,
children: [
ClipRRect(borderRadius: BorderRadius.circular(8.0), child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)),
Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
child: IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, color: Colors.white, size: 20),
onPressed: () => setState(() => setImageCallback(null)),
),
),
],
)
else
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
],
),
if (remarkController != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TextFormField(
controller: remarkController,
decoration: InputDecoration(
labelText: 'Remarks for $title',
hintText: 'Add an optional remark...',
border: const OutlineInputBorder(),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,192 @@
// lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_4_additional_info.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../../../../../models/river_manual_triennial_sampling_data.dart';
import '../../../../../services/river_manual_triennial_sampling_service.dart';
class RiverManualTriennialStep4AdditionalInfo extends StatefulWidget {
final RiverManualTriennialSamplingData data;
final VoidCallback onNext;
const RiverManualTriennialStep4AdditionalInfo({
super.key,
required this.data,
required this.onNext,
});
@override
State<RiverManualTriennialStep4AdditionalInfo> createState() =>
_RiverManualTriennialStep4AdditionalInfoState();
}
class _RiverManualTriennialStep4AdditionalInfoState
extends State<RiverManualTriennialStep4AdditionalInfo> {
final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false;
late final TextEditingController _optionalRemark1Controller;
late final TextEditingController _optionalRemark2Controller;
late final TextEditingController _optionalRemark3Controller;
late final TextEditingController _optionalRemark4Controller;
@override
void initState() {
super.initState();
_optionalRemark1Controller = TextEditingController(text: widget.data.optionalRemark1);
_optionalRemark2Controller = TextEditingController(text: widget.data.optionalRemark2);
_optionalRemark3Controller = TextEditingController(text: widget.data.optionalRemark3);
_optionalRemark4Controller = TextEditingController(text: widget.data.optionalRemark4);
}
@override
void dispose() {
_optionalRemark1Controller.dispose();
_optionalRemark2Controller.dispose();
_optionalRemark3Controller.dispose();
_optionalRemark4Controller.dispose();
super.dispose();
}
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return;
setState(() => _isPickingImage = true);
// --- CORRECTED: Use the correct Triennial service ---
final service = Provider.of<RiverManualTriennialSamplingService>(context, listen: false);
final String? stationCode = widget.data.selectedStation?['sampling_station_code'];
final file = await service.pickAndProcessImage(
source,
data: widget.data, // This now correctly matches the method signature
imageInfo: imageInfo,
isRequired: isRequired,
stationCode: stationCode,
);
if (file != null) {
setState(() => setImageCallback(file));
} else if (mounted) {
_showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true);
}
if (mounted) {
setState(() => _isPickingImage = false);
}
}
void _goToNextStep() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
if (widget.data.sampleTurbidityImage == null) {
_showSnackBar('Please attach the Sample Turbidity photo before proceeding.', isError: true);
return;
}
widget.data.optionalRemark1 = _optionalRemark1Controller.text;
widget.data.optionalRemark2 = _optionalRemark2Controller.text;
widget.data.optionalRemark3 = _optionalRemark3Controller.text;
widget.data.optionalRemark4 = _optionalRemark4Controller.text;
widget.onNext();
}
}
void _showSnackBar(String message, {bool isError = false}) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : null,
));
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
Text("Additional Photos",
style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24),
Text("Required Photo *", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
_buildImagePicker('Sample Turbidity', 'SAMPLE_TURBIDITY', widget.data.sampleTurbidityImage, (file) => widget.data.sampleTurbidityImage = file, isRequired: true),
const Divider(height: 32),
Text("Optional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
_buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller, isRequired: false),
_buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller, isRequired: false),
_buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller, isRequired: false),
_buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller, isRequired: false),
const SizedBox(height: 24),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _goToNextStep,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Next'),
),
],
),
);
}
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
if (imageFile != null)
Stack(
alignment: Alignment.topRight,
children: [
ClipRRect(borderRadius: BorderRadius.circular(8.0), child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)),
Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
child: IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, color: Colors.white, size: 20),
onPressed: () => setState(() => setImageCallback(null)),
),
),
],
)
else
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
],
),
if (remarkController != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TextFormField(
controller: remarkController,
decoration: InputDecoration(
labelText: 'Remarks for $title',
hintText: 'Add an optional remark...',
border: const OutlineInputBorder(),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,342 @@
// lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_5_summary.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../../../auth_provider.dart';
import '../../../../../models/river_manual_triennial_sampling_data.dart';
class RiverManualTriennialStep5Summary extends StatelessWidget {
final RiverManualTriennialSamplingData data;
final VoidCallback onSubmit;
final bool isLoading;
const RiverManualTriennialStep5Summary({
super.key,
required this.data,
required this.onSubmit,
required this.isLoading,
});
// --- START: MODIFICATION FOR HIGHLIGHTING ---
// Added helper logic to re-validate parameters on the summary screen.
/// Maps the app's internal parameter keys to the names used in the database.
static const Map<String, String> _parameterKeyToLimitName = {
'oxygenConcentration': 'Oxygen Conc',
'oxygenSaturation': 'Oxygen Sat',
'ph': 'pH',
'salinity': 'Salinity',
'electricalConductivity': 'Conductivity',
'temperature': 'Temperature',
'tds': 'TDS',
'turbidity': 'Turbidity',
'ammonia': 'Ammonia',
'batteryVoltage': 'Battery',
};
/// Re-validates the final parameters against the defined limits.
Set<String> _getOutOfBoundsKeys(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
// --- MODIFICATION: Use the new river-specific parameter limits list ---
final riverLimits = authProvider.riverParameterLimits ?? [];
// --- END MODIFICATION ---
final Set<String> invalidKeys = {};
final readings = {
'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation,
'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity,
'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity,
'ammonia': data.ammonia, 'batteryVoltage': data.batteryVoltage,
};
double? parseLimitValue(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
readings.forEach((key, value) {
if (value == null || value == -999.0) return;
final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return;
final limitData = riverLimits.firstWhere((l) => l['param_parameter_list'] == limitName, orElse: () => {});
if (limitData.isNotEmpty) {
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
final upperLimit = parseLimitValue(limitData['param_upper_limit']);
if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) {
invalidKeys.add(key);
}
}
});
return invalidKeys;
}
// --- END: MODIFICATION FOR HIGHLIGHTING ---
@override
Widget build(BuildContext context) {
// --- START: MODIFICATION FOR HIGHLIGHTING ---
// Get the set of out-of-bounds keys before building the list.
final outOfBoundsKeys = _getOutOfBoundsKeys(context);
// --- END: MODIFICATION FOR HIGHLIGHTING ---
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
Text(
"Please review all information before submitting.",
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
_buildSectionCard(
context,
"Sampling & Station Details",
[
_buildDetailRow("1st Sampler:", data.firstSamplerName),
_buildDetailRow("2nd Sampler:", data.secondSampler?['first_name']?.toString()),
_buildDetailRow("Sampling Date:", data.samplingDate),
_buildDetailRow("Sampling Time:", data.samplingTime),
_buildDetailRow("Sampling Type:", data.samplingType),
_buildDetailRow("Sample ID Code:", data.sampleIdCode),
const Divider(height: 20),
_buildDetailRow("State:", data.selectedStateName),
_buildDetailRow(
"Station:",
"${data.selectedStation?['sampling_station_code']} | ${data.selectedStation?['sampling_river']} | ${data.selectedStation?['sampling_basin']}"
),
_buildDetailRow("Station Location:", "${data.stationLatitude}, ${data.stationLongitude}"),
],
),
_buildSectionCard(
context,
"Site Info & Required Photos",
[
_buildDetailRow("Current Location:", "${data.currentLatitude}, ${data.currentLongitude}"),
_buildDetailRow("Distance Difference:", data.distanceDifferenceInKm != null ? "${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters" : "N/A"),
if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty)
_buildDetailRow("Distance Remarks:", data.distanceDifferenceRemarks),
const Divider(height: 20),
_buildDetailRow("Weather:", data.weather),
_buildDetailRow("Event Remarks:", data.eventRemarks),
_buildDetailRow("Lab Remarks:", data.labRemarks),
const Divider(height: 20),
_buildImageCard("Background Station", data.backgroundStationImage),
_buildImageCard("Upstream River", data.upstreamRiverImage),
_buildImageCard("Downstream River", data.downstreamRiverImage),
],
),
_buildSectionCard(
context,
"Additional Photos & Remarks",
[
_buildImageCard("Sample Turbidity", data.sampleTurbidityImage),
const Divider(height: 24),
Text("Optional Photos", style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
_buildImageCard("Optional Photo 1", data.optionalImage1, remark: data.optionalRemark1),
_buildImageCard("Optional Photo 2", data.optionalImage2, remark: data.optionalRemark2),
_buildImageCard("Optional Photo 3", data.optionalImage3, remark: data.optionalRemark3),
_buildImageCard("Optional Photo 4", data.optionalImage4, remark: data.optionalRemark4),
],
),
_buildSectionCard(
context,
"Captured Parameters",
[
_buildDetailRow("Sonde ID:", data.sondeId),
_buildDetailRow("Capture Time:", "${data.dataCaptureDate} ${data.dataCaptureTime}"),
const Divider(height: 20),
// --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING ---
_buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration, isOutOfBounds: outOfBoundsKeys.contains('oxygenConcentration')),
_buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation, isOutOfBounds: outOfBoundsKeys.contains('oxygenSaturation')),
_buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph, isOutOfBounds: outOfBoundsKeys.contains('ph')),
_buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity, isOutOfBounds: outOfBoundsKeys.contains('salinity')),
_buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity, isOutOfBounds: outOfBoundsKeys.contains('electricalConductivity')),
_buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature, isOutOfBounds: outOfBoundsKeys.contains('temperature')),
_buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds, isOutOfBounds: outOfBoundsKeys.contains('tds')),
_buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity, isOutOfBounds: outOfBoundsKeys.contains('turbidity')),
_buildParameterListItem(context, icon: Icons.science, label: "Ammonia", unit: "mg/L", value: data.ammonia, isOutOfBounds: outOfBoundsKeys.contains('ammonia')),
_buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage, isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')),
// --- END: MODIFICATION ---
const Divider(height: 20),
_buildFlowrateSummary(context),
],
),
const SizedBox(height: 24),
isLoading
? const Center(child: CircularProgressIndicator())
: ElevatedButton.icon(
onPressed: onSubmit,
icon: const Icon(Icons.cloud_upload),
label: const Text('Confirm & Submit'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 16),
],
);
}
Widget _buildSectionCard(BuildContext context, String title, List<Widget> children) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
const Divider(height: 20, thickness: 1),
...children,
],
),
),
);
}
Widget _buildDetailRow(String label, String? value) {
String displayValue = value?.replaceAll('null - null', '').replaceAll('null |', '').replaceAll('| null', '').trim() ?? 'N/A';
if (displayValue.isEmpty || displayValue == "-") {
displayValue = 'N/A';
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 8),
Expanded(
flex: 3,
child: Text(displayValue, style: const TextStyle(fontSize: 16)),
),
],
),
);
}
// --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING ---
Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required double? value, bool isOutOfBounds = false}) {
final bool isMissing = value == null || value == -999.0;
// Format the value to 5 decimal places if it's a valid number.
final String displayValue = isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim();
// Determine the color for the value based on theme and status.
final Color? defaultTextColor = Theme.of(context).textTheme.bodyLarge?.color;
final Color valueColor = isOutOfBounds
? Colors.red
: (isMissing ? Colors.grey : defaultTextColor ?? Colors.black);
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: Theme.of(context).primaryColor, size: 28),
title: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
trailing: Text(
displayValue,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: valueColor,
fontWeight: isOutOfBounds ? FontWeight.bold : null,
),
),
);
}
// --- END: MODIFICATION ---
Widget _buildImageCard(String title, File? image, {String? remark}) {
final bool hasRemark = remark != null && remark.isNotEmpty;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 8),
if (image != null)
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.file(image, key: UniqueKey(), height: 200, width: double.infinity, fit: BoxFit.cover),
)
else
Container(
height: 100,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8.0),
border: Border.all(color: Colors.grey[300]!)),
child: const Center(child: Text('No Image Attached', style: TextStyle(color: Colors.grey))),
),
if (hasRemark)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic)),
),
],
),
);
}
Widget _buildFlowrateSummary(BuildContext context) {
final method = data.flowrateMethod ?? 'N/A';
List<Widget> children = [
_buildDetailRow("Flowrate Method:", method),
];
if (method == 'Surface Drifter') {
children.add(
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 4.0),
child: Column(
children: [
_buildDetailRow("Height:", data.flowrateSurfaceDrifterHeight != null ? "${data.flowrateSurfaceDrifterHeight} m" : "N/A"),
_buildDetailRow("Distance:", data.flowrateSurfaceDrifterDistance != null ? "${data.flowrateSurfaceDrifterDistance} m" : "N/A"),
_buildDetailRow("Time First:", data.flowrateSurfaceDrifterTimeFirst ?? "N/A"),
_buildDetailRow("Time Last:", data.flowrateSurfaceDrifterTimeLast ?? "N/A"),
],
),
)
);
}
children.add(
_buildDetailRow("Flowrate Value:", data.flowrateValue != null ? '${data.flowrateValue!.toStringAsFixed(4)} m/s' : 'NA')
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
}
}

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.09'), subtitle: const Text('MMS V4 1.2.11'),
dense: true, dense: true,
), ),
ListTile( ListTile(

View File

@ -947,6 +947,94 @@ class RiverApiService {
}; };
} }
Future<Map<String, dynamic>> submitTriennialSample({
required Map<String, String> formData,
required Map<String, File?> imageFiles,
required List<Map<String, dynamic>>? appSettings,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final dataResult = await _baseService.post(baseUrl, 'river/triennial/sample', formData);
if (dataResult['success'] != true) {
return {
'status': 'L1',
'success': false,
'message': 'Failed to submit triennial data: ${dataResult['message']}',
'reportId': null
};
}
final recordId = dataResult['data']?['r_tri_id'];
if (recordId == null) {
return {
'status': 'L2',
'success': false,
'message': 'Data submitted, but failed to get a record ID for images.',
'reportId': null
};
}
final filesToUpload = <String, File>{};
imageFiles.forEach((key, value) {
if (value != null) filesToUpload[key] = value;
});
if (filesToUpload.isEmpty) {
_handleTriennialSuccessAlert(formData, appSettings, isDataOnly: true);
return {
'status': 'L3',
'success': true,
'message': 'Triennial data submitted successfully. No images were attached.',
'reportId': recordId.toString()
};
}
final imageResult = await _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'river/triennial/images',
fields: {'r_tri_id': recordId.toString()},
files: filesToUpload,
);
if (imageResult['success'] != true) {
return {
'status': 'L2',
'success': false,
'message': 'Data submitted, but image upload failed: ${imageResult['message']}',
'reportId': recordId.toString()
};
}
_handleTriennialSuccessAlert(formData, appSettings, isDataOnly: false);
return {
'status': 'S4',
'success': true,
'message': 'Triennial data and images submitted successfully.',
'reportId': recordId.toString()
};
}
Future<void> _handleTriennialSuccessAlert(
Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = formData['r_tri_station_name'] ?? 'N/A';
final stationCode = formData['r_tri_station_code'] ?? 'N/A';
final message = '✅ *River Triennial Sample ${submissionType} Submitted:*\n\n'
'*Station:* $stationName ($stationCode)\n'
'*Date:* ${formData['r_tri_date']}\n'
'*User:* ${formData['first_sampler_name'] ?? 'N/A'}';
final bool wasSent = await _telegramService.sendAlertImmediately('river_triennial', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('river_triennial', message, appSettings);
}
} catch (e) {
debugPrint("Failed to handle River Triennial Telegram alert: $e");
}
}
Future<void> _handleInSituSuccessAlert( Future<void> _handleInSituSuccessAlert(
Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async { Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try { try {

View File

@ -13,7 +13,9 @@ import '../models/air_installation_data.dart';
import '../models/air_collection_data.dart'; import '../models/air_collection_data.dart';
import '../models/tarball_data.dart'; import '../models/tarball_data.dart';
import '../models/in_situ_sampling_data.dart'; import '../models/in_situ_sampling_data.dart';
import '../models/marine_manual_npe_report_data.dart';
import '../models/river_in_situ_sampling_data.dart'; import '../models/river_in_situ_sampling_data.dart';
import '../models/river_manual_triennial_sampling_data.dart';
class LocalStorageService { class LocalStorageService {
@ -464,6 +466,111 @@ class LocalStorageService {
return recentNearbySamples; return recentNearbySamples;
} }
// --- ADDED: Part 4.5: Marine NPE Report Specific Methods ---
Future<Directory?> _getNpeBaseDir({required String serverName}) async {
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
if (mmsv4Dir == null) return null;
final npeDir = Directory(p.join(mmsv4Dir.path, 'marine', 'marine_npe_report'));
if (!await npeDir.exists()) {
await npeDir.create(recursive: true);
}
return npeDir;
}
Future<String?> saveNpeReportData(MarineManualNpeReportData data, {required String serverName}) async {
final baseDir = await _getNpeBaseDir(serverName: serverName);
if (baseDir == null) {
debugPrint("Could not get public storage directory for NPE. Check permissions.");
return null;
}
try {
final stationCode = data.selectedStation?['man_station_code'] ?? data.selectedStation?['tbl_station_code'] ?? 'CUSTOM_LOC';
final timestamp = "${data.eventDate}_${data.eventTime?.replaceAll(':', '-')}";
final eventFolderName = "${stationCode}_${timestamp}_NPE";
final eventDir = Directory(p.join(baseDir.path, eventFolderName));
if (!await eventDir.exists()) {
await eventDir.create(recursive: true);
}
final Map<String, dynamic> jsonData = data.toDbJson();
jsonData['serverConfigName'] = serverName;
final imageFiles = data.toApiImageFiles();
for (var entry in imageFiles.entries) {
final File? imageFile = entry.value;
if (imageFile != null && imageFile.path.isNotEmpty) {
try {
final String originalFileName = p.basename(imageFile.path);
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName));
jsonData[entry.key] = newFile.path;
} catch (e) {
debugPrint("Error processing NPE image file ${imageFile.path}: $e");
}
}
}
final jsonFile = File(p.join(eventDir.path, 'data.json'));
await jsonFile.writeAsString(jsonEncode(jsonData));
debugPrint("NPE Report log saved to: ${jsonFile.path}");
return eventDir.path;
} catch (e) {
debugPrint("Error saving NPE report to local storage: $e");
return null;
}
}
Future<List<Map<String, dynamic>>> getAllNpeLogs() async {
final mmsv4Root = await _getPublicMMSV4Directory(serverName: '');
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
final List<Map<String, dynamic>> allLogs = [];
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
for (var serverDir in serverDirs) {
final baseDir = Directory(p.join(serverDir.path, 'marine', 'marine_npe_report'));
if (!await baseDir.exists()) continue;
try {
final entities = baseDir.listSync();
for (var entity in entities) {
if (entity is Directory) {
final jsonFile = File(p.join(entity.path, 'data.json'));
if (await jsonFile.exists()) {
final content = await jsonFile.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>;
data['logDirectory'] = entity.path;
allLogs.add(data);
}
}
}
} catch (e) {
debugPrint("Error reading NPE logs from ${baseDir.path}: $e");
}
}
return allLogs;
}
Future<void> updateNpeLog(Map<String, dynamic> updatedLogData) async {
final logDir = updatedLogData['logDirectory'];
if (logDir == null) return;
try {
final jsonFile = File(p.join(logDir, 'data.json'));
if (await jsonFile.exists()) {
updatedLogData.remove('isResubmitting');
await jsonFile.writeAsString(jsonEncode(updatedLogData));
debugPrint("NPE Log updated successfully at: ${jsonFile.path}");
}
} catch (e) {
debugPrint("Error updating NPE log: $e");
}
}
// ======================================================================= // =======================================================================
// Part 5: River In-Situ Specific Methods (LOGGING RESTORED) // Part 5: River In-Situ Specific Methods (LOGGING RESTORED)
// ======================================================================= // =======================================================================
@ -587,7 +694,118 @@ class LocalStorageService {
} }
// ======================================================================= // =======================================================================
// --- ADDED: Part 6: Info Centre Document Management --- // Part 6: River Triennial Specific Methods
// =======================================================================
Future<Directory?> _getRiverTriennialBaseDir({required String serverName}) async {
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
if (mmsv4Dir == null) return null;
final triennialDir = Directory(p.join(mmsv4Dir.path, 'river', 'river_triennial_sampling'));
if (!await triennialDir.exists()) {
await triennialDir.create(recursive: true);
}
return triennialDir;
}
Future<String?> saveRiverManualTriennialSamplingData(RiverManualTriennialSamplingData data, {required String serverName}) async {
final baseDir = await _getRiverTriennialBaseDir(serverName: serverName);
if (baseDir == null) {
debugPrint("Could not get public storage directory for River Triennial. Check permissions.");
return null;
}
try {
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN_STATION';
final timestamp = "${data.samplingDate}_${data.samplingTime?.replaceAll(':', '-')}";
final eventFolderName = "${stationCode}_$timestamp";
final eventDir = Directory(p.join(baseDir.path, eventFolderName));
if (!await eventDir.exists()) {
await eventDir.create(recursive: true);
}
final Map<String, dynamic> jsonData = data.toMap();
jsonData['serverConfigName'] = serverName;
final imageFiles = data.toApiImageFiles();
for (var entry in imageFiles.entries) {
final File? imageFile = entry.value;
if (imageFile != null) {
final String originalFileName = p.basename(imageFile.path);
if (p.dirname(imageFile.path) == eventDir.path) {
jsonData[entry.key] = imageFile.path;
} else {
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName));
jsonData[entry.key] = newFile.path;
}
}
}
final jsonFile = File(p.join(eventDir.path, 'data.json'));
await jsonFile.writeAsString(jsonEncode(jsonData));
debugPrint("River Triennial log saved to: ${jsonFile.path}");
return eventDir.path;
} catch (e) {
debugPrint("Error saving River Triennial log to local storage: $e");
return null;
}
}
Future<List<Map<String, dynamic>>> getAllRiverManualTriennialLogs() async {
final mmsv4Root = await _getPublicMMSV4Directory(serverName: '');
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
final List<Map<String, dynamic>> allLogs = [];
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
for (var serverDir in serverDirs) {
final baseDir = Directory(p.join(serverDir.path, 'river', 'river_triennial_sampling'));
if (!await baseDir.exists()) continue;
try {
final entities = baseDir.listSync();
for (var entity in entities) {
if (entity is Directory) {
final jsonFile = File(p.join(entity.path, 'data.json'));
if (await jsonFile.exists()) {
final content = await jsonFile.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>;
data['logDirectory'] = entity.path;
allLogs.add(data);
}
}
}
} catch (e) {
debugPrint("Error reading triennial logs from ${baseDir.path}: $e");
}
}
return allLogs;
}
Future<void> updateRiverManualTriennialLog(Map<String, dynamic> updatedLogData) async {
final logDir = updatedLogData['logDirectory'];
if (logDir == null) {
debugPrint("Cannot update log: logDirectory key is missing.");
return;
}
try {
final jsonFile = File(p.join(logDir, 'data.json'));
if (await jsonFile.exists()) {
updatedLogData.remove('isResubmitting');
await jsonFile.writeAsString(jsonEncode(updatedLogData));
debugPrint("Log updated successfully at: ${jsonFile.path}");
}
} catch (e) {
debugPrint("Error updating river triennial log: $e");
}
}
// =======================================================================
// --- ADDED: Part 7: Info Centre Document Management ---
// ======================================================================= // =======================================================================
final Dio _dio = Dio(); final Dio _dio = Dio();

View File

@ -0,0 +1,17 @@
import '../auth_provider.dart';
import '../models/marine_manual_equipment_maintenance_data.dart';
class MarineManualEquipmentMaintenanceService {
Future<Map<String, dynamic>> submitMaintenanceReport({
required MarineManualEquipmentMaintenanceData data,
required AuthProvider authProvider,
}) async {
// TODO: Implement the full online/offline submission logic here.
print("Submitting Equipment Maintenance Report...");
await Future.delayed(const Duration(seconds: 1));
return {
'success': true,
'message': 'Equipment Maintenance Report submitted (simulation).'
};
}
}

View File

@ -0,0 +1,18 @@
import '../auth_provider.dart';
import '../models/marine_manual_pre_departure_checklist_data.dart';
class MarineManualPreDepartureService {
Future<Map<String, dynamic>> submitChecklist({
required MarineManualPreDepartureChecklistData data,
required AuthProvider authProvider,
}) async {
// TODO: Implement the full online/offline submission logic here,
// similar to your MarineNpeReportService.
print("Submitting Pre-Departure Checklist...");
await Future.delayed(const Duration(seconds: 1));
return {
'success': true,
'message': 'Pre-Departure Checklist submitted (simulation).'
};
}
}

View File

@ -0,0 +1,14 @@
import '../auth_provider.dart';
import '../models/marine_manual_sonde_calibration_data.dart';
class MarineManualSondeCalibrationService {
Future<Map<String, dynamic>> submitCalibration({
required MarineManualSondeCalibrationData data,
required AuthProvider authProvider,
}) async {
// TODO: Implement online/offline submission logic.
print("Submitting Sonde Calibration...");
await Future.delayed(const Duration(seconds: 1));
return {'success': true, 'message': 'Sonde Calibration submitted (simulation).'};
}
}

View File

@ -0,0 +1,327 @@
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:path/path.dart' as p;
import '../auth_provider.dart';
import '../models/marine_manual_npe_report_data.dart';
import 'local_storage_service.dart';
import 'server_config_service.dart';
import 'zipping_service.dart';
import 'submission_api_service.dart';
import 'submission_ftp_service.dart';
import 'telegram_service.dart';
import 'retry_service.dart';
import 'api_service.dart';
class MarineNpeReportService {
final SubmissionApiService _submissionApiService = SubmissionApiService();
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
final ZippingService _zippingService = ZippingService();
final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService();
final DatabaseHelper _dbHelper = DatabaseHelper();
final RetryService _retryService = RetryService();
final TelegramService _telegramService;
MarineNpeReportService(this._telegramService);
Future<Map<String, dynamic>> submitNpeReport({
required MarineManualNpeReportData data,
required AuthProvider authProvider,
String? logDirectory,
}) async {
const String moduleName = 'marine_npe_report';
final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none;
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
if (isOnline && isOfflineSession) {
debugPrint("NPE submission online during offline session. Attempting auto-relogin...");
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
if (transitionSuccess) {
isOfflineSession = false;
} else {
isOnline = false;
}
}
if (isOnline && !isOfflineSession) {
debugPrint("Proceeding with direct ONLINE NPE submission...");
return await _performNpeOnlineSubmission(
data: data,
moduleName: moduleName,
authProvider: authProvider,
logDirectory: logDirectory,
);
} else {
debugPrint("Proceeding with OFFLINE NPE queuing mechanism...");
return await _performNpeOfflineQueuing(
data: data,
moduleName: moduleName,
);
}
}
Future<Map<String, dynamic>> _performNpeOnlineSubmission({
required MarineManualNpeReportData data,
required String moduleName,
required AuthProvider authProvider,
String? logDirectory,
}) async {
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
final imageFilesWithNulls = data.toApiImageFiles();
imageFilesWithNulls.removeWhere((key, value) => value == null);
final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>();
bool anyApiSuccess = false;
Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {};
try {
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/npe/report',
body: data.toApiFormData(),
);
if (apiDataResult['success'] == false &&
(apiDataResult['message'] as String?)?.contains('Unauthorized') == true) {
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
if (reloginSuccess) {
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/npe/report',
body: data.toApiFormData(),
);
}
}
if (apiDataResult['success'] == true) {
anyApiSuccess = true;
data.reportId = apiDataResult['data']?['npe_id']?.toString();
if (data.reportId != null) {
if (finalImageFiles.isNotEmpty) {
apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'marine/npe/images',
fields: {'npe_id': data.reportId!},
files: finalImageFiles,
);
if (apiImageResult['success'] != true) anyApiSuccess = false;
}
} else {
anyApiSuccess = false;
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
}
}
} on SocketException catch (e) {
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': "API submission failed with network error: $e"};
await _retryService.addApiToQueue(endpoint: 'marine/npe/report', method: 'POST', body: data.toApiFormData());
if (finalImageFiles.isNotEmpty && data.reportId != null) {
await _retryService.addApiToQueue(endpoint: 'marine/npe/images', method: 'POST_MULTIPART', fields: {'npe_id': data.reportId!}, files: finalImageFiles);
}
} on TimeoutException catch (e) {
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': "API submission timed out: $e"};
await _retryService.addApiToQueue(endpoint: 'marine/npe/report', method: 'POST', body: data.toApiFormData());
}
Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false;
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} on SocketException catch (e) {
debugPrint("FTP submission failed with network error: $e");
anyFtpSuccess = false;
} on TimeoutException catch (e) {
debugPrint("FTP submission timed out: $e");
anyFtpSuccess = false;
}
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalMessage;
String finalStatus;
if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'NPE Report submitted successfully to all destinations.';
finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'NPE Report sent to API, but some FTP uploads failed and were queued.';
finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission for NPE Report failed and was queued, but files sent to FTP.';
finalStatus = 'L4';
} else {
finalMessage = 'All NPE Report submission attempts failed and have been queued for retry.';
finalStatus = 'L1';
}
await _logAndSave(
data: data,
status: finalStatus,
message: finalMessage,
apiResults: [apiDataResult, apiImageResult],
ftpStatuses: ftpResults['statuses'],
serverName: serverName,
finalImageFiles: finalImageFiles,
logDirectory: logDirectory,
);
if (overallSuccess) {
_handleNpeSuccessAlert(data, authProvider);
}
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
}
Future<Map<String, dynamic>> _performNpeOfflineQueuing({
required MarineManualNpeReportData data,
required String moduleName,
}) async {
final serverConfig = await _serverConfigService.getActiveApiConfig();
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
data.submissionStatus = 'L1';
data.submissionMessage = 'NPE Report queued due to being offline.';
final String? localLogPath = await _localStorageService.saveNpeReportData(data, serverName: serverName);
if (localLogPath == null) {
const message = "Failed to save NPE report to local device storage.";
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {});
return {'success': false, 'message': message};
}
await _retryService.queueTask(
type: 'npe_submission',
payload: {
'module': moduleName,
'localLogPath': localLogPath,
'serverConfig': serverConfig,
},
);
const successMessage = "No internet connection. NPE Report has been saved and queued for upload.";
return {'success': true, 'message': successMessage};
}
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(MarineManualNpeReportData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
final stationCode = data.selectedStation?['man_station_code'] ?? data.selectedStation?['tbl_station_code'] ?? 'CUSTOM_LOC';
final fileTimestamp = "${data.eventDate}_${data.eventTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = '${stationCode}_${fileTimestamp}_NPE';
final Directory? logDirectory = await _localStorageService.getLogDirectory(
serverName: serverName,
module: 'marine',
subModule: 'marine_npe_report',
);
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true);
}
final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
baseFileName: baseFileName,
destinationDir: localSubmissionDir,
);
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(
moduleName: moduleName,
fileToUpload: dataZip,
remotePath: '/${p.basename(dataZip.path)}',
);
}
final imageZip = await _zippingService.createImageZip(
imageFiles: imageFiles.values.toList(),
baseFileName: baseFileName,
destinationDir: localSubmissionDir,
);
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit(
moduleName: moduleName,
fileToUpload: imageZip,
remotePath: '/${p.basename(imageZip.path)}',
);
}
return {
'statuses': <Map<String, dynamic>>[
...(ftpDataResult['statuses'] as List<dynamic>? ?? []),
...(ftpImageResult['statuses'] as List<dynamic>? ?? []),
],
};
}
Future<void> _logAndSave({
required MarineManualNpeReportData data,
required String status,
required String message,
required List<Map<String, dynamic>> apiResults,
required List<Map<String, dynamic>> ftpStatuses,
required String serverName,
required Map<String, File> finalImageFiles,
String? logDirectory,
}) async {
data.submissionStatus = status;
data.submissionMessage = message;
final fileTimestamp = "${data.eventDate}_${data.eventTime}".replaceAll(':', '-').replaceAll(' ', '_');
if (logDirectory != null) {
final Map<String, dynamic> updatedLogData = data.toDbJson();
updatedLogData['submissionStatus'] = status;
updatedLogData['submissionMessage'] = message;
updatedLogData['logDirectory'] = logDirectory;
updatedLogData['serverConfigName'] = serverName;
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses);
final imageFilePaths = data.toApiImageFiles();
imageFilePaths.forEach((key, file) {
if (file != null) updatedLogData[key] = file.path;
});
await _localStorageService.updateNpeLog(updatedLogData);
} else {
await _localStorageService.saveNpeReportData(data, serverName: serverName);
}
final logData = {
'submission_id': data.reportId ?? fileTimestamp,
'module': 'marine',
'type': 'NPE',
'status': data.submissionStatus,
'message': data.submissionMessage,
'report_id': data.reportId,
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toDbJson()),
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()),
'server_name': serverName,
'api_status': jsonEncode(apiResults),
'ftp_status': jsonEncode(ftpStatuses),
};
await _dbHelper.saveSubmissionLog(logData);
}
Future<void> _handleNpeSuccessAlert(MarineManualNpeReportData data, AuthProvider authProvider) async {
try {
final message = data.generateTelegramAlertMessage();
final bool wasSent = await _telegramService.sendAlertImmediately('marine_npe_report', message, authProvider.appSettings);
if (!wasSent) {
await _telegramService.queueMessage('marine_npe_report', message, authProvider.appSettings);
}
} catch (e) {
debugPrint("Failed to handle NPE Telegram alert: $e");
}
}
}

View File

@ -0,0 +1,488 @@
// lib/services/river_manual_triennial_sampling_service.dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:image/image.dart' as img;
import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart';
import 'dart:convert';
import 'package:intl/intl.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import '../auth_provider.dart';
import 'location_service.dart';
import '../models/river_manual_triennial_sampling_data.dart';
import '../bluetooth/bluetooth_manager.dart';
import '../serial/serial_manager.dart';
import 'api_service.dart';
import 'local_storage_service.dart';
import 'server_config_service.dart';
import 'zipping_service.dart';
import 'submission_api_service.dart';
import 'submission_ftp_service.dart';
import 'telegram_service.dart';
import 'retry_service.dart';
class RiverManualTriennialSamplingService {
final LocationService _locationService = LocationService();
final BluetoothManager _bluetoothManager = BluetoothManager();
final SerialManager _serialManager = SerialManager();
final SubmissionApiService _submissionApiService = SubmissionApiService();
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
final DatabaseHelper _dbHelper = DatabaseHelper();
final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService();
final ZippingService _zippingService = ZippingService();
final RetryService _retryService = RetryService();
final TelegramService _telegramService;
final ImagePicker _picker = ImagePicker();
static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
RiverManualTriennialSamplingService(this._telegramService);
Future<Position> getCurrentLocation() => _locationService.getCurrentLocation();
double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2);
Future<File?> pickAndProcessImage(ImageSource source, { required RiverManualTriennialSamplingData data, required String imageInfo, bool isRequired = false, String? stationCode}) async {
try {
final XFile? pickedFile = await _picker.pickImage(
source: source,
imageQuality: 85,
maxWidth: 1024,
);
if (pickedFile == null) {
return null;
}
final bytes = await pickedFile.readAsBytes();
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) {
return null;
}
if (isRequired && originalImage.height > originalImage.width) {
debugPrint("Image rejected: Must be in landscape orientation.");
return null;
}
final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}";
final font = img.arial24;
final textWidth = watermarkTimestamp.length * 12;
img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255),);
img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0));
final tempDir = await getTemporaryDirectory();
final finalStationCode = stationCode ?? 'NA';
final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-');
final newFileName = "${finalStationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
final filePath = p.join(tempDir.path, newFileName);
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
} catch (e) {
debugPrint('Error in pickAndProcessImage: $e');
return null;
}
}
ValueNotifier<BluetoothConnectionState> get bluetoothConnectionState => _bluetoothManager.connectionState;
ValueNotifier<SerialConnectionState> get serialConnectionState => _serialManager.connectionState;
ValueNotifier<String?> get sondeId {
if (_bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected) {
return _bluetoothManager.sondeId;
}
return _serialManager.sondeId;
}
Stream<Map<String, double>> get bluetoothDataStream => _bluetoothManager.dataStream;
Stream<Map<String, double>> get serialDataStream => _serialManager.dataStream;
String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value;
String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value;
Future<bool> requestDevicePermissions() async {
Map<Permission, PermissionStatus> statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.locationWhenInUse,
].request();
if (statuses[Permission.bluetoothScan] == PermissionStatus.granted &&
statuses[Permission.bluetoothConnect] == PermissionStatus.granted) {
return true;
} else {
return false;
}
}
Future<List<BluetoothDevice>> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices();
Future<void> connectToBluetoothDevice(BluetoothDevice device) => _bluetoothManager.connect(device);
void disconnectFromBluetooth() => _bluetoothManager.disconnect();
void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 2));
void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading();
Future<List<UsbDevice>> getAvailableSerialDevices() => _serialManager.getAvailableDevices();
Future<bool> requestUsbPermission(UsbDevice device) async {
try {
return await platform.invokeMethod('requestUsbPermission', {'vid': device.vid, 'pid': device.pid}) ?? false;
} on PlatformException catch (e) {
debugPrint("Failed to request USB permission: '${e.message}'.");
return false;
}
}
Future<void> connectToSerialDevice(UsbDevice device) async {
final bool permissionGranted = await requestUsbPermission(device);
if (permissionGranted) {
await _serialManager.connect(device);
} else {
throw Exception("USB permission was not granted.");
}
}
void disconnectFromSerial() => _serialManager.disconnect();
void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 2));
void stopSerialAutoReading() => _serialManager.stopAutoReading();
void dispose() {
_bluetoothManager.dispose();
_serialManager.dispose();
}
Future<Map<String, dynamic>> submitData({
required RiverManualTriennialSamplingData data,
required List<Map<String, dynamic>>? appSettings,
required AuthProvider authProvider,
String? logDirectory,
}) async {
const String moduleName = 'river_triennial';
final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none;
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
if (isOnline && isOfflineSession) {
debugPrint("River Triennial submission online during offline session. Attempting auto-relogin...");
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
if (transitionSuccess) {
isOfflineSession = false;
} else {
isOnline = false;
}
}
if (isOnline && !isOfflineSession) {
debugPrint("Proceeding with direct ONLINE River Triennial submission...");
return await _performOnlineSubmission(
data: data,
appSettings: appSettings,
moduleName: moduleName,
authProvider: authProvider,
logDirectory: logDirectory,
);
} else {
debugPrint("Proceeding with OFFLINE River Triennial queuing mechanism...");
return await _performOfflineQueuing(
data: data,
moduleName: moduleName,
);
}
}
Future<Map<String, dynamic>> _performOnlineSubmission({
required RiverManualTriennialSamplingData data,
required List<Map<String, dynamic>>? appSettings,
required String moduleName,
required AuthProvider authProvider,
String? logDirectory,
}) async {
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
final imageFilesWithNulls = data.toApiImageFiles();
imageFilesWithNulls.removeWhere((key, value) => value == null);
final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>();
bool anyApiSuccess = false;
Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {};
try {
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'river/triennial/sample',
body: data.toApiFormData(),
);
if (apiDataResult['success'] == false &&
(apiDataResult['message'] as String?)?.contains('Unauthorized') == true) {
debugPrint("API submission failed with Unauthorized. Attempting silent relogin...");
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
if (reloginSuccess) {
debugPrint("Silent relogin successful. Retrying data submission...");
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'river/triennial/sample',
body: data.toApiFormData(),
);
}
}
if (apiDataResult['success'] == true) {
anyApiSuccess = true;
data.reportId = apiDataResult['data']?['r_tri_id']?.toString();
if (data.reportId != null) {
if (finalImageFiles.isNotEmpty) {
apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'river/triennial/images',
fields: {'r_tri_id': data.reportId!},
files: finalImageFiles,
);
if (apiImageResult['success'] != true) {
anyApiSuccess = false;
}
}
} else {
anyApiSuccess = false;
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
}
}
} on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage);
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage};
await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData());
if (finalImageFiles.isNotEmpty && data.reportId != null) {
await _retryService.addApiToQueue(endpoint: 'river/triennial/images', method: 'POST_MULTIPART', fields: {'r_tri_id': data.reportId!}, files: finalImageFiles);
}
} on TimeoutException catch (e) {
final errorMessage = "API submission timed out: $e";
debugPrint(errorMessage);
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage};
await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData());
}
Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false;
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} on SocketException catch (e) {
debugPrint("FTP submission failed with network error: $e");
anyFtpSuccess = false;
} on TimeoutException catch (e) {
debugPrint("FTP submission timed out: $e");
anyFtpSuccess = false;
}
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalMessage;
String finalStatus;
if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'Data submitted successfully to all destinations.';
finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
finalStatus = 'L4';
} else {
finalMessage = 'All submission attempts failed and have been queued for retry.';
finalStatus = 'L1';
}
await _logAndSave(
data: data,
status: finalStatus,
message: finalMessage,
apiResults: [apiDataResult, apiImageResult],
ftpStatuses: ftpResults['statuses'],
serverName: serverName,
logDirectory: logDirectory,
);
if (overallSuccess) {
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty);
}
return {
'status': finalStatus,
'success': overallSuccess,
'message': finalMessage,
'reportId': data.reportId
};
}
Future<Map<String, dynamic>> _performOfflineQueuing({
required RiverManualTriennialSamplingData data,
required String moduleName,
}) async {
final serverConfig = await _serverConfigService.getActiveApiConfig();
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
data.submissionStatus = 'L1';
data.submissionMessage = 'Submission queued due to being offline.';
final String? localLogPath = await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName);
if (localLogPath == null) {
const message = "Failed to save submission to local device storage.";
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName);
return {'status': 'Error', 'success': false, 'message': message};
}
await _retryService.queueTask(
type: 'river_triennial_submission',
payload: {
'module': moduleName,
'localLogPath': p.join(localLogPath, 'data.json'),
'serverConfig': serverConfig,
},
);
const successMessage = "No internet connection. Submission has been saved and queued for upload.";
return {'status': 'Queued', 'success': true, 'message': successMessage};
}
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverManualTriennialSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN';
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = "${stationCode}_$fileTimestamp";
final Directory? logDirectory = await _localStorageService.getLogDirectory(
serverName: serverName,
module: 'river',
subModule: 'river_triennial_sampling',
);
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true);
}
final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': jsonEncode(data.toApiFormData())}, // Assuming similar structure is needed
baseFileName: baseFileName,
destinationDir: localSubmissionDir,
);
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(
moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}');
}
final imageZip = await _zippingService.createImageZip(
imageFiles: imageFiles.values.toList(),
baseFileName: baseFileName,
destinationDir: localSubmissionDir,
);
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit(
moduleName: moduleName, fileToUpload: imageZip, remotePath: '/${p.basename(imageZip.path)}');
}
return {
'statuses': <Map<String, dynamic>>[
...(ftpDataResult['statuses'] as List),
...(ftpImageResult['statuses'] as List),
],
};
}
Future<void> _logAndSave({
required RiverManualTriennialSamplingData data,
required String status,
required String message,
required List<Map<String, dynamic>> apiResults,
required List<Map<String, dynamic>> ftpStatuses,
required String serverName,
String? logDirectory,
}) async {
data.submissionStatus = status;
data.submissionMessage = message;
if (logDirectory != null) {
final Map<String, dynamic> updatedLogData = data.toMap();
updatedLogData['logDirectory'] = logDirectory;
updatedLogData['serverConfigName'] = serverName;
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses);
final imageFilePaths = data.toApiImageFiles();
imageFilePaths.forEach((key, file) {
if (file != null) {
updatedLogData[key] = file.path;
}
});
await _localStorageService.updateRiverManualTriennialLog(updatedLogData);
} else {
await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName);
}
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
final logData = {
'submission_id': data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString(),
'module': 'river', 'type': data.samplingType ?? 'Triennial', 'status': status,
'message': message, 'report_id': data.reportId, 'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()), 'image_data': jsonEncode(imagePaths),
'server_name': serverName, 'api_status': jsonEncode(apiResults), 'ftp_status': jsonEncode(ftpStatuses),
};
await _dbHelper.saveSubmissionLog(logData);
}
Future<void> _handleSuccessAlert(RiverManualTriennialSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'N/A';
final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
final submitter = data.firstSamplerName ?? 'N/A';
final sondeID = data.sondeId ?? 'N/A';
final distanceKm = data.distanceDifferenceInKm ?? 0;
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A';
final buffer = StringBuffer()
..writeln('✅ *River Triennial Sample ${submissionType} Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submitted:* $submissionDate')
..writeln('*Submitted by User:* $submitter')
..writeln('*Sonde ID:* $sondeID')
..writeln('*Status of Submission:* Successful');
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
buffer
..writeln()
..writeln('🔔 *Alert:*')
..writeln('*Distance from station:* $distanceMeters meters');
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
}
final String message = buffer.toString();
final bool wasSent = await _telegramService.sendAlertImmediately('river_triennial', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('river_triennial', message, appSettings);
}
} catch (e) {
debugPrint("Failed to handle River Triennial Telegram alert: $e");
}
}
}

View File

@ -862,6 +862,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" version: "0.7.4"
toggle_switch:
dependency: "direct main"
description:
name: toggle_switch
sha256: dca04512d7c23ed320d6c5ede1211a404f177d54d353bf785b07d15546a86ce5
url: "https://pub.dev"
source: hosted
version: "2.3.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View File

@ -35,6 +35,7 @@ dependencies:
url_launcher: ^6.2.6 url_launcher: ^6.2.6
flutter_pdfview: ^1.3.2 flutter_pdfview: ^1.3.2
dio: ^5.4.3+1 dio: ^5.4.3+1
toggle_switch: ^2.3.0
# --- Device & Hardware Access --- # --- Device & Hardware Access ---
image_picker: ^1.0.7 image_picker: ^1.0.7