Files
pfm-ocr/lib/features/documents/documents_screen.dart
T
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 ada6488592 feat(app): scan-mode sync, confirmation-gated documents, single-pass product classification
Fixes reported from APK field testing: DO/Product scan mode was inconsistent
between the camera drawer and documents screen (now one shared provider,
with an orange/green color cue); unconfirmed scans leaked into history with
placeholder data before the user tapped confirm (backend now gates
GET /documents on a new `confirmed` column, flipped only by PUT); and
Product Scan ran the GPU classifier twice, once at upload and again on
review (now a single pass at upload, persisted and read directly by the
editor). Also removes the unused "Hubungkan ke PO" field and fabricated
PO/SO/DO placeholder values from the Product Scan flow, closes out the
per-document-polling and save-recovery tasks (6.1/6.3), and splits several
touched files to stay under the repo's 256-line guideline.

Full detail in docs/iteration-log.md and backend/docs/iteration-log.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 15:19:32 +07:00

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,
),
),
],
),
);
}
}