2026-09-04 09:48:34 +07:00
|
|
|
import 'dart:convert';
|
2025-08-20 15:15:20 +07:00
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:cashumit/models/receipt_item.dart';
|
2026-09-04 09:48:34 +07:00
|
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
2025-08-20 15:15:20 +07:00
|
|
|
|
|
|
|
|
class AddItemScreen extends StatefulWidget {
|
|
|
|
|
const AddItemScreen({super.key, this.item});
|
2025-09-18 11:39:03 +07:00
|
|
|
|
2025-08-20 15:15:20 +07:00
|
|
|
// Constructor untuk mode edit
|
2025-08-23 16:29:32 +07:00
|
|
|
const AddItemScreen.fromItem(this.item, {super.key});
|
2025-08-20 15:15:20 +07:00
|
|
|
|
|
|
|
|
final ReceiptItem? item;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<AddItemScreen> createState() => _AddItemScreenState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _AddItemScreenState extends State<AddItemScreen> {
|
|
|
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
|
late final TextEditingController _descriptionController;
|
|
|
|
|
late final TextEditingController _quantityController;
|
|
|
|
|
late final TextEditingController _priceController;
|
2026-09-04 09:48:34 +07:00
|
|
|
final FocusNode _priceFocusNode = FocusNode();
|
|
|
|
|
|
|
|
|
|
List<Map<String, dynamic>> _recentItems = [];
|
|
|
|
|
static const String _recentItemsKey = 'recent_receipt_items';
|
2025-08-20 15:15:20 +07:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
2025-09-18 11:39:03 +07:00
|
|
|
|
|
|
|
|
_descriptionController =
|
|
|
|
|
TextEditingController(text: widget.item?.description ?? '');
|
|
|
|
|
_quantityController = TextEditingController(
|
2026-09-04 09:48:34 +07:00
|
|
|
text: widget.item != null
|
|
|
|
|
? (widget.item!.quantity % 1 == 0
|
|
|
|
|
? widget.item!.quantity.toInt().toString()
|
|
|
|
|
: widget.item!.quantity.toString())
|
|
|
|
|
: '1');
|
|
|
|
|
_priceController = TextEditingController(
|
|
|
|
|
text: widget.item != null
|
|
|
|
|
? _formatNumber(widget.item!.price.toInt().toString())
|
|
|
|
|
: '');
|
|
|
|
|
|
|
|
|
|
_loadRecentItems();
|
2025-08-20 15:15:20 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_descriptionController.dispose();
|
|
|
|
|
_quantityController.dispose();
|
|
|
|
|
_priceController.dispose();
|
2026-09-04 09:48:34 +07:00
|
|
|
_priceFocusNode.dispose();
|
2025-08-20 15:15:20 +07:00
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 09:48:34 +07:00
|
|
|
Future<void> _loadRecentItems() async {
|
|
|
|
|
try {
|
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
|
final list = prefs.getStringList(_recentItemsKey) ?? [];
|
|
|
|
|
if (list.isNotEmpty && mounted) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_recentItems = list
|
|
|
|
|
.map((e) => jsonDecode(e) as Map<String, dynamic>)
|
|
|
|
|
.take(6)
|
|
|
|
|
.toList();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _saveRecentItem(String desc, double price) async {
|
|
|
|
|
try {
|
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
|
final list = prefs.getStringList(_recentItemsKey) ?? [];
|
|
|
|
|
final currentList = list
|
|
|
|
|
.map((e) => jsonDecode(e) as Map<String, dynamic>)
|
|
|
|
|
.where((e) => (e['desc'] as String).toLowerCase() != desc.toLowerCase())
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
currentList.insert(0, {'desc': desc, 'price': price});
|
|
|
|
|
final encoded =
|
|
|
|
|
currentList.take(8).map((e) => jsonEncode(e)).toList();
|
|
|
|
|
await prefs.setStringList(_recentItemsKey, encoded);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _formatNumber(String s) {
|
|
|
|
|
final clean = s.replaceAll(RegExp(r'[^\d]'), '');
|
|
|
|
|
if (clean.isEmpty) return '';
|
|
|
|
|
final val = int.tryParse(clean);
|
|
|
|
|
if (val == null) return clean;
|
|
|
|
|
return val.toString().replaceAllMapped(
|
|
|
|
|
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
double _parsePrice(String text) {
|
|
|
|
|
final clean = text.replaceAll('.', '').replaceAll(',', '').trim();
|
|
|
|
|
return double.tryParse(clean) ?? 0.0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onPriceChanged(String value) {
|
|
|
|
|
final formatted = _formatNumber(value);
|
|
|
|
|
if (formatted != value) {
|
|
|
|
|
_priceController.value = TextEditingValue(
|
|
|
|
|
text: formatted,
|
|
|
|
|
selection: TextSelection.collapsed(offset: formatted.length),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _addNominalToPrice(int nominal) {
|
|
|
|
|
final current = _parsePrice(_priceController.text);
|
|
|
|
|
final next = current + nominal;
|
|
|
|
|
final formatted = _formatNumber(next.toInt().toString());
|
|
|
|
|
setState(() {
|
|
|
|
|
_priceController.text = formatted;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _stepQuantity(int delta) {
|
|
|
|
|
final current = double.tryParse(_quantityController.text) ?? 1.0;
|
|
|
|
|
final next = current + delta;
|
|
|
|
|
if (next >= 1) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_quantityController.text = next % 1 == 0
|
|
|
|
|
? next.toInt().toString()
|
|
|
|
|
: next.toString();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _selectRecentItem(Map<String, dynamic> item) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_descriptionController.text = item['desc']?.toString() ?? '';
|
|
|
|
|
final price = (item['price'] as num?)?.toDouble() ?? 0.0;
|
|
|
|
|
if (price > 0) {
|
|
|
|
|
_priceController.text = _formatNumber(price.toInt().toString());
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
_priceFocusNode.requestFocus();
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-20 15:15:20 +07:00
|
|
|
void _saveItem() {
|
|
|
|
|
if (_formKey.currentState!.validate()) {
|
2026-09-04 09:48:34 +07:00
|
|
|
final desc = _descriptionController.text.trim();
|
|
|
|
|
final qty = double.tryParse(_quantityController.text) ?? 1.0;
|
|
|
|
|
final price = _parsePrice(_priceController.text);
|
|
|
|
|
|
|
|
|
|
_saveRecentItem(desc, price);
|
|
|
|
|
|
2025-08-20 15:15:20 +07:00
|
|
|
final item = ReceiptItem(
|
2026-09-04 09:48:34 +07:00
|
|
|
description: desc,
|
|
|
|
|
quantity: qty,
|
|
|
|
|
price: price,
|
2025-08-20 15:15:20 +07:00
|
|
|
);
|
2025-09-18 11:39:03 +07:00
|
|
|
|
2025-08-20 15:15:20 +07:00
|
|
|
Navigator.pop(context, item);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-09-04 09:48:34 +07:00
|
|
|
final primaryColor = Theme.of(context).colorScheme.primary;
|
|
|
|
|
|
2025-08-23 16:29:32 +07:00
|
|
|
return AlertDialog(
|
2026-09-04 09:48:34 +07:00
|
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0),
|
|
|
|
|
title: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Icon(
|
|
|
|
|
widget.item == null ? Icons.add_shopping_cart : Icons.edit_note,
|
|
|
|
|
color: primaryColor,
|
|
|
|
|
size: 22,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
Text(widget.item == null ? 'Tambah Item' : 'Edit Item'),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
content: SizedBox(
|
|
|
|
|
width: MediaQuery.of(context).size.width,
|
|
|
|
|
child: SingleChildScrollView(
|
2025-08-20 15:15:20 +07:00
|
|
|
child: Form(
|
|
|
|
|
key: _formKey,
|
|
|
|
|
child: Column(
|
2025-08-23 16:29:32 +07:00
|
|
|
mainAxisSize: MainAxisSize.min,
|
2026-09-04 09:48:34 +07:00
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
2025-08-20 15:15:20 +07:00
|
|
|
children: [
|
2026-09-04 09:48:34 +07:00
|
|
|
// Kolom Deskripsi Item
|
2025-08-20 15:15:20 +07:00
|
|
|
TextFormField(
|
|
|
|
|
controller: _descriptionController,
|
2026-09-04 09:48:34 +07:00
|
|
|
autofocus: widget.item == null,
|
|
|
|
|
textInputAction: TextInputAction.next,
|
2025-08-20 15:15:20 +07:00
|
|
|
decoration: const InputDecoration(
|
2026-09-04 09:48:34 +07:00
|
|
|
labelText: 'Deskripsi / Nama Item',
|
|
|
|
|
hintText: 'Misal: Kopi Susu, Nasi Goreng',
|
2025-08-20 15:15:20 +07:00
|
|
|
border: OutlineInputBorder(),
|
2026-09-04 09:48:34 +07:00
|
|
|
isDense: true,
|
2025-08-20 15:15:20 +07:00
|
|
|
),
|
|
|
|
|
validator: (value) {
|
2026-09-04 09:48:34 +07:00
|
|
|
if (value == null || value.trim().isEmpty) {
|
|
|
|
|
return 'Mohon masukkan nama item';
|
2025-08-20 15:15:20 +07:00
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
},
|
2026-09-04 09:48:34 +07:00
|
|
|
onFieldSubmitted: (_) => _priceFocusNode.requestFocus(),
|
2025-08-20 15:15:20 +07:00
|
|
|
),
|
2026-09-04 09:48:34 +07:00
|
|
|
|
|
|
|
|
// Saran Item Cepat (Riwayat)
|
|
|
|
|
if (widget.item == null && _recentItems.isNotEmpty) ...[
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
SingleChildScrollView(
|
|
|
|
|
scrollDirection: Axis.horizontal,
|
|
|
|
|
child: Row(
|
|
|
|
|
children: _recentItems.map((recent) {
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: const EdgeInsets.only(right: 6.0),
|
|
|
|
|
child: ActionChip(
|
|
|
|
|
avatar: const Icon(Icons.history, size: 13),
|
|
|
|
|
label: Text(
|
|
|
|
|
recent['desc']?.toString() ?? '',
|
|
|
|
|
style: const TextStyle(fontSize: 11),
|
|
|
|
|
),
|
|
|
|
|
onPressed: () => _selectRecentItem(recent),
|
|
|
|
|
visualDensity: VisualDensity.compact,
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}).toList(),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
|
|
|
|
|
const SizedBox(height: 14),
|
|
|
|
|
|
|
|
|
|
// Baris Jumlah (dengan stepper) & Harga
|
2025-08-20 15:15:20 +07:00
|
|
|
Row(
|
2026-09-04 09:48:34 +07:00
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
2025-08-20 15:15:20 +07:00
|
|
|
children: [
|
2026-09-04 09:48:34 +07:00
|
|
|
// Stepper Jumlah
|
2025-08-20 15:15:20 +07:00
|
|
|
Expanded(
|
2026-09-04 09:48:34 +07:00
|
|
|
flex: 4,
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
const Text(
|
|
|
|
|
'Jumlah (Qty)',
|
|
|
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
Container(
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
border: Border.all(color: Colors.grey.shade400),
|
|
|
|
|
borderRadius: BorderRadius.circular(4),
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
InkWell(
|
|
|
|
|
onTap: () => _stepQuantity(-1),
|
|
|
|
|
child: const Padding(
|
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
|
|
|
|
child: Icon(Icons.remove, size: 16),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: TextFormField(
|
|
|
|
|
controller: _quantityController,
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
decoration: const InputDecoration(
|
|
|
|
|
border: InputBorder.none,
|
|
|
|
|
isDense: true,
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
),
|
|
|
|
|
keyboardType: TextInputType.number,
|
|
|
|
|
validator: (value) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return 'Wajib diisi';
|
|
|
|
|
}
|
|
|
|
|
if (double.tryParse(value) == null) {
|
|
|
|
|
return 'Harus angka';
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
InkWell(
|
|
|
|
|
onTap: () => _stepQuantity(1),
|
|
|
|
|
child: const Padding(
|
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
|
|
|
|
child: Icon(Icons.add, size: 16),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
2025-08-20 15:15:20 +07:00
|
|
|
),
|
|
|
|
|
),
|
2026-09-04 09:48:34 +07:00
|
|
|
|
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
|
|
|
|
|
|
// Input Harga dengan pemisah ribuan
|
2025-08-20 15:15:20 +07:00
|
|
|
Expanded(
|
2026-09-04 09:48:34 +07:00
|
|
|
flex: 6,
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
const Text(
|
|
|
|
|
'Harga Satuan',
|
|
|
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
TextFormField(
|
|
|
|
|
controller: _priceController,
|
|
|
|
|
focusNode: _priceFocusNode,
|
|
|
|
|
textInputAction: TextInputAction.done,
|
|
|
|
|
decoration: const InputDecoration(
|
|
|
|
|
prefixText: 'Rp ',
|
|
|
|
|
hintText: '0',
|
|
|
|
|
border: OutlineInputBorder(),
|
|
|
|
|
isDense: true,
|
|
|
|
|
),
|
|
|
|
|
keyboardType: TextInputType.number,
|
|
|
|
|
onChanged: _onPriceChanged,
|
|
|
|
|
onFieldSubmitted: (_) => _saveItem(),
|
|
|
|
|
validator: (value) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return 'Mohon masukkan harga';
|
|
|
|
|
}
|
|
|
|
|
if (_parsePrice(value) <= 0) {
|
|
|
|
|
return 'Harga harus > 0';
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
],
|
2025-08-20 15:15:20 +07:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-09-04 09:48:34 +07:00
|
|
|
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
|
|
|
|
// Chip Bantuan Nominal Cepat (+5k, +10k, +25k, +50k)
|
|
|
|
|
Wrap(
|
|
|
|
|
spacing: 6,
|
|
|
|
|
children: [
|
|
|
|
|
ActionChip(
|
|
|
|
|
label: const Text('+5rb', style: TextStyle(fontSize: 11)),
|
|
|
|
|
onPressed: () => _addNominalToPrice(5000),
|
|
|
|
|
visualDensity: VisualDensity.compact,
|
|
|
|
|
),
|
|
|
|
|
ActionChip(
|
|
|
|
|
label: const Text('+10rb', style: TextStyle(fontSize: 11)),
|
|
|
|
|
onPressed: () => _addNominalToPrice(10000),
|
|
|
|
|
visualDensity: VisualDensity.compact,
|
|
|
|
|
),
|
|
|
|
|
ActionChip(
|
|
|
|
|
label: const Text('+25rb', style: TextStyle(fontSize: 11)),
|
|
|
|
|
onPressed: () => _addNominalToPrice(25000),
|
|
|
|
|
visualDensity: VisualDensity.compact,
|
|
|
|
|
),
|
|
|
|
|
ActionChip(
|
|
|
|
|
label: const Text('+50rb', style: TextStyle(fontSize: 11)),
|
|
|
|
|
onPressed: () => _addNominalToPrice(50000),
|
|
|
|
|
visualDensity: VisualDensity.compact,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2025-08-20 15:15:20 +07:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-09-04 09:48:34 +07:00
|
|
|
),
|
2025-08-23 16:29:32 +07:00
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.pop(context),
|
|
|
|
|
child: const Text('Batal'),
|
|
|
|
|
),
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: _saveItem,
|
2026-09-04 09:48:34 +07:00
|
|
|
child: Text(widget.item == null ? 'Tambah' : 'Simpan'),
|
2025-08-23 16:29:32 +07:00
|
|
|
),
|
|
|
|
|
],
|
2025-08-20 15:15:20 +07:00
|
|
|
);
|
|
|
|
|
}
|
2025-09-18 11:39:03 +07:00
|
|
|
}
|
|
|
|
|
|