feat(app): implement Product Scan review flow, dynamic batch expiry picker, custom PDFs, PO relationships, and aligned card layouts

This commit is contained in:
Rafhan Mazaya Fathurrahman committed 2026-07-09 12:36:06 +07:00
1 parent 9ff4a4a922
commit 577be04308
69 files changed
+5645 -1657

No files matched your search

+80 -429
View File
@@ -1,15 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../config/app_config.dart';
import '../../models/document_model.dart';
import '../../main.dart'; // To access localStorageProvider
import '../documents/pending_documents_provider.dart';
import '../../data/master_sku.dart';
import '../../core/network/api_client.dart';
import '../../core/network/api_exception.dart';
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';
class EditorScreen extends ConsumerStatefulWidget {
final String? pendingId;
@@ -23,13 +25,12 @@ class EditorScreen extends ConsumerStatefulWidget {
class _EditorScreenState extends ConsumerState<EditorScreen> {
DocumentModel? _document;
// Controllers for Header
// Controllers
final _tanggalCtrl = TextEditingController();
final _noPoCtrl = TextEditingController();
final _noSoCtrl = TextEditingController();
final _noDoCtrl = TextEditingController();
// Controllers for Shipment
final _kepadaYthCtrl = TextEditingController();
final _orderUntukCtrl = TextEditingController();
final _alamatCtrl = TextEditingController();
@@ -37,13 +38,10 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
final _namaDriverCtrl = TextEditingController();
final _namaPenerimaCtrl = TextEditingController();
// Controllers for Geotagging
final _latitudeCtrl = TextEditingController();
final _longitudeCtrl = TextEditingController();
// Controllers for Items
List<Map<String, TextEditingController>> _itemControllers = [];
bool _isAgreed = false;
final _formKey = GlobalKey<FormState>();
@@ -53,10 +51,31 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
_loadDocumentData();
}
void _loadDocumentData() {
if (widget.pendingId == null) {
return;
@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();
}
void _loadDocumentData() {
if (widget.pendingId == null) return;
final pendingDocs = ref.read(pendingDocumentsProvider);
final pendingDoc = pendingDocs.firstWhere(
@@ -96,24 +115,12 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
}
}
void _addItem({String sku = '', String nama = '', String banyak = '', String jumlah = ''}) {
final skuCtrl = TextEditingController(text: sku);
final namaCtrl = TextEditingController(text: nama);
final banyakCtrl = TextEditingController(text: banyak);
final jumlahCtrl = TextEditingController(text: jumlah);
// Auto rename based on master SKU
skuCtrl.addListener(() {
final currentSku = skuCtrl.text;
if (MasterSku.data.containsKey(currentSku)) {
namaCtrl.text = MasterSku.data[currentSku]!;
} else {
namaCtrl.text = 'SKU Tidak Terdaftar';
}
});
setState(() {
_itemControllers.add({
'sku': skuCtrl,
@@ -145,85 +152,22 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setModalState) {
final isButtonEnabled = _isAgreed && _namaPenerimaCtrl.text.trim().isNotEmpty;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 24,
right: 24,
top: 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Konfirmasi Pengiriman', style: AppConfig.h2),
const SizedBox(height: 16),
Text(
'Masukkan nama Anda (sebagai penerima/penjaga toko) dan centang kotak persetujuan untuk mengonfirmasi pengiriman.',
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
),
const SizedBox(height: 16),
TextField(
controller: _namaPenerimaCtrl,
onChanged: (val) {
setModalState(() {});
},
style: AppConfig.bodyMedium,
decoration: InputDecoration(
labelText: 'Nama Penerima (Penjaga Toko)',
labelStyle: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
filled: true,
fillColor: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
),
const SizedBox(height: 16),
Row(
children: [
Checkbox(
value: _isAgreed,
activeColor: AppConfig.primaryColor,
onChanged: (val) {
setModalState(() {
_isAgreed = val ?? false;
});
},
),
Expanded(
child: Text('Saya setuju dan mengonfirmasi bahwa data ini sudah benar.', style: AppConfig.bodyMedium),
)
],
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: isButtonEnabled ? () {
Navigator.pop(context); // close modal
_submitDocument();
} : null,
child: const Text('Konfirmasi'),
),
),
const SizedBox(height: 24),
],
),
);
}
return ConfirmationDialog(
nameController: _namaPenerimaCtrl,
initialAgreed: _isAgreed,
onConfirm: (agreed, name) {
setState(() {
_isAgreed = agreed;
});
Navigator.pop(context);
_submitDocument();
},
);
}
);
}
Future<void> _submitDocument() async {
// Construct final document
final pendingDocs = ref.read(pendingDocumentsProvider);
final pendingDoc = pendingDocs.firstWhere(
(doc) => doc.id == widget.pendingId,
@@ -255,11 +199,9 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
createdAt: DateTime.now(),
);
// Save locally via Riverpod injected LocalStorage
final localStorage = ref.read(localStorageProvider);
await localStorage.saveDocument(finalDoc);
// Try to sync with server
bool syncedToServer = false;
String? syncError;
try {
@@ -269,19 +211,14 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
data: finalDoc.toPutPayload(),
);
syncedToServer = true;
debugPrint('Document synced with server successfully');
} catch (e) {
syncError = ApiException.from(e).displayMessage;
debugPrint('Failed to sync document with server: $syncError. Saved locally (cached).');
}
if (widget.pendingId != null) {
if (syncedToServer) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
} else {
// Keep it visible under "Pending & Processing" with a distinct status so the
// correction isn't silently lost - the user can retry the sync from there
// without re-uploading the image or re-running OCR.
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
widget.pendingId!,
finalDoc,
@@ -291,7 +228,6 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
}
if (mounted) {
// Show SnackBar BEFORE pop so context is still valid
ScaffoldMessenger.of(context).showSnackBar(
syncedToServer
? const SnackBar(content: Text('Dokumen Berhasil Disimpan!'), backgroundColor: AppConfig.successColor)
@@ -301,57 +237,10 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
duration: Duration(seconds: 5),
),
);
// Pass true as result so DocumentsScreen's .then() knows to reload
context.pop(true);
}
}
Widget _buildTextField(
String label,
TextEditingController controller, {
TextInputType keyboardType = TextInputType.text,
bool readOnly = false,
VoidCallback? onTap,
List<TextInputFormatter>? inputFormatters,
String? Function(String?)? validator,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextFormField(
controller: controller,
keyboardType: keyboardType,
readOnly: readOnly,
onTap: onTap,
inputFormatters: inputFormatters,
style: AppConfig.bodyMedium,
decoration: InputDecoration(
labelText: label,
labelStyle: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
filled: true,
fillColor: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppConfig.primaryColor),
),
),
validator: validator ?? (value) {
if (value == null || value.isEmpty) {
return '$label harus diisi';
}
return null;
},
),
);
}
@override
Widget build(BuildContext context) {
if (_document == null) {
@@ -370,23 +259,23 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
padding: AppConfig.screenPadding,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(16),
padding: AppConfig.paddingLarge,
decoration: BoxDecoration(
color: AppConfig.primaryColor.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
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: 12),
const SizedBox(width: AppConfig.spaceMedium),
Expanded(
child: Text(
'Tinjau dan edit data hasil ekstraksi di bawah ini untuk memastikan keakuratannya sebelum konfirmasi.',
@@ -396,287 +285,49 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
],
),
),
const SizedBox(height: 24),
// Header Details
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Detail Header', style: AppConfig.h2),
const SizedBox(height: 16),
_buildTextField(
'Tanggal',
_tanggalCtrl,
readOnly: true,
onTap: () async {
DateTime? pickedDate = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2101),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: AppConfig.primaryColor,
onPrimary: Colors.white,
onSurface: AppConfig.textPrimary,
),
),
child: child!,
);
},
);
if (pickedDate != null) {
String formattedDate = DateFormat('dd MMMM yyyy').format(pickedDate);
_tanggalCtrl.text = formattedDate;
}
}
),
_buildTextField(
'No PO',
_noPoCtrl,
validator: (val) {
if (val == null || val.isEmpty) return 'No PO harus diisi';
if (!RegExp(r'^PO/\d{2}/\d{10}$').hasMatch(val)) {
return 'Format harus PO/[XX]/[XXXXXXXXXX]';
}
return null;
}
),
_buildTextField(
'No SO',
_noSoCtrl,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (val) {
if (val == null || val.isEmpty) return 'No SO harus diisi';
if (val.length != 10) {
return 'SO harus persis 10 angka';
}
return null;
}
),
_buildTextField('No DO', _noDoCtrl),
],
),
),
),
const SizedBox(height: 16),
const SizedBox(height: AppConfig.spaceXL),
// Shipment Details
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Detail Pengiriman', style: AppConfig.h2),
const SizedBox(height: 16),
_buildTextField('Kepada Yth', _kepadaYthCtrl),
_buildTextField('Order Untuk', _orderUntukCtrl),
_buildTextField('Alamat', _alamatCtrl),
_buildTextField('Plat Truk', _platTrukCtrl),
_buildTextField('Nama Driver', _namaDriverCtrl),
],
),
),
),
const SizedBox(height: 16),
// Geotagging Details
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Detail Lokasi', style: AppConfig.h2),
const SizedBox(height: 16),
_buildTextField(
'Lintang',
_latitudeCtrl,
readOnly: true,
),
_buildTextField(
'Bujur',
_longitudeCtrl,
readOnly: true,
),
],
),
),
),
const SizedBox(height: 16),
// Items Section (Editable + CRUD)
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Barang (${_itemControllers.length})', style: AppConfig.h2),
],
),
const SizedBox(height: 16),
..._itemControllers.asMap().entries.map((entry) {
int index = entry.key;
var ctrls = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: ctrls['nama']!,
builder: (context, value, child) {
return Text(
value.text.isNotEmpty ? value.text : 'Item Baru',
style: AppConfig.h3,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _removeItem(index),
tooltip: 'Hapus Barang',
),
],
),
const SizedBox(height: 8),
_buildTextField(
'SKU',
ctrls['sku']!,
validator: (val) {
if (val == null || val.isEmpty) {
return 'SKU harus diisi';
}
if (!MasterSku.data.containsKey(val)) {
return 'SKU tidak terdaftar';
}
return null;
},
),
_buildTextField('Nama Barang', ctrls['nama']!),
Row(
children: [
Expanded(child: _buildTextField('Banyak', ctrls['banyak']!)),
const SizedBox(width: 8),
Expanded(child: _buildTextField('Jumlah', ctrls['jumlah']!)),
],
),
],
),
),
);
}),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _addItem(),
icon: const Icon(Icons.add),
label: const Text('Tambah Item Baru'),
style: OutlinedButton.styleFrom(
foregroundColor: AppConfig.primaryColor,
side: const BorderSide(color: AppConfig.primaryColor),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
HeaderDetailsCard(
tanggalCtrl: _tanggalCtrl,
noPoCtrl: _noPoCtrl,
noSoCtrl: _noSoCtrl,
noDoCtrl: _noDoCtrl,
),
const SizedBox(height: AppConfig.spaceLarge),
const SizedBox(height: 32),
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'),
),
],
),
),
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
height: 54,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: _showConfirmationDialog,
child: const Text('Selesai Ditinjau', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
),
),
);
}
@override
void dispose() {
_tanggalCtrl.dispose();
_noPoCtrl.dispose();
_noSoCtrl.dispose();
_noDoCtrl.dispose();
_kepadaYthCtrl.dispose();
_orderUntukCtrl.dispose();
_alamatCtrl.dispose();
_platTrukCtrl.dispose();
_namaDriverCtrl.dispose();
_namaPenerimaCtrl.dispose();
_latitudeCtrl.dispose();
_longitudeCtrl.dispose();
for (var ctrls in _itemControllers) {
ctrls['sku']?.dispose();
ctrls['nama']?.dispose();
ctrls['banyak']?.dispose();
ctrls['jumlah']?.dispose();
}
super.dispose();
}
}
+137 -63
View File
@@ -10,6 +10,7 @@ class PdfService {
// Load PFM logo asset
final logoImage = await flutterImageProvider(const AssetImage('assets/logo.png'));
final isProductScan = document.orderUntuk == 'PRODUCT SCAN';
// Add page
pdf.addPage(
@@ -23,7 +24,10 @@ class PdfService {
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('TANDA TERIMA PENGIRIMAN', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
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,
@@ -42,18 +46,27 @@ class PdfService {
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
_buildTextRow('Tanggal', document.tanggal),
_buildTextRow('No PO', document.noPo),
_buildTextRow('No SO', document.noSo),
_buildTextRow('No DO', document.noDo),
]
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),
// Shipment Data
pw.Text('Detail Pengiriman', style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
// 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),
@@ -63,72 +76,131 @@ class PdfService {
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
_buildTextRow('Kepada Yth', document.kepadaYth),
_buildTextRow('Order Untuk', document.orderUntuk),
_buildTextRow('Alamat', document.alamat),
_buildTextRow('Plat Truk', document.platTruk),
_buildTextRow('Nama Driver', document.namaDriver),
]
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
pw.Text('Barang Dikirim', style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 8),
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(),
// 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: [
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)'),
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.namaPenerima.isNotEmpty ? document.namaPenerima : '_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 4),
pw.Text('Penerima (Penjaga 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)'),
]
)
]
)
]
)
],
);
@@ -140,7 +212,9 @@ class PdfService {
await Printing.layoutPdf(
onLayout: (PdfPageFormat format) async => pdf.save(),
format: PdfPageFormat.a4,
name: 'Document_${document.noDo.replaceAll('/', '_')}',
name: isProductScan
? 'ProductScan_${document.items.isNotEmpty ? document.items.first.nomorSku : "unknown"}'
: 'Document_${document.noDo.replaceAll('/', '_')}',
);
}
@@ -0,0 +1,307 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../config/app_config.dart';
import '../../models/document_model.dart';
import '../../main.dart';
import '../documents/pending_documents_provider.dart';
class ProductEditorScreen extends ConsumerStatefulWidget {
final String? pendingId;
const ProductEditorScreen({super.key, this.pendingId});
@override
ConsumerState<ProductEditorScreen> createState() => _ProductEditorScreenState();
}
class _ProductEditorScreenState extends ConsumerState<ProductEditorScreen> {
final _expDateCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
final _formKey = GlobalKey<FormState>();
String _selectedSku = '12010119';
String? _selectedBatchOption;
bool _isManualDate = false;
String _imagePath = '';
List<DocumentModel> _doDocs = [];
String _selectedRelatedPo = 'PO-9923488';
final List<Map<String, dynamic>> _matches = [
{'no_sku': '12010119', 'nama_item': 'FIESTA SPICY CHICKEN NUGGET 500 GR', 'confidence': 0.985},
{'no_sku': '12012504', 'nama_item': 'AKUMO NUGGET COIN 200 GR', 'confidence': 0.82},
{'no_sku': '12010220', 'nama_item': 'FIESTA SCHNITZEL 500 GR', 'confidence': 0.75},
];
final Map<String, List<String>> _skuBatches = {
'12010119': ['15/12/2026', '20/04/2027'],
'12012504': ['01/10/2026', '12/03/2027'],
'12010220': ['18/08/2026', '05/01/2027'],
};
@override
void initState() {
super.initState();
_loadPendingDoc();
_loadDoDocs();
_updateBatchOptions();
}
@override
void dispose() {
_expDateCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
void _loadPendingDoc() {
if (widget.pendingId == null) return;
final pendingDoc = ref.read(pendingDocumentsProvider).firstWhere(
(doc) => doc.id == widget.pendingId,
orElse: () => PendingDocument(id: '', imagePath: '')
);
setState(() { _imagePath = pendingDoc.imagePath; });
}
void _loadDoDocs() {
final allDocs = ref.read(localStorageProvider).getAllDocuments();
final doDocs = allDocs.where((d) => d.orderUntuk != 'PRODUCT SCAN').toList();
setState(() {
_doDocs = doDocs;
if (_doDocs.isNotEmpty) {
_selectedRelatedPo = _doDocs.first.noPo;
}
});
}
void _updateBatchOptions() {
final batches = _skuBatches[_selectedSku] ?? [];
if (batches.isNotEmpty) {
_selectedBatchOption = 'Batch 1: ${batches[0]}';
_expDateCtrl.text = batches[0];
_isManualDate = false;
}
}
Future<void> _selectDate() async {
DateTime? pickedDate = await showDatePicker(
context: context,
initialDate: DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime(2020),
lastDate: DateTime(2035),
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(primary: AppConfig.primaryColor, onPrimary: Colors.white, onSurface: AppConfig.textPrimary),
),
child: child!,
),
);
if (pickedDate != null) {
setState(() { _expDateCtrl.text = DateFormat('dd/MM/yyyy').format(pickedDate); });
}
}
void _submit() async {
if (!_formKey.currentState!.validate()) return;
final activeMatch = _matches.firstWhere((m) => m['no_sku'] == _selectedSku, orElse: () => _matches.first);
final String productName = activeMatch['nama_item'] ?? 'Unknown Product';
final finalDoc = DocumentModel(
id: widget.pendingId ?? DateTime.now().millisecondsSinceEpoch.toString(),
tanggal: DateFormat('dd MMMM yyyy').format(DateTime.now()),
noPo: _selectedRelatedPo,
noSo: '1002003004',
noDo: _isManualDate ? 'Batch Manual' : (_selectedBatchOption != null ? _selectedBatchOption!.split(': ')[0] : 'Batch 1'),
kepadaYth: 'PM KELAPA DUA KARAWACI',
orderUntuk: 'PRODUCT SCAN',
alamat: 'Product Scan: Expired ${_expDateCtrl.text}',
platTruk: 'B 1234 PFM',
namaDriver: 'PRODUCT SCAN',
namaPenerima: 'STORE STAFF',
items: [DocumentItem(nomorSku: _selectedSku, namaBarang: productName, banyak: '1', jumlah: '1')],
createdAt: DateTime.now(),
);
await ref.read(localStorageProvider).saveDocument(finalDoc);
if (widget.pendingId != null) {
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Hasil Pemindaian Produk Berhasil Disimpan!'), backgroundColor: AppConfig.successColor),
);
context.pop(true);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Review Hasil Produk'), leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop())),
body: SingleChildScrollView(
padding: AppConfig.screenPadding,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_imagePath.isNotEmpty) ...[
Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium), side: BorderSide(color: Colors.grey.shade300)),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI Crop Region / Preview', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceMedium),
Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall)),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
child: Image.file(File(_imagePath), fit: BoxFit.cover, alignment: const Alignment(0, 0.4)),
),
),
],
),
),
),
const SizedBox(height: AppConfig.spaceLarge),
],
_buildProductDropdownCard(),
const SizedBox(height: AppConfig.spaceLarge),
_buildExpiryCard(),
const SizedBox(height: AppConfig.spaceXL),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Simpan & Konfirmasi Produk', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
),
),
);
}
Widget _buildProductDropdownCard() {
final activeMatch = _matches.firstWhere((m) => m['no_sku'] == _selectedSku, orElse: () => _matches.first);
final double confidence = activeMatch['confidence'];
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium), side: BorderSide(color: Colors.grey.shade300)),
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,
decoration: const InputDecoration(labelText: 'Pilih Produk / SKU', contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8)),
isExpanded: true,
style: AppConfig.bodyMedium,
items: _matches.map((match) {
return DropdownMenuItem<String>(value: match['no_sku'], child: Text('${match['no_sku']} - ${match['nama_item']}', overflow: TextOverflow.ellipsis, maxLines: 1));
}).toList(),
onChanged: (val) { if (val != null) { setState(() { _selectedSku = val; _updateBatchOptions(); }); } },
),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: _selectedRelatedPo,
decoration: const InputDecoration(labelText: 'Hubungkan ke PO Dokumen', contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8)),
isExpanded: true,
style: AppConfig.bodyMedium,
items: [
if (_doDocs.isEmpty)
const DropdownMenuItem(value: 'PO-9923488', child: Text('PO-9923488 (Default)')),
..._doDocs.map((doc) => DropdownMenuItem(value: doc.noPo, child: Text('${doc.noPo} - ${doc.kepadaYth}', overflow: TextOverflow.ellipsis))),
],
onChanged: (val) { if (val != null) { setState(() { _selectedRelatedPo = val; }); } },
),
const SizedBox(height: AppConfig.spaceLarge),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Confidence Score', style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary)),
Text('${(confidence * 100).toStringAsFixed(1)}%', style: const TextStyle(fontWeight: FontWeight.bold, color: AppConfig.successColor)),
],
),
const SizedBox(height: AppConfig.spaceSmall),
LinearProgressIndicator(value: confidence, color: AppConfig.successColor, backgroundColor: Colors.grey.shade200),
],
),
),
);
}
Widget _buildExpiryCard() {
final batches = _skuBatches[_selectedSku] ?? [];
final dropdownItems = <DropdownMenuItem<String>>[];
for (int i = 0; i < batches.length; i++) {
dropdownItems.add(DropdownMenuItem(value: 'Batch ${i + 1}: ${batches[i]}', child: Text('Batch ${i + 1}: ${batches[i]}')));
}
dropdownItems.add(const DropdownMenuItem(value: 'manual', child: Text('Set Tanggal Manual...')));
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium), side: BorderSide(color: Colors.grey.shade300)),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI OCR Expiry Extraction', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: _isManualDate ? 'manual' : _selectedBatchOption,
decoration: const InputDecoration(labelText: 'Pilih Batch Expiry', contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8)),
isExpanded: true,
style: AppConfig.bodyMedium,
items: dropdownItems,
onChanged: (val) {
if (val != null) {
setState(() {
if (val == 'manual') {
_isManualDate = true;
_expDateCtrl.clear();
} else {
_isManualDate = false;
_selectedBatchOption = val;
final parts = val.split(': ');
if (parts.length > 1) { _expDateCtrl.text = parts[1]; }
}
});
}
},
),
if (_isManualDate) ...[
const SizedBox(height: AppConfig.spaceLarge),
TextFormField(
controller: _expDateCtrl,
decoration: InputDecoration(labelText: 'Expired Date Manual (DD/MM/YYYY)', suffixIcon: IconButton(icon: const Icon(Icons.calendar_today, color: AppConfig.primaryColor), onPressed: _selectDate)),
validator: (val) => val == null || val.isEmpty ? 'Tanggal kadaluwarsa harus diisi' : null,
),
],
const SizedBox(height: AppConfig.spaceLarge),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('OCR Confidence Score', style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary)),
Text('${(0.924 * 100).toStringAsFixed(1)}%', style: const TextStyle(fontWeight: FontWeight.bold, color: AppConfig.successColor)),
],
),
const SizedBox(height: AppConfig.spaceSmall),
LinearProgressIndicator(value: 0.924, color: AppConfig.successColor, backgroundColor: Colors.grey.shade200),
const SizedBox(height: AppConfig.spaceMedium),
TextFormField(controller: _notesCtrl, decoration: const InputDecoration(labelText: 'Catatan Tambahan'), maxLines: 2),
],
),
),
);
}
}
@@ -0,0 +1,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),
],
),
);
}
}
@@ -0,0 +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;
},
),
);
}
}
@@ -0,0 +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),
],
),
),
);
}
}
@@ -0,0 +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),
),
),
),
],
),
),
);
}
}
@@ -0,0 +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,
),
],
),
),
);
}
}
@@ -0,0 +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),
],
),
),
);
}
}