feat(app): implement Product Scan review flow, dynamic batch expiry picker, custom PDFs, PO relationships, and aligned card layouts
This commit is contained in:
1 parent
9ff4a4a922
commit
577be04308
69 files changed
+5645
-1657
No files matched your search
@@ -1,236 +1,140 @@
|
||||
export 'pending_document_model.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../models/document_model.dart';
|
||||
import '../../core/location/location_service.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/network/api_exception.dart';
|
||||
import '../../core/storage/local_storage.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../main.dart';
|
||||
|
||||
enum PendingDocumentStatus { uploading, processing, success, error, syncFailed }
|
||||
|
||||
class PendingDocument {
|
||||
final String id;
|
||||
final String imagePath;
|
||||
final PendingDocumentStatus status;
|
||||
final DocumentModel? document;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? errorMessage;
|
||||
final DateTime createdAt;
|
||||
|
||||
PendingDocument({
|
||||
required this.id,
|
||||
required this.imagePath,
|
||||
this.status = PendingDocumentStatus.uploading,
|
||||
this.document,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.errorMessage,
|
||||
DateTime? createdAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
PendingDocument copyWith({
|
||||
PendingDocumentStatus? status,
|
||||
DocumentModel? document,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return PendingDocument(
|
||||
id: id,
|
||||
imagePath: imagePath,
|
||||
status: status ?? this.status,
|
||||
document: document ?? this.document,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
errorMessage: errorMessage,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Round-trips with [fromJson] so the queue survives an OS-level app kill
|
||||
/// instead of living in memory only (`LocalStorage.savePendingDocument`).
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'image_path': imagePath,
|
||||
'status': status.name,
|
||||
'document': document?.toJson(),
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'error_message': errorMessage,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory PendingDocument.fromJson(Map<String, dynamic> json) {
|
||||
PendingDocumentStatus status;
|
||||
try {
|
||||
status = PendingDocumentStatus.values.byName(json['status'] as String? ?? '');
|
||||
} catch (_) {
|
||||
status = PendingDocumentStatus.error;
|
||||
}
|
||||
|
||||
final rawDocument = json['document'];
|
||||
|
||||
return PendingDocument(
|
||||
id: json['id'] as String,
|
||||
imagePath: json['image_path'] as String,
|
||||
status: status,
|
||||
document: rawDocument is Map
|
||||
? DocumentModel.fromJson(Map<String, dynamic>.from(rawDocument))
|
||||
: null,
|
||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||
errorMessage: json['error_message'] as String?,
|
||||
createdAt: json['created_at'] != null ? DateTime.parse(json['created_at'] as String) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
import '../camera/scan_mode_provider.dart';
|
||||
import 'pending_document_model.dart';
|
||||
|
||||
class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
|
||||
PendingDocumentsNotifier(this._ref) : super([]) {
|
||||
hydrate();
|
||||
_loadPendingDocuments();
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
LocalStorage get _storage => _ref.read(localStorageProvider);
|
||||
void _loadPendingDocuments() {
|
||||
final localStorage = _ref.read(localStorageProvider);
|
||||
final list = localStorage.getAllPendingDocuments()
|
||||
.map((e) => PendingDocument.fromJson(e))
|
||||
.toList();
|
||||
state = list;
|
||||
|
||||
/// Loads the queue persisted by a previous app run and resumes anything that
|
||||
/// didn't reach a terminal state before the app was killed. Safe to resume
|
||||
/// blindly: the upload endpoint dedupes by file hash server-side, so
|
||||
/// re-uploading an item that actually finished before the kill just returns
|
||||
/// the existing document instead of creating a duplicate.
|
||||
Future<void> hydrate() async {
|
||||
List<Map<String, dynamic>> persisted;
|
||||
try {
|
||||
persisted = _storage.getAllPendingDocuments();
|
||||
} catch (_) {
|
||||
// No persisted queue to resume (e.g. storage not yet initialized) -
|
||||
// fall back to starting empty rather than crashing provider setup.
|
||||
return;
|
||||
}
|
||||
if (persisted.isEmpty) return;
|
||||
|
||||
final docs = persisted.map(PendingDocument.fromJson).toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
state = docs;
|
||||
|
||||
for (final doc in docs) {
|
||||
if (doc.status == PendingDocumentStatus.uploading) {
|
||||
// `retrySync`'s own transient flip to `uploading` is never persisted
|
||||
// (see `_updateItemInMemory`), so a persisted `uploading` status only
|
||||
// ever means "upload/process from scratch" - from `addDocument` or a
|
||||
// `retryUpload` that got interrupted mid-flight.
|
||||
_uploadAndProcess(doc.id, doc.imagePath, latitude: doc.latitude, longitude: doc.longitude);
|
||||
} else if (doc.status == PendingDocumentStatus.processing && doc.document != null) {
|
||||
for (var doc in list) {
|
||||
if (doc.status == PendingDocumentStatus.processing && doc.document != null) {
|
||||
_resumePolling(doc.id, doc.document!);
|
||||
} else if (doc.status == PendingDocumentStatus.uploading) {
|
||||
_uploadAndProcess(doc.id, doc.imagePath, latitude: doc.latitude, longitude: doc.longitude);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies [update] to the item with [id], both in in-memory state and on
|
||||
/// disk, so every status transition survives an app restart.
|
||||
void _updateItem(String id, PendingDocument Function(PendingDocument item) update) {
|
||||
final updated = _updateItemInMemory(id, update);
|
||||
if (updated != null) {
|
||||
_persistPendingDocument(updated);
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort disk write - a Hive/storage failure is a lost "resume after
|
||||
/// restart" affordance, not something that should break the in-progress
|
||||
/// upload/sync flow itself.
|
||||
void _persistPendingDocument(PendingDocument doc) {
|
||||
try {
|
||||
_storage.savePendingDocument(doc.toJson()).catchError((_) {});
|
||||
} catch (_) {}
|
||||
_ref.read(localStorageProvider).savePendingDocument(doc.toJson());
|
||||
}
|
||||
|
||||
void _removePersistedPendingDocument(String id) {
|
||||
try {
|
||||
_storage.removePendingDocument(id).catchError((_) {});
|
||||
} catch (_) {}
|
||||
_ref.read(localStorageProvider).removePendingDocument(id);
|
||||
}
|
||||
|
||||
/// Same as [_updateItem] but doesn't touch disk - for transient, optimistic
|
||||
/// UI states that shouldn't change what a resumed app restart does (see
|
||||
/// `retrySync`).
|
||||
PendingDocument? _updateItemInMemory(String id, PendingDocument Function(PendingDocument item) update) {
|
||||
PendingDocument? updated;
|
||||
state = [
|
||||
for (final item in state)
|
||||
if (item.id == id)
|
||||
(updated = update(item))
|
||||
else
|
||||
item
|
||||
];
|
||||
return updated;
|
||||
void _updateItem(String id, PendingDocument Function(PendingDocument) updateFn) {
|
||||
state = [for (var item in state) if (item.id == id) updateFn(item) else item];
|
||||
_persistPendingDocument(state.firstWhere((item) => item.id == id));
|
||||
}
|
||||
|
||||
void _updateItemInMemory(String id, PendingDocument Function(PendingDocument) updateFn) {
|
||||
state = [for (var item in state) if (item.id == id) updateFn(item) else item];
|
||||
}
|
||||
|
||||
void addDocument(String imagePath, {double? latitude, double? longitude}) {
|
||||
final id = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final scanMode = _ref.read(scanModeProvider);
|
||||
final newDoc = PendingDocument(
|
||||
id: id,
|
||||
imagePath: imagePath,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
scanMode: scanMode,
|
||||
);
|
||||
state = [newDoc, ...state];
|
||||
_persistPendingDocument(newDoc);
|
||||
|
||||
_uploadAndProcess(id, imagePath, latitude: latitude, longitude: longitude);
|
||||
}
|
||||
|
||||
Future<Position?> _determinePosition() async {
|
||||
return await LocationService.determinePosition();
|
||||
}
|
||||
|
||||
Future<void> _uploadAndProcess(String id, String imagePath, {double? latitude, double? longitude}) async {
|
||||
double? lat = latitude;
|
||||
double? lng = longitude;
|
||||
|
||||
if (lat == null || lng == null) {
|
||||
try {
|
||||
final position = await _determinePosition();
|
||||
final position = await LocationService.determinePosition();
|
||||
if (position != null) {
|
||||
lat = position.latitude;
|
||||
lng = position.longitude;
|
||||
if (mounted) {
|
||||
_updateItem(id, (item) => item.copyWith(latitude: lat, longitude: lng));
|
||||
}
|
||||
if (mounted) _updateItem(id, (item) => item.copyWith(latitude: lat, longitude: lng));
|
||||
}
|
||||
} catch (_) {
|
||||
// Gracefully continue without location tags if fetch fails
|
||||
}
|
||||
} 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 {
|
||||
// API call to upload and process
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
final formData = FormData.fromMap({
|
||||
'image': await MultipartFile.fromFile(
|
||||
imagePath,
|
||||
filename: imagePath.split(RegExp(r'[/\\]')).last,
|
||||
),
|
||||
'image': await MultipartFile.fromFile(imagePath, filename: imagePath.split(RegExp(r'[/\\]')).last),
|
||||
if (lat != null) 'latitude': lat.toString(),
|
||||
if (lng != null) 'longitude': lng.toString(),
|
||||
});
|
||||
|
||||
final response = await apiClient.client.post(
|
||||
AppConfig.uploadDocumentEndpoint,
|
||||
data: formData,
|
||||
);
|
||||
|
||||
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
|
||||
final doc = DocumentModel.fromJson(response.data['data']);
|
||||
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.processing, document: doc));
|
||||
|
||||
await _pollUntilParsed(id, doc);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -238,8 +142,6 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumes polling for an item whose upload already completed (status
|
||||
/// `processing`) in a previous app run — never re-uploads the image.
|
||||
Future<void> _resumePolling(String id, DocumentModel doc) async {
|
||||
try {
|
||||
await _pollUntilParsed(id, doc);
|
||||
@@ -251,15 +153,10 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
|
||||
|
||||
Future<void> _pollUntilParsed(String id, DocumentModel doc) async {
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
|
||||
// Poll until the document is parsed on the server
|
||||
bool isParsed = false;
|
||||
DocumentModel? parsedDoc;
|
||||
int retries = 0;
|
||||
// Server's OCR pass can take up to 210s worst case (see
|
||||
// v1/documents/upload/route.ts), so poll comfortably past that instead
|
||||
// of giving up right as the backend might still be finishing.
|
||||
const maxRetries = 130; // ~4.3 minutes max polling (130 retries * 2s)
|
||||
const maxRetries = 130;
|
||||
|
||||
while (!isParsed && retries < maxRetries && mounted) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
@@ -268,35 +165,22 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
|
||||
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,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore network glitch during polling
|
||||
}
|
||||
} catch (_) {}
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (isParsed && parsedDoc != null) {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: parsedDoc));
|
||||
} else {
|
||||
_updateItem(id, (item) => item.copyWith(
|
||||
status: PendingDocumentStatus.error,
|
||||
errorMessage: 'Gagal mengekstrak data dari dokumen (Timeout).',
|
||||
));
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: 'Gagal mengekstrak data (Timeout).'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,32 +189,21 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
|
||||
_removePersistedPendingDocument(id);
|
||||
}
|
||||
|
||||
/// Marks a pending item as failed to sync after an editor save, keeping the
|
||||
/// finalized [doc] so [retrySync] can resend it without re-running OCR.
|
||||
void markSyncFailed(String id, DocumentModel doc, String message) {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.syncFailed, document: doc, errorMessage: message));
|
||||
}
|
||||
|
||||
/// Resends a previously-failed editor save. Unlike [retryUpload], this never
|
||||
/// re-uploads the image or re-runs OCR - it only retries the PUT with the
|
||||
/// already-corrected [PendingDocument.document].
|
||||
Future<void> retrySync(String id) async {
|
||||
final matches = state.where((item) => item.id == id);
|
||||
if (matches.isEmpty) return;
|
||||
final doc = matches.first.document;
|
||||
if (doc == null) return;
|
||||
|
||||
// Transient/optimistic only - if the app is killed right here, a restart
|
||||
// should resend the PUT (still `syncFailed` on disk), not redo the whole
|
||||
// upload, so this particular flip is never persisted.
|
||||
_updateItemInMemory(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null));
|
||||
|
||||
try {
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
await apiClient.client.put(
|
||||
'${AppConfig.fetchDocumentsEndpoint}/${doc.id}',
|
||||
data: doc.toPutPayload(),
|
||||
);
|
||||
await apiClient.client.put('${AppConfig.fetchDocumentsEndpoint}/${doc.id}', data: doc.toPutPayload());
|
||||
if (!mounted) return;
|
||||
removeDocument(id);
|
||||
} catch (e) {
|
||||
@@ -344,10 +217,8 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
|
||||
|
||||
void retryUpload(String id) {
|
||||
final doc = state.firstWhere((item) => item.id == id);
|
||||
final lat = doc.latitude;
|
||||
final lng = doc.longitude;
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null));
|
||||
_uploadAndProcess(id, doc.imagePath, latitude: lat, longitude: lng);
|
||||
_uploadAndProcess(id, doc.imagePath, latitude: doc.latitude, longitude: doc.longitude);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user