Fixes reported from APK field testing: DO/Product scan mode was inconsistent between the camera drawer and documents screen (now one shared provider, with an orange/green color cue); unconfirmed scans leaked into history with placeholder data before the user tapped confirm (backend now gates GET /documents on a new `confirmed` column, flipped only by PUT); and Product Scan ran the GPU classifier twice, once at upload and again on review (now a single pass at upload, persisted and read directly by the editor). Also removes the unused "Hubungkan ke PO" field and fabricated PO/SO/DO placeholder values from the Product Scan flow, closes out the per-document-polling and save-recovery tasks (6.1/6.3), and splits several touched files to stay under the repo's 256-line guideline. Full detail in docs/iteration-log.md and backend/docs/iteration-log.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
225 lines
7.7 KiB
Dart
225 lines
7.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:intl/intl.dart';
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|