Files
pfm-ocr/lib/features/documents/pending_documents_provider.dart
T
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 4808a798fb Add mobile reliability fixes, Bahasa Indonesia UI, and continue OCR accuracy tuning
Reliability/PoC hardening: dedupe uploads by file_hash, surface editor sync
failures instead of a false success SnackBar with a retry-without-re-OCR path,
bound the OCR pipeline fetches with timeouts, share a single ApiClient/Dio
instance app-wide, tune capture JPEG quality, and add an opt-in
docker-compose.demo.yml for a production-mode run ahead of client demos.

Translate all Flutter-side user-facing text (screens, validators, SnackBars,
the printed delivery receipt, and shared API error messages) to Bahasa
Indonesia.

Also includes in-progress OCR parser/accuracy-tuning work from the same
session: table column/unit normalization fixes, store/customer master data,
accuracy history log, and test-image renaming/cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eRAsLqN9Sg1b9YPz22Lxz
2026-07-04 19:41:24 +07:00

268 lines
7.8 KiB
Dart

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 '../../config/app_config.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,
);
}
}
class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
PendingDocumentsNotifier(this._ref) : super([]);
final Ref _ref;
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];
_uploadAndProcess(id, imagePath, latitude: latitude, longitude: longitude);
}
Future<Position?> _determinePosition() async {
return await LocationService.determinePosition();
}
Future<void> _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) {
state = [
for (final item in state)
if (item.id == id)
item.copyWith(latitude: lat, longitude: lng)
else
item
];
}
}
} 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;
state = [
for (final item in state)
if (item.id == id)
item.copyWith(status: PendingDocumentStatus.processing, document: doc)
else
item
];
// Poll until the document is parsed on the server
bool isParsed = false;
DocumentModel? parsedDoc;
int retries = 0;
const maxRetries = 60; // 2 minutes max polling (60 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<String, dynamic>.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) {
state = [
for (final item in state)
if (item.id == id)
item.copyWith(status: PendingDocumentStatus.success, document: parsedDoc)
else
item
];
} else {
state = [
for (final item in state)
if (item.id == id)
item.copyWith(
status: PendingDocumentStatus.error,
errorMessage: 'Gagal mengekstrak data dari dokumen (Timeout).',
)
else
item
];
}
} catch (e) {
if (!mounted) return;
state = [
for (final item in state)
if (item.id == id)
item.copyWith(status: PendingDocumentStatus.error, errorMessage: e.toString())
else
item
];
}
}
void removeDocument(String id) {
state = state.where((item) => item.id != id).toList();
}
/// 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) {
state = [
for (final item in state)
if (item.id == id)
item.copyWith(status: PendingDocumentStatus.syncFailed, document: doc, errorMessage: message)
else
item
];
}
/// 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<void> 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;
state = [
for (final item in state)
if (item.id == id)
item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null)
else
item
];
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;
state = [
for (final item in state)
if (item.id == id)
item.copyWith(status: PendingDocumentStatus.syncFailed, errorMessage: 'Gagal sinkronisasi ke server: ${e.toString()}')
else
item
];
}
}
void retryUpload(String id) {
final doc = state.firstWhere((item) => item.id == id);
final lat = doc.latitude;
final lng = doc.longitude;
state = [
for (final item in state)
if (item.id == id)
item.copyWith(
status: PendingDocumentStatus.uploading,
errorMessage: null,
)
else
item
];
_uploadAndProcess(id, doc.imagePath, latitude: lat, longitude: lng);
}
}
final pendingDocumentsProvider = StateNotifierProvider<PendingDocumentsNotifier, List<PendingDocument>>((ref) {
return PendingDocumentsNotifier(ref);
});