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
268 lines
7.8 KiB
Dart
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);
|
|
});
|