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

@@ -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),
],
),
),
);
}
}