// lib/services/air_sampling_service.dart import 'dart:io'; 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 'local_storage_service.dart'; import 'telegram_service.dart'; import 'server_config_service.dart'; import 'zipping_service.dart'; // START CHANGE: Import the new common submission services import 'submission_api_service.dart'; import 'submission_ftp_service.dart'; // END CHANGE /// A dedicated service for handling all business logic for the Air Manual Sampling feature. class AirSamplingService { // START CHANGE: Instantiate new services and remove ApiService 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(); // END CHANGE // MODIFIED: Constructor no longer needs ApiService 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 --- 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'; // --- 1. API SUBMISSION (DATA) --- debugPrint("Step 1: Delegating Installation Data submission to SubmissionApiService..."); final dataResult = await _submissionApiService.submitPost( moduleName: moduleName, endpoint: 'air/manual/installation', body: data.toJsonForApi(), ); if (dataResult['success'] != true) { await _logAndSave(data: data, status: 'L1', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Installation'); return {'status': 'L1', 'message': dataResult['message']}; } final recordId = dataResult['data']?['air_man_id']?.toString(); if (recordId == null) { await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Installation'); return {'status': 'L1', 'message': 'API Error: Missing record ID.'}; } data.airManId = int.tryParse(recordId); // --- 2. API SUBMISSION (IMAGES) --- debugPrint("Step 2: Delegating Installation Image submission to SubmissionApiService..."); final imageFiles = data.getImagesForUpload(); final imageResult = await _submissionApiService.submitMultipart( moduleName: moduleName, endpoint: 'air/manual/installation-images', fields: {'air_man_id': recordId}, files: imageFiles, ); final bool apiImagesSuccess = imageResult['success'] == true; // --- 3. FTP SUBMISSION --- debugPrint("Step 3: Delegating Installation FTP submission to SubmissionFtpService..."); final stationCode = data.stationID ?? 'UNKNOWN'; final samplingDateTime = "${data.installationDate}_${data.installationTime}".replaceAll(':', '-').replaceAll(' ', '_'); final baseFileName = "${stationCode}_INSTALLATION_${samplingDateTime}"; // Zip and submit data final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, baseFileName: baseFileName); Map ftpDataResult = {'statuses': []}; if (dataZip != null) { ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}'); } // Zip and submit images final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName); Map ftpImageResult = {'statuses': []}; if (imageZip != null) { ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}'); } final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); // --- 4. DETERMINE FINAL STATUS, LOG, AND ALERT --- String finalStatus; String finalMessage; if (apiImagesSuccess) { finalStatus = ftpSuccess ? 'S4' : 'S2'; finalMessage = ftpSuccess ? 'Data and files submitted successfully.' : 'Data submitted to API. FTP upload failed or was queued.'; } else { finalStatus = ftpSuccess ? 'L2_FTP_ONLY' : 'L2_PENDING_IMAGES'; finalMessage = ftpSuccess ? 'API image upload failed, but files were sent via FTP.' : 'Data submitted, but API image and FTP uploads failed.'; } await _logAndSave(data: data, status: finalStatus, message: finalMessage, apiResults: [dataResult, imageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Installation'); _handleInstallationSuccessAlert(data, appSettings, isDataOnly: !apiImagesSuccess); return {'status': finalStatus, 'message': finalMessage}; } // --- REFACTORED submitCollection method --- 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'; // --- 1. API SUBMISSION (DATA) --- debugPrint("Step 1: Delegating Collection Data submission to SubmissionApiService..."); data.airManId = installationData.airManId; // Ensure collection is linked to installation final dataResult = await _submissionApiService.submitPost( moduleName: moduleName, endpoint: 'air/manual/collection', body: data.toJson(), ); if (dataResult['success'] != true) { await _logAndSave(data: data, installationData: installationData, status: 'L3', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Collection'); return {'status': 'L3', 'message': dataResult['message']}; } // --- 2. API SUBMISSION (IMAGES) --- debugPrint("Step 2: Delegating Collection Image submission to SubmissionApiService..."); final imageFiles = data.getImagesForUpload(); final imageResult = await _submissionApiService.submitMultipart( moduleName: moduleName, endpoint: 'air/manual/collection-images', fields: {'air_man_id': data.airManId.toString()}, files: imageFiles, ); final bool apiImagesSuccess = imageResult['success'] == true; // --- 3. FTP SUBMISSION --- debugPrint("Step 3: Delegating Collection FTP submission to SubmissionFtpService..."); final stationCode = installationData.stationID ?? 'UNKNOWN'; final samplingDateTime = "${data.collectionDate}_${data.collectionTime}".replaceAll(':', '-').replaceAll(' ', '_'); final baseFileName = "${stationCode}_COLLECTION_${samplingDateTime}"; // Zip and submit data (includes both installation and collection data) final combinedJson = jsonEncode({"installation": installationData.toDbJson(), "collection": data.toMap()}); final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': combinedJson}, baseFileName: baseFileName); Map ftpDataResult = {'statuses': []}; if (dataZip != null) { ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}'); } // Zip and submit images final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName); Map ftpImageResult = {'statuses': []}; if (imageZip != null) { ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}'); } final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); // --- 4. DETERMINE FINAL STATUS, LOG, AND ALERT --- String finalStatus; String finalMessage; if (apiImagesSuccess) { finalStatus = ftpSuccess ? 'S4_API_FTP' : 'S3'; finalMessage = ftpSuccess ? 'Data and files submitted successfully.' : 'Data submitted to API. FTP upload failed or was queued.'; } else { finalStatus = ftpSuccess ? 'L4_FTP_ONLY' : 'L4_PENDING_IMAGES'; finalMessage = ftpSuccess ? 'API image upload failed, but files were sent via FTP.' : 'Data submitted, but API image and FTP uploads failed.'; } await _logAndSave(data: data, installationData: installationData, status: finalStatus, message: finalMessage, apiResults: [dataResult, imageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Collection'); _handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: !apiImagesSuccess); 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(), '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; } }