490 lines
20 KiB
Dart
490 lines
20 KiB
Dart
// 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<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));
|
|
}
|
|
|
|
// --- 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<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';
|
|
|
|
// --- 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<String, dynamic> apiDataResult = {};
|
|
Map<String, dynamic> 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<String, dynamic> ftpDataResult = {'statuses': []};
|
|
Map<String, dynamic> 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<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';
|
|
|
|
// --- 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<String, dynamic> apiDataResult = {};
|
|
Map<String, dynamic> 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<String, dynamic> ftpDataResult = {'statuses': []};
|
|
Map<String, dynamic> 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<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,
|
|
// --- 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<String, dynamic> formData;
|
|
List<String> 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<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;
|
|
}
|
|
} |