chore: normalize line endings (CRLF -> LF)
No content changes: git diff --ignore-all-space over these files is empty. The churn came from editing on Windows against a repo checked out with LF.
This commit is contained in:
1 parent
15566a6951
commit
caf8e98378
315 files changed
+86950
-86950
No files matched your search
@@ -1,163 +1,163 @@
|
||||
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;
|
||||
final bool isUnsynced;
|
||||
|
||||
const DocumentCard({
|
||||
super.key,
|
||||
required this.doc,
|
||||
required this.onPrint,
|
||||
this.isUnsynced = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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: 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. 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.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 (Terkonfirmasi / Belum Tersinkron)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
),
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: badgeColor,
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
final bool isUnsynced;
|
||||
|
||||
const DocumentCard({
|
||||
super.key,
|
||||
required this.doc,
|
||||
required this.onPrint,
|
||||
this.isUnsynced = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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: 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. 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.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 (Terkonfirmasi / Belum Tersinkron)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
),
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: badgeColor,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +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());
|
||||
}
|
||||
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());
|
||||
}
|
||||
@@ -1,24 +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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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,224 +1,224 @@
|
||||
import 'package:flutter/material.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';
|
||||
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';
|
||||
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});
|
||||
|
||||
@override
|
||||
ConsumerState<DocumentsScreen> createState() => _DocumentsScreenState();
|
||||
}
|
||||
|
||||
class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
List<DocumentModel> _allDocuments = [];
|
||||
List<DocumentModel> _filteredDocuments = [];
|
||||
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() {
|
||||
super.initState();
|
||||
_loadDocuments();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
Future<void> _loadDocuments() async {
|
||||
final localStorage = ref.read(localStorageProvider);
|
||||
final docs = localStorage.getAllDocuments();
|
||||
|
||||
setState(() {
|
||||
_allDocuments = docs;
|
||||
_filteredDocuments = docs;
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
final updatedDocs = localStorage.getAllDocuments();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_allDocuments = updatedDocs;
|
||||
_filteredDocuments = updatedDocs;
|
||||
_unsyncedDocIds = merged.unsyncedIds;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Offline or failed fetching: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
_filteredDocuments = _allDocuments.where((doc) {
|
||||
return doc.noDo.toLowerCase().contains(query) ||
|
||||
doc.noPo.toString().contains(query) ||
|
||||
doc.noSo.toString().contains(query) ||
|
||||
doc.tanggal.toLowerCase().contains(query) ||
|
||||
doc.kepadaYth.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
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);
|
||||
// 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.docType == 'Product';
|
||||
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 (tabFilteredPendingDocs.isNotEmpty) {
|
||||
listItems.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppConfig.spaceSmall),
|
||||
child: Text('Tertunda & Diproses', style: AppConfig.h3),
|
||||
),
|
||||
);
|
||||
listItems.addAll(tabFilteredPendingDocs.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,
|
||||
isUnsynced: _unsyncedDocIds.contains(doc.id),
|
||||
)));
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Dokumen'),
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.go('/camera')),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: AppConfig.paddingLarge,
|
||||
color: AppConfig.surfaceColor,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Cari berdasarkan No PO, No SO, DO, Tanggal...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
DocumentsTabSwitcher(
|
||||
selectedTab: selectedTab,
|
||||
onTabChanged: (tab) =>
|
||||
ref.read(scanModeProvider.notifier).state = tab,
|
||||
),
|
||||
Expanded(
|
||||
child: (tabFilteredPendingDocs.isEmpty && tabFilteredDocs.isEmpty)
|
||||
? const DocumentsEmptyState()
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppConfig.spaceLarge),
|
||||
children: listItems,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.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';
|
||||
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';
|
||||
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});
|
||||
|
||||
@override
|
||||
ConsumerState<DocumentsScreen> createState() => _DocumentsScreenState();
|
||||
}
|
||||
|
||||
class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
List<DocumentModel> _allDocuments = [];
|
||||
List<DocumentModel> _filteredDocuments = [];
|
||||
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() {
|
||||
super.initState();
|
||||
_loadDocuments();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
Future<void> _loadDocuments() async {
|
||||
final localStorage = ref.read(localStorageProvider);
|
||||
final docs = localStorage.getAllDocuments();
|
||||
|
||||
setState(() {
|
||||
_allDocuments = docs;
|
||||
_filteredDocuments = docs;
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
final updatedDocs = localStorage.getAllDocuments();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_allDocuments = updatedDocs;
|
||||
_filteredDocuments = updatedDocs;
|
||||
_unsyncedDocIds = merged.unsyncedIds;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Offline or failed fetching: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
_filteredDocuments = _allDocuments.where((doc) {
|
||||
return doc.noDo.toLowerCase().contains(query) ||
|
||||
doc.noPo.toString().contains(query) ||
|
||||
doc.noSo.toString().contains(query) ||
|
||||
doc.tanggal.toLowerCase().contains(query) ||
|
||||
doc.kepadaYth.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
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);
|
||||
// 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.docType == 'Product';
|
||||
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 (tabFilteredPendingDocs.isNotEmpty) {
|
||||
listItems.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppConfig.spaceSmall),
|
||||
child: Text('Tertunda & Diproses', style: AppConfig.h3),
|
||||
),
|
||||
);
|
||||
listItems.addAll(tabFilteredPendingDocs.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,
|
||||
isUnsynced: _unsyncedDocIds.contains(doc.id),
|
||||
)));
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Dokumen'),
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.go('/camera')),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: AppConfig.paddingLarge,
|
||||
color: AppConfig.surfaceColor,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Cari berdasarkan No PO, No SO, DO, Tanggal...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
DocumentsTabSwitcher(
|
||||
selectedTab: selectedTab,
|
||||
onTabChanged: (tab) =>
|
||||
ref.read(scanModeProvider.notifier).state = tab,
|
||||
),
|
||||
Expanded(
|
||||
child: (tabFilteredPendingDocs.isEmpty && tabFilteredDocs.isEmpty)
|
||||
? const DocumentsEmptyState()
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppConfig.spaceLarge),
|
||||
children: listItems,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,175 +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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
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,199 +1,199 @@
|
||||
export 'pending_document_model.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.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 '../../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([]) {
|
||||
_loadPendingDocuments();
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
void _loadPendingDocuments() {
|
||||
final localStorage = _ref.read(localStorageProvider);
|
||||
final list = localStorage.getAllPendingDocuments()
|
||||
.map((e) => PendingDocument.fromJson(e))
|
||||
.toList();
|
||||
state = list;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _persistPendingDocument(PendingDocument doc) {
|
||||
_ref.read(localStorageProvider).savePendingDocument(doc.toJson());
|
||||
}
|
||||
|
||||
void _removePersistedPendingDocument(String id) {
|
||||
_ref.read(localStorageProvider).removePendingDocument(id);
|
||||
}
|
||||
|
||||
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<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 LocationService.determinePosition();
|
||||
if (position != null) {
|
||||
lat = position.latitude;
|
||||
lng = position.longitude;
|
||||
if (mounted) _updateItem(id, (item) => item.copyWith(latitude: lat, longitude: lng));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
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);
|
||||
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;
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: ApiException.from(e).displayMessage));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resumePolling(String id, DocumentModel doc) async {
|
||||
try {
|
||||
await _pollUntilParsed(id, doc);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: ApiException.from(e).displayMessage));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pollUntilParsed(String id, DocumentModel doc) async {
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
DocumentModel? resultDoc;
|
||||
String? failureMessage;
|
||||
int retries = 0;
|
||||
const maxRetries = 130;
|
||||
|
||||
while (retries < maxRetries && mounted) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
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 (_) {}
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (resultDoc != null) {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: resultDoc));
|
||||
} else {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: failureMessage ?? 'Gagal mengekstrak data (Timeout).'));
|
||||
}
|
||||
}
|
||||
|
||||
void removeDocument(String id) {
|
||||
state = state.where((item) => item.id != id).toList();
|
||||
_removePersistedPendingDocument(id);
|
||||
}
|
||||
|
||||
void markSyncFailed(String id, DocumentModel doc, String message) {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.syncFailed, document: doc, errorMessage: message));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
_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());
|
||||
if (!mounted) return;
|
||||
removeDocument(id);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(
|
||||
status: PendingDocumentStatus.syncFailed,
|
||||
errorMessage: 'Gagal sinkronisasi ke server: ${ApiException.from(e).displayMessage}',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void retryUpload(String id) {
|
||||
final doc = state.firstWhere((item) => item.id == id);
|
||||
_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) {
|
||||
return PendingDocumentsNotifier(ref);
|
||||
});
|
||||
export 'pending_document_model.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.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 '../../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([]) {
|
||||
_loadPendingDocuments();
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
void _loadPendingDocuments() {
|
||||
final localStorage = _ref.read(localStorageProvider);
|
||||
final list = localStorage.getAllPendingDocuments()
|
||||
.map((e) => PendingDocument.fromJson(e))
|
||||
.toList();
|
||||
state = list;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _persistPendingDocument(PendingDocument doc) {
|
||||
_ref.read(localStorageProvider).savePendingDocument(doc.toJson());
|
||||
}
|
||||
|
||||
void _removePersistedPendingDocument(String id) {
|
||||
_ref.read(localStorageProvider).removePendingDocument(id);
|
||||
}
|
||||
|
||||
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<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 LocationService.determinePosition();
|
||||
if (position != null) {
|
||||
lat = position.latitude;
|
||||
lng = position.longitude;
|
||||
if (mounted) _updateItem(id, (item) => item.copyWith(latitude: lat, longitude: lng));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
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);
|
||||
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;
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: ApiException.from(e).displayMessage));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resumePolling(String id, DocumentModel doc) async {
|
||||
try {
|
||||
await _pollUntilParsed(id, doc);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: ApiException.from(e).displayMessage));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pollUntilParsed(String id, DocumentModel doc) async {
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
DocumentModel? resultDoc;
|
||||
String? failureMessage;
|
||||
int retries = 0;
|
||||
const maxRetries = 130;
|
||||
|
||||
while (retries < maxRetries && mounted) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
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 (_) {}
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (resultDoc != null) {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: resultDoc));
|
||||
} else {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: failureMessage ?? 'Gagal mengekstrak data (Timeout).'));
|
||||
}
|
||||
}
|
||||
|
||||
void removeDocument(String id) {
|
||||
state = state.where((item) => item.id != id).toList();
|
||||
_removePersistedPendingDocument(id);
|
||||
}
|
||||
|
||||
void markSyncFailed(String id, DocumentModel doc, String message) {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.syncFailed, document: doc, errorMessage: message));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
_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());
|
||||
if (!mounted) return;
|
||||
removeDocument(id);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(
|
||||
status: PendingDocumentStatus.syncFailed,
|
||||
errorMessage: 'Gagal sinkronisasi ke server: ${ApiException.from(e).displayMessage}',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void retryUpload(String id) {
|
||||
final doc = state.firstWhere((item) => item.id == id);
|
||||
_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) {
|
||||
return PendingDocumentsNotifier(ref);
|
||||
});
|
||||
@@ -1,39 +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;
|
||||
}
|
||||
}
|
||||
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