import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.dart'; import 'package:geolocator/geolocator.dart'; import '../../models/document_model.dart'; import '../../core/location/location_service.dart'; import '../../core/network/api_client.dart'; import '../../core/network/api_exception.dart'; import '../../core/storage/local_storage.dart'; import '../../config/app_config.dart'; import '../../main.dart'; enum PendingDocumentStatus { uploading, processing, success, error, syncFailed } class PendingDocument { final String id; final String imagePath; final PendingDocumentStatus status; final DocumentModel? document; final double? latitude; final double? longitude; final String? errorMessage; final DateTime createdAt; PendingDocument({ required this.id, required this.imagePath, this.status = PendingDocumentStatus.uploading, this.document, this.latitude, this.longitude, this.errorMessage, DateTime? createdAt, }) : createdAt = createdAt ?? DateTime.now(); PendingDocument copyWith({ PendingDocumentStatus? status, DocumentModel? document, double? latitude, double? longitude, String? errorMessage, }) { return PendingDocument( id: id, imagePath: imagePath, status: status ?? this.status, document: document ?? this.document, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, errorMessage: errorMessage, createdAt: createdAt, ); } /// Round-trips with [fromJson] so the queue survives an OS-level app kill /// instead of living in memory only (`LocalStorage.savePendingDocument`). Map toJson() { return { 'id': id, 'image_path': imagePath, 'status': status.name, 'document': document?.toJson(), 'latitude': latitude, 'longitude': longitude, 'error_message': errorMessage, 'created_at': createdAt.toIso8601String(), }; } factory PendingDocument.fromJson(Map json) { PendingDocumentStatus status; try { status = PendingDocumentStatus.values.byName(json['status'] as String? ?? ''); } catch (_) { status = PendingDocumentStatus.error; } final rawDocument = json['document']; return PendingDocument( id: json['id'] as String, imagePath: json['image_path'] as String, status: status, document: rawDocument is Map ? DocumentModel.fromJson(Map.from(rawDocument)) : null, latitude: (json['latitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(), errorMessage: json['error_message'] as String?, createdAt: json['created_at'] != null ? DateTime.parse(json['created_at'] as String) : null, ); } } class PendingDocumentsNotifier extends StateNotifier> { PendingDocumentsNotifier(this._ref) : super([]) { hydrate(); } final Ref _ref; LocalStorage get _storage => _ref.read(localStorageProvider); /// Loads the queue persisted by a previous app run and resumes anything that /// didn't reach a terminal state before the app was killed. Safe to resume /// blindly: the upload endpoint dedupes by file hash server-side, so /// re-uploading an item that actually finished before the kill just returns /// the existing document instead of creating a duplicate. Future hydrate() async { List> persisted; try { persisted = _storage.getAllPendingDocuments(); } catch (_) { // No persisted queue to resume (e.g. storage not yet initialized) - // fall back to starting empty rather than crashing provider setup. return; } if (persisted.isEmpty) return; final docs = persisted.map(PendingDocument.fromJson).toList() ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); state = docs; for (final doc in docs) { if (doc.status == PendingDocumentStatus.uploading) { // `retrySync`'s own transient flip to `uploading` is never persisted // (see `_updateItemInMemory`), so a persisted `uploading` status only // ever means "upload/process from scratch" - from `addDocument` or a // `retryUpload` that got interrupted mid-flight. _uploadAndProcess(doc.id, doc.imagePath, latitude: doc.latitude, longitude: doc.longitude); } else if (doc.status == PendingDocumentStatus.processing && doc.document != null) { _resumePolling(doc.id, doc.document!); } } } /// Applies [update] to the item with [id], both in in-memory state and on /// disk, so every status transition survives an app restart. void _updateItem(String id, PendingDocument Function(PendingDocument item) update) { final updated = _updateItemInMemory(id, update); if (updated != null) { _persistPendingDocument(updated); } } /// Best-effort disk write - a Hive/storage failure is a lost "resume after /// restart" affordance, not something that should break the in-progress /// upload/sync flow itself. void _persistPendingDocument(PendingDocument doc) { try { _storage.savePendingDocument(doc.toJson()).catchError((_) {}); } catch (_) {} } void _removePersistedPendingDocument(String id) { try { _storage.removePendingDocument(id).catchError((_) {}); } catch (_) {} } /// Same as [_updateItem] but doesn't touch disk - for transient, optimistic /// UI states that shouldn't change what a resumed app restart does (see /// `retrySync`). PendingDocument? _updateItemInMemory(String id, PendingDocument Function(PendingDocument item) update) { PendingDocument? updated; state = [ for (final item in state) if (item.id == id) (updated = update(item)) else item ]; return updated; } void addDocument(String imagePath, {double? latitude, double? longitude}) { final id = DateTime.now().millisecondsSinceEpoch.toString(); final newDoc = PendingDocument( id: id, imagePath: imagePath, latitude: latitude, longitude: longitude, ); state = [newDoc, ...state]; _persistPendingDocument(newDoc); _uploadAndProcess(id, imagePath, latitude: latitude, longitude: longitude); } Future _determinePosition() async { return await LocationService.determinePosition(); } Future _uploadAndProcess(String id, String imagePath, {double? latitude, double? longitude}) async { double? lat = latitude; double? lng = longitude; if (lat == null || lng == null) { try { final position = await _determinePosition(); if (position != null) { lat = position.latitude; lng = position.longitude; if (mounted) { _updateItem(id, (item) => item.copyWith(latitude: lat, longitude: lng)); } } } catch (_) { // Gracefully continue without location tags if fetch fails } } try { // API call to upload and process final apiClient = _ref.read(apiClientProvider); final formData = FormData.fromMap({ 'image': await MultipartFile.fromFile( imagePath, filename: imagePath.split(RegExp(r'[/\\]')).last, ), if (lat != null) 'latitude': lat.toString(), if (lng != null) 'longitude': lng.toString(), }); final response = await apiClient.client.post( AppConfig.uploadDocumentEndpoint, data: formData, ); final doc = DocumentModel.fromJson(response.data['data']); if (!mounted) return; _updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.processing, document: doc)); await _pollUntilParsed(id, doc); } catch (e) { if (!mounted) return; _updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: ApiException.from(e).displayMessage)); } } /// Resumes polling for an item whose upload already completed (status /// `processing`) in a previous app run — never re-uploads the image. Future _resumePolling(String id, DocumentModel doc) async { try { await _pollUntilParsed(id, doc); } catch (e) { if (!mounted) return; _updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: ApiException.from(e).displayMessage)); } } Future _pollUntilParsed(String id, DocumentModel doc) async { final apiClient = _ref.read(apiClientProvider); // Poll until the document is parsed on the server bool isParsed = false; DocumentModel? parsedDoc; int retries = 0; // Server's OCR pass can take up to 210s worst case (see // v1/documents/upload/route.ts), so poll comfortably past that instead // of giving up right as the backend might still be finishing. const maxRetries = 130; // ~4.3 minutes max polling (130 retries * 2s) while (!isParsed && retries < maxRetries && mounted) { await Future.delayed(const Duration(seconds: 2)); if (!mounted) return; try { final getRes = await apiClient.client.get(AppConfig.fetchDocumentsEndpoint); if (getRes.statusCode == 200 && getRes.data['data'] is List) { final list = (getRes.data['data'] as List) .map((e) => DocumentModel.fromJson(Map.from(e))) .toList(); final found = list.firstWhere( (element) => element.id == doc.id, orElse: () => doc, ); if (found != doc) { isParsed = true; parsedDoc = found; } } } catch (_) { // Ignore network glitch during polling } retries++; } if (!mounted) return; if (isParsed && parsedDoc != null) { _updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: parsedDoc)); } else { _updateItem(id, (item) => item.copyWith( status: PendingDocumentStatus.error, errorMessage: 'Gagal mengekstrak data dari dokumen (Timeout).', )); } } void removeDocument(String id) { state = state.where((item) => item.id != id).toList(); _removePersistedPendingDocument(id); } /// Marks a pending item as failed to sync after an editor save, keeping the /// finalized [doc] so [retrySync] can resend it without re-running OCR. void markSyncFailed(String id, DocumentModel doc, String message) { _updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.syncFailed, document: doc, errorMessage: message)); } /// Resends a previously-failed editor save. Unlike [retryUpload], this never /// re-uploads the image or re-runs OCR - it only retries the PUT with the /// already-corrected [PendingDocument.document]. Future retrySync(String id) async { final matches = state.where((item) => item.id == id); if (matches.isEmpty) return; final doc = matches.first.document; if (doc == null) return; // Transient/optimistic only - if the app is killed right here, a restart // should resend the PUT (still `syncFailed` on disk), not redo the whole // upload, so this particular flip is never persisted. _updateItemInMemory(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null)); try { final apiClient = _ref.read(apiClientProvider); await apiClient.client.put( '${AppConfig.fetchDocumentsEndpoint}/${doc.id}', data: doc.toPutPayload(), ); if (!mounted) return; removeDocument(id); } catch (e) { if (!mounted) return; _updateItem(id, (item) => item.copyWith( status: PendingDocumentStatus.syncFailed, errorMessage: 'Gagal sinkronisasi ke server: ${ApiException.from(e).displayMessage}', )); } } void retryUpload(String id) { final doc = state.firstWhere((item) => item.id == id); final lat = doc.latitude; final lng = doc.longitude; _updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null)); _uploadAndProcess(id, doc.imagePath, latitude: lat, longitude: lng); } } final pendingDocumentsProvider = StateNotifierProvider>((ref) { return PendingDocumentsNotifier(ref); });