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>
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-10 15:19:32 +07:00
1 parent 2febe0c886
commit ada6488592
67 files changed
+5705 -1142

No files matched your search

@@ -0,0 +1,53 @@
import '../../models/document_model.dart';
enum DocumentSaveActionKind { putExisting, reuploadThenPut, blocked }
/// Decides how an editor's save flow should reach the server, given whether
/// a real server-assigned document is already resolved. Deliberately
/// separated from the Dio call sites (`editor_screen.dart`,
/// `product_editor_logic.dart`) so the guard against PUTting to a
/// fabricated client-side ID - a millisecond timestamp that can never match
/// the int4 `documents.id` (gap G5, `docs/api-contract-map.md`) - is
/// unit-testable without mocking network I/O, mirroring the pure-decision
/// pattern used by `poll_outcome.dart` (task 6.1).
class DocumentSaveAction {
final DocumentSaveActionKind kind;
final String? serverId;
final String? blockedMessage;
const DocumentSaveAction._(this.kind, {this.serverId, this.blockedMessage});
factory DocumentSaveAction.putExisting(String serverId) =>
DocumentSaveAction._(DocumentSaveActionKind.putExisting, serverId: serverId);
factory DocumentSaveAction.reuploadThenPut() =>
const DocumentSaveAction._(DocumentSaveActionKind.reuploadThenPut);
factory DocumentSaveAction.blocked(String message) =>
DocumentSaveAction._(DocumentSaveActionKind.blocked, blockedMessage: message);
}
/// [existingDocument] is the server document already resolved from a
/// completed upload+parse (e.g. `PendingDocument.document`) - when present,
/// its real id is used for the PUT. When absent (e.g. the editor route lost
/// its `pendingId` on process-death restoration), [localImagePath] - the
/// pending item's originally-captured photo - lets the save flow recover a
/// real server id by re-uploading it (safe: the backend dedups by
/// `file_hash`) before PUTting the corrected fields. If neither is
/// available there is nothing to save against, so saving is blocked with an
/// explicit message instead of PUTting to a made-up ID that can never match
/// a real document.
DocumentSaveAction resolveDocumentSaveAction({
required DocumentModel? existingDocument,
required String localImagePath,
}) {
if (existingDocument != null) {
return DocumentSaveAction.putExisting(existingDocument.id);
}
if (localImagePath.isEmpty) {
return DocumentSaveAction.blocked(
'Tidak dapat menyimpan: dokumen asli tidak ditemukan di perangkat. Silakan pindai ulang.',
);
}
return DocumentSaveAction.reuploadThenPut();
}
+238
View File
@@ -0,0 +1,238 @@
part of 'editor_screen.dart';
mixin EditorLogic on ConsumerState<EditorScreen> {
DocumentModel? _document;
// Controllers
final _tanggalCtrl = TextEditingController();
final _noPoCtrl = TextEditingController();
final _noSoCtrl = TextEditingController();
final _noDoCtrl = TextEditingController();
final _kepadaYthCtrl = TextEditingController();
final _orderUntukCtrl = TextEditingController();
final _alamatCtrl = TextEditingController();
final _platTrukCtrl = TextEditingController();
final _namaDriverCtrl = TextEditingController();
final _namaPenerimaCtrl = TextEditingController();
final _latitudeCtrl = TextEditingController();
final _longitudeCtrl = TextEditingController();
List<Map<String, TextEditingController>> _itemControllers = [];
bool _isAgreed = false;
final _formKey = GlobalKey<FormState>();
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);
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 ConfirmationDialog(
nameController: _namaPenerimaCtrl,
initialAgreed: _isAgreed,
onConfirm: (agreed, name) {
setState(() {
_isAgreed = agreed;
});
Navigator.pop(context);
_submitDocument();
},
);
}
);
}
Future<void> _submitDocument() async {
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 saveAction = resolveDocumentSaveAction(
existingDocument: _document,
localImagePath: pendingDoc.imagePath,
);
if (saveAction.kind == DocumentSaveActionKind.blocked) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(saveAction.blockedMessage!),
backgroundColor: AppConfig.errorColor,
duration: const Duration(seconds: 5),
),
);
return;
}
final apiClient = ref.read(apiClientProvider);
String serverId;
if (saveAction.kind == DocumentSaveActionKind.reuploadThenPut) {
try {
final formData = FormData.fromMap({
'image': await MultipartFile.fromFile(
pendingDoc.imagePath,
filename: pendingDoc.imagePath.split(RegExp(r'[/\\]')).last,
),
if (finalLat != null) 'latitude': finalLat.toString(),
if (finalLng != null) 'longitude': finalLng.toString(),
'scan_mode': pendingDoc.scanMode ?? 'DO',
});
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
serverId = DocumentModel.fromJson(response.data['data']).id;
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal membuat dokumen di server: ${ApiException.from(e).displayMessage}'),
backgroundColor: AppConfig.errorColor,
duration: const Duration(seconds: 5),
),
);
return;
}
} else {
serverId = saveAction.serverId!;
}
final finalDoc = DocumentModel(
id: serverId,
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(),
);
final localStorage = ref.read(localStorageProvider);
await localStorage.saveDocument(finalDoc);
bool syncedToServer = false;
String? syncError;
try {
await apiClient.client.put(
'${AppConfig.fetchDocumentsEndpoint}/${finalDoc.id}',
data: finalDoc.toPutPayload(),
);
syncedToServer = true;
} catch (e) {
syncError = ApiException.from(e).displayMessage;
}
if (widget.pendingId != null) {
if (syncedToServer) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
} else {
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
widget.pendingId!,
finalDoc,
'Belum tersinkron ke server: $syncError',
);
}
}
if (mounted) {
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),
),
);
context.pop(true);
}
}
}
+13 -196
View File
@@ -1,3 +1,6 @@
library editor_screen;
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -7,44 +10,25 @@ import '../../main.dart'; // To access localStorageProvider
import '../documents/pending_documents_provider.dart';
import '../../core/network/api_client.dart';
import '../../core/network/api_exception.dart';
import 'document_save_action.dart';
import 'widgets/header_details_card.dart';
import 'widgets/shipment_details_card.dart';
import 'widgets/location_details_card.dart';
import 'widgets/items_list_card.dart';
import 'widgets/confirmation_dialog.dart';
part 'editor_logic.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
final _tanggalCtrl = TextEditingController();
final _noPoCtrl = TextEditingController();
final _noSoCtrl = TextEditingController();
final _noDoCtrl = TextEditingController();
final _kepadaYthCtrl = TextEditingController();
final _orderUntukCtrl = TextEditingController();
final _alamatCtrl = TextEditingController();
final _platTrukCtrl = TextEditingController();
final _namaDriverCtrl = TextEditingController();
final _namaPenerimaCtrl = TextEditingController();
final _latitudeCtrl = TextEditingController();
final _longitudeCtrl = TextEditingController();
List<Map<String, TextEditingController>> _itemControllers = [];
bool _isAgreed = false;
final _formKey = GlobalKey<FormState>();
class _EditorScreenState extends ConsumerState<EditorScreen> with EditorLogic {
@override
void initState() {
super.initState();
@@ -74,173 +58,6 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
super.dispose();
}
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);
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 ConfirmationDialog(
nameController: _namaPenerimaCtrl,
initialAgreed: _isAgreed,
onConfirm: (agreed, name) {
setState(() {
_isAgreed = agreed;
});
Navigator.pop(context);
_submitDocument();
},
);
}
);
}
Future<void> _submitDocument() async {
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(),
);
final localStorage = ref.read(localStorageProvider);
await localStorage.saveDocument(finalDoc);
bool syncedToServer = false;
String? syncError;
try {
final apiClient = ref.read(apiClientProvider);
await apiClient.client.put(
'${AppConfig.fetchDocumentsEndpoint}/${finalDoc.id}',
data: finalDoc.toPutPayload(),
);
syncedToServer = true;
} catch (e) {
syncError = ApiException.from(e).displayMessage;
}
if (widget.pendingId != null) {
if (syncedToServer) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
} else {
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
widget.pendingId!,
finalDoc,
'Belum tersinkron ke server: $syncError',
);
}
}
if (mounted) {
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),
),
);
context.pop(true);
}
}
@override
Widget build(BuildContext context) {
if (_document == null) {
@@ -286,7 +103,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
),
),
const SizedBox(height: AppConfig.spaceXL),
HeaderDetailsCard(
tanggalCtrl: _tanggalCtrl,
noPoCtrl: _noPoCtrl,
@@ -294,7 +111,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
noDoCtrl: _noDoCtrl,
),
const SizedBox(height: AppConfig.spaceLarge),
ShipmentDetailsCard(
kepadaYthCtrl: _kepadaYthCtrl,
orderUntukCtrl: _orderUntukCtrl,
@@ -303,20 +120,20 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
namaDriverCtrl: _namaDriverCtrl,
),
const SizedBox(height: AppConfig.spaceLarge),
LocationDetailsCard(
latitudeCtrl: _latitudeCtrl,
longitudeCtrl: _longitudeCtrl,
),
const SizedBox(height: AppConfig.spaceLarge),
ItemsListCard(
itemControllers: _itemControllers,
onRemoveItem: _removeItem,
onAddItem: () => _addItem(),
),
const SizedBox(height: AppConfig.spaceXL),
ElevatedButton(
onPressed: _showConfirmationDialog,
style: ElevatedButton.styleFrom(
+1 -1
View File
@@ -10,7 +10,7 @@ class PdfService {
// Load PFM logo asset
final logoImage = await flutterImageProvider(const AssetImage('assets/logo.png'));
final isProductScan = document.orderUntuk == 'PRODUCT SCAN';
final isProductScan = document.docType == 'Product';
// Add page
pdf.addPage(
@@ -0,0 +1,133 @@
part of 'product_editor_screen.dart';
mixin ProductEditorDataLogic on ConsumerState<ProductEditorScreen> {
final _expDateCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
final _formKey = GlobalKey<FormState>();
String _selectedSku = '';
String? _selectedBatchOption;
bool _isManualDate = false;
String _imagePath = '';
DocumentModel? _document;
bool _loading = false;
List<Map<String, dynamic>> _matches = [];
final Map<String, List<String>> _skuBatches = {};
List<Map<String, dynamic>> _skuMasterList = [];
/// True only when `_matches` came from the real classifier response - used
/// to distinguish an actual AI recommendation from the manual-selection
/// fallback list, so the UI never shows a fabricated confidence score.
bool _hasAutoMatch = false;
/// True when even the SKU master list couldn't be loaded, leaving nothing
/// to build a manual-selection fallback from - the one failure mode that
/// blocks the review screen entirely instead of degrading gracefully.
bool _classificationFailed = false;
void _loadPendingDoc() {
if (widget.pendingId == null) return;
final pendingDoc = ref.read(pendingDocumentsProvider).firstWhere(
(doc) => doc.id == widget.pendingId,
orElse: () => PendingDocument(id: '', imagePath: '')
);
setState(() {
_imagePath = pendingDoc.imagePath;
_document = pendingDoc.document;
});
}
/// Populates the SKU dropdown/expiry batches from the document's own data.
/// Product Scan documents carry their classify+OCR result from the single
/// pass the backend already ran at upload time (`_document.productScan*`,
/// backend gap G3 fix) - reading it here is synchronous, no network call,
/// mirroring how `EditorScreen._loadDocumentData()` reads a DO document's
/// already-parsed data with no request at all. Falls back to a plain SKU
/// master list read (a DB query, not a GPU/classifier call) only when the
/// document has no stored match at all - e.g. nothing scored above the
/// match threshold, or the document predates this fix.
Future<void> _fetchClassificationAndSkus() async {
_classificationFailed = false;
final storedMatches = _document?.productScanMatches ?? const [];
final extractedExpiryDate = _document?.productScanExtractedExpiryDate ?? '';
if (storedMatches.isNotEmpty) {
setState(() {
_matches = List<Map<String, dynamic>>.from(storedMatches);
_hasAutoMatch = true;
// Only seed a batch from a real extracted date - an empty result
// means "no date found," not "here are two made-up dates," so it's
// left empty to force manual entry instead (see G7).
_skuBatches.clear();
for (var match in _matches) {
final sku = match['no_sku'];
_skuBatches[sku] = extractedExpiryDate.isNotEmpty ? [extractedExpiryDate] : [];
}
_selectedSku = _matches.first['no_sku'];
_updateBatchOptions();
});
return;
}
setState(() { _loading = true; });
try {
final apiClient = ref.read(apiClientProvider);
final skusRes = await apiClient.client.get(AppConfig.masterSkusEndpoint);
_skuMasterList = parseSkuMasterList(skusRes.data);
if (_skuMasterList.isNotEmpty) {
_matches = _skuMasterList.take(5).map<Map<String, dynamic>>((e) => <String, dynamic>{
'no_sku': e['no_sku'],
'nama_item': e['nama_item'],
'confidence': 0.0,
}).toList();
_selectedSku = _matches.first['no_sku'];
}
_hasAutoMatch = false;
} catch (e) {
debugPrint('Error loading SKU master list fallback: $e');
_classificationFailed = true;
} finally {
if (mounted) {
setState(() {
_loading = false;
_updateBatchOptions();
});
}
}
}
void _updateBatchOptions() {
final batches = _skuBatches[_selectedSku] ?? [];
if (batches.isNotEmpty) {
_selectedBatchOption = 'Batch 1: ${batches[0]}';
_expDateCtrl.text = batches[0];
_isManualDate = false;
} else {
_selectedBatchOption = null;
_expDateCtrl.clear();
_isManualDate = true;
}
}
Future<void> _selectDate() async {
DateTime? pickedDate = await showDatePicker(
context: context,
initialDate: DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime(2020),
lastDate: DateTime(2035),
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(primary: AppConfig.primaryColor, onPrimary: Colors.white, onSurface: AppConfig.textPrimary),
),
child: child!,
),
);
if (pickedDate != null) {
setState(() { _expDateCtrl.text = DateFormat('dd/MM/yyyy').format(pickedDate); });
}
}
}
+93 -257
View File
@@ -1,12 +1,25 @@
import 'dart:io';
library product_editor;
import 'package:dio/dio.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 'package:shared_preferences/shared_preferences.dart';
import '../../config/app_config.dart';
import '../../models/document_model.dart';
import '../../main.dart';
import '../documents/pending_documents_provider.dart';
import '../../core/network/api_client.dart';
import '../../core/network/api_exception.dart';
import 'document_save_action.dart';
import 'product_scan_response_parser.dart';
import 'widgets/product_dropdown_card.dart';
import 'widgets/product_expiry_card.dart';
import 'widgets/product_preview_card.dart';
part 'product_editor_data_logic.dart';
part 'product_editor_submit_logic.dart';
class ProductEditorScreen extends ConsumerStatefulWidget {
final String? pendingId;
@@ -15,290 +28,113 @@ class ProductEditorScreen extends ConsumerStatefulWidget {
ConsumerState<ProductEditorScreen> createState() => _ProductEditorScreenState();
}
class _ProductEditorScreenState extends ConsumerState<ProductEditorScreen> {
final _expDateCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
final _formKey = GlobalKey<FormState>();
String _selectedSku = '12010119';
String? _selectedBatchOption;
bool _isManualDate = false;
String _imagePath = '';
List<DocumentModel> _doDocs = [];
String _selectedRelatedPo = 'PO-9923488';
final List<Map<String, dynamic>> _matches = [
{'no_sku': '12010119', 'nama_item': 'FIESTA SPICY CHICKEN NUGGET 500 GR', 'confidence': 0.985},
{'no_sku': '12012504', 'nama_item': 'AKUMO NUGGET COIN 200 GR', 'confidence': 0.82},
{'no_sku': '12010220', 'nama_item': 'FIESTA SCHNITZEL 500 GR', 'confidence': 0.75},
];
final Map<String, List<String>> _skuBatches = {
'12010119': ['15/12/2026', '20/04/2027'],
'12012504': ['01/10/2026', '12/03/2027'],
'12010220': ['18/08/2026', '05/01/2027'],
};
class _ProductEditorScreenState extends ConsumerState<ProductEditorScreen> with ProductEditorDataLogic, ProductEditorSubmitLogic {
@override
void initState() {
super.initState();
_loadPendingDoc();
_loadDoDocs();
_updateBatchOptions();
_fetchClassificationAndSkus();
}
@override
void dispose() {
_expDateCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
void _loadPendingDoc() {
if (widget.pendingId == null) return;
final pendingDoc = ref.read(pendingDocumentsProvider).firstWhere(
(doc) => doc.id == widget.pendingId,
orElse: () => PendingDocument(id: '', imagePath: '')
);
setState(() { _imagePath = pendingDoc.imagePath; });
}
void _loadDoDocs() {
final allDocs = ref.read(localStorageProvider).getAllDocuments();
final doDocs = allDocs.where((d) => d.orderUntuk != 'PRODUCT SCAN').toList();
setState(() {
_doDocs = doDocs;
if (_doDocs.isNotEmpty) {
_selectedRelatedPo = _doDocs.first.noPo;
}
});
}
void _updateBatchOptions() {
final batches = _skuBatches[_selectedSku] ?? [];
if (batches.isNotEmpty) {
_selectedBatchOption = 'Batch 1: ${batches[0]}';
_expDateCtrl.text = batches[0];
_isManualDate = false;
}
}
Future<void> _selectDate() async {
DateTime? pickedDate = await showDatePicker(
context: context,
initialDate: DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime(2020),
lastDate: DateTime(2035),
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(primary: AppConfig.primaryColor, onPrimary: Colors.white, onSurface: AppConfig.textPrimary),
),
child: child!,
),
);
if (pickedDate != null) {
setState(() { _expDateCtrl.text = DateFormat('dd/MM/yyyy').format(pickedDate); });
}
}
void _submit() async {
if (!_formKey.currentState!.validate()) return;
final activeMatch = _matches.firstWhere((m) => m['no_sku'] == _selectedSku, orElse: () => _matches.first);
final String productName = activeMatch['nama_item'] ?? 'Unknown Product';
final finalDoc = DocumentModel(
id: widget.pendingId ?? DateTime.now().millisecondsSinceEpoch.toString(),
tanggal: DateFormat('dd MMMM yyyy').format(DateTime.now()),
noPo: _selectedRelatedPo,
noSo: '1002003004',
noDo: _isManualDate ? 'Batch Manual' : (_selectedBatchOption != null ? _selectedBatchOption!.split(': ')[0] : 'Batch 1'),
kepadaYth: 'PM KELAPA DUA KARAWACI',
orderUntuk: 'PRODUCT SCAN',
alamat: 'Product Scan: Expired ${_expDateCtrl.text}',
platTruk: 'B 1234 PFM',
namaDriver: 'PRODUCT SCAN',
namaPenerima: 'STORE STAFF',
items: [DocumentItem(nomorSku: _selectedSku, namaBarang: productName, banyak: '1', jumlah: '1')],
createdAt: DateTime.now(),
);
await ref.read(localStorageProvider).saveDocument(finalDoc);
if (widget.pendingId != null) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Hasil Pemindaian Produk Berhasil Disimpan!'), backgroundColor: AppConfig.successColor),
);
context.pop(true);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Review Hasil Produk'), leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop())),
body: SingleChildScrollView(
padding: AppConfig.screenPadding,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_imagePath.isNotEmpty) ...[
Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium), side: BorderSide(color: Colors.grey.shade300)),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI Crop Region / Preview', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceMedium),
Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall)),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
child: Image.file(File(_imagePath), fit: BoxFit.cover, alignment: const Alignment(0, 0.4)),
),
),
],
body: _loading
? const Center(child: CircularProgressIndicator())
: _classificationFailed
? _buildFailureState()
: SingleChildScrollView(
padding: AppConfig.screenPadding,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ProductPreviewCard(imagePath: _imagePath),
if (_imagePath.isNotEmpty) const SizedBox(height: AppConfig.spaceLarge),
ProductDropdownCard(
selectedSku: _selectedSku,
matches: _matches,
hasAutoMatch: _hasAutoMatch,
onSkuChanged: (val) {
if (val != null) {
setState(() {
_selectedSku = val;
_updateBatchOptions();
});
}
},
),
),
const SizedBox(height: AppConfig.spaceLarge),
ProductExpiryCard(
selectedSku: _selectedSku,
selectedBatchOption: _selectedBatchOption,
isManualDate: _isManualDate,
skuBatches: _skuBatches,
expDateCtrl: _expDateCtrl,
notesCtrl: _notesCtrl,
onBatchChanged: (val) {
if (val != null) {
setState(() {
if (val == 'manual') {
_isManualDate = true;
_expDateCtrl.clear();
} else {
_isManualDate = false;
_selectedBatchOption = val;
final parts = val.split(': ');
if (parts.length > 1) { _expDateCtrl.text = parts[1]; }
}
});
}
},
onSelectDate: _selectDate,
),
const SizedBox(height: AppConfig.spaceXL),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Simpan & Konfirmasi Produk', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
const SizedBox(height: AppConfig.spaceLarge),
],
_buildProductDropdownCard(),
const SizedBox(height: AppConfig.spaceLarge),
_buildExpiryCard(),
const SizedBox(height: AppConfig.spaceXL),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Simpan & Konfirmasi Produk', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
),
),
),
);
}
Widget _buildProductDropdownCard() {
final activeMatch = _matches.firstWhere((m) => m['no_sku'] == _selectedSku, orElse: () => _matches.first);
final double confidence = activeMatch['confidence'];
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium), side: BorderSide(color: Colors.grey.shade300)),
/// Explicit, retry-able error state for when the SKU master list couldn't
/// be loaded at all (see docs/api-contract-map.md G7) - replaces the old
/// behavior of silently fabricating fake SKU matches and confidence
/// scores so the screen always looked like it had a working AI result.
Widget _buildFailureState() {
return Center(
child: Padding(
padding: AppConfig.paddingLarge,
padding: AppConfig.screenPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('AI Product Classification', style: AppConfig.h2),
const Icon(Icons.cloud_off, size: 48, color: AppConfig.errorColor),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: _selectedSku,
decoration: const InputDecoration(labelText: 'Pilih Produk / SKU', contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8)),
isExpanded: true,
style: AppConfig.bodyMedium,
items: _matches.map((match) {
return DropdownMenuItem<String>(value: match['no_sku'], child: Text('${match['no_sku']} - ${match['nama_item']}', overflow: TextOverflow.ellipsis, maxLines: 1));
}).toList(),
onChanged: (val) { if (val != null) { setState(() { _selectedSku = val; _updateBatchOptions(); }); } },
),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: _selectedRelatedPo,
decoration: const InputDecoration(labelText: 'Hubungkan ke PO Dokumen', contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8)),
isExpanded: true,
style: AppConfig.bodyMedium,
items: [
if (_doDocs.isEmpty)
const DropdownMenuItem(value: 'PO-9923488', child: Text('PO-9923488 (Default)')),
..._doDocs.map((doc) => DropdownMenuItem(value: doc.noPo, child: Text('${doc.noPo} - ${doc.kepadaYth}', overflow: TextOverflow.ellipsis))),
],
onChanged: (val) { if (val != null) { setState(() { _selectedRelatedPo = val; }); } },
),
const SizedBox(height: AppConfig.spaceLarge),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Confidence Score', style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary)),
Text('${(confidence * 100).toStringAsFixed(1)}%', style: const TextStyle(fontWeight: FontWeight.bold, color: AppConfig.successColor)),
],
),
Text('Gagal Memuat Klasifikasi Produk', style: AppConfig.h3, textAlign: TextAlign.center),
const SizedBox(height: AppConfig.spaceSmall),
LinearProgressIndicator(value: confidence, color: AppConfig.successColor, backgroundColor: Colors.grey.shade200),
],
),
),
);
}
Widget _buildExpiryCard() {
final batches = _skuBatches[_selectedSku] ?? [];
final dropdownItems = <DropdownMenuItem<String>>[];
for (int i = 0; i < batches.length; i++) {
dropdownItems.add(DropdownMenuItem(value: 'Batch ${i + 1}: ${batches[i]}', child: Text('Batch ${i + 1}: ${batches[i]}')));
}
dropdownItems.add(const DropdownMenuItem(value: 'manual', child: Text('Set Tanggal Manual...')));
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium), side: BorderSide(color: Colors.grey.shade300)),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI OCR Expiry Extraction', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: _isManualDate ? 'manual' : _selectedBatchOption,
decoration: const InputDecoration(labelText: 'Pilih Batch Expiry', contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8)),
isExpanded: true,
style: AppConfig.bodyMedium,
items: dropdownItems,
onChanged: (val) {
if (val != null) {
setState(() {
if (val == 'manual') {
_isManualDate = true;
_expDateCtrl.clear();
} else {
_isManualDate = false;
_selectedBatchOption = val;
final parts = val.split(': ');
if (parts.length > 1) { _expDateCtrl.text = parts[1]; }
}
});
}
},
Text(
'Periksa koneksi Anda ke server dan coba lagi.',
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
textAlign: TextAlign.center,
),
if (_isManualDate) ...[
const SizedBox(height: AppConfig.spaceLarge),
TextFormField(
controller: _expDateCtrl,
decoration: InputDecoration(labelText: 'Expired Date Manual (DD/MM/YYYY)', suffixIcon: IconButton(icon: const Icon(Icons.calendar_today, color: AppConfig.primaryColor), onPressed: _selectDate)),
validator: (val) => val == null || val.isEmpty ? 'Tanggal kadaluwarsa harus diisi' : null,
),
],
const SizedBox(height: AppConfig.spaceLarge),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('OCR Confidence Score', style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary)),
Text('${(0.924 * 100).toStringAsFixed(1)}%', style: const TextStyle(fontWeight: FontWeight.bold, color: AppConfig.successColor)),
],
const SizedBox(height: AppConfig.spaceXL),
ElevatedButton.icon(
onPressed: _fetchClassificationAndSkus,
icon: const Icon(Icons.refresh),
label: const Text('Coba Lagi'),
),
const SizedBox(height: AppConfig.spaceSmall),
LinearProgressIndicator(value: 0.924, color: AppConfig.successColor, backgroundColor: Colors.grey.shade200),
const SizedBox(height: AppConfig.spaceMedium),
TextFormField(controller: _notesCtrl, decoration: const InputDecoration(labelText: 'Catatan Tambahan'), maxLines: 2),
],
),
),
@@ -0,0 +1,130 @@
part of 'product_editor_screen.dart';
mixin ProductEditorSubmitLogic on ProductEditorDataLogic {
void _submit() async {
if (!_formKey.currentState!.validate()) return;
final activeMatch = _matches.firstWhere(
(m) => m['no_sku'] == _selectedSku,
orElse: () => _matches.isNotEmpty ? _matches.first : <String, dynamic>{'no_sku': '', 'nama_item': 'Unknown Product', 'confidence': 0.0},
);
final String productName = activeMatch['nama_item'] ?? 'Unknown Product';
final prefs = await SharedPreferences.getInstance();
final currentStoreName = prefs.getString('nama_toko') ?? 'PM KELAPA DUA KARAWACI';
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 saveAction = resolveDocumentSaveAction(
existingDocument: _document,
localImagePath: pendingDoc.imagePath,
);
if (saveAction.kind == DocumentSaveActionKind.blocked) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(saveAction.blockedMessage!),
backgroundColor: AppConfig.errorColor,
duration: const Duration(seconds: 5),
),
);
return;
}
final apiClient = ref.read(apiClientProvider);
String serverId;
if (saveAction.kind == DocumentSaveActionKind.reuploadThenPut) {
try {
final formData = FormData.fromMap({
'image': await MultipartFile.fromFile(
pendingDoc.imagePath,
filename: pendingDoc.imagePath.split(RegExp(r'[/\\]')).last,
),
if (finalLat != null) 'latitude': finalLat.toString(),
if (finalLng != null) 'longitude': finalLng.toString(),
'scan_mode': pendingDoc.scanMode ?? 'Product',
});
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
serverId = DocumentModel.fromJson(response.data['data']).id;
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal membuat dokumen di server: ${ApiException.from(e).displayMessage}'),
backgroundColor: AppConfig.errorColor,
duration: const Duration(seconds: 5),
),
);
return;
}
} else {
serverId = saveAction.serverId!;
}
final finalDoc = DocumentModel(
id: serverId,
tanggal: DateFormat('dd MMMM yyyy').format(DateTime.now()),
// No PO-link concept for Product Scan documents anymore (the
// "Hubungkan ke PO Dokumen" field was removed - user request).
noPo: '',
noSo: '1002003004',
noDo: _isManualDate ? 'Batch Manual' : (_selectedBatchOption != null ? _selectedBatchOption!.split(': ')[0] : 'Batch 1'),
kepadaYth: currentStoreName,
orderUntuk: 'PRODUCT SCAN',
alamat: 'Product Scan: Expired ${_expDateCtrl.text}',
platTruk: 'B 1234 PFM',
namaDriver: 'PRODUCT SCAN',
namaPenerima: 'STORE STAFF',
items: [DocumentItem(nomorSku: _selectedSku, namaBarang: productName, banyak: '1', jumlah: '1')],
latitude: finalLat,
longitude: finalLng,
createdAt: DateTime.now(),
docType: 'Product',
);
final localStorage = ref.read(localStorageProvider);
await localStorage.saveDocument(finalDoc);
bool syncedToServer = false;
String? syncError;
try {
await apiClient.client.put(
'${AppConfig.fetchDocumentsEndpoint}/${finalDoc.id}',
data: finalDoc.toPutPayload(),
);
syncedToServer = true;
} catch (e) {
syncError = ApiException.from(e).displayMessage;
}
if (widget.pendingId != null) {
if (syncedToServer) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
} else {
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
widget.pendingId!,
finalDoc,
'Belum tersinkron ke server: $syncError',
);
}
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
syncedToServer
? const SnackBar(content: Text('Hasil Pemindaian Produk 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),
),
);
context.pop(true);
}
}
@@ -0,0 +1,41 @@
/// Pure parsing helpers for the v1 product-scan endpoints
/// (`GET /master/skus`, `POST /scan-product`), kept free of Flutter/network
/// imports so the "unwrap the v1 envelope" logic is unit-testable without
/// mocking Dio - the same pattern used by `document_sync_merge.dart`.
library;
List<Map<String, dynamic>> parseSkuMasterList(dynamic responseData) {
final List raw = responseData?['data'] ?? [];
return raw.map((e) => Map<String, dynamic>.from(e as Map)).toList();
}
class ScanProductResult {
final List<Map<String, dynamic>> matches;
final String extractedExpiryDate;
const ScanProductResult({
required this.matches,
required this.extractedExpiryDate,
});
}
ScanProductResult parseScanProductResponse(dynamic responseData) {
final data = responseData?['data'] ?? {};
final List rawMatches = data['possibleMatches'] ?? [];
final matches = rawMatches.map<Map<String, dynamic>>((m) {
return <String, dynamic>{
'no_sku': m['no_sku']?.toString() ?? '',
'nama_item': m['nama_item']?.toString() ?? '',
'confidence': (m['score'] as num?)?.toDouble() ?? 0.0,
};
}).toList();
final ocr = data['ocr'] ?? {};
final extractedExpiryDate = ocr['extracted_expired_date']?.toString() ?? '';
return ScanProductResult(
matches: matches,
extractedExpiryDate: extractedExpiryDate,
);
}
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import '../../../config/app_config.dart';
class ProductDropdownCard extends StatelessWidget {
final String selectedSku;
final List<Map<String, dynamic>> matches;
final bool hasAutoMatch;
final ValueChanged<String?> onSkuChanged;
const ProductDropdownCard({
super.key,
required this.selectedSku,
required this.matches,
required this.hasAutoMatch,
required this.onSkuChanged,
});
@override
Widget build(BuildContext context) {
final typedMatches = matches.cast<Map<String, dynamic>>().toList();
final activeMatch = typedMatches.firstWhere(
(m) => m['no_sku'] == selectedSku,
orElse: () => typedMatches.isNotEmpty ? typedMatches.first : <String, dynamic>{'no_sku': '', 'nama_item': 'Unknown', 'confidence': 0.0},
);
final rawConfidence = activeMatch['confidence'] ?? activeMatch['score'] ?? activeMatch['yoloSimilarity'];
final double confidence = rawConfidence is num
? rawConfidence.toDouble()
: (rawConfidence != null
? double.tryParse(rawConfidence.toString()) ?? 0.0
: 0.0);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI Product Classification', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: selectedSku.isNotEmpty && typedMatches.any((m) => m['no_sku'] == selectedSku) ? selectedSku : null,
decoration: const InputDecoration(
labelText: 'Pilih Produk / SKU',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
isExpanded: true,
style: AppConfig.bodyMedium,
items: typedMatches.map((match) {
return DropdownMenuItem<String>(
value: match['no_sku'],
child: Text(
'${match['no_sku']} - ${match['nama_item']}',
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
);
}).toList(),
onChanged: onSkuChanged,
),
const SizedBox(height: AppConfig.spaceSmall),
// Product name for the selected SKU - informational only, not a
// form field (mirrors ProductExpiryCard's "Tanggal terdeteksi
// otomatis dari OCR" info row below the batch dropdown).
Row(
children: [
const Icon(Icons.inventory_2_outlined, size: 16, color: AppConfig.textSecondary),
const SizedBox(width: AppConfig.spaceXS),
Expanded(
child: Text(
(activeMatch['nama_item'] as String?)?.isNotEmpty == true
? activeMatch['nama_item'] as String
: '-',
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: AppConfig.spaceLarge),
if (hasAutoMatch) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Confidence Score', style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary)),
Text(
'${(confidence * 100).toStringAsFixed(1)}%',
style: const TextStyle(fontWeight: FontWeight.bold, color: AppConfig.successColor),
),
],
),
const SizedBox(height: AppConfig.spaceSmall),
LinearProgressIndicator(
value: confidence,
color: AppConfig.successColor,
backgroundColor: Colors.grey.shade200,
),
] else
Row(
children: [
const Icon(Icons.info_outline, size: 16, color: AppConfig.textSecondary),
const SizedBox(width: AppConfig.spaceXS),
Expanded(
child: Text(
'Tidak ada rekomendasi otomatis — pilih SKU secara manual.',
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import '../../../config/app_config.dart';
class ProductExpiryCard extends StatelessWidget {
final String selectedSku;
final String? selectedBatchOption;
final bool isManualDate;
final Map<String, List<String>> skuBatches;
final TextEditingController expDateCtrl;
final TextEditingController notesCtrl;
final ValueChanged<String?> onBatchChanged;
final VoidCallback onSelectDate;
const ProductExpiryCard({
super.key,
required this.selectedSku,
required this.selectedBatchOption,
required this.isManualDate,
required this.skuBatches,
required this.expDateCtrl,
required this.notesCtrl,
required this.onBatchChanged,
required this.onSelectDate,
});
@override
Widget build(BuildContext context) {
final batches = skuBatches[selectedSku] ?? [];
final dropdownItems = <DropdownMenuItem<String>>[];
for (int i = 0; i < batches.length; i++) {
dropdownItems.add(DropdownMenuItem(
value: 'Batch ${i + 1}: ${batches[i]}',
child: Text('Batch ${i + 1}: ${batches[i]}'),
));
}
dropdownItems.add(const DropdownMenuItem(value: 'manual', child: Text('Set Tanggal Manual...')));
// Ensure value matches an item or falls back
String? dropdownValue = isManualDate ? 'manual' : selectedBatchOption;
if (dropdownValue != null && dropdownValue != 'manual' && !dropdownItems.any((item) => item.value == dropdownValue)) {
dropdownValue = dropdownItems.isNotEmpty ? dropdownItems.first.value : null;
}
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI OCR Expiry Extraction', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: dropdownValue,
decoration: const InputDecoration(
labelText: 'Pilih Batch Expiry',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
isExpanded: true,
style: AppConfig.bodyMedium,
items: dropdownItems,
onChanged: onBatchChanged,
),
if (isManualDate) ...[
const SizedBox(height: AppConfig.spaceLarge),
TextFormField(
controller: expDateCtrl,
decoration: InputDecoration(
labelText: 'Expired Date Manual (DD/MM/YYYY)',
suffixIcon: IconButton(
icon: const Icon(Icons.calendar_today, color: AppConfig.primaryColor),
onPressed: onSelectDate,
),
),
validator: (val) => val == null || val.isEmpty ? 'Tanggal kadaluwarsa harus diisi' : null,
),
],
const SizedBox(height: AppConfig.spaceLarge),
Row(
children: [
Icon(
isManualDate ? Icons.edit_calendar : Icons.auto_awesome,
size: 16,
color: isManualDate ? Colors.deepOrange : AppConfig.successColor,
),
const SizedBox(width: AppConfig.spaceXS),
Text(
isManualDate ? 'Tanggal diinput manual' : 'Tanggal terdeteksi otomatis dari OCR',
style: AppConfig.bodySmall.copyWith(
color: isManualDate ? Colors.deepOrange : AppConfig.successColor,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: AppConfig.spaceLarge),
TextFormField(
controller: notesCtrl,
decoration: const InputDecoration(labelText: 'Catatan Tambahan'),
maxLines: 2,
),
],
),
),
);
}
}
@@ -0,0 +1,47 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../../../config/app_config.dart';
class ProductPreviewCard extends StatelessWidget {
final String imagePath;
const ProductPreviewCard({super.key, required this.imagePath});
@override
Widget build(BuildContext context) {
if (imagePath.isEmpty) return const SizedBox.shrink();
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI Crop Region / Preview', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceMedium),
Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
child: Image.file(
File(imagePath),
fit: BoxFit.cover,
alignment: const Alignment(0, 0.4),
),
),
),
],
),
),
);
}
}