Update: 2026-07-30 02:27:45

This commit is contained in:
Hamza-Ayed
2026-07-30 02:27:45 +03:00
parent 5f62455113
commit ca4a7c2e70
56 changed files with 3391 additions and 709 deletions
@@ -0,0 +1,9 @@
/// Single source of truth for the API base URL.
///
/// It used to be duplicated as a literal in every place that needed a bare Dio,
/// which meant a URL change had to be found in several files.
class ApiConfig {
const ApiConfig._();
static const String baseUrl = 'https://musadaq.intaleqapp.com/api/v1/';
}
+2 -1
View File
@@ -1,9 +1,10 @@
import 'package:dio/dio.dart';
import 'api_config.dart';
import 'hmac_interceptor.dart';
import '../storage/secure_storage.dart';
class DioClient {
static const String baseUrl = 'https://musadaq.intaleqapp.com/api/v1/'; // Update with actual URL
static const String baseUrl = ApiConfig.baseUrl;
late final Dio dio;
DioClient() {
@@ -1,15 +1,32 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import '../storage/secure_storage.dart';
import 'package:get/get.dart';
import '../storage/secure_storage.dart';
import '../utils/logger.dart';
import 'api_config.dart';
/// Signs outgoing requests with the per-device secret and attaches the JWT.
///
/// The signature format must match HmacMiddleware on the server exactly:
/// payload = "METHOD:path:timestampMs[:jsonBody]"
/// signature = HMAC-SHA256(payload, device_secret) (lowercase hex)
/// headers = X-Signature, X-Timestamp
///
/// Bodies that are not JSON maps (FormData uploads) are signed without a body
/// segment, because there is no canonical string form to hash.
class HmacInterceptor extends Interceptor {
final SecureStorage secureStorage;
HmacInterceptor(this.secureStorage);
/// Guards against several parallel 401s all firing a refresh at once.
static Future<bool>? _inFlightRefresh;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
void onRequest(
RequestOptions options, RequestInterceptorHandler handler) async {
final token = await secureStorage.getToken();
final deviceSecret = await secureStorage.getDeviceSecret();
@@ -20,35 +37,123 @@ class HmacInterceptor extends Interceptor {
// Only sign if we have a device secret (after login)
if (deviceSecret != null) {
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
// Create signature payload
String payload = '${options.method}:${options.path}:$timestamp';
// Include body in signature if present
if (options.data != null && options.data is Map) {
final method = options.method.toUpperCase();
final path = canonicalPath(options.path);
var payload = '$method:$path:$timestamp';
if (options.data is Map) {
payload += ':${jsonEncode(options.data)}';
}
// Generate HMAC-SHA256
final key = utf8.encode(deviceSecret);
final bytes = utf8.encode(payload);
final hmac = Hmac(sha256, key);
final digest = hmac.convert(bytes);
final hmac = Hmac(sha256, utf8.encode(deviceSecret));
final digest = hmac.convert(utf8.encode(payload));
// Attach headers
options.headers['X-Timestamp'] = timestamp;
options.headers['X-Signature'] = digest.toString();
}
super.onRequest(options, handler);
handler.next(options);
}
/// Strips a leading slash and any api/v1 prefix so client and server hash the
/// same string regardless of how the base URL is configured.
static String canonicalPath(String path) {
var p = path;
if (p.startsWith('http')) {
p = Uri.parse(p).path;
}
p = p.split('?').first;
p = p.replaceFirst(RegExp(r'^/+'), '');
for (final prefix in ['api/v1/', 'api/', 'v1/']) {
if (p.startsWith(prefix)) {
p = p.substring(prefix.length);
break;
}
}
return p;
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.response?.statusCode == 401) {
// Handle Token Expiry / Unauthorized
// TODO: Trigger logout or token refresh
void onError(DioException err, ErrorInterceptorHandler handler) async {
final status = err.response?.statusCode;
final path = err.requestOptions.path;
// Only try to recover from an expired access token, and never for the auth
// endpoints themselves (that would recurse).
final isAuthCall = path.contains('auth/');
if (status != 401 || isAuthCall) {
return handler.next(err);
}
final refreshed = await _refreshSession();
if (!refreshed) {
// The session is genuinely gone. Clear it and send the user to login once.
// Previously this branch was an empty TODO, so the app just surfaced
// unexplained errors forever once the 30-day JWT expired.
await secureStorage.clearAll();
if (Get.currentRoute != '/login') {
Get.offAllNamed('/login');
}
return handler.next(err);
}
// Retry the original request once with the fresh token.
try {
final retryDio = Dio(BaseOptions(baseUrl: ApiConfig.baseUrl));
retryDio.interceptors.add(HmacInterceptor(secureStorage));
final response = await retryDio.fetch(err.requestOptions);
return handler.resolve(response);
} catch (e) {
AppLogger.error('Retry after token refresh failed', e);
return handler.next(err);
}
}
/// Exchanges the stored refresh token for a new access token.
/// Concurrent callers share a single in-flight request.
Future<bool> _refreshSession() {
return _inFlightRefresh ??= _doRefresh().whenComplete(() {
_inFlightRefresh = null;
});
}
Future<bool> _doRefresh() async {
try {
final refreshToken = await secureStorage.getRefreshToken();
final deviceId = await secureStorage.getDeviceId();
if (refreshToken == null || deviceId == null) return false;
// A bare Dio: this request must not pass back through this interceptor.
final dio = Dio(BaseOptions(
baseUrl: ApiConfig.baseUrl,
headers: {'Content-Type': 'application/json'},
));
final res = await dio.post('auth/refresh', data: {
'refresh_token': refreshToken,
'device_id': deviceId,
});
final data = res.data is Map ? res.data['data'] : null;
final newAccess = data is Map ? data['access_token'] as String? : null;
if (newAccess == null) return false;
await secureStorage.saveToken(newAccess);
final newRefresh = data is Map ? data['refresh_token'] as String? : null;
if (newRefresh != null && newRefresh.isNotEmpty) {
await secureStorage.saveRefreshToken(newRefresh);
}
AppLogger.print('Access token refreshed');
return true;
} catch (e) {
AppLogger.error('Token refresh failed', e);
return false;
}
super.onError(err, handler);
}
}
@@ -3,74 +3,215 @@ import 'package:dio/dio.dart';
import '../network/dio_client.dart';
import '../utils/logger.dart';
/// Outcome of a batch upload.
///
/// The old API returned `String?` — a null told the caller "it failed" but not
/// why, so the UI could only ever show one generic message. Quota rejections,
/// a dead network and a partially uploaded batch all looked identical.
class BatchUploadResult {
final bool success;
final String? batchId;
/// Arabic message from the server, safe to show to the user.
final String? errorMessage;
/// Images that never made it to the server.
final int failedUploads;
/// Images that were accepted by the server.
final int uploadedCount;
const BatchUploadResult({
required this.success,
this.batchId,
this.errorMessage,
this.failedUploads = 0,
this.uploadedCount = 0,
});
bool get isPartial => success && failedUploads > 0;
}
class InvoiceUploadService {
final Dio _dio = DioClient().client;
/// Uploads a batch of images to the server
/// Returns the batchId if successful, null otherwise.
Future<String?> uploadBatch({
/// How many times a single image upload is retried before giving up on it.
static const int _maxUploadAttempts = 3;
/// Uploads a batch of images to the server.
///
/// Individual images are retried on transient failures; an image that still
/// fails is reported in [BatchUploadResult.failedUploads] rather than silently
/// dropped. The batch is only finalized if at least one image landed.
Future<BatchUploadResult> uploadBatch({
required String companyId,
required List<File> images,
required Function(int current, int total) onProgress,
required void Function(int current, int total) onProgress,
}) async {
String? batchId;
try {
// 1. Create Batch
AppLogger.print('Creating new batch for company: $companyId');
final createResponse = await _dio.post('batches/create', data: {
'company_id': companyId,
// Server reads 'expected_images'; 'total_images' is sent too for older
// builds of the API that only understood that key.
'expected_images': images.length,
'total_images': images.length,
});
if (createResponse.statusCode != 200 && createResponse.statusCode != 201) {
throw Exception('Failed to create batch');
return BatchUploadResult(
success: false,
errorMessage: _messageOf(createResponse.data) ?? 'فشل إنشاء الدفعة',
);
}
final String batchId = createResponse.data['data']['batch_id'];
batchId = createResponse.data['data']['batch_id'] as String;
AppLogger.print('Batch created successfully: $batchId');
// 2. Upload Images sequentially
// 2. Upload images sequentially, retrying each one.
int uploaded = 0;
int failed = 0;
String? lastError;
for (int i = 0; i < images.length; i++) {
final file = images[i];
final fileName = file.path.split('/').last;
FormData formData = FormData.fromMap({
'batch_id': batchId,
'image': await MultipartFile.fromFile(file.path, filename: fileName),
'order_index': i + 1,
});
AppLogger.print('Uploading image ${i + 1}/${images.length}: $fileName');
await _dio.post(
'batches/upload-image',
data: formData,
onSendProgress: (int sent, int total) {
// Can be used for detailed progress bar per image if needed
},
final ok = await _uploadSingleImage(
batchId: batchId,
file: file,
order: i + 1,
onError: (msg) => lastError = msg,
);
if (ok) {
uploaded++;
} else {
failed++;
AppLogger.error('Image ${i + 1} failed after $_maxUploadAttempts attempts', lastError);
}
onProgress(i + 1, images.length);
}
// Nothing landed — do not finalize an empty batch, the server rejects it
// anyway and the user needs a real error.
if (uploaded == 0) {
return BatchUploadResult(
success: false,
batchId: batchId,
errorMessage: lastError ?? 'فشل رفع جميع الصور. تحقق من اتصالك بالإنترنت.',
failedUploads: failed,
);
}
// 3. Finalize Batch
AppLogger.print('Finalizing batch: $batchId');
AppLogger.print('Finalizing batch: $batchId ($uploaded uploaded, $failed failed)');
final finalizeResponse = await _dio.post('batches/finalize', data: {
'batch_id': batchId,
});
if (finalizeResponse.statusCode != 200) {
throw Exception('Failed to finalize batch');
return BatchUploadResult(
success: false,
batchId: batchId,
errorMessage: _messageOf(finalizeResponse.data) ?? 'فشل بدء معالجة الدفعة',
failedUploads: failed,
uploadedCount: uploaded,
);
}
AppLogger.print('Batch finalized successfully!');
return batchId;
return BatchUploadResult(
success: true,
batchId: batchId,
failedUploads: failed,
uploadedCount: uploaded,
);
} on DioException catch (e) {
AppLogger.error('Upload batch failed (DioException)', e.response?.data);
return null;
return BatchUploadResult(
success: false,
batchId: batchId,
errorMessage: _messageOf(e.response?.data) ?? _networkMessage(e),
);
} catch (e) {
AppLogger.error('Upload batch failed', e);
return null;
return BatchUploadResult(
success: false,
batchId: batchId,
errorMessage: 'حدث خطأ غير متوقع أثناء الرفع',
);
}
}
Future<bool> _uploadSingleImage({
required String batchId,
required File file,
required int order,
required void Function(String message) onError,
}) async {
final fileName = file.path.split('/').last;
for (int attempt = 1; attempt <= _maxUploadAttempts; attempt++) {
try {
final formData = FormData.fromMap({
'batch_id': batchId,
'image': await MultipartFile.fromFile(file.path, filename: fileName),
// Server reads 'image_order'; 'order_index' kept for compatibility.
'image_order': order,
'order_index': order,
});
AppLogger.print('Uploading image $order: $fileName (attempt $attempt)');
final res = await _dio.post('batches/upload-image', data: formData);
if (res.statusCode == 200 && res.data['success'] == true) {
return true;
}
onError(_messageOf(res.data) ?? 'فشل رفع الصورة');
} on DioException catch (e) {
final status = e.response?.statusCode ?? 0;
onError(_messageOf(e.response?.data) ?? _networkMessage(e));
// 4xx means the server rejected this file on its merits (bad type, over
// quota, batch closed). Retrying cannot change the answer.
if (status >= 400 && status < 500) {
return false;
}
} catch (e) {
onError('فشل قراءة الصورة من الجهاز');
return false;
}
if (attempt < _maxUploadAttempts) {
await Future.delayed(Duration(milliseconds: 500 * attempt));
}
}
return false;
}
static String? _messageOf(dynamic data) {
if (data is Map && data['message'] is String) {
final msg = data['message'] as String;
if (msg.trim().isNotEmpty) return msg;
}
return null;
}
static String _networkMessage(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return 'انتهت مهلة الاتصال بالخادم. تحقق من الإنترنت وحاول مرة أخرى.';
case DioExceptionType.connectionError:
return 'لا يوجد اتصال بالإنترنت.';
default:
return 'فشل الاتصال بالخادم.';
}
}
}
@@ -0,0 +1,127 @@
import 'dart:io';
import 'package:flutter/services.dart';
import '../utils/logger.dart';
/// Bridge to the platform's "ongoing task" UI.
///
/// * iOS → ActivityKit Live Activity (lock screen + Dynamic Island).
/// * Android → an ongoing progress notification, promoted to a Live Update
/// (the Android 16 equivalent of a Live Activity) where the OS supports it.
///
/// Every method is best-effort: a missing platform implementation or a user who
/// denied notifications must never break an upload. Failures are logged, not
/// thrown.
class LiveActivityService {
LiveActivityService._();
static final LiveActivityService instance = LiveActivityService._();
static const MethodChannel _channel =
MethodChannel('com.musadaq.liveactivity');
bool _active = false;
/// True while an activity/notification is currently on screen.
bool get isActive => _active;
/// Called when iOS rotates the ActivityKit push token mid-activity. The old
/// token stops working, so whoever registered it must send the new one.
void Function(String token)? onPushTokenChanged;
bool _handlerInstalled = false;
void _installHandler() {
if (_handlerInstalled) return;
_handlerInstalled = true;
_channel.setMethodCallHandler((call) async {
if (call.method == 'onPushTokenChanged') {
final token = call.arguments as String?;
if (token != null && token.isNotEmpty) {
_pushToken = token;
AppLogger.print('LiveActivity push token rotated');
onPushTokenChanged?.call(token);
}
}
return null;
});
}
/// The ActivityKit push token for the running Live Activity, if any.
///
/// iOS only, and only available after [start]. This is NOT the FCM token: the
/// server must target this token specifically (APNs `liveactivity` push type)
/// to update the activity while the app is closed.
String? _pushToken;
String? get pushToken => _pushToken;
Future<void> start({
required String companyName,
required int total,
}) async {
_installHandler();
try {
final token = await _channel.invokeMethod<String>('start', {
'companyName': companyName,
'total': total,
'current': 0,
});
_active = true;
if (token != null && token.isNotEmpty) {
_pushToken = token;
AppLogger.print('LiveActivity started, push token acquired');
} else {
AppLogger.print('LiveActivity started (no push token)');
}
} on MissingPluginException {
// Platform has no implementation — nothing to show, carry on.
} on PlatformException catch (e) {
AppLogger.error('LiveActivity start failed', e.message);
} catch (e) {
AppLogger.error('LiveActivity start failed', e);
}
}
Future<void> update({
required int current,
required int total,
int failed = 0,
bool isDone = false,
String? statusText,
}) async {
if (!_active) return;
try {
await _channel.invokeMethod('update', {
'current': current,
'total': total,
'failed': failed,
'isDone': isDone,
if (statusText != null) 'statusText': statusText,
});
} on MissingPluginException {
// ignored
} catch (e) {
AppLogger.error('LiveActivity update failed', e);
}
}
Future<void> end({String? finalText}) async {
if (!_active) return;
_active = false;
_pushToken = null;
try {
await _channel.invokeMethod('end', {
if (finalText != null) 'finalText': finalText,
});
} on MissingPluginException {
// ignored
} catch (e) {
AppLogger.error('LiveActivity end failed', e);
}
}
/// Whether this platform can show a Live Activity at all.
/// Used to decide if the ActivityKit push token is worth uploading.
bool get supportsPushToken => Platform.isIOS;
}
@@ -1,29 +1,175 @@
import 'dart:convert';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:get/get.dart';
import '../network/dio_client.dart';
import '../storage/secure_storage.dart';
import '../utils/logger.dart';
/// Push notifications, end to end.
///
/// Previously this class only asked for permission and logged foreground
/// messages, so:
/// * nothing was ever shown while the app was open,
/// * tapping a notification did nothing,
/// * a token rotated by FCM was never sent to the server, silently killing
/// notifications for that device forever,
/// * on Android the channel the server targets did not exist, so the OS
/// dropped the notification outright.
class PushNotificationService {
static final FirebaseMessaging _fcm = FirebaseMessaging.instance;
static final FlutterLocalNotificationsPlugin _local =
FlutterLocalNotificationsPlugin();
/// Must match the channel_id the server sends and the
/// default_notification_channel_id in AndroidManifest.xml.
static const String channelId = 'high_importance_channel';
static const String channelName = 'إشعارات مُصادَق';
static const String channelDescription =
'إشعارات معالجة الفواتير والتنبيهات المهمة';
static const AndroidNotificationChannel _channel = AndroidNotificationChannel(
channelId,
channelName,
description: channelDescription,
importance: Importance.high,
playSound: true,
);
static bool _initialized = false;
static Future<void> initialize() async {
// 1. Request permissions (iOS)
NotificationSettings settings = await _fcm.requestPermission(
if (_initialized) return;
_initialized = true;
// 1. Request permission (iOS + Android 13+).
final settings = await _fcm.requestPermission(
alert: true,
badge: true,
sound: true,
);
AppLogger.print('User granted permission: ${settings.authorizationStatus}');
// 2. Handle foreground messages
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
AppLogger.print('Received foreground message: ${message.notification?.title}');
// You can show a local notification here if needed
// 2. Create the Android channel BEFORE any notification arrives. On Android
// 8+ a notification addressed to a non-existent channel is discarded.
await _local
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(_channel);
// 3. Initialise local notifications so foreground messages are visible.
await _local.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/launcher_icon'),
iOS: DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
),
),
onDidReceiveNotificationResponse: (response) {
if (response.payload != null && response.payload!.isNotEmpty) {
_routeFromPayload(jsonDecode(response.payload!));
}
},
);
// 4. Foreground messages: FCM does not draw anything itself here, so present
// the notification ourselves.
FirebaseMessaging.onMessage.listen(_showForeground);
// 5. Notification tapped while the app was backgrounded.
FirebaseMessaging.onMessageOpenedApp.listen((message) {
AppLogger.print('Notification opened app: ${message.messageId}');
_routeFromPayload(message.data);
});
// 6. App launched from a terminated state by tapping a notification.
final initialMessage = await _fcm.getInitialMessage();
if (initialMessage != null) {
// Defer until the first route is on screen.
Future.delayed(const Duration(milliseconds: 800),
() => _routeFromPayload(initialMessage.data));
}
// 7. FCM rotates tokens. Without this the server keeps a dead token.
_fcm.onTokenRefresh.listen((token) async {
AppLogger.print('FCM token refreshed');
await registerTokenWithServer(token);
});
}
/// Draw a notification for a foreground message.
/// Data-only messages (batch progress) are intentionally not shown.
static Future<void> _showForeground(RemoteMessage message) async {
final notification = message.notification;
if (notification == null) {
AppLogger.print('Silent data message: ${message.data['type']}');
return;
}
await _local.show(
message.hashCode,
notification.title,
notification.body,
NotificationDetails(
android: AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/launcher_icon',
),
iOS: const DarwinNotificationDetails(presentSound: true),
),
payload: jsonEncode(message.data),
);
}
/// Navigate based on the notification payload.
static void _routeFromPayload(Map<dynamic, dynamic> data) {
final type = data['type']?.toString();
final invoiceId = data['invoice_id']?.toString();
switch (type) {
case 'batch_complete':
case 'invoice_processed':
case 'batch_progress':
if (invoiceId != null && invoiceId.isNotEmpty) {
Get.toNamed('/invoice-detail', arguments: invoiceId);
} else {
Get.toNamed('/main');
}
break;
default:
Get.toNamed('/notifications');
}
}
/// The FCM registration token for this device.
///
/// On iOS the APNs token must exist first; asking for the FCM token too early
/// throws, which is why this used to return null on iPhones.
static Future<String?> getToken() async {
try {
String? token = await _fcm.getToken();
if (Platform.isIOS) {
final apnsToken = await _fcm.getAPNSToken();
if (apnsToken == null) {
// Give APNs registration a moment, then try once more.
await Future.delayed(const Duration(seconds: 3));
final retry = await _fcm.getAPNSToken();
if (retry == null) {
AppLogger.error('APNs token unavailable - push disabled on this device', null);
return null;
}
}
}
final token = await _fcm.getToken();
AppLogger.print('FCM Token: $token');
return token;
} catch (e) {
@@ -31,4 +177,22 @@ class PushNotificationService {
return null;
}
}
/// Send the current token to the server. Safe to call when not signed in.
static Future<void> registerTokenWithServer([String? token]) async {
try {
final jwt = await SecureStorage().getToken();
if (jwt == null) return; // Not signed in yet; login will send it.
final pushToken = token ?? await getToken();
if (pushToken == null) return;
await DioClient().client.post('auth/mobile/register-device', data: {
'push_token': pushToken,
});
AppLogger.print('Push token registered with server');
} catch (e) {
AppLogger.error('Failed to register push token', e);
}
}
}
@@ -1,48 +1,158 @@
import 'dart:async';
import 'package:get/get.dart';
import 'live_activity_service.dart';
/// Batch upload / processing state shown in the in-app overlay, and mirrored to
/// the OS (iOS Live Activity, Android Live Update) so progress stays visible
/// when the app is backgrounded.
class UploadProgressService extends GetxService {
var isUploading = false.obs;
var progress = 0.0.obs;
var companyName = ''.obs;
var totalImages = 0.obs;
var currentImageIndex = 0.obs;
var status = 'uploading'.obs; // uploading, processing, done
void startUpload(String company, int total) {
/// uploading | processing | done | partial | failed
var status = 'uploading'.obs;
/// Images the server could not extract.
var failedImages = 0.obs;
/// User-facing reason, set when [status] is `failed` or `partial`.
var errorMessage = ''.obs;
final LiveActivityService _liveActivity = LiveActivityService.instance;
Timer? _dismissTimer;
/// The ActivityKit push token of the running Live Activity, if the platform
/// provided one. The caller uploads it so the server can push updates.
String? get liveActivityPushToken => _liveActivity.pushToken;
Future<void> startUpload(String company, int total) async {
_dismissTimer?.cancel();
isUploading.value = true;
companyName.value = company;
totalImages.value = total;
currentImageIndex.value = 0;
failedImages.value = 0;
errorMessage.value = '';
progress.value = 0.0;
status.value = 'uploading';
await _liveActivity.start(companyName: company, total: total);
}
void updateProgress(double p, int current) {
progress.value = p;
currentImageIndex.value = current;
// Upload occupies the first half of the bar, extraction the second half.
_liveActivity.update(
current: current,
total: totalImages.value,
statusText: 'جارٍ رفع الصور',
);
}
void startProcessing() {
status.value = 'processing';
progress.value = 0.5;
currentImageIndex.value = 0;
_liveActivity.update(
current: 0,
total: totalImages.value,
statusText: 'جارٍ استخراج البيانات',
);
}
void updateProcessingProgress(int processed, int total) {
void updateProcessingProgress(int processed, int total, {int failed = 0}) {
status.value = 'processing';
progress.value = processed / total;
// Guard against a divide-by-zero that would put NaN into the progress bar.
progress.value = total > 0 ? (processed + failed) / total : 0.0;
currentImageIndex.value = processed;
totalImages.value = total;
failedImages.value = failed;
_liveActivity.update(
current: processed,
total: total,
failed: failed,
statusText: 'جارٍ استخراج البيانات',
);
}
/// All images processed successfully.
void complete() {
status.value = 'done';
progress.value = 1.0;
Future.delayed(const Duration(seconds: 3), () {
currentImageIndex.value = totalImages.value;
_liveActivity.update(
current: totalImages.value,
total: totalImages.value,
isDone: true,
statusText: 'تم بنجاح',
);
_scheduleDismiss(const Duration(seconds: 3), 'تم بنجاح');
}
/// Some images succeeded, some permanently failed.
void completePartial({
required int processed,
required int failed,
required int total,
String? message,
}) {
status.value = 'partial';
totalImages.value = total;
currentImageIndex.value = processed;
failedImages.value = failed;
progress.value = 1.0;
errorMessage.value =
message ?? 'نجحت $processed فاتورة وفشلت $failed. يمكنك إعادة تصوير الفواتير الفاشلة.';
_liveActivity.update(
current: processed,
total: total,
failed: failed,
isDone: true,
statusText: 'اكتمل مع أخطاء',
);
// Kept on screen longer than a clean success: the user has to act on it.
_scheduleDismiss(const Duration(seconds: 10), 'اكتمل مع أخطاء');
}
/// Nothing could be processed, or the upload itself failed.
void fail([String? message]) {
status.value = 'failed';
errorMessage.value = message ?? 'فشلت العملية. يرجى المحاولة مرة أخرى.';
_liveActivity.update(
current: currentImageIndex.value,
total: totalImages.value,
failed: failedImages.value,
isDone: true,
statusText: 'فشلت العملية',
);
_scheduleDismiss(const Duration(seconds: 8), 'فشلت العملية');
}
/// Hide the overlay immediately (user dismissed it).
void dismiss() {
_dismissTimer?.cancel();
isUploading.value = false;
_liveActivity.end();
}
void _scheduleDismiss(Duration after, String finalText) {
_dismissTimer?.cancel();
_dismissTimer = Timer(after, () {
isUploading.value = false;
_liveActivity.end(finalText: finalText);
});
}
void fail() {
isUploading.value = false;
@override
void onClose() {
_dismissTimer?.cancel();
super.onClose();
}
}
@@ -7,6 +7,8 @@ class SecureStorage {
static const String _keyDeviceSecret = 'device_secret';
static const String _keyUserId = 'user_id';
static const String _keyEmail = 'user_email';
static const String _keyRefreshToken = 'refresh_token';
static const String _keyDeviceId = 'device_id';
Future<void> saveToken(String token) async {
await _storage.write(key: _keyToken, value: token);
@@ -24,6 +26,25 @@ class SecureStorage {
return await _storage.read(key: _keyDeviceSecret);
}
/// Long-lived token used to mint a new access token without re-login.
Future<void> saveRefreshToken(String token) async {
await _storage.write(key: _keyRefreshToken, value: token);
}
Future<String?> getRefreshToken() async {
return await _storage.read(key: _keyRefreshToken);
}
/// The device fingerprint sent at login. Needed to refresh, because refresh
/// tokens are stored per device on the server.
Future<void> saveDeviceId(String deviceId) async {
await _storage.write(key: _keyDeviceId, value: deviceId);
}
Future<String?> getDeviceId() async {
return await _storage.read(key: _keyDeviceId);
}
Future<void> saveEmail(String email) async {
await _storage.write(key: _keyEmail, value: email);
}