chore: normalize line endings (CRLF -> LF)
No content changes: git diff --ignore-all-space over these files is empty. The churn came from editing on Windows against a repo checked out with LF.
This commit is contained in:
1 parent
15566a6951
commit
caf8e98378
315 files changed
+86950
-86950
No files matched your search
@@ -1,53 +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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -1,238 +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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,150 +1,150 @@
|
||||
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';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../models/document_model.dart';
|
||||
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> with EditorLogic {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDocumentData();
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
@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: AppConfig.screenPadding,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: AppConfig.paddingLarge,
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.primaryColor.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
border: Border.all(color: AppConfig.primaryColor.withOpacity(0.2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: AppConfig.primaryColor),
|
||||
const SizedBox(width: AppConfig.spaceMedium),
|
||||
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: AppConfig.spaceXL),
|
||||
|
||||
HeaderDetailsCard(
|
||||
tanggalCtrl: _tanggalCtrl,
|
||||
noPoCtrl: _noPoCtrl,
|
||||
noSoCtrl: _noSoCtrl,
|
||||
noDoCtrl: _noDoCtrl,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
|
||||
ShipmentDetailsCard(
|
||||
kepadaYthCtrl: _kepadaYthCtrl,
|
||||
orderUntukCtrl: _orderUntukCtrl,
|
||||
alamatCtrl: _alamatCtrl,
|
||||
platTrukCtrl: _platTrukCtrl,
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
),
|
||||
child: const Text('Simpan & Konfirmasi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../models/document_model.dart';
|
||||
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> with EditorLogic {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDocumentData();
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
@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: AppConfig.screenPadding,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: AppConfig.paddingLarge,
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.primaryColor.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
border: Border.all(color: AppConfig.primaryColor.withOpacity(0.2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: AppConfig.primaryColor),
|
||||
const SizedBox(width: AppConfig.spaceMedium),
|
||||
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: AppConfig.spaceXL),
|
||||
|
||||
HeaderDetailsCard(
|
||||
tanggalCtrl: _tanggalCtrl,
|
||||
noPoCtrl: _noPoCtrl,
|
||||
noSoCtrl: _noSoCtrl,
|
||||
noDoCtrl: _noDoCtrl,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
|
||||
ShipmentDetailsCard(
|
||||
kepadaYthCtrl: _kepadaYthCtrl,
|
||||
orderUntukCtrl: _orderUntukCtrl,
|
||||
alamatCtrl: _alamatCtrl,
|
||||
platTrukCtrl: _platTrukCtrl,
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
),
|
||||
child: const Text('Simpan & Konfirmasi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,235 +1,235 @@
|
||||
import 'package:flutter/widgets.dart' show AssetImage;
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
class PdfService {
|
||||
static Future<void> generateAndPrintReceipt(DocumentModel document) async {
|
||||
final pdf = pw.Document();
|
||||
|
||||
// Load PFM logo asset
|
||||
final logoImage = await flutterImageProvider(const AssetImage('assets/logo.png'));
|
||||
final isProductScan = document.docType == 'Product';
|
||||
|
||||
// Add page
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
build: (pw.Context context) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header & Title
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
isProductScan ? 'BUKTI VERIFIKASI SCAN PRODUK' : 'TANDA TERIMA PENGIRIMAN',
|
||||
style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold)
|
||||
),
|
||||
pw.SizedBox(
|
||||
width: 60,
|
||||
height: 60,
|
||||
child: pw.Image(logoImage),
|
||||
),
|
||||
]
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// Document Headers
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.all(10),
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: PdfColors.grey),
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: isProductScan
|
||||
? [
|
||||
_buildTextRow('Tanggal Scan', document.tanggal),
|
||||
_buildTextRow('Toko / Cabang', document.kepadaYth),
|
||||
_buildTextRow('Jenis Verifikasi', 'Pemindaian Produk & Kadaluwarsa'),
|
||||
]
|
||||
: [
|
||||
_buildTextRow('Tanggal', document.tanggal),
|
||||
_buildTextRow('No PO', document.noPo),
|
||||
_buildTextRow('No SO', document.noSo),
|
||||
_buildTextRow('No DO', document.noDo),
|
||||
]
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// Detail Scan / Pengiriman Section
|
||||
pw.Text(
|
||||
isProductScan ? 'Informasi Verifikasi' : 'Detail Pengiriman',
|
||||
style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.all(10),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: PdfColors.grey100,
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: isProductScan
|
||||
? [
|
||||
_buildTextRow('Nama Produk', document.items.isNotEmpty ? document.items.first.namaBarang : '-'),
|
||||
_buildTextRow('Kode SKU', document.items.isNotEmpty ? document.items.first.nomorSku : '-'),
|
||||
_buildTextRow('Nomor Batch', document.noDo),
|
||||
_buildTextRow('Tanggal Expired', document.alamat.replaceAll("Product Scan: Expired ", "")),
|
||||
]
|
||||
: [
|
||||
_buildTextRow('Kepada Yth', document.kepadaYth),
|
||||
_buildTextRow('Order Untuk', document.orderUntuk),
|
||||
_buildTextRow('Alamat', document.alamat),
|
||||
_buildTextRow('Plat Truk', document.platTruk),
|
||||
_buildTextRow('Nama Driver', document.namaDriver),
|
||||
]
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// Items Table / Scanned Product Details
|
||||
pw.Text(
|
||||
isProductScan ? 'Detail Item Scanned' : 'Barang Dikirim',
|
||||
style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
|
||||
isProductScan
|
||||
? pw.Table.fromTextArray(
|
||||
context: context,
|
||||
border: pw.TableBorder.all(color: PdfColors.grey),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
|
||||
headerHeight: 25,
|
||||
cellHeight: 25,
|
||||
columnWidths: {
|
||||
0: const pw.FixedColumnWidth(100), // SKU
|
||||
1: const pw.FlexColumnWidth(2), // Nama Barang
|
||||
2: const pw.FixedColumnWidth(100), // Batch
|
||||
3: const pw.FixedColumnWidth(100), // Expired
|
||||
},
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.center,
|
||||
3: pw.Alignment.center,
|
||||
},
|
||||
headers: ['Nomor SKU', 'Nama Barang', 'Nomor Batch', 'Expired Date'],
|
||||
data: [
|
||||
[
|
||||
document.items.isNotEmpty ? document.items.first.nomorSku : '-',
|
||||
document.items.isNotEmpty ? document.items.first.namaBarang : '-',
|
||||
document.noDo,
|
||||
document.alamat.replaceAll("Product Scan: Expired ", ""),
|
||||
]
|
||||
],
|
||||
)
|
||||
: pw.Table.fromTextArray(
|
||||
context: context,
|
||||
border: pw.TableBorder.all(color: PdfColors.grey),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
|
||||
headerHeight: 25,
|
||||
cellHeight: 25,
|
||||
columnWidths: {
|
||||
0: const pw.FixedColumnWidth(80), // SKU
|
||||
1: const pw.FlexColumnWidth(2), // Nama Barang
|
||||
2: const pw.FixedColumnWidth(70), // Banyak
|
||||
3: const pw.FixedColumnWidth(70), // Jumlah
|
||||
},
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
},
|
||||
headers: ['Nomor SKU', 'Nama Barang', 'Banyak', 'Jumlah'],
|
||||
data: document.items.map((item) {
|
||||
return [
|
||||
item.nomorSku,
|
||||
item.namaBarang,
|
||||
item.banyak,
|
||||
item.jumlah,
|
||||
];
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
pw.Spacer(),
|
||||
|
||||
// Footer & Signatures
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: isProductScan
|
||||
? [
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text('_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Petugas / Staff Toko'),
|
||||
]
|
||||
),
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text('_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Penanggung Jawab Toko'),
|
||||
]
|
||||
)
|
||||
]
|
||||
: [
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text(document.namaDriver.isNotEmpty ? document.namaDriver : '_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Pengirim (Driver)'),
|
||||
]
|
||||
),
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text(document.namaPenerima.isNotEmpty ? document.namaPenerima : '_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Penerima (Penjaga Toko)'),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Prompt user to view/print/save the PDF natively
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (PdfPageFormat format) async => pdf.save(),
|
||||
format: PdfPageFormat.a4,
|
||||
name: isProductScan
|
||||
? 'ProductScan_${document.items.isNotEmpty ? document.items.first.nomorSku : "unknown"}'
|
||||
: 'Document_${document.noDo.replaceAll('/', '_')}',
|
||||
);
|
||||
}
|
||||
|
||||
static pw.Widget _buildTextRow(String label, String value) {
|
||||
return pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 2),
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.SizedBox(width: 100, child: pw.Text(label, style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
|
||||
pw.Expanded(
|
||||
child: pw.Text(': $value', softWrap: true),
|
||||
),
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/widgets.dart' show AssetImage;
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
class PdfService {
|
||||
static Future<void> generateAndPrintReceipt(DocumentModel document) async {
|
||||
final pdf = pw.Document();
|
||||
|
||||
// Load PFM logo asset
|
||||
final logoImage = await flutterImageProvider(const AssetImage('assets/logo.png'));
|
||||
final isProductScan = document.docType == 'Product';
|
||||
|
||||
// Add page
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
build: (pw.Context context) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header & Title
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
isProductScan ? 'BUKTI VERIFIKASI SCAN PRODUK' : 'TANDA TERIMA PENGIRIMAN',
|
||||
style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold)
|
||||
),
|
||||
pw.SizedBox(
|
||||
width: 60,
|
||||
height: 60,
|
||||
child: pw.Image(logoImage),
|
||||
),
|
||||
]
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// Document Headers
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.all(10),
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: PdfColors.grey),
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: isProductScan
|
||||
? [
|
||||
_buildTextRow('Tanggal Scan', document.tanggal),
|
||||
_buildTextRow('Toko / Cabang', document.kepadaYth),
|
||||
_buildTextRow('Jenis Verifikasi', 'Pemindaian Produk & Kadaluwarsa'),
|
||||
]
|
||||
: [
|
||||
_buildTextRow('Tanggal', document.tanggal),
|
||||
_buildTextRow('No PO', document.noPo),
|
||||
_buildTextRow('No SO', document.noSo),
|
||||
_buildTextRow('No DO', document.noDo),
|
||||
]
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// Detail Scan / Pengiriman Section
|
||||
pw.Text(
|
||||
isProductScan ? 'Informasi Verifikasi' : 'Detail Pengiriman',
|
||||
style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.all(10),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: PdfColors.grey100,
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: isProductScan
|
||||
? [
|
||||
_buildTextRow('Nama Produk', document.items.isNotEmpty ? document.items.first.namaBarang : '-'),
|
||||
_buildTextRow('Kode SKU', document.items.isNotEmpty ? document.items.first.nomorSku : '-'),
|
||||
_buildTextRow('Nomor Batch', document.noDo),
|
||||
_buildTextRow('Tanggal Expired', document.alamat.replaceAll("Product Scan: Expired ", "")),
|
||||
]
|
||||
: [
|
||||
_buildTextRow('Kepada Yth', document.kepadaYth),
|
||||
_buildTextRow('Order Untuk', document.orderUntuk),
|
||||
_buildTextRow('Alamat', document.alamat),
|
||||
_buildTextRow('Plat Truk', document.platTruk),
|
||||
_buildTextRow('Nama Driver', document.namaDriver),
|
||||
]
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// Items Table / Scanned Product Details
|
||||
pw.Text(
|
||||
isProductScan ? 'Detail Item Scanned' : 'Barang Dikirim',
|
||||
style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
|
||||
isProductScan
|
||||
? pw.Table.fromTextArray(
|
||||
context: context,
|
||||
border: pw.TableBorder.all(color: PdfColors.grey),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
|
||||
headerHeight: 25,
|
||||
cellHeight: 25,
|
||||
columnWidths: {
|
||||
0: const pw.FixedColumnWidth(100), // SKU
|
||||
1: const pw.FlexColumnWidth(2), // Nama Barang
|
||||
2: const pw.FixedColumnWidth(100), // Batch
|
||||
3: const pw.FixedColumnWidth(100), // Expired
|
||||
},
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.center,
|
||||
3: pw.Alignment.center,
|
||||
},
|
||||
headers: ['Nomor SKU', 'Nama Barang', 'Nomor Batch', 'Expired Date'],
|
||||
data: [
|
||||
[
|
||||
document.items.isNotEmpty ? document.items.first.nomorSku : '-',
|
||||
document.items.isNotEmpty ? document.items.first.namaBarang : '-',
|
||||
document.noDo,
|
||||
document.alamat.replaceAll("Product Scan: Expired ", ""),
|
||||
]
|
||||
],
|
||||
)
|
||||
: pw.Table.fromTextArray(
|
||||
context: context,
|
||||
border: pw.TableBorder.all(color: PdfColors.grey),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
|
||||
headerHeight: 25,
|
||||
cellHeight: 25,
|
||||
columnWidths: {
|
||||
0: const pw.FixedColumnWidth(80), // SKU
|
||||
1: const pw.FlexColumnWidth(2), // Nama Barang
|
||||
2: const pw.FixedColumnWidth(70), // Banyak
|
||||
3: const pw.FixedColumnWidth(70), // Jumlah
|
||||
},
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
},
|
||||
headers: ['Nomor SKU', 'Nama Barang', 'Banyak', 'Jumlah'],
|
||||
data: document.items.map((item) {
|
||||
return [
|
||||
item.nomorSku,
|
||||
item.namaBarang,
|
||||
item.banyak,
|
||||
item.jumlah,
|
||||
];
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
pw.Spacer(),
|
||||
|
||||
// Footer & Signatures
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: isProductScan
|
||||
? [
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text('_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Petugas / Staff Toko'),
|
||||
]
|
||||
),
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text('_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Penanggung Jawab Toko'),
|
||||
]
|
||||
)
|
||||
]
|
||||
: [
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text(document.namaDriver.isNotEmpty ? document.namaDriver : '_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Pengirim (Driver)'),
|
||||
]
|
||||
),
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text(document.namaPenerima.isNotEmpty ? document.namaPenerima : '_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Penerima (Penjaga Toko)'),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Prompt user to view/print/save the PDF natively
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (PdfPageFormat format) async => pdf.save(),
|
||||
format: PdfPageFormat.a4,
|
||||
name: isProductScan
|
||||
? 'ProductScan_${document.items.isNotEmpty ? document.items.first.nomorSku : "unknown"}'
|
||||
: 'Document_${document.noDo.replaceAll('/', '_')}',
|
||||
);
|
||||
}
|
||||
|
||||
static pw.Widget _buildTextRow(String label, String value) {
|
||||
return pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 2),
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.SizedBox(width: 100, child: pw.Text(label, style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
|
||||
pw.Expanded(
|
||||
child: pw.Text(': $value', softWrap: true),
|
||||
),
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,133 +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); });
|
||||
}
|
||||
}
|
||||
}
|
||||
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); });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +1,143 @@
|
||||
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;
|
||||
const ProductEditorScreen({super.key, this.pendingId});
|
||||
@override
|
||||
ConsumerState<ProductEditorScreen> createState() => _ProductEditorScreenState();
|
||||
}
|
||||
|
||||
class _ProductEditorScreenState extends ConsumerState<ProductEditorScreen> with ProductEditorDataLogic, ProductEditorSubmitLogic {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPendingDoc();
|
||||
_fetchClassificationAndSkus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@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: _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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.screenPadding,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48, color: AppConfig.errorColor),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
Text('Gagal Memuat Klasifikasi Produk', style: AppConfig.h3, textAlign: TextAlign.center),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
Text(
|
||||
'Periksa koneksi Anda ke server dan coba lagi.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _fetchClassificationAndSkus,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Coba Lagi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
const ProductEditorScreen({super.key, this.pendingId});
|
||||
@override
|
||||
ConsumerState<ProductEditorScreen> createState() => _ProductEditorScreenState();
|
||||
}
|
||||
|
||||
class _ProductEditorScreenState extends ConsumerState<ProductEditorScreen> with ProductEditorDataLogic, ProductEditorSubmitLogic {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPendingDoc();
|
||||
_fetchClassificationAndSkus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@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: _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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.screenPadding,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48, color: AppConfig.errorColor),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
Text('Gagal Memuat Klasifikasi Produk', style: AppConfig.h3, textAlign: TextAlign.center),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
Text(
|
||||
'Periksa koneksi Anda ke server dan coba lagi.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _fetchClassificationAndSkus,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Coba Lagi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,130 +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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +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,
|
||||
);
|
||||
}
|
||||
/// 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,
|
||||
);
|
||||
}
|
||||
@@ -1,101 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class ConfirmationDialog extends StatefulWidget {
|
||||
final TextEditingController nameController;
|
||||
final bool initialAgreed;
|
||||
final void Function(bool, String) onConfirm;
|
||||
|
||||
const ConfirmationDialog({
|
||||
super.key,
|
||||
required this.nameController,
|
||||
required this.initialAgreed,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConfirmationDialog> createState() => _ConfirmationDialogState();
|
||||
}
|
||||
|
||||
class _ConfirmationDialogState extends State<ConfirmationDialog> {
|
||||
late bool _isAgreed;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isAgreed = widget.initialAgreed;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isButtonEnabled = _isAgreed && widget.nameController.text.trim().isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
left: AppConfig.spaceXL,
|
||||
right: AppConfig.spaceXL,
|
||||
top: AppConfig.spaceXL,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Konfirmasi Pengiriman', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
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: AppConfig.spaceLarge),
|
||||
TextField(
|
||||
controller: widget.nameController,
|
||||
onChanged: (val) {
|
||||
setState(() {});
|
||||
},
|
||||
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(AppConfig.borderRadiusMedium),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _isAgreed,
|
||||
activeColor: AppConfig.primaryColor,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_isAgreed = val ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text('Saya setuju dan mengonfirmasi bahwa data ini sudah benar.', style: AppConfig.bodyMedium),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: isButtonEnabled ? () {
|
||||
widget.onConfirm(_isAgreed, widget.nameController.text.trim());
|
||||
} : null,
|
||||
child: const Text('Konfirmasi'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class ConfirmationDialog extends StatefulWidget {
|
||||
final TextEditingController nameController;
|
||||
final bool initialAgreed;
|
||||
final void Function(bool, String) onConfirm;
|
||||
|
||||
const ConfirmationDialog({
|
||||
super.key,
|
||||
required this.nameController,
|
||||
required this.initialAgreed,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConfirmationDialog> createState() => _ConfirmationDialogState();
|
||||
}
|
||||
|
||||
class _ConfirmationDialogState extends State<ConfirmationDialog> {
|
||||
late bool _isAgreed;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isAgreed = widget.initialAgreed;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isButtonEnabled = _isAgreed && widget.nameController.text.trim().isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
left: AppConfig.spaceXL,
|
||||
right: AppConfig.spaceXL,
|
||||
top: AppConfig.spaceXL,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Konfirmasi Pengiriman', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
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: AppConfig.spaceLarge),
|
||||
TextField(
|
||||
controller: widget.nameController,
|
||||
onChanged: (val) {
|
||||
setState(() {});
|
||||
},
|
||||
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(AppConfig.borderRadiusMedium),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _isAgreed,
|
||||
activeColor: AppConfig.primaryColor,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_isAgreed = val ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text('Saya setuju dan mengonfirmasi bahwa data ini sudah benar.', style: AppConfig.bodyMedium),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: isButtonEnabled ? () {
|
||||
widget.onConfirm(_isAgreed, widget.nameController.text.trim());
|
||||
} : null,
|
||||
child: const Text('Konfirmasi'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class EditorTextField extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final TextInputType keyboardType;
|
||||
final bool readOnly;
|
||||
final VoidCallback? onTap;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
const EditorTextField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.readOnly = false,
|
||||
this.onTap,
|
||||
this.inputFormatters,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppConfig.spaceLarge),
|
||||
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(AppConfig.borderRadiusMedium),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
borderSide: const BorderSide(color: AppConfig.primaryColor),
|
||||
),
|
||||
),
|
||||
validator: validator ?? (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '$label harus diisi';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class EditorTextField extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final TextInputType keyboardType;
|
||||
final bool readOnly;
|
||||
final VoidCallback? onTap;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
const EditorTextField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.readOnly = false,
|
||||
this.onTap,
|
||||
this.inputFormatters,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppConfig.spaceLarge),
|
||||
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(AppConfig.borderRadiusMedium),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
borderSide: const BorderSide(color: AppConfig.primaryColor),
|
||||
),
|
||||
),
|
||||
validator: validator ?? (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '$label harus diisi';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class HeaderDetailsCard extends StatelessWidget {
|
||||
final TextEditingController tanggalCtrl;
|
||||
final TextEditingController noPoCtrl;
|
||||
final TextEditingController noSoCtrl;
|
||||
final TextEditingController noDoCtrl;
|
||||
|
||||
const HeaderDetailsCard({
|
||||
super.key,
|
||||
required this.tanggalCtrl,
|
||||
required this.noPoCtrl,
|
||||
required this.noSoCtrl,
|
||||
required this.noDoCtrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Detail Header', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
EditorTextField(
|
||||
label: 'Tanggal',
|
||||
controller: 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;
|
||||
}
|
||||
},
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'No PO',
|
||||
controller: 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;
|
||||
},
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'No SO',
|
||||
controller: 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;
|
||||
},
|
||||
),
|
||||
EditorTextField(label: 'No DO', controller: noDoCtrl),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class HeaderDetailsCard extends StatelessWidget {
|
||||
final TextEditingController tanggalCtrl;
|
||||
final TextEditingController noPoCtrl;
|
||||
final TextEditingController noSoCtrl;
|
||||
final TextEditingController noDoCtrl;
|
||||
|
||||
const HeaderDetailsCard({
|
||||
super.key,
|
||||
required this.tanggalCtrl,
|
||||
required this.noPoCtrl,
|
||||
required this.noSoCtrl,
|
||||
required this.noDoCtrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Detail Header', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
EditorTextField(
|
||||
label: 'Tanggal',
|
||||
controller: 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;
|
||||
}
|
||||
},
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'No PO',
|
||||
controller: 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;
|
||||
},
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'No SO',
|
||||
controller: 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;
|
||||
},
|
||||
),
|
||||
EditorTextField(label: 'No DO', controller: noDoCtrl),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,149 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../data/master_sku.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class ItemsListCard extends StatelessWidget {
|
||||
final List<Map<String, TextEditingController>> itemControllers;
|
||||
final void Function(int) onRemoveItem;
|
||||
final VoidCallback onAddItem;
|
||||
|
||||
const ItemsListCard({
|
||||
super.key,
|
||||
required this.itemControllers,
|
||||
required this.onRemoveItem,
|
||||
required this.onAddItem,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Barang (${itemControllers.length})', style: AppConfig.h2),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
...itemControllers.asMap().entries.map((entry) {
|
||||
int index = entry.key;
|
||||
var ctrls = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppConfig.spaceLarge),
|
||||
child: Container(
|
||||
padding: AppConfig.paddingMedium,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
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: () => onRemoveItem(index),
|
||||
tooltip: 'Hapus Barang',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
EditorTextField(
|
||||
label: 'SKU',
|
||||
controller: ctrls['sku']!,
|
||||
validator: (val) {
|
||||
if (val == null || val.isEmpty) {
|
||||
return 'SKU harus diisi';
|
||||
}
|
||||
if (!MasterSku.data.containsKey(val)) {
|
||||
return 'SKU tidak terdaftar';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'Nama Barang',
|
||||
controller: ctrls['nama']!,
|
||||
readOnly: true,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: EditorTextField(
|
||||
label: 'Banyak',
|
||||
controller: ctrls['banyak']!,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppConfig.spaceMedium),
|
||||
Expanded(
|
||||
child: EditorTextField(
|
||||
label: 'Jumlah',
|
||||
controller: ctrls['jumlah']!,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onAddItem,
|
||||
icon: const Icon(Icons.add, color: AppConfig.primaryColor),
|
||||
label: const Text(
|
||||
'Tambah Barang',
|
||||
style: TextStyle(
|
||||
color: AppConfig.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: AppConfig.primaryColor),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: AppConfig.spaceMedium),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../data/master_sku.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class ItemsListCard extends StatelessWidget {
|
||||
final List<Map<String, TextEditingController>> itemControllers;
|
||||
final void Function(int) onRemoveItem;
|
||||
final VoidCallback onAddItem;
|
||||
|
||||
const ItemsListCard({
|
||||
super.key,
|
||||
required this.itemControllers,
|
||||
required this.onRemoveItem,
|
||||
required this.onAddItem,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Barang (${itemControllers.length})', style: AppConfig.h2),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
...itemControllers.asMap().entries.map((entry) {
|
||||
int index = entry.key;
|
||||
var ctrls = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppConfig.spaceLarge),
|
||||
child: Container(
|
||||
padding: AppConfig.paddingMedium,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
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: () => onRemoveItem(index),
|
||||
tooltip: 'Hapus Barang',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
EditorTextField(
|
||||
label: 'SKU',
|
||||
controller: ctrls['sku']!,
|
||||
validator: (val) {
|
||||
if (val == null || val.isEmpty) {
|
||||
return 'SKU harus diisi';
|
||||
}
|
||||
if (!MasterSku.data.containsKey(val)) {
|
||||
return 'SKU tidak terdaftar';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'Nama Barang',
|
||||
controller: ctrls['nama']!,
|
||||
readOnly: true,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: EditorTextField(
|
||||
label: 'Banyak',
|
||||
controller: ctrls['banyak']!,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppConfig.spaceMedium),
|
||||
Expanded(
|
||||
child: EditorTextField(
|
||||
label: 'Jumlah',
|
||||
controller: ctrls['jumlah']!,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onAddItem,
|
||||
icon: const Icon(Icons.add, color: AppConfig.primaryColor),
|
||||
label: const Text(
|
||||
'Tambah Barang',
|
||||
style: TextStyle(
|
||||
color: AppConfig.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: AppConfig.primaryColor),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: AppConfig.spaceMedium),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class LocationDetailsCard extends StatelessWidget {
|
||||
final TextEditingController latitudeCtrl;
|
||||
final TextEditingController longitudeCtrl;
|
||||
|
||||
const LocationDetailsCard({
|
||||
super.key,
|
||||
required this.latitudeCtrl,
|
||||
required this.longitudeCtrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Detail Lokasi', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
EditorTextField(
|
||||
label: 'Lintang',
|
||||
controller: latitudeCtrl,
|
||||
readOnly: true,
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'Bujur',
|
||||
controller: longitudeCtrl,
|
||||
readOnly: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class LocationDetailsCard extends StatelessWidget {
|
||||
final TextEditingController latitudeCtrl;
|
||||
final TextEditingController longitudeCtrl;
|
||||
|
||||
const LocationDetailsCard({
|
||||
super.key,
|
||||
required this.latitudeCtrl,
|
||||
required this.longitudeCtrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Detail Lokasi', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
EditorTextField(
|
||||
label: 'Lintang',
|
||||
controller: latitudeCtrl,
|
||||
readOnly: true,
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'Bujur',
|
||||
controller: longitudeCtrl,
|
||||
readOnly: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,120 +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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,111 +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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class ShipmentDetailsCard extends StatelessWidget {
|
||||
final TextEditingController kepadaYthCtrl;
|
||||
final TextEditingController orderUntukCtrl;
|
||||
final TextEditingController alamatCtrl;
|
||||
final TextEditingController platTrukCtrl;
|
||||
final TextEditingController namaDriverCtrl;
|
||||
|
||||
const ShipmentDetailsCard({
|
||||
super.key,
|
||||
required this.kepadaYthCtrl,
|
||||
required this.orderUntukCtrl,
|
||||
required this.alamatCtrl,
|
||||
required this.platTrukCtrl,
|
||||
required this.namaDriverCtrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Detail Pengiriman', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
EditorTextField(label: 'Kepada Yth', controller: kepadaYthCtrl),
|
||||
EditorTextField(label: 'Order Untuk', controller: orderUntukCtrl),
|
||||
EditorTextField(label: 'Alamat', controller: alamatCtrl),
|
||||
EditorTextField(label: 'Plat Truk', controller: platTrukCtrl),
|
||||
EditorTextField(label: 'Nama Driver', controller: namaDriverCtrl),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class ShipmentDetailsCard extends StatelessWidget {
|
||||
final TextEditingController kepadaYthCtrl;
|
||||
final TextEditingController orderUntukCtrl;
|
||||
final TextEditingController alamatCtrl;
|
||||
final TextEditingController platTrukCtrl;
|
||||
final TextEditingController namaDriverCtrl;
|
||||
|
||||
const ShipmentDetailsCard({
|
||||
super.key,
|
||||
required this.kepadaYthCtrl,
|
||||
required this.orderUntukCtrl,
|
||||
required this.alamatCtrl,
|
||||
required this.platTrukCtrl,
|
||||
required this.namaDriverCtrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Detail Pengiriman', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
EditorTextField(label: 'Kepada Yth', controller: kepadaYthCtrl),
|
||||
EditorTextField(label: 'Order Untuk', controller: orderUntukCtrl),
|
||||
EditorTextField(label: 'Alamat', controller: alamatCtrl),
|
||||
EditorTextField(label: 'Plat Truk', controller: platTrukCtrl),
|
||||
EditorTextField(label: 'Nama Driver', controller: namaDriverCtrl),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user