Update: 2026-07-30 02:27:45
This commit is contained in:
@@ -10,6 +10,10 @@ import '../../../core/utils/app_snackbar.dart';
|
||||
import '../../../core/services/push_notification_service.dart';
|
||||
|
||||
class AuthController extends GetxController {
|
||||
/// App-store review account that skips biometric setup. Must match
|
||||
/// REVIEWER_EMAIL on the server.
|
||||
static const String _reviewerEmail = 'reviewer@musadaq.jo';
|
||||
|
||||
final Dio _dio = DioClient().client;
|
||||
final SecureStorage _storage = SecureStorage();
|
||||
|
||||
@@ -96,6 +100,12 @@ class AuthController extends GetxController {
|
||||
// Save secure data
|
||||
await _storage.saveToken(data['access_token']);
|
||||
await _storage.saveDeviceSecret(data['device_secret']);
|
||||
// Both are required to refresh the session later: the server keeps
|
||||
// refresh tokens per device.
|
||||
await _storage.saveDeviceId(deviceId);
|
||||
if (data['refresh_token'] != null) {
|
||||
await _storage.saveRefreshToken(data['refresh_token']);
|
||||
}
|
||||
if (data['user']['email'] != null) {
|
||||
await _storage.saveEmail(data['user']['email']);
|
||||
}
|
||||
@@ -103,7 +113,7 @@ class AuthController extends GetxController {
|
||||
AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح');
|
||||
|
||||
// Navigate to Biometric Setup (unless it's the reviewer)
|
||||
if (data['user']['email'] == 'reviewer@musadaq.jo') {
|
||||
if (data['user']['email'] == _reviewerEmail) {
|
||||
Get.offAllNamed(AppRoutes.MAIN);
|
||||
} else {
|
||||
Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP);
|
||||
@@ -170,6 +180,10 @@ class AuthController extends GetxController {
|
||||
if (data['device_secret'] != null) {
|
||||
await _storage.saveDeviceSecret(data['device_secret']);
|
||||
}
|
||||
await _storage.saveDeviceId(deviceId);
|
||||
if (data['refresh_token'] != null) {
|
||||
await _storage.saveRefreshToken(data['refresh_token']);
|
||||
}
|
||||
|
||||
if (data['user']['email'] != null) {
|
||||
await _storage.saveEmail(data['user']['email']);
|
||||
@@ -178,7 +192,7 @@ class AuthController extends GetxController {
|
||||
AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح');
|
||||
|
||||
// Navigate to Dashboard for reviewer, else Biometric Setup
|
||||
if (email == 'reviewer@musadaq.jo') {
|
||||
if (email == _reviewerEmail) {
|
||||
Get.offAllNamed(AppRoutes.MAIN);
|
||||
} else {
|
||||
Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP);
|
||||
|
||||
@@ -17,8 +17,11 @@ class MainShellView extends StatefulWidget {
|
||||
|
||||
class _MainShellViewState extends State<MainShellView> {
|
||||
final MainShellController _shellController = Get.find<MainShellController>();
|
||||
// Must be find(), not put(): main.dart already registers the permanent
|
||||
// instance. Re-putting it here swapped in a second, empty instance that other
|
||||
// controllers were no longer holding, so progress updates went nowhere.
|
||||
final UploadProgressService _progressService =
|
||||
Get.put(UploadProgressService());
|
||||
Get.find<UploadProgressService>();
|
||||
|
||||
// 5 pages: Home(0), Invoices(1), [Scanner FAB](2), Notifications(3), Settings(4)
|
||||
final List<Widget> _pages = const [
|
||||
@@ -179,13 +182,37 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
final status = _progressService.status.value;
|
||||
final progress = _progressService.progress.value;
|
||||
|
||||
Color accentColor =
|
||||
status == 'done' ? const Color(0xFF10B981) : const Color(0xFF0F4C81);
|
||||
String statusText = status == 'uploading'
|
||||
? 'جاري رفع الصور...'
|
||||
: (status == 'processing'
|
||||
? 'جاري استخراج البيانات...'
|
||||
: 'اكتملت المعالجة ✓');
|
||||
// Terminal states must look different from in-progress ones, otherwise a
|
||||
// failed or partially failed batch is indistinguishable from a success.
|
||||
final bool isTerminal =
|
||||
status == 'done' || status == 'partial' || status == 'failed';
|
||||
|
||||
final Color accentColor = switch (status) {
|
||||
'done' => const Color(0xFF10B981),
|
||||
'partial' => const Color(0xFFF59E0B),
|
||||
'failed' => const Color(0xFFEF4444),
|
||||
_ => const Color(0xFF0F4C81),
|
||||
};
|
||||
|
||||
final String statusText = switch (status) {
|
||||
'uploading' => 'جاري رفع الصور...',
|
||||
'processing' => 'جاري استخراج البيانات...',
|
||||
'done' => 'اكتملت المعالجة ✓',
|
||||
'partial' => 'اكتملت المعالجة مع أخطاء',
|
||||
'failed' => 'فشلت العملية',
|
||||
_ => 'جاري المعالجة...',
|
||||
};
|
||||
|
||||
final IconData? terminalIcon = switch (status) {
|
||||
'done' => Icons.check_circle,
|
||||
'partial' => Icons.warning_amber_rounded,
|
||||
'failed' => Icons.error_outline,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
final String detailText = _progressService.errorMessage.value.isNotEmpty
|
||||
? _progressService.errorMessage.value
|
||||
: '${_progressService.companyName.value} • ${_progressService.currentImageIndex.value}/${_progressService.totalImages.value}';
|
||||
|
||||
return Card(
|
||||
elevation: 8,
|
||||
@@ -199,9 +226,8 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
status == 'done'
|
||||
? const Icon(Icons.check_circle,
|
||||
color: Color(0xFF10B981), size: 24)
|
||||
terminalIcon != null
|
||||
? Icon(terminalIcon, color: accentColor, size: 24)
|
||||
: const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
@@ -213,10 +239,14 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(statusText,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
color: status == 'failed' ? accentColor : null)),
|
||||
Text(
|
||||
'${_progressService.companyName.value} • ${_progressService.currentImageIndex.value}/${_progressService.totalImages.value}',
|
||||
detailText,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isDark ? Colors.white38 : Colors.grey),
|
||||
@@ -224,13 +254,23 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(progress * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: accentColor),
|
||||
),
|
||||
if (isTerminal)
|
||||
// A terminal card no longer moves on its own, so give the user
|
||||
// a way to clear it instead of waiting out a timer.
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
color: isDark ? Colors.white38 : Colors.grey,
|
||||
onPressed: _progressService.dismiss,
|
||||
tooltip: 'إخفاء',
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'${(progress * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: accentColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart' hide FormData, MultipartFile;
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -12,6 +12,7 @@ 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 {
|
||||
@@ -31,50 +32,87 @@ class ScannerController extends GetxController {
|
||||
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() {
|
||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||
_fcmSubscription =
|
||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||
final data = message.data;
|
||||
final type = data['type'];
|
||||
final batchId = data['batch_id'];
|
||||
|
||||
if (batchId != currentBatchId.value) return;
|
||||
// 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 =
|
||||
int.tryParse(data['processed'].toString()) ?? 0;
|
||||
totalImagesCount.value = int.tryParse(data['total'].toString()) ?? 0;
|
||||
processedImagesCount.value = processed;
|
||||
totalImagesCount.value = total;
|
||||
|
||||
// Update global progress service
|
||||
_progressService.updateProcessingProgress(
|
||||
processedImagesCount.value, totalImagesCount.value);
|
||||
_progressService.updateProcessingProgress(processed, total,
|
||||
failed: failed);
|
||||
|
||||
// If it's a single invoice, we can navigate directly
|
||||
if (totalImagesCount.value == 1 && data['invoice_id'] != null) {
|
||||
// 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;
|
||||
_progressService.complete();
|
||||
|
||||
// Open invoice details
|
||||
Get.toNamed('/invoice-detail', arguments: data['invoice_id']);
|
||||
_finishBatch(
|
||||
status: failed > 0 ? 'partial_fail' : 'done',
|
||||
processed: processed,
|
||||
failed: failed,
|
||||
total: total,
|
||||
items: _itemsFromPush(data),
|
||||
);
|
||||
}
|
||||
} else if (type == 'batch_complete') {
|
||||
isBatchDone.value = true;
|
||||
_progressService.complete();
|
||||
|
||||
// Optionally navigate to invoices list or specific invoice
|
||||
if (data['invoice_id'] != null && data['invoice_id'].toString().isNotEmpty) {
|
||||
Get.toNamed('/invoice-detail', arguments: data['invoice_id']);
|
||||
}
|
||||
_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 {
|
||||
@@ -92,24 +130,36 @@ class ScannerController extends GetxController {
|
||||
Future<void> addImage(String imagePath) async {
|
||||
File originalFile = File(imagePath);
|
||||
capturedImages.add(originalFile);
|
||||
int index = capturedImages.length - 1;
|
||||
|
||||
if (imagePath.toLowerCase().endsWith('.pdf')) {
|
||||
AppLogger.print('Added PDF file, skipping image processing: $imagePath');
|
||||
return;
|
||||
}
|
||||
|
||||
ImageProcessingService.processInvoiceImage(originalFile)
|
||||
// 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) {
|
||||
if (processedFile != null && index < capturedImages.length) {
|
||||
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(
|
||||
@@ -176,9 +226,9 @@ class ScannerController extends GetxController {
|
||||
try {
|
||||
isProcessing.value = true;
|
||||
uploadProgress.value = 0.0;
|
||||
|
||||
_progressService.startUpload(selectedCompanyName.value, 1);
|
||||
|
||||
|
||||
await _progressService.startUpload(selectedCompanyName.value, 1);
|
||||
|
||||
final file = File(filePath);
|
||||
final fileName = file.path.split('/').last;
|
||||
|
||||
@@ -201,11 +251,12 @@ class ScannerController extends GetxController {
|
||||
AppSnackbar.showSuccess('تم بنجاح', response.data['message'] ?? 'تم استيراد البيانات بنجاح');
|
||||
Get.back();
|
||||
} else {
|
||||
_progressService.fail();
|
||||
AppSnackbar.showError('خطأ', response.data['message'] ?? 'فشل استيراد ملف الإكسل');
|
||||
final msg = response.data['message'] ?? 'فشل استيراد ملف الإكسل';
|
||||
_progressService.fail(msg);
|
||||
AppSnackbar.showError('خطأ', msg);
|
||||
}
|
||||
} catch (e) {
|
||||
_progressService.fail();
|
||||
_progressService.fail('حدث خطأ أثناء رفع ملف الإكسل');
|
||||
AppLogger.error('Excel upload failed', e);
|
||||
AppSnackbar.showError('خطأ', 'حدث خطأ أثناء رفع ملف الإكسل');
|
||||
} finally {
|
||||
@@ -233,26 +284,42 @@ class ScannerController extends GetxController {
|
||||
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 ${capturedImages.length} images to company ${selectedCompanyId.value}...');
|
||||
'Uploading batch of ${imagesToUpload.length} images to company ${selectedCompanyId.value}...');
|
||||
|
||||
// Start global progress
|
||||
_progressService.startUpload(selectedCompanyName.value, capturedImages.length);
|
||||
await _progressService.startUpload(
|
||||
selectedCompanyName.value, imagesToUpload.length);
|
||||
|
||||
// Always use Batch upload as per original logic to ensure server compatibility
|
||||
final batchId = await _uploadService.uploadBatch(
|
||||
// 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: capturedImages,
|
||||
images: imagesToUpload,
|
||||
onProgress: (current, total) {
|
||||
uploadProgress.value = current / total;
|
||||
uploadProgress.value = total > 0 ? current / total : 0.0;
|
||||
_progressService.updateProgress(uploadProgress.value, current);
|
||||
},
|
||||
);
|
||||
|
||||
if (batchId != null) {
|
||||
if (result.success && result.batchId != null) {
|
||||
final batchId = result.batchId!;
|
||||
currentBatchId.value = batchId;
|
||||
totalImagesCount.value = capturedImages.length;
|
||||
totalImagesCount.value = result.uploadedCount;
|
||||
processedImagesCount.value = 0;
|
||||
isBatchDone.value = false;
|
||||
|
||||
capturedImages.clear();
|
||||
uploadProgress.value = 0.0;
|
||||
@@ -262,16 +329,29 @@ class ScannerController extends GetxController {
|
||||
|
||||
_progressService.startProcessing();
|
||||
Get.back(); // Go back to dashboard, progress will show in overlay
|
||||
AppSnackbar.showSuccess(
|
||||
'تم البدء', 'تم رفع الصور بنجاح، جاري استخراج البيانات في الخلفية');
|
||||
|
||||
|
||||
if (result.isPartial) {
|
||||
// Be honest: some photos never reached the server.
|
||||
AppSnackbar.showWarning(
|
||||
'تم البدء مع تنبيه',
|
||||
'تم رفع ${result.uploadedCount} صورة، وفشل رفع ${result.failedUploads}. جاري استخراج البيانات للمرفوعة.',
|
||||
);
|
||||
} else {
|
||||
AppSnackbar.showSuccess(
|
||||
'تم البدء', 'تم رفع الصور بنجاح، جاري استخراج البيانات في الخلفية');
|
||||
}
|
||||
|
||||
_startPolling(batchId);
|
||||
} else {
|
||||
_progressService.fail();
|
||||
AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً');
|
||||
// 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();
|
||||
_progressService.fail('حدث خطأ غير متوقع أثناء الرفع');
|
||||
AppLogger.error('Failed to upload batch/single', e);
|
||||
AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع أثناء الرفع');
|
||||
} finally {
|
||||
@@ -279,52 +359,148 @@ class ScannerController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
// Check status periodically
|
||||
int consecutiveErrors = 0;
|
||||
final deadline = DateTime.now().add(_pollTimeout);
|
||||
|
||||
Future.doWhile(() async {
|
||||
// Wait before checking
|
||||
// First poll is after 8 seconds (AI takes time), subsequent are 5 seconds
|
||||
await Future.delayed(Duration(seconds: firstPoll ? 8 : 5));
|
||||
firstPoll = false;
|
||||
|
||||
// If we are no longer interested in this batch or it's done, stop polling
|
||||
// A newer batch was started, or a push notification already finished us.
|
||||
if (currentBatchId.value != batchId || isBatchDone.value) return false;
|
||||
|
||||
try {
|
||||
final res = await DioClient().client.get('batches/status', queryParameters: {'batch_id': batchId});
|
||||
if (res.data['success'] == true) {
|
||||
final batch = res.data['data']['batch'];
|
||||
final items = res.data['data']['items'] as List;
|
||||
|
||||
processedImagesCount.value = int.tryParse(batch['processed_images'].toString()) ?? 0;
|
||||
totalImagesCount.value = int.tryParse(batch['total_images'].toString()) ?? 1;
|
||||
|
||||
_progressService.updateProcessingProgress(processedImagesCount.value, totalImagesCount.value);
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
AppLogger.error('Polling timed out for batch $batchId', null);
|
||||
isBatchDone.value = true;
|
||||
_progressService.fail(
|
||||
'استغرقت المعالجة وقتاً أطول من المتوقع. تحقق من قائمة الفواتير بعد قليل.');
|
||||
AppSnackbar.showWarning('تأخر في المعالجة',
|
||||
'ما زالت الدفعة قيد المعالجة على الخادم. راجع قائمة الفواتير بعد قليل.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (batch['status'] == 'done') {
|
||||
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;
|
||||
_progressService.complete();
|
||||
|
||||
// If it's a single invoice, find the invoice_id and navigate
|
||||
if (totalImagesCount.value == 1 && items.isNotEmpty) {
|
||||
final invoiceId = items.first['invoice_id'];
|
||||
if (invoiceId != null) {
|
||||
Get.toNamed('/invoice-detail', arguments: invoiceId);
|
||||
}
|
||||
}
|
||||
_finishBatch(
|
||||
status: batchStatus,
|
||||
processed: processed,
|
||||
failed: failed,
|
||||
total: total,
|
||||
items: items,
|
||||
);
|
||||
return false; // Stop polling
|
||||
}
|
||||
} else {
|
||||
consecutiveErrors++;
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Polling error', 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;
|
||||
|
||||
Reference in New Issue
Block a user