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

+21 -9
View File
@@ -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),
],
),
);
}
}
+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,
),
),
),
),
),
);
}
}
@@ -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) {
+39
View File
@@ -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;
}
}