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 = [].obs; var isProcessing = false.obs; var uploadProgress = 0.0.obs; var companies = >[].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(); /// Kept so the listener is torn down with the controller — otherwise it /// outlives the screen and calls Get.toNamed() from a disposed controller. StreamSubscription? _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 data) { final invoiceId = data['invoice_id']; if (invoiceId == null || invoiceId.toString().isEmpty) return const []; return [ {'invoice_id': invoiceId} ]; } Future 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>.from(res.data['data']); } } catch (e) { AppLogger.error('Failed to fetch companies', e); } finally { isLoadingCompanies.value = false; } } Future 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> _pendingImageProcessing = []; Future 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 pickFromGallery() async { try { final ImagePicker picker = ImagePicker(); final List 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 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 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 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.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 _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 getSavePath() async { final directory = await getTemporaryDirectory(); final fileName = 'invoice_${DateTime.now().millisecondsSinceEpoch}.jpg'; return path.join(directory.path, fileName); } }