Files
musadaq-saas/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart
T

515 lines
19 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:get/get.dart' hide FormData, MultipartFile;
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:file_picker/file_picker.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/services/upload_progress_service.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/app_snackbar.dart';
import '../../../core/services/image_processing_service.dart';
import '../../../core/services/invoice_upload_service.dart';
import '../../../core/services/live_activity_service.dart';
import '../../../core/network/dio_client.dart';
class ScannerController extends GetxController {
var capturedImages = <File>[].obs;
var isProcessing = false.obs;
var uploadProgress = 0.0.obs;
var companies = <Map<String, dynamic>>[].obs;
var isLoadingCompanies = false.obs;
var currentBatchId = ''.obs;
var processedImagesCount = 0.obs;
var totalImagesCount = 0.obs;
var isBatchDone = false.obs;
var selectedCompanyId = ''.obs;
var selectedCompanyName = ''.obs;
final InvoiceUploadService _uploadService = InvoiceUploadService();
final UploadProgressService _progressService =
Get.find<UploadProgressService>();
/// Kept so the listener is torn down with the controller — otherwise it
/// outlives the screen and calls Get.toNamed() from a disposed controller.
StreamSubscription<RemoteMessage>? _fcmSubscription;
@override
void onInit() {
super.onInit();
fetchCompanies();
_initFcmListener();
// iOS can hand out a new ActivityKit token while an activity is running; the
// previous one silently stops working, so re-register whenever it rotates.
LiveActivityService.instance.onPushTokenChanged =
(_) => _registerLiveActivityToken();
}
@override
void onClose() {
_fcmSubscription?.cancel();
LiveActivityService.instance.onPushTokenChanged = null;
super.onClose();
}
void _initFcmListener() {
_fcmSubscription =
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
final data = message.data;
final type = data['type'];
final batchId = data['batch_id'];
// Ignore progress for a batch we are no longer tracking.
if (batchId == null || batchId != currentBatchId.value) return;
if (isBatchDone.value) return;
final processed = _toInt(data['processed']);
final failed = _toInt(data['failed']);
final total = _toInt(data['total'], fallback: totalImagesCount.value);
if (type == 'invoice_processed' || type == 'batch_progress') {
processedImagesCount.value = processed;
totalImagesCount.value = total;
_progressService.updateProcessingProgress(processed, total,
failed: failed);
// The server marks the last progress push of a batch as done, which lets
// us finish immediately instead of waiting for the next poll.
final isDone = data['is_done'] == '1' || data['is_done'] == 1;
if (isDone) {
isBatchDone.value = true;
_finishBatch(
status: failed > 0 ? 'partial_fail' : 'done',
processed: processed,
failed: failed,
total: total,
items: _itemsFromPush(data),
);
}
} else if (type == 'batch_complete') {
isBatchDone.value = true;
_finishBatch(
status: (data['status'] ?? 'done').toString(),
processed: processed,
failed: failed,
total: total,
items: _itemsFromPush(data),
);
}
});
}
/// Shapes a push payload like a /batches/status item list so _finishBatch can
/// treat both sources the same way.
static List _itemsFromPush(Map<String, dynamic> data) {
final invoiceId = data['invoice_id'];
if (invoiceId == null || invoiceId.toString().isEmpty) return const [];
return [
{'invoice_id': invoiceId}
];
}
Future<void> fetchCompanies() async {
isLoadingCompanies.value = true;
try {
final res = await DioClient().client.get('companies');
if (res.data['success'] == true && res.data['data'] != null) {
companies.value = List<Map<String, dynamic>>.from(res.data['data']);
}
} catch (e) {
AppLogger.error('Failed to fetch companies', e);
} finally {
isLoadingCompanies.value = false;
}
}
Future<void> addImage(String imagePath) async {
File originalFile = File(imagePath);
capturedImages.add(originalFile);
if (imagePath.toLowerCase().endsWith('.pdf')) {
AppLogger.print('Added PDF file, skipping image processing: $imagePath');
return;
}
// Enhancement runs in the background so the camera stays responsive, but the
// result must be written back by identity, not by index: the list can be
// reordered, have items removed, or be cleared by an upload while this is in
// flight, and a stale index would overwrite the wrong photo.
final future = ImageProcessingService.processInvoiceImage(originalFile)
.then((processedFile) {
final index = capturedImages.indexOf(originalFile);
if (processedFile != null && index != -1) {
capturedImages[index] = processedFile;
AppLogger.print('Finished processing image in background.');
}
}).catchError((e) {
AppLogger.error('Failed to process image in background', e);
});
// Tracked so uploadBatch() can wait for enhancement to finish instead of
// shipping the unprocessed originals.
_pendingImageProcessing.add(future);
future.whenComplete(() => _pendingImageProcessing.remove(future));
}
/// Background image-enhancement futures still in flight.
final List<Future<void>> _pendingImageProcessing = [];
Future<void> pickPdfFile() async {
try {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
allowMultiple: true,
);
if (result != null) {
for (var file in result.files) {
if (file.path != null) {
addImage(file.path!);
}
}
AppSnackbar.showSuccess('تمت الإضافة', 'تم استيراد ملفات PDF بنجاح');
}
} catch (e) {
AppLogger.error('Failed to pick PDF', e);
AppSnackbar.showError('خطأ', 'تعذر استيراد ملفات PDF');
}
}
Future<void> pickFromGallery() async {
try {
final ImagePicker picker = ImagePicker();
final List<XFile> images = await picker.pickMultiImage();
if (images.isNotEmpty) {
for (var image in images) {
addImage(image.path);
}
AppSnackbar.showSuccess('تمت الإضافة', 'تم استيراد الصور من المعرض بنجاح');
}
} catch (e) {
AppLogger.error('Failed to pick from gallery', e);
AppSnackbar.showError('خطأ', 'تعذر استيراد الصور من المعرض');
}
}
Future<void> pickExcelFile() async {
if (selectedCompanyId.isEmpty) {
AppSnackbar.showWarning('تنبيه', 'الرجاء اختيار الشركة أولاً');
return;
}
try {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['xlsx', 'xls', 'csv'],
allowMultiple: false,
);
if (result != null && result.files.single.path != null) {
final filePath = result.files.single.path!;
await uploadExcel(filePath);
}
} catch (e) {
AppLogger.error('Failed to pick Excel', e);
AppSnackbar.showError('خطأ', 'تعذر استيراد ملف الإكسل');
}
}
Future<void> uploadExcel(String filePath) async {
try {
isProcessing.value = true;
uploadProgress.value = 0.0;
await _progressService.startUpload(selectedCompanyName.value, 1);
final file = File(filePath);
final fileName = file.path.split('/').last;
FormData formData = FormData.fromMap({
'company_id': selectedCompanyId.value,
'file': await MultipartFile.fromFile(file.path, filename: fileName),
});
final response = await DioClient().client.post(
'excel/import',
data: formData,
onSendProgress: (sent, total) {
uploadProgress.value = sent / total;
_progressService.updateProgress(uploadProgress.value, 1);
},
);
if (response.data['success'] == true) {
_progressService.complete();
AppSnackbar.showSuccess('تم بنجاح', response.data['message'] ?? 'تم استيراد البيانات بنجاح');
Get.back();
} else {
final msg = response.data['message'] ?? 'فشل استيراد ملف الإكسل';
_progressService.fail(msg);
AppSnackbar.showError('خطأ', msg);
}
} catch (e) {
_progressService.fail('حدث خطأ أثناء رفع ملف الإكسل');
AppLogger.error('Excel upload failed', e);
AppSnackbar.showError('خطأ', 'حدث خطأ أثناء رفع ملف الإكسل');
} finally {
isProcessing.value = false;
}
}
void removeImage(int index) {
if (index >= 0 && index < capturedImages.length) {
capturedImages.removeAt(index);
}
}
Future<void> uploadBatch() async {
if (capturedImages.isEmpty) {
AppSnackbar.showWarning('تنبيه', 'الرجاء تصوير فاتورة واحدة على الأقل');
return;
}
if (selectedCompanyId.isEmpty) {
AppSnackbar.showWarning('تنبيه', 'الرجاء اختيار الشركة أولاً');
return;
}
try {
isProcessing.value = true;
uploadProgress.value = 0.0;
// Wait for any in-flight image enhancement so we upload the processed
// versions rather than whichever originals happened to still be in place.
if (_pendingImageProcessing.isNotEmpty) {
AppLogger.print(
'Waiting for ${_pendingImageProcessing.length} image(s) to finish processing...');
await Future.wait(List.of(_pendingImageProcessing));
}
final imagesToUpload = List<File>.of(capturedImages);
AppLogger.print(
'Uploading batch of ${imagesToUpload.length} images to company ${selectedCompanyId.value}...');
// Start global progress
await _progressService.startUpload(
selectedCompanyName.value, imagesToUpload.length);
// Register the Live Activity push token so the server can update the
// lock-screen activity while the app is closed.
await _registerLiveActivityToken();
final result = await _uploadService.uploadBatch(
companyId: selectedCompanyId.value,
images: imagesToUpload,
onProgress: (current, total) {
uploadProgress.value = total > 0 ? current / total : 0.0;
_progressService.updateProgress(uploadProgress.value, current);
},
);
if (result.success && result.batchId != null) {
final batchId = result.batchId!;
currentBatchId.value = batchId;
totalImagesCount.value = result.uploadedCount;
processedImagesCount.value = 0;
isBatchDone.value = false;
capturedImages.clear();
uploadProgress.value = 0.0;
isProcessing.value = false;
selectedCompanyId.value = '';
selectedCompanyName.value = '';
_progressService.startProcessing();
Get.back(); // Go back to dashboard, progress will show in overlay
if (result.isPartial) {
// Be honest: some photos never reached the server.
AppSnackbar.showWarning(
'تم البدء مع تنبيه',
'تم رفع ${result.uploadedCount} صورة، وفشل رفع ${result.failedUploads}. جاري استخراج البيانات للمرفوعة.',
);
} else {
AppSnackbar.showSuccess(
'تم البدء', 'تم رفع الصور بنجاح، جاري استخراج البيانات في الخلفية');
}
_startPolling(batchId);
} else {
// Surface the server's actual reason (quota, bad file, no network)
// instead of one generic failure message.
final message =
result.errorMessage ?? 'فشل رفع الفواتير، يرجى المحاولة لاحقاً';
_progressService.fail(message);
AppSnackbar.showError('خطأ', message);
}
} catch (e) {
_progressService.fail('حدث خطأ غير متوقع أثناء الرفع');
AppLogger.error('Failed to upload batch/single', e);
AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع أثناء الرفع');
} finally {
isProcessing.value = false;
}
}
/// Push the ActivityKit token (iOS) up to the server so it can drive the Live
/// Activity remotely. Silent no-op elsewhere.
Future<void> _registerLiveActivityToken() async {
final token = _progressService.liveActivityPushToken;
if (token == null || token.isEmpty) return;
try {
await DioClient().client.post('auth/mobile/register-device', data: {
'live_activity_token': token,
});
AppLogger.print('Live Activity token registered with server');
} catch (e) {
// Non-critical: local updates still work while the app is foregrounded.
AppLogger.error('Failed to register Live Activity token', e);
}
}
/// Hard ceiling on polling. Without one, a batch that never reached a terminal
/// state (the old code only ever stopped on 'done') kept the app hitting the
/// API every 5s forever while the user stared at a frozen progress bar.
static const Duration _pollTimeout = Duration(minutes: 10);
/// Consecutive network errors tolerated before giving up.
static const int _maxPollErrors = 5;
void _startPolling(String batchId) {
bool firstPoll = true;
int consecutiveErrors = 0;
final deadline = DateTime.now().add(_pollTimeout);
Future.doWhile(() async {
// First poll is after 8 seconds (AI takes time), subsequent are 5 seconds
await Future.delayed(Duration(seconds: firstPoll ? 8 : 5));
firstPoll = false;
// A newer batch was started, or a push notification already finished us.
if (currentBatchId.value != batchId || isBatchDone.value) return false;
if (DateTime.now().isAfter(deadline)) {
AppLogger.error('Polling timed out for batch $batchId', null);
isBatchDone.value = true;
_progressService.fail(
'استغرقت المعالجة وقتاً أطول من المتوقع. تحقق من قائمة الفواتير بعد قليل.');
AppSnackbar.showWarning('تأخر في المعالجة',
'ما زالت الدفعة قيد المعالجة على الخادم. راجع قائمة الفواتير بعد قليل.');
return false;
}
try {
final res = await DioClient()
.client
.get('batches/status', queryParameters: {'batch_id': batchId});
if (res.data['success'] == true) {
consecutiveErrors = 0;
final data = res.data['data'];
final batch = data['batch'];
final items = (data['items'] as List?) ?? const [];
final processed = _toInt(batch['processed_images']);
final failed = _toInt(batch['failed_images']);
final total = _toInt(batch['total_images'], fallback: 1);
final batchStatus = (batch['status'] ?? '').toString();
processedImagesCount.value = processed;
totalImagesCount.value = total;
_progressService.updateProcessingProgress(processed, total,
failed: failed);
// The server tells us when to stop; fall back to the status string for
// older API builds that do not send is_terminal.
final isTerminal = data['is_terminal'] == true ||
const ['done', 'partial_fail', 'failed'].contains(batchStatus);
if (isTerminal) {
isBatchDone.value = true;
_finishBatch(
status: batchStatus,
processed: processed,
failed: failed,
total: total,
items: items,
);
return false; // Stop polling
}
} else {
consecutiveErrors++;
}
} catch (e) {
consecutiveErrors++;
AppLogger.error('Polling error ($consecutiveErrors/$_maxPollErrors)', e);
if (consecutiveErrors >= _maxPollErrors) {
isBatchDone.value = true;
_progressService.fail(
'تعذّر الاتصال بالخادم لمتابعة حالة المعالجة. راجع قائمة الفواتير لاحقاً.');
return false;
}
}
return true; // Continue polling
});
}
/// Single place that decides what the user sees when a batch reaches a
/// terminal state, so polling and push notifications behave identically.
void _finishBatch({
required String status,
required int processed,
required int failed,
required int total,
List items = const [],
}) {
if (failed > 0 && processed > 0) {
_progressService.completePartial(
processed: processed, failed: failed, total: total);
AppSnackbar.showWarning('اكتمل مع أخطاء',
'نجحت $processed فاتورة وفشلت $failed. يمكنك إعادة تصوير الفواتير الفاشلة.');
} else if (processed == 0 && failed > 0) {
_progressService.fail(
'فشل استخراج البيانات من جميع الصور. يرجى إعادة التصوير بإضاءة أفضل.');
AppSnackbar.showError('فشلت المعالجة',
'لم نتمكن من استخراج البيانات. يرجى إعادة التصوير بإضاءة أفضل.');
} else {
_progressService.complete();
}
// Auto-open the invoice only for a clean single-invoice batch.
if (total == 1 && failed == 0 && items.isNotEmpty) {
final invoiceId = items.first['invoice_id'];
if (invoiceId != null && invoiceId.toString().isNotEmpty) {
Get.toNamed('/invoice-detail', arguments: invoiceId);
}
}
}
static int _toInt(dynamic value, {int fallback = 0}) {
if (value == null) return fallback;
return int.tryParse(value.toString()) ?? fallback;
}
void selectCompany(String id, String name) {
selectedCompanyId.value = id;
selectedCompanyName.value = name;
}
Future<String> getSavePath() async {
final directory = await getTemporaryDirectory();
final fileName = 'invoice_${DateTime.now().millisecondsSinceEpoch}.jpg';
return path.join(directory.path, fileName);
}
}