Files
pfm-ocr/lib/features/documents/documents_screen.dart
T
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 4808a798fb Add mobile reliability fixes, Bahasa Indonesia UI, and continue OCR accuracy tuning
Reliability/PoC hardening: dedupe uploads by file_hash, surface editor sync
failures instead of a false success SnackBar with a retry-without-re-OCR path,
bound the OCR pipeline fetches with timeouts, share a single ApiClient/Dio
instance app-wide, tune capture JPEG quality, and add an opt-in
docker-compose.demo.yml for a production-mode run ahead of client demos.

Translate all Flutter-side user-facing text (screens, validators, SnackBars,
the printed delivery receipt, and shared API error messages) to Bahasa
Indonesia.

Also includes in-progress OCR parser/accuracy-tuning work from the same
session: table column/unit normalization fixes, store/customer master data,
accuracy history log, and test-image renaming/cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eRAsLqN9Sg1b9YPz22Lxz
2026-07-04 19:41:24 +07:00

443 lines
16 KiB
Dart

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 '../../models/document_model.dart';
import '../../main.dart'; // For localStorageProvider
import 'pending_documents_provider.dart';
import '../editor/pdf_service.dart';
import '../../core/network/api_client.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 = [];
@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();
// 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);
}
final updatedDocs = localStorage.getAllDocuments();
if (mounted) {
setState(() {
_allDocuments = updatedDocs;
_filteredDocuments = updatedDocs;
});
}
} catch (e) {
debugPrint('Offline or failed fetching from server: $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();
}
@override
Widget build(BuildContext context) {
final pendingDocs = ref.watch(pendingDocumentsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Dokumen'),
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),
color: AppConfig.surfaceColor,
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: 'Cari berdasarkan No PO, No SO, DO, Tanggal...',
prefixIcon: Icon(Icons.search),
),
),
),
// List of documents
Expanded(
child: (pendingDocs.isEmpty && _filteredDocuments.isEmpty)
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.folder_open, size: 64, color: AppConfig.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16),
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)),
],
],
),
),
],
),
);
}
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)),
),
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);
},
),
],
),
),
);
} 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);
}
}