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
@@ -6,33 +6,45 @@ import '../../models/document_model.dart';
|
||||
class DocumentCard extends StatelessWidget {
|
||||
final DocumentModel doc;
|
||||
final Future<void> Function(DocumentModel) onPrint;
|
||||
final bool isUnsynced;
|
||||
|
||||
const DocumentCard({
|
||||
super.key,
|
||||
required this.doc,
|
||||
required this.onPrint,
|
||||
this.isUnsynced = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isProductScan = doc.orderUntuk == 'PRODUCT SCAN';
|
||||
final isProductScan = doc.docType == 'Product';
|
||||
|
||||
// Header category text
|
||||
final categoryText = isProductScan
|
||||
? (doc.items.isNotEmpty ? doc.items.first.namaBarang : 'Product Scan')
|
||||
: (doc.namaPenerima.isNotEmpty ? doc.namaPenerima : 'Staff Toko');
|
||||
|
||||
// Category color (greenish-blue for product, orange-yellow for DO)
|
||||
final categoryColor = isProductScan ? AppConfig.primaryColor : Colors.orange.shade700;
|
||||
// Category color: Product → green (primaryColor), DO → orange (doModeColor).
|
||||
// doModeColor is the shared constant for the DO orange cue across the app
|
||||
// (camera drawer, tab switcher, and here) — was an ad-hoc inline value.
|
||||
final categoryColor = isProductScan ? AppConfig.primaryColor : AppConfig.doModeColor;
|
||||
|
||||
// Sync status badge (see docs/api-contract-map.md G6 - a document can be
|
||||
// corrected locally after its PUT sync failed; the corrected copy is
|
||||
// shown here, so the badge must reflect that it hasn't reached the
|
||||
// server yet rather than always claiming "Terkonfirmasi").
|
||||
final badgeLabel = isUnsynced ? 'Belum Tersinkron' : 'Terkonfirmasi';
|
||||
final badgeColor = isUnsynced ? Colors.deepOrange : AppConfig.successColor;
|
||||
|
||||
// Bold title
|
||||
final boldTitle = isProductScan
|
||||
? doc.noDo
|
||||
: doc.noPo;
|
||||
|
||||
// Subtitle text
|
||||
// Subtitle text. Product Scan no longer links to a PO document (field
|
||||
// removed - user request), so its subtitle is just the expiry date.
|
||||
final subtitleText = isProductScan
|
||||
? '${_formatDateString(doc.alamat.replaceAll("Product Scan: Expired ", ""))} • ${doc.noPo}'
|
||||
? _formatDateString(doc.alamat.replaceAll("Product Scan: Expired ", ""))
|
||||
: '${doc.tanggal} • ${doc.items.length} Item';
|
||||
|
||||
return Card(
|
||||
@@ -92,17 +104,17 @@ class DocumentCard extends StatelessWidget {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Status badge (e.g. Approved / Terkonfirmasi)
|
||||
// Status badge (Terkonfirmasi / Belum Tersinkron)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.successColor.withOpacity(0.1),
|
||||
color: badgeColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
),
|
||||
child: Text(
|
||||
'Terkonfirmasi',
|
||||
badgeLabel,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: AppConfig.successColor,
|
||||
color: badgeColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
/// Result of merging the server's document list with local corrections that
|
||||
/// haven't made it back to the server yet.
|
||||
class MergedDocuments {
|
||||
final List<DocumentModel> docs;
|
||||
final Set<String> unsyncedIds;
|
||||
|
||||
const MergedDocuments(this.docs, this.unsyncedIds);
|
||||
}
|
||||
|
||||
/// Merges the server's authoritative document list with local corrections for
|
||||
/// documents whose `PUT` sync failed (`syncFailed` in the pending queue).
|
||||
///
|
||||
/// Without this, refreshing the documents list would silently overwrite a
|
||||
/// driver's corrected-but-unsynced edit with the server's stale pre-edit copy
|
||||
/// on every successful fetch - the correction is still recoverable via the
|
||||
/// pending queue's retry action, but the history list would show the wrong
|
||||
/// data (or the document would vanish from it entirely) until the retry
|
||||
/// succeeds. `unsyncedOverrides` wins over `serverDocs` for matching ids, and
|
||||
/// is kept even if the server list doesn't (yet) include that id at all.
|
||||
MergedDocuments mergeDocumentsWithUnsyncedOverrides({
|
||||
required List<DocumentModel> serverDocs,
|
||||
required Map<String, DocumentModel> unsyncedOverrides,
|
||||
}) {
|
||||
final merged = <String, DocumentModel>{
|
||||
for (final doc in serverDocs) doc.id: doc,
|
||||
};
|
||||
for (final entry in unsyncedOverrides.entries) {
|
||||
merged[entry.key] = entry.value;
|
||||
}
|
||||
return MergedDocuments(merged.values.toList(), unsyncedOverrides.keys.toSet());
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
class DocumentsEmptyState extends StatelessWidget {
|
||||
const DocumentsEmptyState({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
class DocumentsTabSwitcher extends StatelessWidget {
|
||||
final String selectedTab;
|
||||
final ValueChanged<String> onTabChanged;
|
||||
|
||||
const DocumentsTabSwitcher({
|
||||
super.key,
|
||||
required this.selectedTab,
|
||||
required this.onTabChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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', Icons.description, Icons.description_outlined),
|
||||
_buildTabItem('Product', 'Product Scan', Icons.inventory_2, Icons.inventory_2_outlined),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabItem(String value, String label, IconData activeIcon, IconData inactiveIcon) {
|
||||
final isActive = selectedTab == value;
|
||||
// Color cue: DO mode -> doModeColor (orange), Product mode -> primaryColor (green).
|
||||
// Applies to both the icon and the label text (user feedback: "icon dan
|
||||
// juga button turn color sesuai warna statenya") - mirrors the same
|
||||
// icon+color pairing already used in camera_drawer_mode_toggle.dart and
|
||||
// document_card.dart's top-label (task 8.1, docs/api-contract-map.md G11).
|
||||
// Generic/default chrome elsewhere (search icon, print icon, tooltips)
|
||||
// deliberately stays untouched.
|
||||
final activeColor = value == 'DO' ? AppConfig.doModeColor : AppConfig.primaryColor;
|
||||
final color = isActive ? activeColor : AppConfig.textSecondary;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onTabChanged(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: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(isActive ? activeIcon : inactiveIcon, size: 16, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
enum PollOutcomeKind { done, failed, pending }
|
||||
|
||||
/// Result of inspecting a single `GET /documents/:id` poll response.
|
||||
/// Deliberately separated from the Dio call site
|
||||
/// (`pending_documents_provider.dart`'s `_pollUntilParsed`) so the
|
||||
/// parse-status branching is unit-testable without mocking network I/O -
|
||||
/// mirrors the pure-function pattern already used for `document_sync_merge.dart`
|
||||
/// (task 6.2) and `product_scan_response_parser.dart` (task 7.1).
|
||||
class PollOutcome {
|
||||
final PollOutcomeKind kind;
|
||||
final DocumentModel? document;
|
||||
final String? message;
|
||||
|
||||
const PollOutcome._(this.kind, {this.document, this.message});
|
||||
|
||||
factory PollOutcome.done(DocumentModel document) =>
|
||||
PollOutcome._(PollOutcomeKind.done, document: document);
|
||||
|
||||
factory PollOutcome.failed(String message) =>
|
||||
PollOutcome._(PollOutcomeKind.failed, message: message);
|
||||
|
||||
static const PollOutcome pending = PollOutcome._(PollOutcomeKind.pending);
|
||||
}
|
||||
|
||||
/// `parseStatus` absent/unrecognized (a legacy/cached response predating
|
||||
/// backend task 9.1) is treated the same as `"pending"` - keep polling rather
|
||||
/// than surface a false failure.
|
||||
PollOutcome resolvePollOutcome(DocumentModel fetched) {
|
||||
switch (fetched.parseStatus) {
|
||||
case 'done':
|
||||
return PollOutcome.done(fetched);
|
||||
case 'failed':
|
||||
return PollOutcome.failed('Gagal mengekstrak data (parsing gagal di server).');
|
||||
default:
|
||||
return PollOutcome.pending;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user