feat(app): implement Product Scan review flow, dynamic batch expiry picker, custom PDFs, PO relationships, and aligned card layouts

This commit is contained in:
Rafhan Mazaya Fathurrahman committed 2026-07-09 12:36:06 +07:00
1 parent 9ff4a4a922
commit 577be04308
69 files changed
+5645 -1657

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../config/app_config.dart';
import '../../models/document_model.dart';
class DocumentCard extends StatelessWidget {
final DocumentModel doc;
final Future<void> Function(DocumentModel) onPrint;
const DocumentCard({
super.key,
required this.doc,
required this.onPrint,
});
@override
Widget build(BuildContext context) {
final isProductScan = doc.orderUntuk == 'PRODUCT SCAN';
// 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;
// Bold title
final boldTitle = isProductScan
? doc.noDo
: doc.noPo;
// Subtitle text
final subtitleText = isProductScan
? '${_formatDateString(doc.alamat.replaceAll("Product Scan: Expired ", ""))} • ${doc.noPo}'
: '${doc.tanggal} • ${doc.items.length} Item';
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: AppConfig.spaceMedium),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
side: BorderSide(color: Colors.grey.shade200),
),
child: InkWell(
onTap: () => onPrint(doc),
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
child: Padding(
padding: AppConfig.paddingLarge,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top category label
Text(
categoryText,
style: AppConfig.bodySmall.copyWith(
color: categoryColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppConfig.spaceXS),
// Large bold title
Text(
boldTitle,
style: AppConfig.bodyLarge.copyWith(
fontWeight: FontWeight.bold,
color: AppConfig.textPrimary,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppConfig.spaceXS),
// Subtitle details
Text(
subtitleText,
style: AppConfig.bodyMedium.copyWith(
color: AppConfig.textSecondary,
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: AppConfig.spaceMedium),
// Right column with status badge and print button
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Status badge (e.g. Approved / Terkonfirmasi)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppConfig.successColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
),
child: Text(
'Terkonfirmasi',
style: AppConfig.bodySmall.copyWith(
color: AppConfig.successColor,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
const SizedBox(height: AppConfig.spaceMedium),
// Print button styled as light-gray square container with print icon
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
),
child: const Icon(
Icons.print,
size: 18,
color: AppConfig.textSecondary,
),
),
],
),
],
),
),
),
);
}
String _formatDateString(String input) {
try {
if (input.contains('/')) {
final parts = input.split('/');
if (parts.length == 3) {
final day = int.parse(parts[0]);
final month = int.parse(parts[1]);
final year = int.parse(parts[2]);
final date = DateTime(year, month, day);
return DateFormat('dd MMMM yyyy').format(date);
}
}
} catch (e) {
// Fallback
}
return input;
}
}
+175 -319
View File
@@ -1,13 +1,16 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../config/app_config.dart';
import '../../models/document_model.dart';
import '../../main.dart'; // For localStorageProvider
import '../../main.dart';
import 'pending_documents_provider.dart';
import '../editor/pdf_service.dart';
import '../../core/network/api_client.dart';
import 'pending_document_card.dart';
import 'document_card.dart';
class DocumentsScreen extends ConsumerStatefulWidget {
const DocumentsScreen({super.key});
@@ -20,6 +23,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
final _searchController = TextEditingController();
List<DocumentModel> _allDocuments = [];
List<DocumentModel> _filteredDocuments = [];
String _selectedTab = 'DO'; // 'DO' | 'Product'
@override
void initState() {
@@ -30,7 +34,72 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
Future<void> _loadDocuments() async {
final localStorage = ref.read(localStorageProvider);
final docs = localStorage.getAllDocuments();
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();
}
setState(() {
_allDocuments = docs;
_filteredDocuments = docs;
@@ -39,14 +108,11 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
try {
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.client.get(AppConfig.fetchDocumentsEndpoint);
final List serverData = response.data['data'] ?? [];
final List<DocumentModel> serverDocs = serverData
.map((json) => DocumentModel.fromJson(Map<String, dynamic>.from(json)))
.toList();
// Merge server docs into Hive without clearing first,
// so any locally-saved doc (e.g. just confirmed offline) is preserved
for (var doc in serverDocs) {
await localStorage.saveDocument(doc);
}
@@ -59,7 +125,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
});
}
} catch (e) {
debugPrint('Offline or failed fetching from server: $e');
debugPrint('Offline or failed fetching: $e');
}
}
@@ -82,23 +148,82 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
super.dispose();
}
Future<void> _printReceipt(DocumentModel doc) async {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Membuat PDF...'), duration: Duration(seconds: 1), backgroundColor: AppConfig.secondaryColor),
);
await PdfService.generateAndPrintReceipt(doc);
}
@override
Widget build(BuildContext context) {
final pendingDocs = ref.watch(pendingDocumentsProvider);
final tabFilteredDocs = _filteredDocuments.where((doc) {
final isProduct = doc.orderUntuk == 'PRODUCT SCAN';
if (_selectedTab == 'DO') return !isProduct;
if (_selectedTab == 'Product') return isProduct;
return true;
}).toList();
final Map<String, List<DocumentModel>> groupedDocs = {};
for (var doc in tabFilteredDocs) {
final date = doc.createdAt;
final monthYearStr = DateFormat('MMMM yyyy').format(date);
if (!groupedDocs.containsKey(monthYearStr)) {
groupedDocs[monthYearStr] = [];
}
groupedDocs[monthYearStr]!.add(doc);
}
final List<Widget> listItems = [];
if (pendingDocs.isNotEmpty) {
listItems.add(
Padding(
padding: const EdgeInsets.only(bottom: AppConfig.spaceSmall),
child: Text('Tertunda & Diproses', style: AppConfig.h3),
),
);
listItems.addAll(pendingDocs.map((doc) => PendingDocumentCard(
doc: doc,
onReload: () { if (mounted) _loadDocuments(); },
)));
listItems.add(const SizedBox(height: AppConfig.spaceLarge));
listItems.add(const Divider());
listItems.add(const SizedBox(height: AppConfig.spaceLarge));
}
if (groupedDocs.isNotEmpty) {
groupedDocs.forEach((monthYear, docs) {
listItems.add(
Padding(
padding: const EdgeInsets.only(top: AppConfig.spaceLarge, bottom: AppConfig.spaceSmall),
child: Text(
monthYear,
style: AppConfig.bodyMedium.copyWith(
fontWeight: FontWeight.bold,
color: AppConfig.textSecondary,
),
),
),
);
listItems.addAll(docs.map((doc) => DocumentCard(
doc: doc,
onPrint: _printReceipt,
)));
});
}
return Scaffold(
appBar: AppBar(
title: const Text('Dokumen'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/camera'),
),
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.go('/camera')),
),
body: Column(
children: [
// Sticky Search Bar
Container(
padding: const EdgeInsets.all(16.0),
padding: AppConfig.paddingLarge,
color: AppConfig.surfaceColor,
child: TextField(
controller: _searchController,
@@ -108,41 +233,22 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
),
),
),
// List of documents
_buildTabSwitcher(),
Expanded(
child: (pendingDocs.isEmpty && _filteredDocuments.isEmpty)
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: 16),
const SizedBox(height: AppConfig.spaceLarge),
Text('Tidak ada dokumen ditemukan', style: AppConfig.bodyLarge),
],
),
)
: ListView(
padding: const EdgeInsets.all(16.0),
children: [
if (pendingDocs.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text('Tertunda & Diproses', style: AppConfig.h3),
),
...pendingDocs.map((doc) => _buildPendingCard(doc)),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
],
if (_filteredDocuments.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text('Dokumen Terkonfirmasi', style: AppConfig.h3),
),
..._filteredDocuments.map((doc) => _buildDocumentCard(doc)),
],
],
padding: const EdgeInsets.symmetric(horizontal: AppConfig.spaceLarge),
children: listItems,
),
),
],
@@ -150,293 +256,43 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
);
}
Widget _buildPendingCard(PendingDocument doc) {
Color statusColor;
String statusText;
IconData statusIcon;
switch (doc.status) {
case PendingDocumentStatus.uploading:
case PendingDocumentStatus.processing:
statusColor = Colors.orange;
statusText = 'Sedang memproses dokumen...';
statusIcon = Icons.cloud_upload;
break;
case PendingDocumentStatus.success:
statusColor = AppConfig.successColor;
statusText = 'Ketuk untuk dikonfirmasi';
statusIcon = Icons.check_circle;
break;
case PendingDocumentStatus.error:
statusColor = AppConfig.errorColor;
statusText = 'Gagal Memproses';
statusIcon = Icons.error;
break;
case PendingDocumentStatus.syncFailed:
statusColor = Colors.deepOrange;
statusText = 'Belum tersinkron ke server';
statusIcon = Icons.sync_problem;
break;
}
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 12.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: statusColor.withOpacity(0.5)),
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'),
],
),
child: InkWell(
onTap: () {
if (doc.status == PendingDocumentStatus.success) {
context.push('/editor', extra: doc.id).then((_) {
// Always reload when returning from editor,
// whether confirmed or just pressed back
if (mounted) _loadDocuments();
});
} else if (doc.status == PendingDocumentStatus.error) {
// Show error options
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Coba Unggah Ulang'),
onTap: () {
Navigator.pop(ctx);
ref.read(pendingDocumentsProvider.notifier).retryUpload(doc.id);
},
),
ListTile(
leading: const Icon(Icons.delete, color: AppConfig.errorColor),
title: const Text('Hapus Dokumen', style: TextStyle(color: AppConfig.errorColor)),
onTap: () {
Navigator.pop(ctx);
ref.read(pendingDocumentsProvider.notifier).removeDocument(doc.id);
},
),
],
),
);
}
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,
),
);
} else if (doc.status == PendingDocumentStatus.syncFailed) {
// The correction was saved locally but never reached the server - retry
// just the sync (no re-upload, no re-running OCR).
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.sync),
title: const Text('Coba Sinkron Ulang'),
onTap: () {
Navigator.pop(ctx);
ref.read(pendingDocumentsProvider.notifier).retrySync(doc.id);
},
),
ListTile(
leading: const Icon(Icons.delete, color: AppConfig.errorColor),
title: const Text('Hapus Dokumen', style: TextStyle(color: AppConfig.errorColor)),
onTap: () {
Navigator.pop(ctx);
ref.read(pendingDocumentsProvider.notifier).removeDocument(doc.id);
},
),
],
),
),
);
}
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(doc.imagePath),
width: 60,
height: 60,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
width: 60,
height: 60,
color: Colors.grey.shade200,
child: const Icon(Icons.image, color: Colors.grey),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(statusIcon, size: 16, color: statusColor),
const SizedBox(width: 4),
Expanded(
child: Text(
statusText,
style: AppConfig.bodyMedium.copyWith(color: statusColor, fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
if (doc.status == PendingDocumentStatus.uploading || doc.status == PendingDocumentStatus.processing)
const LinearProgressIndicator(),
if (doc.status == PendingDocumentStatus.success)
Text('Ketuk untuk mereview dan konfirmasi data', style: AppConfig.bodySmall),
if (doc.status == PendingDocumentStatus.error)
Text(doc.errorMessage ?? 'Kesalahan tidak diketahui', style: AppConfig.bodySmall.copyWith(color: AppConfig.errorColor), maxLines: 1, overflow: TextOverflow.ellipsis),
if (doc.status == PendingDocumentStatus.syncFailed)
Text(doc.errorMessage ?? 'Ketuk untuk coba sinkron ulang', style: AppConfig.bodySmall.copyWith(color: Colors.deepOrange), maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
),
if (doc.status == PendingDocumentStatus.success)
const Icon(Icons.chevron_right, color: AppConfig.primaryColor),
],
),
),
),
),
);
}
Widget _buildDocumentCard(DocumentModel doc) {
return Card(
elevation: 4,
shadowColor: Colors.black.withOpacity(0.05),
margin: const EdgeInsets.only(bottom: 16.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade200),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'DO: ${doc.noDo}',
style: AppConfig.h3.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: AppConfig.successColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle, size: 14, color: AppConfig.successColor),
const SizedBox(width: 4),
Text('Terkonfirmasi', style: AppConfig.bodySmall.copyWith(color: AppConfig.successColor, fontWeight: FontWeight.bold)),
],
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade100),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: _buildInfoItem(Icons.receipt_long, 'PO: ${doc.noPo}')),
Expanded(child: _buildInfoItem(Icons.inventory_2, 'SO: ${doc.noSo}')),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: _buildInfoItem(Icons.calendar_today, doc.tanggal)),
Expanded(child: _buildInfoItem(Icons.person, doc.kepadaYth)),
],
),
if (doc.latitude != null && doc.longitude != null) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: _buildInfoItem(Icons.location_on, 'Lat: ${doc.latitude!.toStringAsFixed(4)}')),
Expanded(child: _buildInfoItem(Icons.location_on, 'Lng: ${doc.longitude!.toStringAsFixed(4)}')),
],
),
],
],
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _printReceipt(doc),
icon: const Icon(Icons.print, color: AppConfig.primaryColor),
label: const Text('Cetak Tanda Terima', style: TextStyle(color: AppConfig.primaryColor, fontWeight: FontWeight.bold)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppConfig.primaryColor),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
);
}
Widget _buildInfoItem(IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: AppConfig.textSecondary),
const SizedBox(width: 6),
Expanded(
child: Text(
text,
style: AppConfig.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Future<void> _printReceipt(DocumentModel doc) async {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Membuat PDF...'),
duration: Duration(seconds: 1),
backgroundColor: AppConfig.secondaryColor,
),
);
await PdfService.generateAndPrintReceipt(doc);
}
}
@@ -0,0 +1,175 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../config/app_config.dart';
import 'pending_documents_provider.dart';
class PendingDocumentCard extends ConsumerWidget {
final PendingDocument doc;
final VoidCallback onReload;
const PendingDocumentCard({
super.key,
required this.doc,
required this.onReload,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
Color statusColor;
String statusText;
IconData statusIcon;
switch (doc.status) {
case PendingDocumentStatus.uploading:
case PendingDocumentStatus.processing:
statusColor = Colors.orange;
statusText = 'Sedang memproses dokumen...';
statusIcon = Icons.cloud_upload;
break;
case PendingDocumentStatus.success:
statusColor = AppConfig.successColor;
statusText = 'Ketuk untuk dikonfirmasi';
statusIcon = Icons.check_circle;
break;
case PendingDocumentStatus.error:
statusColor = AppConfig.errorColor;
statusText = 'Gagal Memproses';
statusIcon = Icons.error;
break;
case PendingDocumentStatus.syncFailed:
statusColor = Colors.deepOrange;
statusText = 'Belum tersinkron ke server';
statusIcon = Icons.sync_problem;
break;
}
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: AppConfig.spaceMedium),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
side: BorderSide(color: statusColor.withOpacity(0.5)),
),
child: InkWell(
onTap: () {
if (doc.status == PendingDocumentStatus.success) {
final routePath = doc.scanMode == 'Product' ? '/product-editor' : '/editor';
context.push(routePath, extra: doc.id).then((_) {
onReload();
});
} else if (doc.status == PendingDocumentStatus.error) {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Coba Unggah Ulang'),
onTap: () {
Navigator.pop(ctx);
ref.read(pendingDocumentsProvider.notifier).retryUpload(doc.id);
},
),
ListTile(
leading: const Icon(Icons.delete, color: AppConfig.errorColor),
title: const Text('Hapus Dokumen', style: TextStyle(color: AppConfig.errorColor)),
onTap: () {
Navigator.pop(ctx);
ref.read(pendingDocumentsProvider.notifier).removeDocument(doc.id);
},
),
],
),
),
);
} else if (doc.status == PendingDocumentStatus.syncFailed) {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.sync),
title: const Text('Coba Sinkron Ulang'),
onTap: () {
Navigator.pop(ctx);
ref.read(pendingDocumentsProvider.notifier).retrySync(doc.id);
},
),
ListTile(
leading: const Icon(Icons.delete, color: AppConfig.errorColor),
title: const Text('Hapus Dokumen', style: TextStyle(color: AppConfig.errorColor)),
onTap: () {
Navigator.pop(ctx);
ref.read(pendingDocumentsProvider.notifier).removeDocument(doc.id);
},
),
],
),
),
);
}
},
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
child: Padding(
padding: AppConfig.paddingLarge,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
child: Image.file(
File(doc.imagePath),
width: 60,
height: 60,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
width: 60,
height: 60,
color: Colors.grey.shade200,
child: const Icon(Icons.image, color: Colors.grey),
),
),
),
const SizedBox(width: AppConfig.spaceLarge),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(statusIcon, size: 16, color: statusColor),
const SizedBox(width: AppConfig.spaceXS),
Expanded(
child: Text(
statusText,
style: AppConfig.bodyMedium.copyWith(color: statusColor, fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: AppConfig.spaceSmall),
if (doc.status == PendingDocumentStatus.uploading || doc.status == PendingDocumentStatus.processing)
const LinearProgressIndicator(),
if (doc.status == PendingDocumentStatus.success)
Text('Ketuk untuk mereview dan konfirmasi data', style: AppConfig.bodySmall),
if (doc.status == PendingDocumentStatus.error)
Text(doc.errorMessage ?? 'Kesalahan tidak diketahui', style: AppConfig.bodySmall.copyWith(color: AppConfig.errorColor), maxLines: 1, overflow: TextOverflow.ellipsis),
if (doc.status == PendingDocumentStatus.syncFailed)
Text(doc.errorMessage ?? 'Ketuk untuk coba sinkron ulang', style: AppConfig.bodySmall.copyWith(color: Colors.deepOrange), maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
),
if (doc.status == PendingDocumentStatus.success)
const Icon(Icons.chevron_right, color: AppConfig.primaryColor),
],
),
),
),
);
}
}
@@ -0,0 +1,87 @@
import '../../models/document_model.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;
final String? scanMode;
PendingDocument({
required this.id,
required this.imagePath,
this.status = PendingDocumentStatus.uploading,
this.document,
this.latitude,
this.longitude,
this.errorMessage,
DateTime? createdAt,
this.scanMode,
}) : createdAt = createdAt ?? DateTime.now();
PendingDocument copyWith({
PendingDocumentStatus? status,
DocumentModel? document,
double? latitude,
double? longitude,
String? errorMessage,
String? scanMode,
}) {
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,
scanMode: scanMode ?? this.scanMode,
);
}
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(),
'scan_mode': scanMode,
};
}
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,
scanMode: json['scan_mode'] as String?,
);
}
}
@@ -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);
}
}