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
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-04 19:41:24 +07:00
1 parent 095dd4cb8b
commit 4808a798fb
78 files changed
+8069 -584

No files matched your search

+45 -44
View File
@@ -158,10 +158,10 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Confirm Submission', style: AppConfig.h2),
Text('Konfirmasi Pengiriman', style: AppConfig.h2),
const SizedBox(height: 16),
Text(
'Please enter your name (as recipient/storekeeper) and check the agreement box to confirm delivery.',
'Masukkan nama Anda (sebagai penerima/penjaga toko) dan centang kotak persetujuan untuk mengonfirmasi pengiriman.',
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
),
const SizedBox(height: 16),
@@ -195,7 +195,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
},
),
Expanded(
child: Text('I agree and confirm the data is accurate.', style: AppConfig.bodyMedium),
child: Text('Saya setuju dan mengonfirmasi bahwa data ini sudah benar.', style: AppConfig.bodyMedium),
)
],
),
@@ -208,7 +208,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
Navigator.pop(context); // close modal
_submitDocument();
} : null,
child: const Text('Confirm'),
child: const Text('Konfirmasi'),
),
),
const SizedBox(height: 24),
@@ -259,45 +259,46 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
await localStorage.saveDocument(finalDoc);
// Try to sync with server
bool syncedToServer = false;
String? syncError;
try {
final apiClient = ApiClient();
final apiClient = ref.read(apiClientProvider);
await apiClient.client.put(
'${AppConfig.fetchDocumentsEndpoint}/${finalDoc.id}',
data: {
'tanggal': finalDoc.tanggal,
'noPo': finalDoc.noPo,
'noSo': finalDoc.noSo,
'noDo': finalDoc.noDo,
'kepadaYth': finalDoc.kepadaYth,
'orderUntuk': finalDoc.orderUntuk,
'alamat': finalDoc.alamat,
'platTruk': finalDoc.platTruk,
'namaDriver': finalDoc.namaDriver,
'namaPenerima': finalDoc.namaPenerima,
'latitude': finalDoc.latitude,
'longitude': finalDoc.longitude,
'items': finalDoc.items.map((e) => {
'nomor_sku': e.nomorSku,
'nama_barang': e.namaBarang,
'banyak': e.banyak,
'jumlah': e.jumlah,
}).toList(),
},
data: finalDoc.toPutPayload(),
);
syncedToServer = true;
debugPrint('Document synced with server successfully');
} catch (e) {
syncError = e.toString();
debugPrint('Failed to sync document with server: $e. Saved locally (cached).');
}
// Remove from pending
if (widget.pendingId != null) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
if (syncedToServer) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
} else {
// Keep it visible under "Pending & Processing" with a distinct status so the
// correction isn't silently lost - the user can retry the sync from there
// without re-uploading the image or re-running OCR.
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
widget.pendingId!,
finalDoc,
'Belum tersinkron ke server: $syncError',
);
}
}
if (mounted) {
// Show SnackBar BEFORE pop so context is still valid
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Document Saved Successfully!'), backgroundColor: AppConfig.successColor),
syncedToServer
? const SnackBar(content: Text('Dokumen Berhasil Disimpan!'), backgroundColor: AppConfig.successColor)
: const SnackBar(
content: Text('Tersimpan lokal, tapi GAGAL sinkron ke server. Cek koneksi lalu retry dari daftar dokumen.'),
backgroundColor: Colors.deepOrange,
duration: Duration(seconds: 5),
),
);
// Pass true as result so DocumentsScreen's .then() knows to reload
context.pop(true);
@@ -342,7 +343,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
),
validator: validator ?? (value) {
if (value == null || value.isEmpty) {
return 'Please enter $label';
return '$label harus diisi';
}
return null;
},
@@ -354,14 +355,14 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
Widget build(BuildContext context) {
if (_document == null) {
return Scaffold(
appBar: AppBar(title: const Text('Review Data')),
body: const Center(child: Text('Document not found or still processing')),
appBar: AppBar(title: const Text('Tinjau Data')),
body: const Center(child: Text('Dokumen tidak ditemukan atau masih diproses')),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Review & Edit Data'),
title: const Text('Tinjau & Edit Data'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
@@ -387,7 +388,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
const SizedBox(width: 12),
Expanded(
child: Text(
'Please review and edit the extracted data below to ensure accuracy before confirmation.',
'Tinjau dan edit data hasil ekstraksi di bawah ini untuk memastikan keakuratannya sebelum konfirmasi.',
style: AppConfig.bodyMedium.copyWith(color: AppConfig.primaryColor),
),
),
@@ -408,7 +409,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Header Details', style: AppConfig.h2),
Text('Detail Header', style: AppConfig.h2),
const SizedBox(height: 16),
_buildTextField(
'Tanggal',
@@ -443,7 +444,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
'No PO',
_noPoCtrl,
validator: (val) {
if (val == null || val.isEmpty) return 'Please enter No PO';
if (val == null || val.isEmpty) return 'No PO harus diisi';
if (!RegExp(r'^PO/\d{2}/\d{10}$').hasMatch(val)) {
return 'Format harus PO/[XX]/[XXXXXXXXXX]';
}
@@ -459,7 +460,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
LengthLimitingTextInputFormatter(10),
],
validator: (val) {
if (val == null || val.isEmpty) return 'Please enter No SO';
if (val == null || val.isEmpty) return 'No SO harus diisi';
if (val.length != 10) {
return 'SO harus persis 10 angka';
}
@@ -485,7 +486,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Shipment Details', style: AppConfig.h2),
Text('Detail Pengiriman', style: AppConfig.h2),
const SizedBox(height: 16),
_buildTextField('Kepada Yth', _kepadaYthCtrl),
_buildTextField('Order Untuk', _orderUntukCtrl),
@@ -510,15 +511,15 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Geotagging Details', style: AppConfig.h2),
Text('Detail Lokasi', style: AppConfig.h2),
const SizedBox(height: 16),
_buildTextField(
'Latitude',
'Lintang',
_latitudeCtrl,
readOnly: true,
),
_buildTextField(
'Longitude',
'Bujur',
_longitudeCtrl,
readOnly: true,
),
@@ -543,7 +544,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Items (${_itemControllers.length})', style: AppConfig.h2),
Text('Barang (${_itemControllers.length})', style: AppConfig.h2),
],
),
const SizedBox(height: 16),
@@ -581,7 +582,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _removeItem(index),
tooltip: 'Delete Item',
tooltip: 'Hapus Barang',
),
],
),
@@ -591,7 +592,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
ctrls['sku']!,
validator: (val) {
if (val == null || val.isEmpty) {
return 'Please enter SKU';
return 'SKU harus diisi';
}
if (!MasterSku.data.containsKey(val)) {
return 'SKU tidak terdaftar';
@@ -647,7 +648,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: _showConfirmationDialog,
child: const Text('Review Complete', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
child: const Text('Selesai Ditinjau', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
),
+3 -3
View File
@@ -23,7 +23,7 @@ class PdfService {
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('RECEIPT OF DELIVERY', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
pw.Text('TANDA TERIMA PENGIRIMAN', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(
width: 60,
height: 60,
@@ -53,7 +53,7 @@ class PdfService {
pw.SizedBox(height: 20),
// Shipment Data
pw.Text('Shipment Details', style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
pw.Text('Detail Pengiriman', style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 8),
pw.Container(
padding: const pw.EdgeInsets.all(10),
@@ -75,7 +75,7 @@ class PdfService {
pw.SizedBox(height: 20),
// Items Table
pw.Text('Items Delivered', style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
pw.Text('Barang Dikirim', style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 8),
pw.Table.fromTextArray(
context: context,