environment_monitoring_app/lib/services/air_sampling_service.dart

371 lines
17 KiB
Dart

// 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<String, dynamic> _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<File?> 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<Map<String, dynamic>> submitInstallation(AirInstallationData data, List<Map<String, dynamic>>? 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<String, dynamic> 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<String, dynamic> 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<Map<String, dynamic>> submitCollection(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? 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<String, dynamic> 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<String, dynamic> 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<void> _logAndSave({
required dynamic data,
AirInstallationData? installationData,
required String status,
required String message,
required List<Map<String, dynamic>> apiResults,
required List<Map<String, dynamic>> ftpStatuses,
required String serverName,
required String type,
}) async {
String refID;
Map<String, dynamic> formData;
List<String> 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<void> _handleInstallationSuccessAlert(AirInstallationData data, List<Map<String, dynamic>>? 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<void> _handleCollectionSuccessAlert(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? 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<String> _getInstallationImagePaths(AirInstallationData data) {
final List<File?> 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<String> _getCollectionImagePaths(AirCollectionData data) {
final List<File?> 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<List<AirInstallationData>> 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;
}
}