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:
1 parent
2febe0c886
commit
ada6488592
67 files changed
+5705
-1142
No files matched your search
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user