Update: 2026-07-30 02:27:45
This commit is contained in:
@@ -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/';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,21 +19,29 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 1. Initialize Firebase & Notifications
|
||||
// 1. Initialize Firebase
|
||||
await Firebase.initializeApp();
|
||||
await PushNotificationService.initialize();
|
||||
|
||||
// 2. Register background handler
|
||||
// 2. Register the background handler BEFORE any listener is attached, so a
|
||||
// message arriving during startup is not lost.
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||
|
||||
// 3. Security check (MUST be first)
|
||||
// 3. Notifications (permissions, Android channel, tap routing, token refresh)
|
||||
await PushNotificationService.initialize();
|
||||
|
||||
// 4. Security check (MUST be first)
|
||||
Get.put(DeviceSecurityService(), permanent: true);
|
||||
|
||||
// 4. Register global services
|
||||
// 5. Register global services
|
||||
Get.put(UploadProgressService(), permanent: true);
|
||||
Get.put(HomeWidgetService(), permanent: true);
|
||||
Get.put(ShorebirdUpdateService(), permanent: true);
|
||||
|
||||
// 6. Re-sync the push token on every launch. It can rotate while the app is
|
||||
// closed (reinstall, restore from backup, FCM rotation), and a stale token on
|
||||
// the server means the device silently stops receiving notifications.
|
||||
PushNotificationService.registerTokenWithServer();
|
||||
|
||||
runApp(const MusadaqApp());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user