add telegram alert for marine report module

This commit is contained in:
ALim Aidrus 2025-11-23 22:07:48 +08:00
parent 05d29bc107
commit 033391b770
14 changed files with 821 additions and 344 deletions

View File

@ -132,9 +132,10 @@ void main() async {
// --- START: Instantiate Marine Report Services ---
final MarineNpeReportService marineNpeService = MarineNpeReportService(telegramService);
final MarineManualPreDepartureService marinePreDepartureService = MarineManualPreDepartureService(apiService);
final MarineManualSondeCalibrationService marineSondeCalibrationService = MarineManualSondeCalibrationService(apiService);
final MarineManualEquipmentMaintenanceService marineEquipmentMaintenanceService = MarineManualEquipmentMaintenanceService(apiService);
// FIX: Added telegramService to constructors below
final MarineManualPreDepartureService marinePreDepartureService = MarineManualPreDepartureService(apiService, telegramService);
final MarineManualSondeCalibrationService marineSondeCalibrationService = MarineManualSondeCalibrationService(apiService, telegramService);
final MarineManualEquipmentMaintenanceService marineEquipmentMaintenanceService = MarineManualEquipmentMaintenanceService(apiService, telegramService);
// --- END: Instantiate Marine Report Services ---
telegramService.setApiService(apiService);

View File

@ -4,9 +4,7 @@ import 'dart:convert';
class MarineManualEquipmentMaintenanceData {
int? conductedByUserId;
// --- START: ADDED FIELD ---
String? conductedByUserName;
// --- END: ADDED FIELD ---
String? maintenanceDate;
String? lastMaintenanceDate;
String? scheduleMaintenance;
@ -30,11 +28,9 @@ class MarineManualEquipmentMaintenanceData {
String? vanDornNewSerial;
Map<String, Map<String, String>> vanDornReplacements = {};
// --- START: Added Fields ---
String? submissionStatus;
String? submissionMessage;
String? reportId;
// --- END: Added Fields ---
// Constructor to initialize maps
@ -76,7 +72,6 @@ class MarineManualEquipmentMaintenanceData {
vanDornReplacements[item] = {'Last Date': '', 'New Date': ''});
}
// --- START: ADDED METHOD ---
/// Creates a JSON object for offline database storage.
Map<String, dynamic> toDbJson() {
return {
@ -104,7 +99,6 @@ class MarineManualEquipmentMaintenanceData {
'reportId': reportId,
};
}
// --- END: ADDED METHOD ---
// MODIFIED: This method now builds the complex nested structure the PHP controller expects.
Map<String, dynamic> toApiFormData() {
@ -178,4 +172,32 @@ class MarineManualEquipmentMaintenanceData {
'van_dorn_replacements': vanDornReplacementsList,
};
}
// --- START: ADDED TELEGRAM METHOD ---
String generateTelegramAlertMessage() {
final buffer = StringBuffer()
..writeln('🛠 *Equipment Maintenance Report Submitted*')
..writeln()
..writeln('*Conducted By:* ${conductedByUserName ?? "N/A"}')
..writeln('*Date:* ${maintenanceDate ?? "N/A"}')
..writeln('*Time:* ${timeStart ?? "-"} to ${timeEnd ?? "-"}')
..writeln('*Location:* ${location ?? "N/A"}')
..writeln('*Type:* ${isReplacement ? "Replacement" : "Routine Check"}')
..writeln('*Schedule:* ${scheduleMaintenance ?? "N/A"}');
if (ysiSondeComments != null && ysiSondeComments!.isNotEmpty) {
buffer.writeln('\n*YSI Remarks:* $ysiSondeComments');
}
if (vanDornComments != null && vanDornComments!.isNotEmpty) {
buffer.writeln('\n*Van Dorn Remarks:* $vanDornComments');
}
buffer
..writeln()
..writeln('*Status of Submission:* Successful');
return buffer.toString();
}
// --- END: ADDED TELEGRAM METHOD ---
}

View File

@ -113,16 +113,29 @@ class MarineManualNpeReportData {
add('npe_time', eventTime);
add('first_sampler_user_id', firstSamplerUserId);
// add('npe_source_origin', sourceOrigin); // Disabled to prevent SQL error
// --- FIX START: Correctly map 'station_id' to specific API fields ---
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']);
// Check if it is a Manual Station (has 'man_station_code')
if (selectedStation!.containsKey('man_station_code')) {
// Map generic 'station_id' to API's expected 'man_station_id'
add('man_station_id', selectedStation!['station_id'] ?? selectedStation!['man_station_id']);
add('station_code', selectedStation!['man_station_code']);
add('station_name', selectedStation!['man_station_name']);
}
// Check if it is a Tarball Station (has 'tbl_station_code')
else if (selectedStation!.containsKey('tbl_station_code')) {
// Map generic 'station_id' to API's expected 'tbl_station_id'
add('tbl_station_id', selectedStation!['station_id'] ?? selectedStation!['tbl_station_id']);
add('station_code', selectedStation!['tbl_station_code']);
add('station_name', selectedStation!['tbl_station_name']);
}
} else {
add('station_name', locationDescription);
// New Location: Use description
add('location_description', locationDescription);
add('state_name', stateName);
}
// --- FIX END ---
add('latitude', latitude);
add('longitude', longitude);
add('npe_oxygen_sat', oxygenSaturation);
@ -166,7 +179,6 @@ class MarineManualNpeReportData {
String generateTelegramAlertMessage() {
String locationDesc;
// --- START: MODIFIED to include Station Code + Name ---
if (selectedStation != null) {
final code = selectedStation!['man_station_code'] ?? selectedStation!['tbl_station_code'] ?? 'N/A';
final name = selectedStation!['man_station_name'] ?? selectedStation!['tbl_station_name'] ?? 'N/A';
@ -174,7 +186,6 @@ class MarineManualNpeReportData {
} else {
locationDesc = locationDescription ?? 'A custom location';
}
// --- END: MODIFIED ---
final buffer = StringBuffer()
..writeln('🚨 *Notification of Pollution Event (NPE) Submitted:*')

View File

@ -6,9 +6,7 @@ class MarineManualPreDepartureChecklistData {
String? reporterName;
int? reporterUserId;
String? submissionDate;
// --- START: ADDED FIELD ---
String? location;
// --- END: ADDED FIELD ---
// Key: Item description, Value: true if 'Yes', false if 'No'
Map<String, bool> checklistItems = {};
@ -16,22 +14,19 @@ class MarineManualPreDepartureChecklistData {
// Key: Item description, Value: Remarks text
Map<String, String> remarks = {};
// --- START: Added Fields ---
String? submissionStatus;
String? submissionMessage;
String? reportId;
// --- END: Added Fields ---
MarineManualPreDepartureChecklistData();
// --- START: ADDED METHOD ---
/// Creates a JSON object for offline database storage.
Map<String, dynamic> toDbJson() {
return {
'reporterName': reporterName,
'reporterUserId': reporterUserId,
'submissionDate': submissionDate,
'location': location, // <-- ADDED
'location': location,
'checklistItems': checklistItems,
'remarks': remarks,
'submissionStatus': submissionStatus,
@ -39,7 +34,6 @@ class MarineManualPreDepartureChecklistData {
'reportId': reportId,
};
}
// --- END: ADDED METHOD ---
// MODIFIED: This method now builds the nested array structure the PHP controller expects.
Map<String, dynamic> toApiFormData() {
@ -57,12 +51,42 @@ class MarineManualPreDepartureChecklistData {
});
return {
'reporter_user_id': reporterUserId.toString(), // The controller gets this from auth, but good to send.
'reporter_user_id': reporterUserId.toString(),
'submission_date': submissionDate,
// Note: 'location' is not sent to the API in this method,
// but it will be saved in the local log via toDbJson().
// If the API needs it, it must be added here.
// Note: 'location' is not sent to the API in this method, but saved in db.json
'items': itemsList, // Send the formatted list
};
}
// --- START: ADDED TELEGRAM METHOD ---
String generateTelegramAlertMessage() {
int checkedCount = checklistItems.values.where((v) => v == true).length;
int totalCount = checklistItems.length;
final buffer = StringBuffer()
..writeln('🚤 *Pre-Departure Checklist Submitted*')
..writeln()
..writeln('*Reporter:* ${reporterName ?? "N/A"}')
..writeln('*Date:* ${submissionDate ?? "N/A"}')
..writeln('*Location:* ${location ?? "N/A"}')
..writeln('*Items Checked:* $checkedCount / $totalCount');
// Add remarks only if they exist for specific items
final activeRemarks = remarks.entries
.where((e) => e.value.trim().isNotEmpty)
.map((e) => "- ${e.key}: ${e.value}")
.toList();
if (activeRemarks.isNotEmpty) {
buffer.writeln('\n*Specific Remarks:*');
buffer.writeAll(activeRemarks, '\n');
}
buffer
..writeln()
..writeln('*Status of Submission:* Successful');
return buffer.toString();
}
// --- END: ADDED TELEGRAM METHOD ---
}

View File

@ -2,9 +2,7 @@
class MarineManualSondeCalibrationData {
int? calibratedByUserId;
// --- START: ADDED FIELD ---
String? calibratedByUserName;
// --- END: ADDED FIELD ---
// Header fields from PDF
String? sondeSerialNumber;
@ -22,7 +20,7 @@ class MarineManualSondeCalibrationData {
double? ph10Before;
double? ph10After;
// Other parameters (Mv removed per PDF)
// Other parameters
double? condBefore;
double? condAfter;
double? doBefore;
@ -33,16 +31,13 @@ class MarineManualSondeCalibrationData {
double? turbidity124After;
String? calibrationStatus;
String? remarks; // Matches "COMMENT/OBSERVATION"
String? remarks;
// --- START: Added Fields ---
String? submissionStatus;
String? submissionMessage;
String? reportId;
// --- END: Added Fields ---
Map<String, dynamic> toApiFormData() {
// This flat structure matches MarineSondeCalibrationController.php
return {
'calibrated_by_user_id': calibratedByUserId.toString(),
'sonde_serial_number': sondeSerialNumber,
@ -70,12 +65,11 @@ class MarineManualSondeCalibrationData {
};
}
// --- START: ADDED toDbJson METHOD ---
/// Creates a JSON object for offline database storage.
Map<String, dynamic> toDbJson() {
return {
'calibratedByUserId': calibratedByUserId,
'calibratedByUserName': calibratedByUserName, // <-- ADDED
'calibratedByUserName': calibratedByUserName,
'sondeSerialNumber': sondeSerialNumber,
'firmwareVersion': firmwareVersion,
'korVersion': korVersion,
@ -103,5 +97,31 @@ class MarineManualSondeCalibrationData {
'reportId': reportId,
};
}
// --- END: ADDED toDbJson METHOD ---
// --- START: ADDED TELEGRAM METHOD ---
String generateTelegramAlertMessage() {
final buffer = StringBuffer()
..writeln('⚖️ *Sonde Calibration Report Submitted*')
..writeln()
..writeln('*Calibrated By:* ${calibratedByUserName ?? "N/A"}')
..writeln('*Location:* ${location ?? "N/A"}')
..writeln('*Sonde Serial:* ${sondeSerialNumber ?? "N/A"}')
..writeln('*Start:* ${startDateTime ?? "N/A"}')
..writeln('*End:* ${endDateTime ?? "N/A"}');
if (calibrationStatus != null && calibrationStatus!.isNotEmpty) {
buffer.writeln('*Result:* $calibrationStatus');
}
if (remarks != null && remarks!.isNotEmpty) {
buffer.writeln('\n*Remarks:* $remarks');
}
buffer
..writeln()
..writeln('*Status of Submission:* Successful');
return buffer.toString();
}
// --- END: ADDED TELEGRAM METHOD ---
}

View File

@ -368,12 +368,29 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> with WidgetsB
if (_isPickingImage) return;
setState(() => _isPickingImage = true);
// --- FIX START: Extract correct code and name for proper filename generation ---
String stationName = _locationController.text;
String stationCode = 'NA';
// Check _npeData first as it is the source of truth for the selected station
if (_npeData.selectedStation != null) {
stationCode = _npeData.selectedStation!['man_station_code'] ??
_npeData.selectedStation!['tbl_station_code'] ??
'NA';
}
// --- FIX END ---
final watermarkData = InSituSamplingData()
..samplingDate = _eventDateTimeController.text.split(' ')[0]
..samplingTime = _eventDateTimeController.text.split(' ').length > 1 ? _eventDateTimeController.text.split(' ')[1] : ''
..currentLatitude = _latController.text
..currentLongitude = _longController.text
..selectedStation = {'man_station_name': _locationController.text};
// --- FIX: Pass BOTH code and name to the image service ---
..selectedStation = {
'man_station_name': stationName,
'man_station_code': stationCode,
'tbl_station_code': stationCode, // Fallback depending on processor logic
};
final file = await _samplingService.pickAndProcessImage(
source,

View File

@ -249,12 +249,29 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> with Widget
if (_isPickingImage) return;
setState(() => _isPickingImage = true);
// --- FIX START: Extract correct code and name for proper filename generation ---
String stationName = _locationController.text;
String stationCode = 'NA';
// Check _npeData first as it is the source of truth for the selected station
if (_npeData.selectedStation != null) {
stationCode = _npeData.selectedStation!['man_station_code'] ??
_npeData.selectedStation!['tbl_station_code'] ??
'NA';
}
// --- FIX END ---
final watermarkData = InSituSamplingData()
..samplingDate = _eventDateTimeController.text.split(' ')[0]
..samplingTime = _eventDateTimeController.text.split(' ').length > 1 ? _eventDateTimeController.text.split(' ')[1] : ''
..currentLatitude = _latController.text
..currentLongitude = _longController.text
..selectedStation = {'man_station_name': _locationController.text};
// --- FIX: Pass BOTH code and name to the image service ---
..selectedStation = {
'man_station_name': stationName,
'man_station_code': stationCode,
'tbl_station_code': stationCode, // Fallback depending on processor logic
};
final file = await _samplingService.pickAndProcessImage(
source,

View File

@ -234,12 +234,28 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> with Widget
if (_isPickingImage) return;
setState(() => _isPickingImage = true);
// --- FIX START: Sanitize Location Description for Filename ---
String rawLocation = _locationController.text.trim();
String filenamePrefix;
if (rawLocation.isNotEmpty) {
// Replace spaces with underscores for the filename
filenamePrefix = rawLocation.replaceAll(' ', '_');
} else {
// Fallback if the user hasn't typed anything yet
filenamePrefix = 'NEW_LOCATION';
}
// --- FIX END ---
final watermarkData = InSituSamplingData()
..samplingDate = _eventDateTimeController.text.split(' ')[0]
..samplingTime = _eventDateTimeController.text.split(' ').length > 1 ? _eventDateTimeController.text.split(' ')[1] : ''
..currentLatitude = _latController.text
..currentLongitude = _longController.text
..selectedStation = {'man_station_name': _locationController.text};
..selectedStation = {
'man_station_name': rawLocation, // Keep spaces for the Watermark text
'man_station_code': filenamePrefix, // Use underscores for the Filename
};
final file = await _samplingService.pickAndProcessImage(
source,
@ -618,7 +634,7 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> with Widget
Widget _buildNPEImagePicker({
required String title,
File? imageFile,
imageFile,
required VoidCallback onClear,
required int imageNumber,
required TextEditingController remarkController, // ADDED

View File

@ -37,6 +37,7 @@ class _SubmissionPreferencesSettingsScreenState
{'key': 'marine_tarball', 'name': 'Marine Tarball'},
{'key': 'marine_in_situ', 'name': 'Marine In-Situ'},
{'key': 'marine_investigative', 'name': 'Marine Investigative'},
{'key': 'marine_report', 'name': 'Marine Report'},
{'key': 'river_in_situ', 'name': 'River In-Situ'},
{'key': 'river_triennial', 'name': 'River Triennial'},
{'key': 'river_investigative', 'name': 'River Investigative'},

View File

@ -5,6 +5,7 @@ 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_equipment_maintenance_data.dart';
@ -14,40 +15,52 @@ import 'package:environment_monitoring_app/services/local_storage_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/submission_api_service.dart';
import 'package:environment_monitoring_app/services/submission_ftp_service.dart';
import 'package:environment_monitoring_app/services/zipping_service.dart';
import 'user_preferences_service.dart'; // ADDED
import 'telegram_service.dart'; // --- ADDED IMPORT ---
import 'base_api_service.dart'; // Import for SessionExpiredException
class MarineManualEquipmentMaintenanceService {
// Use the new generic submission service
final SubmissionApiService _submissionApiService = SubmissionApiService();
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
final ZippingService _zippingService = ZippingService();
final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService();
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
final DatabaseHelper _dbHelper = DatabaseHelper();
final RetryService _retryService = RetryService();
final TelegramService _telegramService; // --- ADDED FIELD ---
// Keep ApiService for getPreviousMaintenanceLogs
final ApiService _apiService;
MarineManualEquipmentMaintenanceService(this._apiService);
// *** START: Renamed this method ***
// --- MODIFIED CONSTRUCTOR ---
MarineManualEquipmentMaintenanceService(this._apiService, this._telegramService);
/// Fetches all Maintenance logs stored locally on the device.
Future<List<Map<String, dynamic>>> getLocalMaintenanceLogs() async {
return await _localStorageService.getAllEquipmentMaintenanceLogs();
}
/// Main submission method with online/offline branching logic
Future<Map<String, dynamic>> submitMaintenanceReport({
// *** END: Renamed this method ***
required MarineManualEquipmentMaintenanceData data,
required AuthProvider authProvider,
List<Map<String, dynamic>>? appSettings, // Added for consistency
BuildContext? context, // Added for consistency
List<Map<String, dynamic>>? appSettings,
BuildContext? context,
String? logDirectory,
}) async {
const String moduleName = 'marine_equipment_maintenance';
// Unified module name for preferences
const String moduleName = 'marine_report';
// --- START: ADDED LINE ---
// Populate the user name from the AuthProvider
data.conductedByUserName = authProvider.profileData?['first_name'] as String?;
// --- END: ADDED LINE ---
final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none;
bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
bool isOfflineSession = authProvider.isLoggedIn &&
(authProvider.profileData?['token']
?.startsWith("offline-session-") ??
@ -93,56 +106,107 @@ class MarineManualEquipmentMaintenanceService {
(await _serverConfigService.getActiveApiConfig())?['config_name']
as String? ??
'Default';
Map<String, dynamic> apiResult;
bool anyApiSuccess = false;
Map<String, dynamic> apiResult = {};
try {
apiResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/maintenance', // Endpoint from marine_api_service.dart
body: data.toApiFormData(),
);
} on SessionExpiredException {
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
if (reloginSuccess) {
// 1. API Submission
final pref = await _userPreferencesService.getModulePreference(moduleName);
bool isApiEnabled = pref?['is_api_enabled'] ?? true;
if (isApiEnabled) {
try {
apiResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/maintenance',
endpoint: 'marine/maintenance', // Endpoint from marine_api_service.dart
body: data.toApiFormData(),
);
} else {
if (apiResult['success'] == false && (apiResult['message'] as String?)?.contains('Unauthorized') == true) {
// Handle silent relogin
if (await authProvider.attemptSilentRelogin()) {
apiResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/maintenance',
body: data.toApiFormData(),
);
}
}
if (apiResult['success'] == true) {
anyApiSuccess = true;
data.reportId = apiResult['data']?['maintenance_id']?.toString();
}
} on SocketException catch (e) {
apiResult = {
'success': false,
'message': 'Session expired. Please log in again.'
'message': "API submission failed with network error: $e"
};
// Queue API manually if failed
await _retryService.addApiToQueue(endpoint: 'marine/maintenance', method: 'POST', body: data.toApiFormData());
} on TimeoutException catch (e) {
apiResult = {
'success': false,
'message': "API submission timed out: $e"
};
await _retryService.addApiToQueue(endpoint: 'marine/maintenance', method: 'POST', body: data.toApiFormData());
} catch (e) {
apiResult = {
'success': false,
'message': 'An unexpected error occurred: $e'
};
}
} on SocketException catch (e) {
apiResult = {
'success': false,
'message': "API submission failed with network error: $e"
};
// submission_api_service will queue this failure
} on TimeoutException catch (e) {
apiResult = {
'success': false,
'message': "API submission timed out: $e"
};
// submission_api_service will queue this failure
} catch (e) {
apiResult = {
'success': false,
'message': 'An unexpected error occurred: $e'
};
} else {
anyApiSuccess = true; // Treat as success if disabled by user
}
// Log the final result
final bool overallSuccess = apiResult['success'] == true;
final String finalMessage =
apiResult['message'] ?? (overallSuccess ? 'Submission successful.' : 'Submission failed.');
final String finalStatus = overallSuccess ? 'S4' : 'L1'; // S4 = API Success
// 2. FTP Submission (Data Zip Only - No Images for Maintenance)
Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false;
if (overallSuccess) {
// Assuming the API returns an ID. Adjust 'maintenance_id' if needed.
data.reportId = apiResult['data']?['maintenance_id']?.toString();
bool isFtpEnabled = pref?['is_ftp_enabled'] ?? true;
// Check status to avoid duplicate uploads (L4 = API Fail, FTP Success; S4 = Both Success)
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
// Check active FTPs
final enabledFtpConfigs = await _userPreferencesService.getEnabledFtpConfigsForModule(moduleName);
if (!isFtpEnabled) {
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user.', 'success': true}]};
anyFtpSuccess = true;
} else if (previousFtpSuccess) {
debugPrint("FTP submission skipped: Already successful.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful.', 'success': true}]};
anyFtpSuccess = true;
} else if (enabledFtpConfigs.isEmpty) {
debugPrint("FTP submission skipped: No active FTP configurations found for $moduleName.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'No active FTP servers.', 'success': true}]};
anyFtpSuccess = true;
} else {
try {
ftpResults = await _generateAndUploadFtpFiles(data, serverName, moduleName);
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) {
debugPrint("FTP submission error: $e");
anyFtpSuccess = false;
}
}
// 3. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalMessage;
String finalStatus;
if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'Maintenance Report submitted successfully to all destinations.';
finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Maintenance Report sent to API, but FTP upload failed.';
finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed, but file sent to FTP.';
finalStatus = 'L4';
} else {
finalMessage = apiResult['message'] ?? 'All submission attempts failed.';
finalStatus = 'L1';
}
await _logAndSave(
@ -150,11 +214,18 @@ class MarineManualEquipmentMaintenanceService {
status: finalStatus,
message: finalMessage,
apiResult: apiResult,
ftpStatuses: ftpResults['statuses'],
serverName: serverName,
logDirectory: logDirectory,
);
return apiResult;
// --- START: ADDED TELEGRAM ALERT ---
if (overallSuccess) {
_handleSuccessAlert(data, authProvider);
}
// --- END: ADDED TELEGRAM ALERT ---
return {'success': overallSuccess, 'message': finalMessage};
}
/// Handles saving the submission to local storage and queuing for retry.
@ -181,12 +252,13 @@ class MarineManualEquipmentMaintenanceService {
status: 'Error',
message: message,
apiResult: {},
ftpStatuses: [],
serverName: serverName);
return {'success': false, 'message': message};
}
await _retryService.queueTask(
type: 'equipment_maintenance_submission', // New task type
type: 'equipment_maintenance_submission',
payload: {
'module': moduleName,
'localLogPath': localLogPath,
@ -199,12 +271,40 @@ class MarineManualEquipmentMaintenanceService {
return {'success': true, 'message': successMessage};
}
/// Generates zip files and uploads them via FTP.
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(MarineManualEquipmentMaintenanceData data, String serverName, String moduleName) async {
final timestamp = data.maintenanceDate ?? DateTime.now().toIso8601String();
final baseFileName = 'maintenance_$timestamp';
final Directory? logDirectory = await _localStorageService.getLogDirectory(serverName: serverName, module: 'marine', subModule: 'marine_equipment_maintenance',);
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, 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, // Added ! to ensure non-nullable
remotePath: '/${p.basename(dataZip.path)}'
);
}
return {'statuses': ftpDataResult['statuses'] ?? []};
}
/// Logs the submission to the local file system and the central SQL database.
Future<void> _logAndSave({
required MarineManualEquipmentMaintenanceData data,
required String status,
required String message,
required Map<String, dynamic> apiResult,
required List<Map<String, dynamic>> ftpStatuses,
required String serverName,
String? logDirectory,
}) async {
@ -213,24 +313,20 @@ class MarineManualEquipmentMaintenanceService {
final fileTimestamp = data.maintenanceDate ?? DateTime.now().toIso8601String();
// Use the new toDbJson() method to get ALL data for logging
final Map<String, dynamic> logDataMap = data.toDbJson();
// Add submission-specific metadata
logDataMap['api_status'] = jsonEncode(apiResult);
logDataMap['ftp_status'] = jsonEncode(ftpStatuses);
logDataMap['serverConfigName'] = serverName;
if (logDirectory != null) {
// This is an update to an existing log file
// --- START: MODIFIED BLOCK ---
final Map<String, dynamic> updatedLogData = data.toDbJson();
// Add metadata
updatedLogData['submissionStatus'] = status;
updatedLogData['submissionMessage'] = message;
updatedLogData['logDirectory'] = logDirectory;
updatedLogData['serverConfigName'] = serverName;
updatedLogData['api_status'] = jsonEncode(apiResult);
// All other fields (ysiSondeChecks, etc.) are now in toDbJson()
// --- END: MODIFIED BLOCK ---
// This method is added to LocalStorageService
await _localStorageService.updateEquipmentMaintenanceLog(updatedLogData);
logDataMap['logDirectory'] = logDirectory;
await _localStorageService.updateEquipmentMaintenanceLog(logDataMap);
} else {
// This is a new log
// This method is added to LocalStorageService
await _localStorageService.saveEquipmentMaintenanceData(data, serverName: serverName);
}
@ -242,19 +338,16 @@ class MarineManualEquipmentMaintenanceService {
'message': data.submissionMessage,
'report_id': data.reportId,
'created_at': DateTime.now().toIso8601String(),
// --- START: MODIFIED LINE ---
'form_data': jsonEncode(data.toDbJson()), // Log the full DbJson
// --- END: MODIFIED LINE ---
'image_data': null, // No images
'server_name': serverName,
'api_status': jsonEncode(apiResult),
'ftp_status': null, // No FTP
'ftp_status': jsonEncode(ftpStatuses),
};
await _dbHelper.saveSubmissionLog(logData);
}
/// Fetches previous maintenance logs to populate the form
/// THIS METHOD IS UNCHANGED as it's a simple GET request.
Future<Map<String, dynamic>> getPreviousMaintenanceLogs({
required AuthProvider authProvider,
}) async {
@ -275,4 +368,18 @@ class MarineManualEquipmentMaintenanceService {
return {'success': false, 'message': 'An unexpected error occurred: $e'};
}
}
// --- START: NEW TELEGRAM ALERT METHOD ---
Future<void> _handleSuccessAlert(MarineManualEquipmentMaintenanceData data, AuthProvider authProvider) async {
try {
final message = data.generateTelegramAlertMessage();
// Using 'marine_npe_report' ID/module config as requested
if (!await _telegramService.sendAlertImmediately('marine_npe_report', message, authProvider.appSettings)) {
await _telegramService.queueMessage('marine_npe_report', message, authProvider.appSettings);
}
} catch (e) {
debugPrint("Telegram Alert Error (Maintenance): $e");
}
}
// --- END: NEW TELEGRAM ALERT METHOD ---
}

View File

@ -5,6 +5,7 @@ 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_pre_departure_checklist_data.dart';
@ -14,44 +15,52 @@ import 'package:environment_monitoring_app/services/local_storage_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/submission_api_service.dart';
import 'package:environment_monitoring_app/services/submission_ftp_service.dart';
import 'package:environment_monitoring_app/services/zipping_service.dart';
import 'user_preferences_service.dart'; // ADDED
import 'telegram_service.dart'; // --- ADDED IMPORT ---
import 'base_api_service.dart'; // Import for SessionExpiredException
class MarineManualPreDepartureService {
// Use the new generic submission service
final SubmissionApiService _submissionApiService = SubmissionApiService();
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
final ZippingService _zippingService = ZippingService();
final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService();
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
final DatabaseHelper _dbHelper = DatabaseHelper();
final RetryService _retryService = RetryService();
final TelegramService _telegramService; // --- ADDED FIELD ---
// The ApiService is kept only if other non-submission methods need it.
// For this refactor, we'll remove it from the constructor.
// final ApiService _apiService;
// MarineManualPreDepartureService(this._apiService);
MarineManualPreDepartureService(ApiService apiService); // Keep constructor signature for main.dart
// --- MODIFIED CONSTRUCTOR ---
MarineManualPreDepartureService(ApiService apiService, this._telegramService);
/// Fetches all Checklist logs stored locally on the device.
Future<List<Map<String, dynamic>>> getLocalChecklistLogs() async {
return await _localStorageService.getAllPreDepartureLogs();
}
/// Main submission method with online/offline branching logic
Future<Map<String, dynamic>> submitChecklist({
required MarineManualPreDepartureChecklistData data,
required AuthProvider authProvider,
List<Map<String, dynamic>>? appSettings, // Added for consistency
BuildContext? context, // Added for consistency
List<Map<String, dynamic>>? appSettings,
BuildContext? context,
String? logDirectory,
}) async {
const String moduleName = 'marine_pre_departure';
// Unified module name for preferences
const String moduleName = 'marine_report';
// --- START: ADDED LINE ---
// Populate the user name from the AuthProvider
data.reporterName = authProvider.profileData?['first_name'] as String?;
// --- END: ADDED LINE ---
final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none;
bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
bool isOfflineSession = authProvider.isLoggedIn &&
(authProvider.profileData?['token']
?.startsWith("offline-session-") ??
false);
(authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
if (isOnline && isOfflineSession) {
debugPrint(
@ -93,56 +102,108 @@ class MarineManualPreDepartureService {
(await _serverConfigService.getActiveApiConfig())?['config_name']
as String? ??
'Default';
Map<String, dynamic> apiResult;
bool anyApiSuccess = false;
Map<String, dynamic> apiResult = {};
try {
apiResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/checklist', // Endpoint from marine_api_service.dart
body: data.toApiFormData(),
);
} on SessionExpiredException {
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
if (reloginSuccess) {
// 1. API Submission
// Check if API is enabled in preferences
final pref = await _userPreferencesService.getModulePreference(moduleName);
bool isApiEnabled = pref?['is_api_enabled'] ?? true;
if (isApiEnabled) {
try {
apiResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/checklist',
endpoint: 'marine/checklist', // Endpoint from marine_api_service.dart
body: data.toApiFormData(),
);
} else {
if (apiResult['success'] == false && (apiResult['message'] as String?)?.contains('Unauthorized') == true) {
// Handle silent relogin
if (await authProvider.attemptSilentRelogin()) {
apiResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/checklist',
body: data.toApiFormData(),
);
}
}
if (apiResult['success'] == true) {
anyApiSuccess = true;
data.reportId = apiResult['data']?['checklist_id']?.toString();
}
} on SocketException catch (e) {
apiResult = {
'success': false,
'message': 'Session expired. Please log in again.'
'message': "API submission failed with network error: $e"
};
// Queue API manually
await _retryService.addApiToQueue(endpoint: 'marine/checklist', method: 'POST', body: data.toApiFormData());
} on TimeoutException catch (e) {
apiResult = {
'success': false,
'message': "API submission timed out: $e"
};
await _retryService.addApiToQueue(endpoint: 'marine/checklist', method: 'POST', body: data.toApiFormData());
} catch (e) {
apiResult = {
'success': false,
'message': 'An unexpected error occurred: $e'
};
}
} on SocketException catch (e) {
apiResult = {
'success': false,
'message': "API submission failed with network error: $e"
};
// submission_api_service will queue this failure
} on TimeoutException catch (e) {
apiResult = {
'success': false,
'message': "API submission timed out: $e"
};
// submission_api_service will queue this failure
} catch (e) {
apiResult = {
'success': false,
'message': 'An unexpected error occurred: $e'
};
} else {
anyApiSuccess = true; // Treated as success if disabled by user
}
// Log the final result
final bool overallSuccess = apiResult['success'] == true;
final String finalMessage =
apiResult['message'] ?? (overallSuccess ? 'Submission successful.' : 'Submission failed.');
final String finalStatus = overallSuccess ? 'S4' : 'L1'; // S4 = API Success
// 2. FTP Submission (Data Zip Only - No Images for Checklist)
Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false;
if (overallSuccess) {
// Assuming the API returns an ID. Adjust 'checklist_id' if needed.
data.reportId = apiResult['data']?['checklist_id']?.toString();
bool isFtpEnabled = pref?['is_ftp_enabled'] ?? true;
// Check if this record was already successfully sent to FTP (L4 or S4 status)
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
// Check if there are any active FTP configs for this module
final enabledFtpConfigs = await _userPreferencesService.getEnabledFtpConfigsForModule(moduleName);
if (!isFtpEnabled) {
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user.', 'success': true}]};
anyFtpSuccess = true;
} else if (previousFtpSuccess) {
debugPrint("FTP submission skipped: Already successful in previous attempt.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful.', 'success': true}]};
anyFtpSuccess = true;
} else if (enabledFtpConfigs.isEmpty) {
debugPrint("FTP submission skipped: No active FTP configurations found for $moduleName.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'No active FTP servers.', 'success': true}]};
anyFtpSuccess = true; // Treated as success to avoid indefinite L1 state
} else {
try {
ftpResults = await _generateAndUploadFtpFiles(data, serverName, moduleName);
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) {
debugPrint("FTP submission error: $e");
anyFtpSuccess = false;
}
}
// 3. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalMessage;
String finalStatus;
if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'Checklist submitted successfully to all destinations.';
finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Checklist sent to API, but FTP upload failed.';
finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed, but file sent to FTP.';
finalStatus = 'L4';
} else {
finalMessage = apiResult['message'] ?? 'All submission attempts failed.';
finalStatus = 'L1';
}
await _logAndSave(
@ -150,11 +211,18 @@ class MarineManualPreDepartureService {
status: finalStatus,
message: finalMessage,
apiResult: apiResult,
ftpStatuses: ftpResults['statuses'],
serverName: serverName,
logDirectory: logDirectory,
);
return apiResult;
// --- START: ADDED TELEGRAM ALERT ---
if (overallSuccess) {
_handleSuccessAlert(data, authProvider);
}
// --- END: ADDED TELEGRAM ALERT ---
return {'success': overallSuccess, 'message': finalMessage};
}
/// Handles saving the submission to local storage and queuing for retry.
@ -181,6 +249,7 @@ class MarineManualPreDepartureService {
status: 'Error',
message: message,
apiResult: {},
ftpStatuses: [],
serverName: serverName);
return {'success': false, 'message': message};
}
@ -199,12 +268,47 @@ class MarineManualPreDepartureService {
return {'success': true, 'message': successMessage};
}
/// Generates zip files and uploads them via FTP.
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(MarineManualPreDepartureChecklistData data, String serverName, String moduleName) async {
final timestamp = data.submissionDate ?? DateTime.now().toIso8601String();
final baseFileName = 'checklist_$timestamp';
final Directory? logDirectory = await _localStorageService.getLogDirectory(
serverName: serverName,
module: 'marine',
subModule: 'marine_pre_departure',
);
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, 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, // Added ! to ensure non-nullable if needed, but flow guarantees it's checked or handled
remotePath: '/${p.basename(dataZip.path)}'
);
}
return {'statuses': ftpDataResult['statuses'] ?? []};
}
/// Logs the submission to the local file system and the central SQL database.
Future<void> _logAndSave({
required MarineManualPreDepartureChecklistData data,
required String status,
required String message,
required Map<String, dynamic> apiResult,
required List<Map<String, dynamic>> ftpStatuses,
required String serverName,
String? logDirectory,
}) async {
@ -223,6 +327,7 @@ class MarineManualPreDepartureService {
updatedLogData['logDirectory'] = logDirectory;
updatedLogData['serverConfigName'] = serverName;
updatedLogData['api_status'] = jsonEncode(apiResult);
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses);
// All other fields are now in toDbJson()
// --- END: MODIFIED BLOCK ---
@ -248,8 +353,22 @@ class MarineManualPreDepartureService {
'image_data': null, // No images
'server_name': serverName,
'api_status': jsonEncode(apiResult),
'ftp_status': null, // No FTP
'ftp_status': jsonEncode(ftpStatuses),
};
await _dbHelper.saveSubmissionLog(logData);
}
// --- START: NEW TELEGRAM ALERT METHOD ---
Future<void> _handleSuccessAlert(MarineManualPreDepartureChecklistData data, AuthProvider authProvider) async {
try {
final message = data.generateTelegramAlertMessage();
// Using 'marine_npe_report' ID/module config as requested
if (!await _telegramService.sendAlertImmediately('marine_npe_report', message, authProvider.appSettings)) {
await _telegramService.queueMessage('marine_npe_report', message, authProvider.appSettings);
}
} catch (e) {
debugPrint("Telegram Alert Error (Checklist): $e");
}
}
// --- END: NEW TELEGRAM ALERT METHOD ---
}

View File

@ -5,6 +5,7 @@ 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_sonde_calibration_data.dart';
@ -14,44 +15,51 @@ import 'package:environment_monitoring_app/services/local_storage_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/submission_api_service.dart';
import 'package:environment_monitoring_app/services/submission_ftp_service.dart';
import 'package:environment_monitoring_app/services/zipping_service.dart';
import 'user_preferences_service.dart'; // ADDED
import 'telegram_service.dart'; // --- ADDED IMPORT ---
import 'base_api_service.dart'; // Import for SessionExpiredException
class MarineManualSondeCalibrationService {
// Use the new generic submission service
final SubmissionApiService _submissionApiService = SubmissionApiService();
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
final ZippingService _zippingService = ZippingService();
final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService();
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
final DatabaseHelper _dbHelper = DatabaseHelper();
final RetryService _retryService = RetryService();
final TelegramService _telegramService; // --- ADDED FIELD ---
// The ApiService is kept only if other non-submission methods need it.
// For this refactor, we'll remove it from the constructor.
// final ApiService _apiService;
// MarineManualSondeCalibrationService(this._apiService);
MarineManualSondeCalibrationService(ApiService apiService); // Keep constructor signature for main.dart
// --- MODIFIED CONSTRUCTOR ---
MarineManualSondeCalibrationService(ApiService apiService, this._telegramService);
/// Fetches all Calibration logs stored locally on the device.
Future<List<Map<String, dynamic>>> getLocalCalibrationLogs() async {
return await _localStorageService.getAllSondeCalibrationLogs();
}
/// Main submission method with online/offline branching logic
Future<Map<String, dynamic>> submitCalibration({
required MarineManualSondeCalibrationData data,
required AuthProvider authProvider,
List<Map<String, dynamic>>? appSettings, // Added for consistency
BuildContext? context, // Added for consistency
List<Map<String, dynamic>>? appSettings,
BuildContext? context,
String? logDirectory,
}) async {
const String moduleName = 'marine_sonde_calibration';
// Unified module name for preferences
const String moduleName = 'marine_report';
// --- START: ADDED LINE ---
// Populate the user name from the AuthProvider
data.calibratedByUserName = authProvider.profileData?['first_name'] as String?;
// --- END: ADDED LINE ---
final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none;
bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
bool isOfflineSession = authProvider.isLoggedIn &&
(authProvider.profileData?['token']
?.startsWith("offline-session-") ??
false);
(authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
if (isOnline && isOfflineSession) {
debugPrint(
@ -93,56 +101,108 @@ class MarineManualSondeCalibrationService {
(await _serverConfigService.getActiveApiConfig())?['config_name']
as String? ??
'Default';
Map<String, dynamic> apiResult;
bool anyApiSuccess = false;
Map<String, dynamic> apiResult = {};
try {
apiResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/calibration', // Endpoint from marine_api_service.dart
body: data.toApiFormData(),
);
} on SessionExpiredException {
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
if (reloginSuccess) {
// 1. API Submission
// Check if API is enabled in preferences
final pref = await _userPreferencesService.getModulePreference(moduleName);
bool isApiEnabled = pref?['is_api_enabled'] ?? true;
if (isApiEnabled) {
try {
apiResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/calibration',
endpoint: 'marine/calibration', // Endpoint from marine_api_service.dart
body: data.toApiFormData(),
);
} else {
if (apiResult['success'] == false && (apiResult['message'] as String?)?.contains('Unauthorized') == true) {
// Handle silent relogin
if (await authProvider.attemptSilentRelogin()) {
apiResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/calibration',
body: data.toApiFormData(),
);
}
}
if (apiResult['success'] == true) {
anyApiSuccess = true;
data.reportId = apiResult['data']?['calibration_id']?.toString();
}
} on SocketException catch (e) {
apiResult = {
'success': false,
'message': 'Session expired. Please log in again.'
'message': "API submission failed with network error: $e"
};
// Queue API manually
await _retryService.addApiToQueue(endpoint: 'marine/calibration', method: 'POST', body: data.toApiFormData());
} on TimeoutException catch (e) {
apiResult = {
'success': false,
'message': "API submission timed out: $e"
};
await _retryService.addApiToQueue(endpoint: 'marine/calibration', method: 'POST', body: data.toApiFormData());
} catch (e) {
apiResult = {
'success': false,
'message': 'An unexpected error occurred: $e'
};
}
} on SocketException catch (e) {
apiResult = {
'success': false,
'message': "API submission failed with network error: $e"
};
// submission_api_service will queue this failure
} on TimeoutException catch (e) {
apiResult = {
'success': false,
'message': "API submission timed out: $e"
};
// submission_api_service will queue this failure
} catch (e) {
apiResult = {
'success': false,
'message': 'An unexpected error occurred: $e'
};
} else {
anyApiSuccess = true; // Treated as success if disabled by user
}
// Log the final result
final bool overallSuccess = apiResult['success'] == true;
final String finalMessage =
apiResult['message'] ?? (overallSuccess ? 'Submission successful.' : 'Submission failed.');
final String finalStatus = overallSuccess ? 'S4' : 'L1'; // S4 = API Success
// 2. FTP Submission (Data Zip Only - No Images for Calibration)
Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false;
if (overallSuccess) {
// Assuming the API returns an ID. Adjust 'calibration_id' if needed.
data.reportId = apiResult['data']?['calibration_id']?.toString();
bool isFtpEnabled = pref?['is_ftp_enabled'] ?? true;
// Check status to avoid duplicate uploads (L4 = API Fail, FTP Success; S4 = Both Success)
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
// Check active FTPs
final enabledFtpConfigs = await _userPreferencesService.getEnabledFtpConfigsForModule(moduleName);
if (!isFtpEnabled) {
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user.', 'success': true}]};
anyFtpSuccess = true;
} else if (previousFtpSuccess) {
debugPrint("FTP submission skipped: Already successful in previous attempt.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful.', 'success': true}]};
anyFtpSuccess = true;
} else if (enabledFtpConfigs.isEmpty) {
debugPrint("FTP submission skipped: No active FTP configurations found for $moduleName.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'No active FTP servers.', 'success': true}]};
anyFtpSuccess = true;
} else {
try {
ftpResults = await _generateAndUploadFtpFiles(data, serverName, moduleName);
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) {
debugPrint("FTP submission error: $e");
anyFtpSuccess = false;
}
}
// 3. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalMessage;
String finalStatus;
if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'Calibration submitted successfully to all destinations.';
finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Calibration sent to API, but FTP upload failed.';
finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed, but file sent to FTP.';
finalStatus = 'L4';
} else {
finalMessage = apiResult['message'] ?? 'All submission attempts failed.';
finalStatus = 'L1';
}
await _logAndSave(
@ -150,11 +210,18 @@ class MarineManualSondeCalibrationService {
status: finalStatus,
message: finalMessage,
apiResult: apiResult,
ftpStatuses: ftpResults['statuses'],
serverName: serverName,
logDirectory: logDirectory,
);
return apiResult;
// --- START: ADDED TELEGRAM ALERT ---
if (overallSuccess) {
_handleSuccessAlert(data, authProvider);
}
// --- END: ADDED TELEGRAM ALERT ---
return {'success': overallSuccess, 'message': finalMessage};
}
/// Handles saving the submission to local storage and queuing for retry.
@ -181,6 +248,7 @@ class MarineManualSondeCalibrationService {
status: 'Error',
message: message,
apiResult: {},
ftpStatuses: [],
serverName: serverName);
return {'success': false, 'message': message};
}
@ -199,12 +267,47 @@ class MarineManualSondeCalibrationService {
return {'success': true, 'message': successMessage};
}
/// Generates zip files and uploads them via FTP.
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(MarineManualSondeCalibrationData data, String serverName, String moduleName) async {
final fileTimestamp = data.startDateTime?.replaceAll(':', '-').replaceAll(' ', '_') ?? DateTime.now().toIso8601String();
final baseFileName = 'calibration_${data.sondeSerialNumber}_$fileTimestamp';
final Directory? logDirectory = await _localStorageService.getLogDirectory(
serverName: serverName,
module: 'marine',
subModule: 'marine_sonde_calibration',
);
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, 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, // Added ! to ensure non-nullable
remotePath: '/${p.basename(dataZip.path)}'
);
}
return {'statuses': ftpDataResult['statuses'] ?? []};
}
/// Logs the submission to the local file system and the central SQL database.
Future<void> _logAndSave({
required MarineManualSondeCalibrationData data,
required String status,
required String message,
required Map<String, dynamic> apiResult,
required List<Map<String, dynamic>> ftpStatuses,
required String serverName,
String? logDirectory,
}) async {
@ -213,14 +316,12 @@ class MarineManualSondeCalibrationService {
final fileTimestamp = data.startDateTime?.replaceAll(':', '-').replaceAll(' ', '_') ?? DateTime.now().toIso8601String();
// --- START: MODIFIED BLOCK ---
// Use the new toDbJson() method to get ALL data for logging
final Map<String, dynamic> logDataMap = data.toDbJson();
// Add submission-specific metadata
logDataMap['api_status'] = jsonEncode(apiResult);
logDataMap['ftp_status'] = jsonEncode(ftpStatuses);
logDataMap['serverConfigName'] = serverName;
// --- END: MODIFIED BLOCK ---
if (logDirectory != null) {
// This is an update to an existing log file
@ -228,7 +329,6 @@ class MarineManualSondeCalibrationService {
await _localStorageService.updateSondeCalibrationLog(logDataMap);
} else {
// This is a new log
// Pass the complete data object, which now includes the user name
await _localStorageService.saveSondeCalibrationData(data, serverName: serverName);
}
@ -240,12 +340,28 @@ class MarineManualSondeCalibrationService {
'message': data.submissionMessage,
'report_id': data.reportId,
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toDbJson()), // <-- Use toDbJson here
// --- START: MODIFIED LINE ---
'form_data': jsonEncode(data.toDbJson()), // Log the full DbJson
// --- END: MODIFIED LINE ---
'image_data': null, // No images
'server_name': serverName,
'api_status': jsonEncode(apiResult),
'ftp_status': null, // No FTP
'ftp_status': jsonEncode(ftpStatuses),
};
await _dbHelper.saveSubmissionLog(logData);
}
// --- START: NEW TELEGRAM ALERT METHOD ---
Future<void> _handleSuccessAlert(MarineManualSondeCalibrationData data, AuthProvider authProvider) async {
try {
final message = data.generateTelegramAlertMessage();
// Using 'marine_npe_report' ID/module config as requested
if (!await _telegramService.sendAlertImmediately('marine_npe_report', message, authProvider.appSettings)) {
await _telegramService.queueMessage('marine_npe_report', message, authProvider.appSettings);
}
} catch (e) {
debugPrint("Telegram Alert Error (Calibration): $e");
}
}
// --- END: NEW TELEGRAM ALERT METHOD ---
}

View File

@ -1,4 +1,4 @@
// lib/services/marine_tarball_sampling_service.dart
// lib/services/marine_npe_report_service.dart
import 'dart:async';
import 'dart:io';
@ -16,9 +16,8 @@ import 'submission_api_service.dart';
import 'submission_ftp_service.dart';
import 'telegram_service.dart';
import 'retry_service.dart';
import 'api_service.dart';
import 'package:environment_monitoring_app/services/database_helper.dart';
import 'user_preferences_service.dart'; // ADDED
class MarineNpeReportService {
final SubmissionApiService _submissionApiService = SubmissionApiService();
@ -26,35 +25,34 @@ class MarineNpeReportService {
final ZippingService _zippingService = ZippingService();
final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService();
final UserPreferencesService _userPreferencesService = UserPreferencesService(); // ADDED
final DatabaseHelper _dbHelper = DatabaseHelper();
final RetryService _retryService = RetryService();
final TelegramService _telegramService;
MarineNpeReportService(this._telegramService);
Future<List<Map<String, dynamic>>> getLocalNpeLogs() async {
return await _localStorageService.getAllNpeLogs();
}
Future<Map<String, dynamic>> submitNpeReport({
required MarineManualNpeReportData data,
required AuthProvider authProvider,
String? logDirectory,
}) async {
const String moduleName = 'marine_npe_report';
const String moduleName = 'marine_report';
final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none;
bool isOnline = !connectivityResult.contains(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 (transitionSuccess) isOfflineSession = false; else isOnline = false;
}
if (isOnline && !isOfflineSession) {
debugPrint("Proceeding with direct ONLINE NPE submission...");
return await _performNpeOnlineSubmission(
data: data,
moduleName: moduleName,
@ -62,7 +60,6 @@ class MarineNpeReportService {
logDirectory: logDirectory,
);
} else {
debugPrint("Proceeding with OFFLINE NPE queuing mechanism...");
return await _performNpeOfflineQueuing(
data: data,
moduleName: moduleName,
@ -85,92 +82,92 @@ class MarineNpeReportService {
Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {};
try {
// --- MODIFIED: Use the new endpoint path for data ---
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/npe/report', // <-- Updated endpoint
body: data.toApiFormData(),
);
// 1. API Submission
// Check if API is enabled in preferences
final pref = await _userPreferencesService.getModulePreference(moduleName);
bool isApiEnabled = pref?['is_api_enabled'] ?? true;
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', // <-- Updated endpoint
body: data.toApiFormData(),
);
if (isApiEnabled) {
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) {
if (await authProvider.attemptSilentRelogin()) {
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 (apiDataResult['success'] == true) {
anyApiSuccess = true;
data.reportId = apiDataResult['data']?['npe_id']?.toString();
if (data.reportId != null) {
if (finalImageFiles.isNotEmpty) {
// --- MODIFIED: Use the new endpoint path for images ---
if (data.reportId != null && finalImageFiles.isNotEmpty) {
apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'marine/npe/images', // <-- Updated endpoint
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 Network Error: $e"};
await _retryService.addApiToQueue(endpoint: 'marine/npe/report', method: 'POST', body: data.toApiFormData());
} on TimeoutException catch (e) {
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': "API Timeout: $e"};
await _retryService.addApiToQueue(endpoint: 'marine/npe/report', method: 'POST', body: data.toApiFormData());
}
} on SocketException catch (e) {
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': "API submission failed with network error: $e"};
// --- MODIFIED: Update queue with new endpoints ---
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"};
// --- MODIFIED: Update queue with new endpoint ---
await _retryService.addApiToQueue(endpoint: 'marine/npe/report', method: 'POST', body: data.toApiFormData());
} else {
anyApiSuccess = true; // Treated as success if disabled by user
}
// 2. FTP Submission
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;
bool isFtpEnabled = pref?['is_ftp_enabled'] ?? true;
// Check if this record was already successfully sent to FTP (L4 or S4 status)
bool previousFtpSuccess = data.submissionStatus == 'L4' || data.submissionStatus == 'S4';
// Check if there are any active FTP configs for this module
final enabledFtpConfigs = await _userPreferencesService.getEnabledFtpConfigsForModule(moduleName);
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';
if (!isFtpEnabled) {
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'FTP disabled by user.', 'success': true}]};
anyFtpSuccess = true;
} else if (previousFtpSuccess) {
debugPrint("FTP submission skipped: Already successful in previous attempt.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'Already successful.', 'success': true}]};
anyFtpSuccess = true;
} else if (enabledFtpConfigs.isEmpty) {
debugPrint("FTP submission skipped: No active FTP configurations found for $moduleName.");
ftpResults = {'statuses': [{'status': 'Skipped', 'message': 'No active FTP servers.', 'success': true}]};
anyFtpSuccess = true; // Treated as success to avoid indefinite L1 state
} else {
finalMessage = 'All NPE Report submission attempts failed and have been queued for retry.';
finalStatus = 'L1';
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) {
debugPrint("FTP Error: $e");
anyFtpSuccess = false;
}
}
// 3. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalStatus = (anyApiSuccess && anyFtpSuccess) ? 'S4' : (anyApiSuccess ? 'S3' : (anyFtpSuccess ? 'L4' : 'L1'));
String finalMessage = overallSuccess ? 'Submission successful.' : 'Submission failed/queued.';
await _logAndSave(
data: data,
status: finalStatus,
@ -182,9 +179,7 @@ class MarineNpeReportService {
logDirectory: logDirectory,
);
if (overallSuccess) {
_handleNpeSuccessAlert(data, authProvider);
}
if (overallSuccess) _handleNpeSuccessAlert(data, authProvider);
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
}
@ -197,56 +192,54 @@ class MarineNpeReportService {
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
data.submissionStatus = 'L1';
data.submissionMessage = 'NPE Report queued due to being offline.';
data.submissionMessage = 'Queued (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};
}
if (localLogPath == null) return {'success': false, 'message': "Failed to save locally."};
await _retryService.queueTask(
type: 'npe_submission',
payload: {
'module': moduleName,
'localLogPath': localLogPath,
'serverConfig': serverConfig,
},
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};
return {'success': true, 'message': "Saved locally and queued for upload."};
}
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';
// --- FIX START: Logic to determine correct Station Code or Sanitized New Location ---
String stationCode;
if (data.selectedStation != null) {
// If it's an existing station (Manual or Tarball)
stationCode = data.selectedStation?['man_station_code'] ??
data.selectedStation?['tbl_station_code'] ??
'NA';
} else {
// If it's a New Location, use the description and replace spaces with underscores
String rawDesc = data.locationDescription?.trim() ?? 'NEW_LOCATION';
if (rawDesc.isEmpty) rawDesc = 'NEW_LOCATION';
stationCode = rawDesc.replaceAll(' ', '_');
}
// --- FIX END ---
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? 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);
}
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)}',
);
ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}');
}
final imageZip = await _zippingService.createImageZip(
@ -256,11 +249,7 @@ class MarineNpeReportService {
);
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit(
moduleName: moduleName,
fileToUpload: imageZip,
remotePath: '/${p.basename(imageZip.path)}',
);
ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/${p.basename(imageZip.path)}');
}
return {
@ -308,8 +297,8 @@ class MarineNpeReportService {
'submission_id': data.reportId ?? fileTimestamp,
'module': 'marine',
'type': 'NPE',
'status': data.submissionStatus,
'message': data.submissionMessage,
'status': status,
'message': message,
'report_id': data.reportId,
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toDbJson()),
@ -324,12 +313,9 @@ class MarineNpeReportService {
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) {
if (!await _telegramService.sendAlertImmediately('marine_npe_report', message, authProvider.appSettings)) {
await _telegramService.queueMessage('marine_npe_report', message, authProvider.appSettings);
}
} catch (e) {
debugPrint("Failed to handle NPE Telegram alert: $e");
}
} catch (e) { debugPrint("Telegram Alert Error: $e"); }
}
}

View File

@ -17,6 +17,7 @@ class UserPreferencesService {
{'key': 'marine_tarball', 'name': 'Marine Tarball'},
{'key': 'marine_in_situ', 'name': 'Marine In-Situ'},
{'key': 'marine_investigative', 'name': 'Marine Investigative'},
{'key': 'marine_report', 'name': 'Marine Report'},
{'key': 'river_in_situ', 'name': 'River In-Situ'},
{'key': 'river_triennial', 'name': 'River Triennial'},
{'key': 'river_investigative', 'name': 'River Investigative'},
@ -52,9 +53,21 @@ class UserPreferencesService {
);
// 2. Determine default API links
// This is correct: Tick any API server marked as 'is_active' by default.
final defaultApiLinks = allApiConfigs.map((config) {
bool isEnabled = (config['is_active'] == 1 || config['is_active'] == true);
bool isActive = (config['is_active'] == 1 || config['is_active'] == true);
bool isPstwHq = (config['config_name'] == 'PSTW_HQ');
bool isEnabled;
// --- MODIFIED: Special logic for Marine Report ---
// For marine_report, ONLY tick PSTW_HQ by default. Ignore other active APIs.
if (moduleKey == 'marine_report') {
isEnabled = isPstwHq;
} else {
// For other modules, tick if Active OR PSTW_HQ
isEnabled = isActive || isPstwHq;
}
return {...config, 'is_enabled': isEnabled};
}).toList();
@ -149,8 +162,15 @@ class UserPreferencesService {
isEnabled = matchingLink['is_enabled'] as bool? ?? false;
} else {
// No preference saved for this config. Apply default logic.
// (This handles newly synced configs automatically)
isEnabled = (config['is_active'] == 1 || config['is_active'] == true);
bool isActive = (config['is_active'] == 1 || config['is_active'] == true);
bool isPstwHq = (config['config_name'] == 'PSTW_HQ');
// --- MODIFIED: Special logic for Marine Report ---
if (moduleName == 'marine_report') {
isEnabled = isPstwHq;
} else {
isEnabled = isActive || isPstwHq;
}
}
// --- END MODIFICATION ---