Files
pfm-ocr/lib/features/editor/editor_screen.dart
T
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 e60ab63154 Adopt agents-settings kit, ship Product/SKU scan models, harden auth, verify OCR accuracy
Backend (app-pfm-ocr-v2/backend):
- Product/SKU scan feature complete: trained DINOv2 index (118 reference
  photos, 16 SKU classes) and YOLO classifier (83.3% top-1 val accuracy),
  fixed scripts/install-pipeline.sh (was missing ultralytics/torch), fully
  browser-verified end-to-end on /scan-pfm. Mobile m-scan-pfm page cancelled
  (Flutter app handles mobile; web UI is desktop-only for pipeline testing).
- Fixed a real data-loss bug: Save Ground Truth (scan-pfm and the DO-flow's
  manual-label) was silently writing into the pfm-web-app container's
  ephemeral filesystem instead of the host, because /sources wasn't
  bind-mounted in docker-compose.yml. Added the mount, recovered an
  orphaned entry.
- accounts.password is now bcrypt-hashed (bcryptjs, idempotent migration
  in db/init.ts) instead of plaintext; login route compares hashes.
- /api/v1/documents/* (list, PUT, upload) now enforces real 401 auth,
  matching what the Flutter client already sends. The "classic" routes
  deliberately stay open — they're dev-only web UI with no login flow and
  won't exist in production.
- OCR accuracy investigated end-to-end: real baseline is 95.10% overall
  (target met; accuracy_report.md was stale at 75.04%, now flagged). Fixed
  one genuine parser.ts bug (SO/DO field duplication in the global fallback
  regex); remaining gaps are OCR/layout-model limitations, not parser bugs.
- Adopted a standalone copy of the fhanyuh/agents-settings e/n workflow
  scoped to backend/ (AGENTS.md Part A/B split, SKILLS.md, plans/, docs/),
  independent of the root copy which now covers Flutter only.
- next-implementation.md deleted; content folded into
  backend/plans/next-enhancements.md for traceability.

Root:
- Adopted fhanyuh/agents-settings kit (AGENTS.md, SKILLS.md, plans/,
  docs/feature-list.md), scoped to the Flutter app only.
- Pending documents queue now persists to Hive (lib/core/storage) instead
  of memory-only, surviving an app kill mid-upload.

Removed backend_backup/ (stale Express/Prisma prototype, superseded by
pfm-web-app) and the completed plans/next-enhancement-plan.md checklist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 11:56:32 +07:00

683 lines
26 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.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'; // To access localStorageProvider
import '../documents/pending_documents_provider.dart';
import '../../data/master_sku.dart';
import '../../core/network/api_client.dart';
import '../../core/network/api_exception.dart';
class EditorScreen extends ConsumerStatefulWidget {
final String? pendingId;
const EditorScreen({super.key, this.pendingId});
@override
ConsumerState<EditorScreen> createState() => _EditorScreenState();
}
class _EditorScreenState extends ConsumerState<EditorScreen> {
DocumentModel? _document;
// Controllers for Header
final _tanggalCtrl = TextEditingController();
final _noPoCtrl = TextEditingController();
final _noSoCtrl = TextEditingController();
final _noDoCtrl = TextEditingController();
// Controllers for Shipment
final _kepadaYthCtrl = TextEditingController();
final _orderUntukCtrl = TextEditingController();
final _alamatCtrl = TextEditingController();
final _platTrukCtrl = TextEditingController();
final _namaDriverCtrl = TextEditingController();
final _namaPenerimaCtrl = TextEditingController();
// Controllers for Geotagging
final _latitudeCtrl = TextEditingController();
final _longitudeCtrl = TextEditingController();
// Controllers for Items
List<Map<String, TextEditingController>> _itemControllers = [];
bool _isAgreed = false;
final _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
_loadDocumentData();
}
void _loadDocumentData() {
if (widget.pendingId == null) {
return;
}
final pendingDocs = ref.read(pendingDocumentsProvider);
final pendingDoc = pendingDocs.firstWhere(
(doc) => doc.id == widget.pendingId,
orElse: () => PendingDocument(id: '', imagePath: '')
);
if (pendingDoc.document != null) {
final doc = pendingDoc.document!;
setState(() {
_document = doc;
_tanggalCtrl.text = doc.tanggal;
_noPoCtrl.text = doc.noPo;
_noSoCtrl.text = doc.noSo;
_noDoCtrl.text = doc.noDo;
_kepadaYthCtrl.text = doc.kepadaYth;
_orderUntukCtrl.text = doc.orderUntuk;
_alamatCtrl.text = doc.alamat;
_platTrukCtrl.text = doc.platTruk;
_namaDriverCtrl.text = doc.namaDriver;
final lat = pendingDoc.latitude ?? doc.latitude;
final lng = pendingDoc.longitude ?? doc.longitude;
_latitudeCtrl.text = lat != null ? lat.toString() : '-';
_longitudeCtrl.text = lng != null ? lng.toString() : '-';
for (var item in doc.items) {
_addItem(
sku: item.nomorSku,
nama: item.namaBarang,
banyak: item.banyak,
jumlah: item.jumlah,
);
}
});
}
}
void _addItem({String sku = '', String nama = '', String banyak = '', String jumlah = ''}) {
final skuCtrl = TextEditingController(text: sku);
final namaCtrl = TextEditingController(text: nama);
final banyakCtrl = TextEditingController(text: banyak);
final jumlahCtrl = TextEditingController(text: jumlah);
// Auto rename based on master SKU
skuCtrl.addListener(() {
final currentSku = skuCtrl.text;
if (MasterSku.data.containsKey(currentSku)) {
namaCtrl.text = MasterSku.data[currentSku]!;
} else {
namaCtrl.text = 'SKU Tidak Terdaftar';
}
});
setState(() {
_itemControllers.add({
'sku': skuCtrl,
'nama': namaCtrl,
'banyak': banyakCtrl,
'jumlah': jumlahCtrl,
});
});
}
void _removeItem(int index) {
setState(() {
final ctrls = _itemControllers[index];
ctrls['sku']?.dispose();
ctrls['nama']?.dispose();
ctrls['banyak']?.dispose();
ctrls['jumlah']?.dispose();
_itemControllers.removeAt(index);
});
}
void _showConfirmationDialog() {
if (!_formKey.currentState!.validate()) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setModalState) {
final isButtonEnabled = _isAgreed && _namaPenerimaCtrl.text.trim().isNotEmpty;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 24,
right: 24,
top: 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Konfirmasi Pengiriman', style: AppConfig.h2),
const SizedBox(height: 16),
Text(
'Masukkan nama Anda (sebagai penerima/penjaga toko) dan centang kotak persetujuan untuk mengonfirmasi pengiriman.',
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
),
const SizedBox(height: 16),
TextField(
controller: _namaPenerimaCtrl,
onChanged: (val) {
setModalState(() {});
},
style: AppConfig.bodyMedium,
decoration: InputDecoration(
labelText: 'Nama Penerima (Penjaga Toko)',
labelStyle: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
filled: true,
fillColor: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
),
const SizedBox(height: 16),
Row(
children: [
Checkbox(
value: _isAgreed,
activeColor: AppConfig.primaryColor,
onChanged: (val) {
setModalState(() {
_isAgreed = val ?? false;
});
},
),
Expanded(
child: Text('Saya setuju dan mengonfirmasi bahwa data ini sudah benar.', style: AppConfig.bodyMedium),
)
],
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: isButtonEnabled ? () {
Navigator.pop(context); // close modal
_submitDocument();
} : null,
child: const Text('Konfirmasi'),
),
),
const SizedBox(height: 24),
],
),
);
}
);
}
);
}
Future<void> _submitDocument() async {
// Construct final document
final pendingDocs = ref.read(pendingDocumentsProvider);
final pendingDoc = pendingDocs.firstWhere(
(doc) => doc.id == widget.pendingId,
orElse: () => PendingDocument(id: '', imagePath: '')
);
final double? finalLat = pendingDoc.latitude ?? _document?.latitude;
final double? finalLng = pendingDoc.longitude ?? _document?.longitude;
final finalDoc = DocumentModel(
id: _document?.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
tanggal: _tanggalCtrl.text,
noPo: _noPoCtrl.text,
noSo: _noSoCtrl.text,
noDo: _noDoCtrl.text,
kepadaYth: _kepadaYthCtrl.text,
orderUntuk: _orderUntukCtrl.text,
alamat: _alamatCtrl.text,
platTruk: _platTrukCtrl.text,
namaDriver: _namaDriverCtrl.text,
namaPenerima: _namaPenerimaCtrl.text,
latitude: finalLat,
longitude: finalLng,
items: _itemControllers.map((ctrls) => DocumentItem(
nomorSku: ctrls['sku']!.text,
namaBarang: ctrls['nama']!.text,
banyak: ctrls['banyak']!.text,
jumlah: ctrls['jumlah']!.text,
)).toList(),
createdAt: DateTime.now(),
);
// Save locally via Riverpod injected LocalStorage
final localStorage = ref.read(localStorageProvider);
await localStorage.saveDocument(finalDoc);
// Try to sync with server
bool syncedToServer = false;
String? syncError;
try {
final apiClient = ref.read(apiClientProvider);
await apiClient.client.put(
'${AppConfig.fetchDocumentsEndpoint}/${finalDoc.id}',
data: finalDoc.toPutPayload(),
);
syncedToServer = true;
debugPrint('Document synced with server successfully');
} catch (e) {
syncError = ApiException.from(e).displayMessage;
debugPrint('Failed to sync document with server: $syncError. Saved locally (cached).');
}
if (widget.pendingId != null) {
if (syncedToServer) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
} else {
// Keep it visible under "Pending & Processing" with a distinct status so the
// correction isn't silently lost - the user can retry the sync from there
// without re-uploading the image or re-running OCR.
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
widget.pendingId!,
finalDoc,
'Belum tersinkron ke server: $syncError',
);
}
}
if (mounted) {
// Show SnackBar BEFORE pop so context is still valid
ScaffoldMessenger.of(context).showSnackBar(
syncedToServer
? const SnackBar(content: Text('Dokumen Berhasil Disimpan!'), backgroundColor: AppConfig.successColor)
: const SnackBar(
content: Text('Tersimpan lokal, tapi GAGAL sinkron ke server. Cek koneksi lalu retry dari daftar dokumen.'),
backgroundColor: Colors.deepOrange,
duration: Duration(seconds: 5),
),
);
// Pass true as result so DocumentsScreen's .then() knows to reload
context.pop(true);
}
}
Widget _buildTextField(
String label,
TextEditingController controller, {
TextInputType keyboardType = TextInputType.text,
bool readOnly = false,
VoidCallback? onTap,
List<TextInputFormatter>? inputFormatters,
String? Function(String?)? validator,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextFormField(
controller: controller,
keyboardType: keyboardType,
readOnly: readOnly,
onTap: onTap,
inputFormatters: inputFormatters,
style: AppConfig.bodyMedium,
decoration: InputDecoration(
labelText: label,
labelStyle: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
filled: true,
fillColor: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppConfig.primaryColor),
),
),
validator: validator ?? (value) {
if (value == null || value.isEmpty) {
return '$label harus diisi';
}
return null;
},
),
);
}
@override
Widget build(BuildContext context) {
if (_document == null) {
return Scaffold(
appBar: AppBar(title: const Text('Tinjau Data')),
body: const Center(child: Text('Dokumen tidak ditemukan atau masih diproses')),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Tinjau & Edit Data'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppConfig.primaryColor.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppConfig.primaryColor.withOpacity(0.2)),
),
child: Row(
children: [
const Icon(Icons.info_outline, color: AppConfig.primaryColor),
const SizedBox(width: 12),
Expanded(
child: Text(
'Tinjau dan edit data hasil ekstraksi di bawah ini untuk memastikan keakuratannya sebelum konfirmasi.',
style: AppConfig.bodyMedium.copyWith(color: AppConfig.primaryColor),
),
),
],
),
),
const SizedBox(height: 24),
// Header Details
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Detail Header', style: AppConfig.h2),
const SizedBox(height: 16),
_buildTextField(
'Tanggal',
_tanggalCtrl,
readOnly: true,
onTap: () async {
DateTime? pickedDate = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2101),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: AppConfig.primaryColor,
onPrimary: Colors.white,
onSurface: AppConfig.textPrimary,
),
),
child: child!,
);
},
);
if (pickedDate != null) {
String formattedDate = DateFormat('dd MMMM yyyy').format(pickedDate);
_tanggalCtrl.text = formattedDate;
}
}
),
_buildTextField(
'No PO',
_noPoCtrl,
validator: (val) {
if (val == null || val.isEmpty) return 'No PO harus diisi';
if (!RegExp(r'^PO/\d{2}/\d{10}$').hasMatch(val)) {
return 'Format harus PO/[XX]/[XXXXXXXXXX]';
}
return null;
}
),
_buildTextField(
'No SO',
_noSoCtrl,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (val) {
if (val == null || val.isEmpty) return 'No SO harus diisi';
if (val.length != 10) {
return 'SO harus persis 10 angka';
}
return null;
}
),
_buildTextField('No DO', _noDoCtrl),
],
),
),
),
const SizedBox(height: 16),
// Shipment Details
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Detail Pengiriman', style: AppConfig.h2),
const SizedBox(height: 16),
_buildTextField('Kepada Yth', _kepadaYthCtrl),
_buildTextField('Order Untuk', _orderUntukCtrl),
_buildTextField('Alamat', _alamatCtrl),
_buildTextField('Plat Truk', _platTrukCtrl),
_buildTextField('Nama Driver', _namaDriverCtrl),
],
),
),
),
const SizedBox(height: 16),
// Geotagging Details
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Detail Lokasi', style: AppConfig.h2),
const SizedBox(height: 16),
_buildTextField(
'Lintang',
_latitudeCtrl,
readOnly: true,
),
_buildTextField(
'Bujur',
_longitudeCtrl,
readOnly: true,
),
],
),
),
),
const SizedBox(height: 16),
// Items Section (Editable + CRUD)
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Barang (${_itemControllers.length})', style: AppConfig.h2),
],
),
const SizedBox(height: 16),
..._itemControllers.asMap().entries.map((entry) {
int index = entry.key;
var ctrls = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: ctrls['nama']!,
builder: (context, value, child) {
return Text(
value.text.isNotEmpty ? value.text : 'Item Baru',
style: AppConfig.h3,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _removeItem(index),
tooltip: 'Hapus Barang',
),
],
),
const SizedBox(height: 8),
_buildTextField(
'SKU',
ctrls['sku']!,
validator: (val) {
if (val == null || val.isEmpty) {
return 'SKU harus diisi';
}
if (!MasterSku.data.containsKey(val)) {
return 'SKU tidak terdaftar';
}
return null;
},
),
_buildTextField('Nama Barang', ctrls['nama']!),
Row(
children: [
Expanded(child: _buildTextField('Banyak', ctrls['banyak']!)),
const SizedBox(width: 8),
Expanded(child: _buildTextField('Jumlah', ctrls['jumlah']!)),
],
),
],
),
),
);
}),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _addItem(),
icon: const Icon(Icons.add),
label: const Text('Tambah Item Baru'),
style: OutlinedButton.styleFrom(
foregroundColor: AppConfig.primaryColor,
side: const BorderSide(color: AppConfig.primaryColor),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
),
const SizedBox(height: 32),
],
),
),
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
height: 54,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: _showConfirmationDialog,
child: const Text('Selesai Ditinjau', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
),
),
);
}
@override
void dispose() {
_tanggalCtrl.dispose();
_noPoCtrl.dispose();
_noSoCtrl.dispose();
_noDoCtrl.dispose();
_kepadaYthCtrl.dispose();
_orderUntukCtrl.dispose();
_alamatCtrl.dispose();
_platTrukCtrl.dispose();
_namaDriverCtrl.dispose();
_namaPenerimaCtrl.dispose();
_latitudeCtrl.dispose();
_longitudeCtrl.dispose();
for (var ctrls in _itemControllers) {
ctrls['sku']?.dispose();
ctrls['nama']?.dispose();
ctrls['banyak']?.dispose();
ctrls['jumlah']?.dispose();
}
super.dispose();
}
}