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

+52 -126
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
@@ -11,6 +10,10 @@ import '../editor/pdf_service.dart';
import '../../core/network/api_client.dart';
import 'pending_document_card.dart';
import 'document_card.dart';
import '../camera/scan_mode_provider.dart';
import 'documents_tab_switcher.dart';
import 'documents_empty_state.dart';
import 'document_sync_merge.dart';
class DocumentsScreen extends ConsumerStatefulWidget {
const DocumentsScreen({super.key});
@@ -23,7 +26,12 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
final _searchController = TextEditingController();
List<DocumentModel> _allDocuments = [];
List<DocumentModel> _filteredDocuments = [];
String _selectedTab = 'DO'; // 'DO' | 'Product'
Set<String> _unsyncedDocIds = {};
// _selectedTab removed in task 8.1 -- scanModeProvider is the single source
// of truth for the active tab, shared with CameraDrawer / CameraDrawerModeToggle.
// Caching it locally (and seeding from the provider in initState) caused mode
// desync when the camera drawer changed the mode after this screen was open.
// See docs/api-contract-map.md G11, plans/next-enhancements.md §8.1.
@override
void initState() {
@@ -34,71 +42,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
Future<void> _loadDocuments() async {
final localStorage = ref.read(localStorageProvider);
var docs = localStorage.getAllDocuments();
final prefs = await SharedPreferences.getInstance();
final currentStoreName = prefs.getString('nama_toko') ?? 'PM KELAPA DUA KARAWACI';
bool databaseUpdated = false;
final hasProductScan = docs.any((doc) => doc.orderUntuk == 'PRODUCT SCAN' && doc.kepadaYth == currentStoreName);
if (!hasProductScan) {
final mockProdDoc = DocumentModel(
id: 'MOCK-PROD-SCAN-1',
tanggal: DateFormat('dd MMMM yyyy').format(DateTime.now().subtract(const Duration(days: 1))),
noPo: 'PO-PROD-12010119',
noSo: '1002003004',
noDo: 'Batch 1',
kepadaYth: currentStoreName,
orderUntuk: 'PRODUCT SCAN',
alamat: 'Product Scan: Expired 15/12/2026',
platTruk: 'B 1234 PFM',
namaDriver: 'PRODUCT SCAN',
namaPenerima: 'STORE STAFF',
items: [
DocumentItem(
nomorSku: '12010119',
namaBarang: 'FIESTA SPICY CHICKEN NUGGET 500 GR',
banyak: '1',
jumlah: '1',
)
],
createdAt: DateTime.now().subtract(const Duration(days: 1)),
);
await localStorage.saveDocument(mockProdDoc);
databaseUpdated = true;
}
final hasDoScan = docs.any((doc) => doc.orderUntuk != 'PRODUCT SCAN' && doc.kepadaYth == currentStoreName);
if (!hasDoScan) {
final mockDoDoc = DocumentModel(
id: 'MOCK-DO-SCAN-1',
tanggal: DateFormat('dd MMMM yyyy').format(DateTime.now().subtract(const Duration(days: 2))),
noPo: 'PO-9923488',
noSo: '1002008892',
noDo: 'DO-9988223',
kepadaYth: currentStoreName,
orderUntuk: 'DELIVERY ORDER',
alamat: 'Jl. Boulevard Raya No. 12, Karawaci',
platTruk: 'B 9876 PFM',
namaDriver: 'Budi Santoso',
namaPenerima: 'Staff Toko',
items: [
DocumentItem(
nomorSku: '12010119',
namaBarang: 'FIESTA SPICY CHICKEN NUGGET 500 GR',
banyak: '10',
jumlah: '10',
)
],
createdAt: DateTime.now().subtract(const Duration(days: 2)),
);
await localStorage.saveDocument(mockDoDoc);
databaseUpdated = true;
}
if (databaseUpdated) {
docs = localStorage.getAllDocuments();
}
final docs = localStorage.getAllDocuments();
setState(() {
_allDocuments = docs;
@@ -113,7 +57,22 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
.map((json) => DocumentModel.fromJson(Map<String, dynamic>.from(json)))
.toList();
for (var doc in serverDocs) {
// A document whose editor save PUT failed (syncFailed) still has its
// corrected version saved locally - don't let the server's stale
// pre-edit copy silently overwrite it on refresh (see
// docs/api-contract-map.md G6).
final unsyncedOverrides = <String, DocumentModel>{
for (final pending in ref.read(pendingDocumentsProvider))
if (pending.status == PendingDocumentStatus.syncFailed && pending.document != null)
pending.document!.id: pending.document!,
};
final merged = mergeDocumentsWithUnsyncedOverrides(
serverDocs: serverDocs,
unsyncedOverrides: unsyncedOverrides,
);
await localStorage.documentBox.clear();
for (var doc in merged.docs) {
await localStorage.saveDocument(doc);
}
@@ -122,6 +81,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
setState(() {
_allDocuments = updatedDocs;
_filteredDocuments = updatedDocs;
_unsyncedDocIds = merged.unsyncedIds;
});
}
} catch (e) {
@@ -158,11 +118,21 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
@override
Widget build(BuildContext context) {
final pendingDocs = ref.watch(pendingDocumentsProvider);
// Single source of truth for the active tab -- shared with CameraDrawer
// via scanModeProvider (task 8.1, docs/api-contract-map.md G11).
final selectedTab = ref.watch(scanModeProvider);
final tabFilteredPendingDocs = pendingDocs.where((doc) {
final isProduct = doc.scanMode == 'Product';
if (selectedTab == 'DO') return !isProduct;
if (selectedTab == 'Product') return isProduct;
return true;
}).toList();
final tabFilteredDocs = _filteredDocuments.where((doc) {
final isProduct = doc.orderUntuk == 'PRODUCT SCAN';
if (_selectedTab == 'DO') return !isProduct;
if (_selectedTab == 'Product') return isProduct;
final isProduct = doc.docType == 'Product';
if (selectedTab == 'DO') return !isProduct;
if (selectedTab == 'Product') return isProduct;
return true;
}).toList();
@@ -178,14 +148,14 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
final List<Widget> listItems = [];
if (pendingDocs.isNotEmpty) {
if (tabFilteredPendingDocs.isNotEmpty) {
listItems.add(
Padding(
padding: const EdgeInsets.only(bottom: AppConfig.spaceSmall),
child: Text('Tertunda & Diproses', style: AppConfig.h3),
),
);
listItems.addAll(pendingDocs.map((doc) => PendingDocumentCard(
listItems.addAll(tabFilteredPendingDocs.map((doc) => PendingDocumentCard(
doc: doc,
onReload: () { if (mounted) _loadDocuments(); },
)));
@@ -211,6 +181,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
listItems.addAll(docs.map((doc) => DocumentCard(
doc: doc,
onPrint: _printReceipt,
isUnsynced: _unsyncedDocIds.contains(doc.id),
)));
});
}
@@ -233,19 +204,14 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
),
),
),
_buildTabSwitcher(),
DocumentsTabSwitcher(
selectedTab: selectedTab,
onTabChanged: (tab) =>
ref.read(scanModeProvider.notifier).state = tab,
),
Expanded(
child: (pendingDocs.isEmpty && tabFilteredDocs.isEmpty)
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.folder_open, size: 64, color: AppConfig.textSecondary.withOpacity(0.5)),
const SizedBox(height: AppConfig.spaceLarge),
Text('Tidak ada dokumen ditemukan', style: AppConfig.bodyLarge),
],
),
)
child: (tabFilteredPendingDocs.isEmpty && tabFilteredDocs.isEmpty)
? const DocumentsEmptyState()
: ListView(
padding: const EdgeInsets.symmetric(horizontal: AppConfig.spaceLarge),
children: listItems,
@@ -255,44 +221,4 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
),
);
}
Widget _buildTabSwitcher() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: AppConfig.spaceLarge, vertical: AppConfig.spaceSmall),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium)),
child: Row(
children: [
_buildTabItem('DO', 'DO Scan'),
_buildTabItem('Product', 'Product Scan'),
],
),
);
}
Widget _buildTabItem(String value, String label) {
final isActive = _selectedTab == value;
return Expanded(
child: GestureDetector(
onTap: () => setState(() { _selectedTab = value; }),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
boxShadow: isActive ? [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 4, offset: const Offset(0, 2))] : null,
),
child: Center(
child: Text(
label,
style: TextStyle(
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: isActive ? AppConfig.primaryColor : AppConfig.textSecondary,
),
),
),
),
),
);
}
}