// lib/services/air_sampling_service.dart import 'dart:io'; import 'dart:async'; // Added for TimeoutException import 'package:flutter/foundation.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:image/image.dart' as img; import 'package:intl/intl.dart'; import 'dart:convert'; import '../models/air_installation_data.dart'; import '../models/air_collection_data.dart'; import 'api_service.dart'; import 'package:environment_monitoring_app/services/database_helper.dart'; import 'local_storage_service.dart'; import 'telegram_service.dart'; import 'server_config_service.dart'; import 'zipping_service.dart'; import 'submission_api_service.dart'; import 'submission_ftp_service.dart'; import 'retry_service.dart'; // Added for queuing failed tasks /// A dedicated service for handling all business logic for the Air Manual Sampling feature. class AirSamplingService { final DatabaseHelper _dbHelper; final TelegramService _telegramService; final SubmissionApiService _submissionApiService = SubmissionApiService(); final SubmissionFtpService _submissionFtpService = SubmissionFtpService(); final ServerConfigService _serverConfigService = ServerConfigService(); final ZippingService _zippingService = ZippingService(); final LocalStorageService _localStorageService = LocalStorageService(); final RetryService _retryService = RetryService(); // Added AirSamplingService(this._dbHelper, this._telegramService); // This helper method remains unchanged as it's for local saving logic Map _toMapForLocalSave(dynamic data) { if (data is AirInstallationData) { final map = data.toMap(); map['imageFront'] = data.imageFront; map['imageBack'] = data.imageBack; map['imageLeft'] = data.imageLeft; map['imageRight'] = data.imageRight; map['optionalImage1'] = data.optionalImage1; map['optionalImage2'] = data.optionalImage2; map['optionalImage3'] = data.optionalImage3; map['optionalImage4'] = data.optionalImage4; if (data.collectionData != null) { map['collectionData'] = _toMapForLocalSave(data.collectionData); } return map; } else if (data is AirCollectionData) { final map = data.toMap(); map['imageFront'] = data.imageFront; map['imageBack'] = data.imageBack; map['imageLeft'] = data.imageLeft; map['imageRight'] = data.imageRight; map['imageChart'] = data.imageChart; map['imageFilterPaper'] = data.imageFilterPaper; map['optionalImage1'] = data.optionalImage1; map['optionalImage2'] = data.optionalImage2; map['optionalImage3'] = data.optionalImage3; map['optionalImage4'] = data.optionalImage4; return map; } return {}; } // This image processing utility remains unchanged Future pickAndProcessImage( ImageSource source, { required String stationCode, required String imageInfo, String processType = 'INSTALL', required bool isRequired, }) async { final picker = ImagePicker(); final XFile? photo = await picker.pickImage( source: source, imageQuality: 85, maxWidth: 1024); if (photo == null) return null; final bytes = await photo.readAsBytes(); img.Image? originalImage = img.decodeImage(bytes); if (originalImage == null) return null; if (isRequired && originalImage.height > originalImage.width) { debugPrint("Image orientation check failed: Image must be in landscape mode."); return null; } final String watermarkTimestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()); 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 fileTimestamp = watermarkTimestamp.replaceAll(':', '-').replaceAll(' ', '_'); final newFileName = "${stationCode}_${fileTimestamp}_${processType.toUpperCase()}_${imageInfo.replaceAll(' ', '')}.jpg"; final filePath = path.join(tempDir.path, newFileName); return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); } // --- START: MODIFIED FILENAME LOGIC --- /// Generates a unique timestamp ID from the sampling date and time. String _generateTimestampId(String? date, String? time) { final String dateTimeString = "${date ?? ''} ${time ?? ''}"; try { // Air time format seems to be 'HH:mm' final DateTime samplingDateTime = DateFormat('yyyy-MM-dd HH:mm').parse(dateTimeString); return samplingDateTime.millisecondsSinceEpoch.toString(); } catch (e) { debugPrint("Could not parse '$dateTimeString' for timestamp ID, using current time. Error: $e"); return DateTime.now().millisecondsSinceEpoch.toString(); } } // --- END: MODIFIED FILENAME LOGIC --- // --- REFACTORED submitInstallation method with granular error handling --- Future> submitInstallation(AirInstallationData data, List>? appSettings) async { const String moduleName = 'air_installation'; final activeConfig = await _serverConfigService.getActiveApiConfig(); final serverName = activeConfig?['config_name'] as String? ?? 'Default'; // --- START: MODIFIED FILENAME LOGIC --- // Generate the unique timestamp ID and base filename FIRST. final String timestampId = _generateTimestampId(data.installationDate, data.installationTime); final String stationCode = data.stationID ?? 'UNKNOWN'; final String baseFileName = "${stationCode}_INSTALLATION_${timestampId}"; // Assign this as the primary refID for the log data.refID = baseFileName; // --- END: MODIFIED FILENAME LOGIC --- bool anyApiSuccess = false; Map apiDataResult = {}; Map apiImageResult = {}; final imageFiles = data.getImagesForUpload(); String? apiRecordId; // Will hold the DB ID (e.g., 102) // Step 1: Attempt API Submission try { apiDataResult = await _submissionApiService.submitPost( moduleName: moduleName, endpoint: 'air/manual/installation', body: data.toJsonForApi(), ); if (apiDataResult['success'] == true) { apiRecordId = apiDataResult['data']?['air_man_id']?.toString(); if (apiRecordId != null) { data.airManId = int.tryParse(apiRecordId); // Save the DB ID apiImageResult = await _submissionApiService.submitMultipart( moduleName: moduleName, endpoint: 'air/manual/installation-images', fields: {'air_man_id': apiRecordId}, // Use DB ID for image upload files: imageFiles, ); anyApiSuccess = apiImageResult['success'] == true; } else { anyApiSuccess = false; apiDataResult['message'] = 'API Error: Missing 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: 'air/manual/installation', method: 'POST', body: data.toJsonForApi()); } on TimeoutException catch (e) { final errorMessage = "API submission timed out: $e"; debugPrint(errorMessage); anyApiSuccess = false; apiDataResult = {'success': false, 'message': errorMessage}; await _retryService.addApiToQueue(endpoint: 'air/manual/installation', method: 'POST', body: data.toJsonForApi()); } // Step 2: Attempt FTP Submission Map ftpDataResult = {'statuses': []}; Map ftpImageResult = {'statuses': []}; bool anyFtpSuccess = false; try { // --- FILENAME LOGIC MOVED --- // baseFileName is already generated above. final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, baseFileName: baseFileName); if (dataZip != null) { ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}'); } final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName); if (imageZip != null) { ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}'); } anyFtpSuccess = !(ftpDataResult['statuses'] as List).any((s) => s['success'] == false) && !(ftpImageResult['statuses'] as List).any((s) => s['success'] == false); } 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; } // Step 3: Determine Final Status String finalStatus; String finalMessage; if (anyApiSuccess && anyFtpSuccess) { finalStatus = 'S4'; finalMessage = 'Data and files submitted successfully.'; } else if (anyApiSuccess && !anyFtpSuccess) { finalStatus = 'S3'; finalMessage = 'Data submitted to API, but FTP upload failed and was queued.'; } else if (!anyApiSuccess && anyFtpSuccess) { finalStatus = 'L4'; finalMessage = 'API submission failed, but files were sent via FTP.'; } else { finalStatus = 'L1'; finalMessage = 'Both API and FTP submissions failed and were queued.'; } // --- START: MODIFIED LOGGING --- await _logAndSave( data: data, status: finalStatus, message: finalMessage, apiResults: [apiDataResult, apiImageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Installation', baseFileName: baseFileName, // Pass the generated filename apiRecordId: apiRecordId, // Pass the DB ID ); // --- END: MODIFIED LOGGING --- if (anyApiSuccess || anyFtpSuccess) { _handleInstallationSuccessAlert(data, appSettings, isDataOnly: imageFiles.isEmpty); } return {'status': finalStatus, 'message': finalMessage}; } // --- REFACTORED submitCollection method with granular error handling --- Future> submitCollection(AirCollectionData data, AirInstallationData installationData, List>? appSettings) async { const String moduleName = 'air_collection'; final activeConfig = await _serverConfigService.getActiveApiConfig(); final serverName = activeConfig?['config_name'] as String? ?? 'Default'; // --- START: MODIFIED FILENAME LOGIC --- // Generate the unique timestamp ID and base filename FIRST. final String timestampId = _generateTimestampId(data.collectionDate, data.collectionTime); final String stationCode = installationData.stationID ?? 'UNKNOWN'; final String baseFileName = "${stationCode}_COLLECTION_${timestampId}"; // Assign this as the primary refID for the log data.installationRefID = baseFileName; // --- END: MODIFIED FILENAME LOGIC --- bool anyApiSuccess = false; Map apiDataResult = {}; Map apiImageResult = {}; final imageFiles = data.getImagesForUpload(); String? apiRecordId; // Will hold the DB ID // Step 1: Attempt API Submission try { // Use the DB ID from the original installation data.airManId = installationData.airManId; apiRecordId = data.airManId?.toString(); // Store it for logging apiDataResult = await _submissionApiService.submitPost( moduleName: moduleName, endpoint: 'air/manual/collection', body: data.toJson(), ); if (apiDataResult['success'] == true) { apiImageResult = await _submissionApiService.submitMultipart( moduleName: moduleName, endpoint: 'air/manual/collection-images', fields: {'air_man_id': data.airManId.toString()}, // Use DB ID files: imageFiles, ); anyApiSuccess = apiImageResult['success'] == true; } } 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: 'air/manual/collection', method: 'POST', body: data.toJson()); } on TimeoutException catch (e) { final errorMessage = "API submission timed out: $e"; debugPrint(errorMessage); anyApiSuccess = false; apiDataResult = {'success': false, 'message': errorMessage}; await _retryService.addApiToQueue(endpoint: 'air/manual/collection', method: 'POST', body: data.toJson()); } // Step 2: Attempt FTP Submission Map ftpDataResult = {'statuses': []}; Map ftpImageResult = {'statuses': []}; bool anyFtpSuccess = false; try { // --- FILENAME LOGIC MOVED --- // baseFileName is already generated above. final combinedJson = jsonEncode({"installation": installationData.toDbJson(), "collection": data.toMap()}); final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': combinedJson}, baseFileName: baseFileName); if (dataZip != null) { ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}'); } final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName); if (imageZip != null) { ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}'); } anyFtpSuccess = !(ftpDataResult['statuses'] as List).any((s) => s['success'] == false) && !(ftpImageResult['statuses'] as List).any((s) => s['success'] == false); } 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; } // Step 3: Determine Final Status String finalStatus; String finalMessage; if (anyApiSuccess && anyFtpSuccess) { finalStatus = 'S4'; finalMessage = 'Data and files submitted successfully.'; } else if (anyApiSuccess && !anyFtpSuccess) { finalStatus = 'S3'; finalMessage = 'Data submitted to API, but FTP upload failed and was queued.'; } else if (!anyApiSuccess && anyFtpSuccess) { finalStatus = 'L4'; finalMessage = 'API submission failed, but files were sent via FTP.'; } else { finalStatus = 'L1'; finalMessage = 'Both API and FTP submissions failed and were queued.'; } // --- START: MODIFIED LOGGING --- await _logAndSave( data: data, installationData: installationData, status: finalStatus, message: finalMessage, apiResults: [apiDataResult, apiImageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Collection', baseFileName: baseFileName, // Pass the generated filename apiRecordId: apiRecordId, // Pass the DB ID ); // --- END: MODIFIED LOGGING --- if(anyApiSuccess || anyFtpSuccess) { _handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: imageFiles.isEmpty); } return {'status': finalStatus, 'message': finalMessage}; } /// Centralized method for logging and saving data locally. Future _logAndSave({ required dynamic data, AirInstallationData? installationData, required String status, required String message, required List> apiResults, required List> ftpStatuses, required String serverName, required String type, // --- START: MODIFIED LOGGING --- required String baseFileName, // Use this as the primary ID String? apiRecordId, // The ID from the server DB (e.g., 102) // --- END: MODIFIED LOGGING --- }) async { String refID; Map formData; List imagePaths; if (type == 'Installation') { final installation = data as AirInstallationData; installation.status = status; refID = installation.refID!; // This is now baseFileName formData = installation.toMap(); imagePaths = _getInstallationImagePaths(installation); await _localStorageService.saveAirSamplingRecord(_toMapForLocalSave(installation), refID, serverName: serverName); } else { final collection = data as AirCollectionData; collection.status = status; refID = collection.installationRefID!; // This is now baseFileName formData = collection.toMap(); imagePaths = _getCollectionImagePaths(collection); await _localStorageService.saveAirSamplingRecord(_toMapForLocalSave(installationData!..collectionData = collection), refID, serverName: serverName); } final logData = { // --- START: MODIFIED LOGGING --- 'submission_id': baseFileName, // Use the timestamp-based filename as the primary ID 'module': 'air', 'type': type, 'status': status, 'message': message, 'report_id': apiRecordId, // Store the server DB ID (e.g., 102) here // --- END: MODIFIED LOGGING --- 'created_at': DateTime.now().toIso8601String(), 'form_data': jsonEncode(formData), 'image_data': jsonEncode(imagePaths), 'server_name': serverName, 'api_status': jsonEncode(apiResults), 'ftp_status': jsonEncode(ftpStatuses), }; await _dbHelper.saveSubmissionLog(logData); } // Helper and Alert methods remain unchanged Future _handleInstallationSuccessAlert(AirInstallationData data, List>? appSettings, {required bool isDataOnly}) async { try { final message = data.generateInstallationTelegramAlert(isDataOnly: isDataOnly); final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message, appSettings); if (!wasSent) { await _telegramService.queueMessage('air_manual', message, appSettings); } } catch (e) { debugPrint("Failed to handle Air Manual Installation Telegram alert: $e"); } } Future _handleCollectionSuccessAlert(AirCollectionData data, AirInstallationData installationData, List>? appSettings, {required bool isDataOnly}) async { try { final message = data.generateCollectionTelegramAlert(installationData, isDataOnly: isDataOnly); final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message, appSettings); if (!wasSent) { await _telegramService.queueMessage('air_manual', message, appSettings); } } catch (e) { debugPrint("Failed to handle Air Manual Collection Telegram alert: $e"); } } List _getInstallationImagePaths(AirInstallationData data) { final List files = [ data.imageFront, data.imageBack, data.imageLeft, data.imageRight, data.optionalImage1, data.optionalImage2, data.optionalImage3, data.optionalImage4, ]; return files.where((f) => f != null).map((f) => f!.path).toList(); } List _getCollectionImagePaths(AirCollectionData data) { final List files = [ data.imageFront, data.imageBack, data.imageLeft, data.imageRight, data.imageChart, data.imageFilterPaper, data.optionalImage1, data.optionalImage2, data.optionalImage3, data.optionalImage4, ]; return files.where((f) => f != null).map((f) => f!.path).toList(); } // getPendingInstallations can be moved to a different service or screen logic later Future> getPendingInstallations() async { debugPrint("Fetching pending installations from local storage..."); final logs = await _dbHelper.loadSubmissionLogs(module: 'air'); final pendingInstallations = logs ?.where((log) { final status = log['status']; return status == 'S1' || status == 'S2'; }) .map((log) => AirInstallationData.fromJson(jsonDecode(log['form_data']))) .toList() ?? []; return pendingInstallations; } }