// 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)); } // --- 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'; bool anyApiSuccess = false; Map apiDataResult = {}; Map apiImageResult = {}; final imageFiles = data.getImagesForUpload(); // Step 1: Attempt API Submission try { apiDataResult = await _submissionApiService.submitPost( moduleName: moduleName, endpoint: 'air/manual/installation', body: data.toJsonForApi(), ); if (apiDataResult['success'] == true) { final recordId = apiDataResult['data']?['air_man_id']?.toString(); if (recordId != null) { data.airManId = int.tryParse(recordId); apiImageResult = await _submissionApiService.submitMultipart( moduleName: moduleName, endpoint: 'air/manual/installation-images', fields: {'air_man_id': recordId}, 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 { final stationCode = data.stationID ?? 'UNKNOWN'; final samplingDateTime = "${data.installationDate}_${data.installationTime}".replaceAll(':', '-').replaceAll(' ', '_'); final baseFileName = "${stationCode}_INSTALLATION_${samplingDateTime}"; 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.'; } await _logAndSave(data: data, status: finalStatus, message: finalMessage, apiResults: [apiDataResult, apiImageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Installation'); 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'; bool anyApiSuccess = false; Map apiDataResult = {}; Map apiImageResult = {}; final imageFiles = data.getImagesForUpload(); // Step 1: Attempt API Submission try { data.airManId = installationData.airManId; 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()}, 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 { final stationCode = installationData.stationID ?? 'UNKNOWN'; final samplingDateTime = "${data.collectionDate}_${data.collectionTime}".replaceAll(':', '-').replaceAll(' ', '_'); final baseFileName = "${stationCode}_COLLECTION_${samplingDateTime}"; 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.'; } await _logAndSave(data: data, installationData: installationData, status: finalStatus, message: finalMessage, apiResults: [apiDataResult, apiImageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Collection'); 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, }) async { String refID; Map formData; List imagePaths; if (type == 'Installation') { final installation = data as AirInstallationData; installation.status = status; refID = installation.refID!; 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!; formData = collection.toMap(); imagePaths = _getCollectionImagePaths(collection); await _localStorageService.saveAirSamplingRecord(_toMapForLocalSave(installationData!..collectionData = collection), refID, serverName: serverName); } final logData = { 'submission_id': refID, 'module': 'air', 'type': type, 'status': status, 'message': message, 'report_id': (data.airManId ?? installationData?.airManId)?.toString(), '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; } }