feat(app): scan-mode sync, confirmation-gated documents, single-pass product classification

Fixes reported from APK field testing: DO/Product scan mode was inconsistent
between the camera drawer and documents screen (now one shared provider,
with an orange/green color cue); unconfirmed scans leaked into history with
placeholder data before the user tapped confirm (backend now gates
GET /documents on a new `confirmed` column, flipped only by PUT); and
Product Scan ran the GPU classifier twice, once at upload and again on
review (now a single pass at upload, persisted and read directly by the
editor). Also removes the unused "Hubungkan ke PO" field and fabricated
PO/SO/DO placeholder values from the Product Scan flow, closes out the
per-document-polling and save-recovery tasks (6.1/6.3), and splits several
touched files to stay under the repo's 256-line guideline.

Full detail in docs/iteration-log.md and backend/docs/iteration-log.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-10 15:19:32 +07:00
1 parent 2febe0c886
commit ada6488592
67 files changed
+5705 -1142

No files matched your search

@@ -2,7 +2,6 @@ export 'pending_document_model.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import 'package:intl/intl.dart';
import '../../models/document_model.dart';
import '../../core/location/location_service.dart';
import '../../core/network/api_client.dart';
@@ -11,6 +10,7 @@ import '../../config/app_config.dart';
import '../../main.dart';
import '../camera/scan_mode_provider.dart';
import 'pending_document_model.dart';
import 'poll_outcome.dart';
class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
PendingDocumentsNotifier(this._ref) : super([]) {
@@ -82,52 +82,16 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
} catch (_) {}
}
final scanMode = _ref.read(scanModeProvider);
if (scanMode == 'Product') {
await Future.delayed(const Duration(seconds: 2));
final todayStr = DateFormat('dd MMMM yyyy').format(DateTime.now());
final dummyDoc = DocumentModel(
id: id,
tanggal: todayStr,
noPo: 'PO-PRODUCT-001',
noSo: '1002003004',
noDo: 'DO-PRODUCT-999',
kepadaYth: 'PM KELAPA DUA KARAWACI',
orderUntuk: 'REPLENISHMENT SKU',
alamat: 'Jl. Ancol Barat VIII no.1, Jakarta Utara',
platTruk: 'B 1234 PFM',
namaDriver: 'DUMMY DRIVER (PRODUCT SCAN)',
namaPenerima: '',
latitude: lat,
longitude: lng,
items: [
DocumentItem(
nomorSku: '12010119',
namaBarang: 'FIESTA SPICY CHICKEN NUGGET 500 GR',
banyak: '10',
jumlah: '10',
),
DocumentItem(
nomorSku: '12012504',
namaBarang: 'AKUMO NUGGET COIN 200 GR',
banyak: '5',
jumlah: '5',
),
],
createdAt: DateTime.now(),
);
if (!mounted) return;
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: dummyDoc));
return;
}
try {
final scanMode = _ref.read(scanModeProvider);
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(),
'scan_mode': scanMode,
});
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
@@ -153,23 +117,26 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
Future<void> _pollUntilParsed(String id, DocumentModel doc) async {
final apiClient = _ref.read(apiClientProvider);
bool isParsed = false;
DocumentModel? parsedDoc;
DocumentModel? resultDoc;
String? failureMessage;
int retries = 0;
const maxRetries = 130;
while (!isParsed && retries < maxRetries && mounted) {
while (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;
final getRes = await apiClient.client.get('${AppConfig.fetchDocumentsEndpoint}/${doc.id}');
if (getRes.statusCode == 200 && getRes.data['data'] != null) {
final fetched = DocumentModel.fromJson(Map<String, dynamic>.from(getRes.data['data']));
final outcome = resolvePollOutcome(fetched);
if (outcome.kind == PollOutcomeKind.done) {
resultDoc = outcome.document;
break;
} else if (outcome.kind == PollOutcomeKind.failed) {
failureMessage = outcome.message;
break;
}
}
} catch (_) {}
@@ -177,10 +144,10 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
}
if (!mounted) return;
if (isParsed && parsedDoc != null) {
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: parsedDoc));
if (resultDoc != null) {
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: resultDoc));
} else {
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: 'Gagal mengekstrak data (Timeout).'));
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: failureMessage ?? 'Gagal mengekstrak data (Timeout).'));
}
}
@@ -220,6 +187,11 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null));
_uploadAndProcess(id, doc.imagePath, latitude: doc.latitude, longitude: doc.longitude);
}
void clearQueue() {
state = const [];
_ref.read(localStorageProvider).pendingDocumentsBox.clear();
}
}
final pendingDocumentsProvider = StateNotifierProvider<PendingDocumentsNotifier, List<PendingDocument>>((ref) {