Files
app-grosir/lib/screens/kasir_screen.dart

1889 lines
76 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:async';
import '../models/product.dart';
import '../models/sale.dart';
import '../state/app_state.dart';
import '../services/printer_service.dart';
import 'produk_screen.dart' show productImageUrl;
import '../utils/colors.dart';
import '../utils/format.dart';
import '../utils/rupiah_input.dart';
class KasirScreen extends StatefulWidget {
const KasirScreen({super.key});
@override
State<KasirScreen> createState() => _KasirScreenState();
}
class _KasirScreenState extends State<KasirScreen> {
final TextEditingController _search = TextEditingController();
Timer? _searchDebounce;
bool _isGrid = false; // default: list (foto lebih jelas)
String? _lastAddedId;
Timer? _lastAddedTimer;
@override
void dispose() {
_searchDebounce?.cancel();
_lastAddedTimer?.cancel();
_search.dispose();
super.dispose();
}
void _markAdded(String id) {
_lastAddedTimer?.cancel();
setState(() => _lastAddedId = id);
_lastAddedTimer = Timer(const Duration(milliseconds: 900), () {
if (mounted) setState(() => _lastAddedId = null);
});
}
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
if (state.loading) {
return const Center(child: CircularProgressIndicator(color: kAccent));
}
final query = _search.text.toLowerCase();
final isReseller = state.customerType == 'reseller';
final products = state.products
.where((p) =>
p.name.toLowerCase().contains(query) ||
p.barcode.toLowerCase().contains(query))
.toList();
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 6),
child: Row(
children: [
Expanded(
child: TextField(
controller: _search,
onChanged: (_) {
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
if (mounted) setState(() {});
});
},
style: const TextStyle(color: kText),
decoration: InputDecoration(
hintText: 'Cari produk...',
prefixIcon: const Icon(Icons.search, color: kMuted),
suffixIcon: _search.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.close, color: kMuted),
onPressed: () {
_search.clear();
setState(() {});
},
)
: null,
),
),
),
const SizedBox(width: 8),
Stack(
children: [
IconButton(
tooltip: 'Buka keranjang',
style: IconButton.styleFrom(
backgroundColor: kSurface,
foregroundColor: kAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: kBorder),
),
),
icon: const Icon(Icons.shopping_cart),
onPressed: () => _openCartSheet(context, state),
),
if (state.cart.isNotEmpty)
Positioned(
right: 0,
top: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: kDanger,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${state.cart.fold(0, (s, c) => s + c.qty)}',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold),
),
),
),
],
),
const SizedBox(width: 8),
IconButton(
tooltip: _isGrid ? 'Tampilan list' : 'Tampilan grid',
style: IconButton.styleFrom(
backgroundColor: kSurface,
foregroundColor: kAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: kBorder),
),
),
icon: Icon(_isGrid ? Icons.view_list : Icons.grid_view),
onPressed: () => setState(() => _isGrid = !_isGrid),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
final nameCtrl = TextEditingController();
final priceCtrl = TextEditingController();
final qtyCtrl = TextEditingController(text: '1');
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (ctx) {
bool submitting = false;
return StatefulBuilder(
builder: (context, setDialog) {
return AlertDialog(
title: const Text('Tambah Pesanan Manual'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: 'Nama Produk',
prefixIcon: Icon(Icons.shopping_bag_outlined),
),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 12),
TextField(
controller: priceCtrl,
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(
labelText: 'Harga',
prefixText: 'Rp ',
prefixIcon: Icon(Icons.payments_outlined),
),
),
const SizedBox(height: 12),
TextField(
controller: qtyCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Qty',
prefixIcon: Icon(Icons.numbers),
),
),
],
),
),
actions: [
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: submitting ? null : () => Navigator.pop(ctx),
style: OutlinedButton.styleFrom(
foregroundColor: kMuted,
side: const BorderSide(color: kBorder),
),
child: const Text('Batal'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: submitting
? null
: () {
final name = nameCtrl.text.trim();
final price = (RupiahInputFormatter.getRawValue(priceCtrl.text) ?? 0).toDouble();
final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1;
if (name.isEmpty || price <= 0 || qty <= 0) return;
Navigator.pop(ctx, {
'name': name,
'price': price,
'qty': qty,
});
},
style: FilledButton.styleFrom(backgroundColor: kAccent, foregroundColor: const Color(0xFF06210F)),
child: submitting
? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF06210F)))
: const Text('Tambah'),
),
),
],
),
],
);
}
);
},
);
if (result != null) {
final manualProduct = Product.manual(
result['name'] as String,
(result['price'] as double),
);
for (int i = 0; i < (result['qty'] as int); i++) {
state.addToCart(manualProduct);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Pesanan manual ditambahkan: ${result['name']}'),
backgroundColor: kSuccess,
),
);
}
}
},
icon: const Icon(Icons.edit_note_rounded),
label: const Text('Tambah Manual'),
),
),
),
if (state.resellerEnabled) Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
child: Row(
children: [
const Text('Pembeli:',
style: TextStyle(color: kMuted, fontSize: 13)),
const SizedBox(width: 10),
Expanded(
child: SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'umum', label: Text('Umum')),
ButtonSegment(
value: 'reseller', label: Text('Reseller')),
],
selected: {state.customerType},
onSelectionChanged: (set) =>
state.setCustomerType(set.first),
style: SegmentedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
),
],
),
),
Expanded(
child: _isGrid
? GridView.builder(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
mainAxisExtent: 168,
),
itemCount: products.length,
itemBuilder: (context, i) {
final p = products[i];
// Hijau sesaat hanya untuk produk yang baru ditap,
// BUKAN semua isi keranjang — tap selalu tambah qty.
final highlight = _lastAddedId == p.id;
return _KasirCard(
key: ValueKey(p.id),
product: p,
price: isReseller ? p.priceReseller : p.price,
inCart: highlight,
onTap: () {
state.addToCart(p);
_markAdded(p.id);
},
);
},
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
itemCount: products.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, i) {
final p = products[i];
final highlight = _lastAddedId == p.id;
return _KasirListTile(
key: ValueKey(p.id),
product: p,
price: isReseller ? p.priceReseller : p.price,
inCart: highlight,
onTap: () {
state.addToCart(p);
_markAdded(p.id);
},
);
},
),
),
],
);
}
void _openCartSheet(BuildContext context, AppState state) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.35,
maxChildSize: 0.92,
builder: (_, controller) => Container(
decoration: const BoxDecoration(
color: kSurface,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
border: Border(top: BorderSide(color: kBorder)),
),
child: _CartSheetContent(
scrollController: controller,
onPay: () => _showPaymentDialog(sheetContext, state),
),
),
),
);
}
void _showPaymentDialog(BuildContext sheetContext, AppState state) {
final paidCtrl = TextEditingController();
double change = 0;
final total = state.total;
String paymentMethod = 'tunai';
final Map<String, double> splitPayment = {};
showDialog(
context: sheetContext,
builder: (dialogContext) {
final isPayingRef = [false];
DateTime? payingStart;
return StatefulBuilder(
builder: (context, setDialog) {
final messenger = ScaffoldMessenger.of(context);
void updatePaid(String v) {
if (isPayingRef[0]) return;
final paid = (RupiahInputFormatter.getRawValue(v) ?? 0).toDouble();
setDialog(() {
change = paid - total;
});
}
Future<void> handlePay() async {
if (isPayingRef[0]) return;
final paid =
(RupiahInputFormatter.getRawValue(paidCtrl.text) ?? 0)
.toDouble();
if (paymentMethod == 'tunai' && paid < total) {
messenger.showSnackBar(
const SnackBar(
content: Text('Uang dibayar kurang dari total'),
backgroundColor: kDanger,
),
);
return;
}
payingStart = DateTime.now();
setDialog(() => isPayingRef[0] = true);
try {
final isBayarNanti = paymentMethod == 'bayar_nanti';
final sale = await state.checkout(
isBayarNanti ? 0 : paid,
paymentMethod: isBayarNanti ? 'tunai' : paymentMethod,
splitPayment: paymentMethod == 'split' && splitPayment.isNotEmpty
? Map<String, double>.from(splitPayment)
: null,
paymentStatus: isBayarNanti ? 'unpaid' : 'paid',
dueAmount: isBayarNanti ? total : 0.0,
);
if (!dialogContext.mounted) return;
final elapsed = DateTime.now().difference(payingStart!);
if (elapsed < const Duration(milliseconds: 300)) {
await Future.delayed(const Duration(milliseconds: 300) - elapsed);
}
if (!dialogContext.mounted) return;
Navigator.pop(dialogContext);
Navigator.pop(sheetContext);
if (mounted) {
HapticFeedback.mediumImpact();
_showReceipt(dialogContext, sale, isBayarNanti: isBayarNanti);
}
} catch (e) {
if (!dialogContext.mounted) return;
Navigator.pop(dialogContext);
Navigator.pop(sheetContext);
messenger.showSnackBar(
const SnackBar(
content: Text(
'Transaksi disimpan offline dan akan disinkronkan saat koneksi kembali'),
backgroundColor: kSuccess,
duration: Duration(seconds: 3),
),
);
} finally {
if (dialogContext.mounted) {
setDialog(() => isPayingRef[0] = false);
}
}
}
return AlertDialog(
contentPadding: const EdgeInsets.all(20),
content: SizedBox(
width: double.maxFinite,
child: Stack(
children: [
SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.payments_outlined, color: kAccent),
const SizedBox(width: 8),
const Text('Pembayaran',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: kMuted),
onPressed: isPayingRef[0]
? null
: () => Navigator.pop(dialogContext),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(14),
),
child: Column(
children: [
const Text('TOTAL BELANJA',
style: TextStyle(
fontSize: 11,
color: kMuted,
letterSpacing: 1)),
const SizedBox(height: 4),
Text(formatRupiah(total),
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w800,
color: kAccent)),
],
),
),
const SizedBox(height: 16),
const Align(
alignment: Alignment.centerLeft,
child: Text('Metode Pembayaran',
style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w600)),
),
const SizedBox(height: 8),
Column(
children: [
Row(
children: [
Expanded(
child: _PaymentMethodChip(
label: 'Tunai',
icon: Icons.money_outlined,
selected: paymentMethod == 'tunai',
onTap: isPayingRef[0]
? null
: () => setDialog(() => paymentMethod = 'tunai'),
),
),
const SizedBox(width: 8),
Expanded(
child: _PaymentMethodChip(
label: 'Bank',
icon: Icons.account_balance_outlined,
selected: paymentMethod == 'transfer',
onTap: isPayingRef[0]
? null
: () => setDialog(() => paymentMethod = 'transfer'),
),
),
const SizedBox(width: 8),
Expanded(
child: _PaymentMethodChip(
label: 'QRIS',
icon: Icons.qr_code_2_outlined,
selected: paymentMethod == 'qris',
onTap: isPayingRef[0]
? null
: () => setDialog(() => paymentMethod = 'qris'),
),
),
],
),
const SizedBox(height: 8),
const Divider(height: 1, thickness: 1),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _PaymentMethodChip(
label: 'Bayar Nanti',
icon: Icons.watch_later_outlined,
selected: paymentMethod == 'bayar_nanti',
onTap: isPayingRef[0]
? null
: () => setDialog(() => paymentMethod = 'bayar_nanti'),
),
),
const SizedBox(width: 8),
Expanded(
child: _PaymentMethodChip(
label: 'Split',
icon: Icons.call_split_outlined,
selected: paymentMethod == 'split',
onTap: isPayingRef[0]
? null
: () => setDialog(() => paymentMethod = 'split'),
),
),
],
),
],
),
const SizedBox(height: 16),
if (paymentMethod == 'tunai' || paymentMethod == 'bayar_nanti') ...[
AbsorbPointer(
absorbing: isPayingRef[0] || paymentMethod == 'bayar_nanti',
child: TextField(
controller: paidCtrl,
keyboardType: TextInputType.number,
inputFormatters: paymentMethod == 'bayar_nanti' ? [] : [RupiahInputFormatter()],
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w600),
decoration: InputDecoration(
labelText: paymentMethod == 'bayar_nanti' ? 'Belum Bayar' : 'Uang Dibayar',
prefixText: 'Rp ',
helperText: paymentMethod == 'bayar_nanti' ? 'Pembayaran dilakukan saat customer datang' : null,
),
onChanged: paymentMethod == 'bayar_nanti' ? null : updatePaid,
),
),
const SizedBox(height: 10),
AbsorbPointer(
absorbing: isPayingRef[0],
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
_Chip('Uang Pas', () {
paidCtrl.text =
RupiahInputFormatter.formatNumber(total.round());
updatePaid(paidCtrl.text);
}),
_Chip('+5rb', () {
final current =
RupiahInputFormatter.getRawValue(paidCtrl.text) ?? 0;
paidCtrl.text =
RupiahInputFormatter.formatNumber(current + 5000);
updatePaid(paidCtrl.text);
}),
_Chip('+10rb', () {
final current =
RupiahInputFormatter.getRawValue(paidCtrl.text) ?? 0;
paidCtrl.text =
RupiahInputFormatter.formatNumber(current + 10000);
updatePaid(paidCtrl.text);
}),
_Chip('+20rb', () {
final current =
RupiahInputFormatter.getRawValue(paidCtrl.text) ?? 0;
paidCtrl.text =
RupiahInputFormatter.formatNumber(current + 20000);
updatePaid(paidCtrl.text);
}),
_Chip('+50rb', () {
final current =
RupiahInputFormatter.getRawValue(paidCtrl.text) ?? 0;
paidCtrl.text =
RupiahInputFormatter.formatNumber(current + 50000);
updatePaid(paidCtrl.text);
}),
_Chip('+100rb', () {
final current =
RupiahInputFormatter.getRawValue(paidCtrl.text) ?? 0;
paidCtrl.text =
RupiahInputFormatter.formatNumber(current + 100000);
updatePaid(paidCtrl.text);
}),
],
),
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: paymentMethod == 'bayar_nanti'
? kMuted.withValues(alpha: 0.12)
: change >= 0
? kSuccess.withValues(alpha: 0.12)
: kDanger.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(paymentMethod == 'bayar_nanti' ? 'Status' : 'Kembalian',
style: const TextStyle(color: kMuted)),
Text(
paymentMethod == 'bayar_nanti'
? 'Belum Bayar'
: formatRupiah(change > 0 ? change : 0),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: paymentMethod == 'bayar_nanti'
? kMuted
: change >= 0
? kSuccess
: kDanger,
),
),
],
),
),
] else if (paymentMethod == 'split') ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pembayaran Gabungan',
style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
...['tunai', 'transfer', 'qris'].map((method) {
final label = method == 'tunai'
? 'Tunai'
: method == 'transfer'
? 'Bank'
: 'QRIS';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: Text(label,
style: const TextStyle(fontSize: 13)),
),
SizedBox(
width: 120,
child: TextField(
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: InputDecoration(
labelText: 'Rp $label',
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 8),
),
onChanged: (v) {
final raw = RupiahInputFormatter.getRawValue(v) ?? 0;
setDialog(() {
if (raw > 0) {
splitPayment[method] = raw.toDouble();
} else {
splitPayment.remove(method);
}
});
},
),
),
],
),
);
}),
const SizedBox(height: 8),
Text(
'Total split: ${formatRupiah(splitPayment.values.fold(0.0, (s, v) => s + v))} / ${formatRupiah(total)}',
style: TextStyle(
fontSize: 12,
color: splitPayment.values.fold(0.0, (s, v) => s + v) >= total
? kSuccess
: kDanger,
),
),
],
),
),
] else ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(12),
),
child: Text(
paymentMethod == 'transfer'
? 'Transfer ke rekening toko. Konfirmasi pembayaran sebelum lanjut.'
: 'Tampilkan kode QRIS ke customer untuk scan.',
style: const TextStyle(fontSize: 13, color: kMuted),
),
),
const SizedBox(height: 12),
AbsorbPointer(
absorbing: isPayingRef[0],
child: TextField(
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w600),
decoration: InputDecoration(
labelText: paymentMethod == 'transfer'
? 'Jumlah Bank'
: 'Jumlah QRIS',
prefixText: 'Rp ',
),
onChanged: (v) {
final paid = (RupiahInputFormatter.getRawValue(v) ?? 0).toDouble();
setDialog(() {
change = paid - total;
});
},
),
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: change >= 0
? kSuccess.withValues(alpha: 0.12)
: kDanger.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Status',
style: TextStyle(color: kMuted)),
Text(
change >= 0 ? 'Lunas' : 'Kurang ${formatRupiah(change.abs())}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: change >= 0 ? kSuccess : kDanger,
),
),
],
),
),
],
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: isPayingRef[0] ? null : handlePay,
style: FilledButton.styleFrom(
backgroundColor: kSuccess,
foregroundColor: const Color(0xFF06210F),
),
child: isPayingRef[0]
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF06210F),
),
),
SizedBox(width: 10),
Text('Memproses...',
style: TextStyle(
fontWeight: FontWeight.w700)),
],
)
: Text(paymentMethod == 'bayar_nanti' ? 'Pesanan Sekarang' : paymentMethod == 'tunai' ? 'Selesai & Cetak' : 'Selesai'),
),
),
],
),
),
],
),
),
);
},
);
},
);
}
void _showReceipt(BuildContext context, Sale sale, {bool isBayarNanti = false}) {
showDialog(
context: context,
builder: (ctx) {
final state = ctx.read<AppState>();
bool printing = false;
return StatefulBuilder(
builder: (context, setReceipt) {
return AlertDialog(
contentPadding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
icon: Icon(isBayarNanti ? Icons.receipt_long : Icons.check_circle, color: kSuccess, size: 28),
title: Text(isBayarNanti ? 'Pesanan Berhasil' : 'Pembayaran Berhasil'),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${formatTanggal(sale.date)} ${formatWaktu(sale.date)} • #${sale.id.toUpperCase()}',
style: const TextStyle(color: kMuted, fontSize: 12),
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...sale.items.map((it) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(
it.productId.startsWith('manual-')
? '[MANUAL] ${it.name} x${it.qty}'
: '${it.name} x${it.qty}',
style: TextStyle(
fontSize: 13,
color: it.productId.startsWith('manual-') ? kAccent : null,
),
),
),
Text(formatRupiah(it.subtotal),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600)),
],
),
)),
const SizedBox(height: 8),
Text(
'Kasir: ${state.getProfileName(sale.userId)}',
style: const TextStyle(color: kMuted, fontSize: 12),
),
if (sale.discount > 0)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Diskon',
style: TextStyle(color: kMuted)),
Text('-${formatRupiah(sale.discount)}',
style: const TextStyle(color: kMuted)),
],
),
),
const Divider(height: 20),
_ReceiptRow('Total', formatRupiah(sale.total), true),
_ReceiptRow(
'Metode', _paymentMethodLabel(sale.paymentMethod), false),
if (sale.paymentStatus == 'unpaid') ...[
_ReceiptRow('Status', 'Belum Lunas', false),
_ReceiptRow('Sisa', formatRupiah(sale.dueAmount), false),
] else if (sale.paymentMethod == 'tunai') ...[
_ReceiptRow('Bayar', formatRupiah(sale.paid), false),
_ReceiptRow('Kembali', formatRupiah(sale.change), false),
],
if (sale.splitPayment != null &&
sale.splitPayment!.isNotEmpty)
...sale.splitPayment!.entries.map((e) {
final label = e.key == 'tunai'
? 'Tunai'
: e.key == 'transfer'
? 'Bank'
: 'QRIS';
return _ReceiptRow(
'Split $label', formatRupiah(e.value), false);
}),
const SizedBox(height: 12),
const Text('Terima kasih!',
style: TextStyle(color: kMuted)),
],
),
),
),
],
),
),
actions: [
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: printing
? null
: () async {
setReceipt(() => printing = true);
HapticFeedback.lightImpact();
final messenger = ScaffoldMessenger.of(context);
final prefs = await SharedPreferences.getInstance();
final storeName =
prefs.getString('store_name') ?? 'Toko Grosir';
final err = await printerService.printSale(
sale,
storeName,
kasirName: state.getProfileName(sale.userId),
whatsapp: prefs.getString('store_whatsapp'),
notes: prefs.getString('store_notes'),
);
if (!context.mounted) return;
setReceipt(() => printing = false);
messenger.showSnackBar(
SnackBar(
content: Text(err == null
? 'Struk dikirim ke printer'
: 'Gagal cetak: $err'),
backgroundColor: err == null ? kSuccess : kDanger,
),
);
},
icon: printing
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF06210F),
),
)
: const Icon(Icons.print_outlined, size: 18),
label: Text(printing ? 'Mencetak...' : 'Cetak Struk'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Tutup'),
),
),
],
);
}
);
},
);
}
}
class _CartBar extends StatefulWidget {
final VoidCallback onTap;
final int itemCount;
const _CartBar({required this.onTap, required this.itemCount});
@override
State<_CartBar> createState() => _CartBarState();
}
class _CartBarState extends State<_CartBar>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 180),
vsync: this,
);
_scaleAnim = Tween<double>(begin: 1.0, end: 0.9).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void didUpdateWidget(covariant _CartBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.itemCount > oldWidget.itemCount) {
_controller.forward().then((_) => _controller.reverse());
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
return Material(
color: kSurface,
child: InkWell(
onTap: widget.onTap,
child: ScaleTransition(
scale: _scaleAnim,
child: Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: kBorder)),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: kAccent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.shopping_cart_outlined, color: kAccent, size: 20),
),
const SizedBox(width: 10),
Text('${widget.itemCount} item',
style: const TextStyle(
fontWeight: FontWeight.w700, fontSize: 15)),
const Spacer(),
Text(formatRupiah(state.total),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: kAccent)),
const SizedBox(width: 8),
Icon(Icons.expand_less, color: kMuted.withValues(alpha: 0.7)),
],
),
),
),
),
);
}
}
class _CartSheetContent extends StatelessWidget {
final ScrollController scrollController;
final VoidCallback onPay;
const _CartSheetContent(
{required this.scrollController, required this.onPay});
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
final items = state.cart;
final children = <Widget>[
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: kBorder,
borderRadius: BorderRadius.circular(4),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
const Icon(Icons.shopping_cart_outlined, color: kAccent),
const SizedBox(width: 8),
const Text('Keranjang',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 17)),
const Spacer(),
if (items.isNotEmpty)
OutlinedButton(
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
icon: Icon(Icons.delete_forever_outlined, color: kDanger, size: 28),
title: const Text('Kosongkan Keranjang?'),
content: const Text(
'Semua item di keranjang akan dihapus. Tindakan ini tidak dapat dibatalkan.',
),
actions: [
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx, false),
style: OutlinedButton.styleFrom(
foregroundColor: kMuted,
side: const BorderSide(color: kBorder),
),
child: const Text('Batal'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: kDanger,
),
child: const Text('Kosongkan'),
),
),
],
),
);
if (confirm == true && context.mounted) {
state.clearCart();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Keranjang dikosongkan'),
backgroundColor: kSuccess,
),
);
}
},
style: OutlinedButton.styleFrom(
foregroundColor: kDanger,
side: const BorderSide(color: kDanger),
),
child: const Text('Kosongkan'),
),
],
),
),
const Divider(height: 12),
];
if (items.isEmpty) {
children.add(Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Column(
children: [
Icon(Icons.shopping_cart_outlined, size: 48, color: kMuted),
const SizedBox(height: 12),
Text('Keranjang kosong', style: TextStyle(color: kMuted)),
],
),
),
));
} else {
for (var i = 0; i < items.length; i++) {
final line = items[i];
children.add(Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
children: [
_KasirAvatar(product: line.product, size: 36, radius: 18),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(line.product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600)),
),
if (line.product.id.startsWith('manual-'))
Container(
margin: const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: kAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: kAccent.withValues(alpha: 0.4)),
),
child: Text(
'MANUAL',
style: TextStyle(
color: kAccent,
fontSize: 9,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
),
),
],
),
const SizedBox(height: 2),
Text(
'${formatRupiah(line.price)} /pcs',
style: const TextStyle(fontSize: 11, color: kMuted),
),
],
),
),
if (state.resellerEnabled) InkWell(
onTap: () => state.toggleLineMode(line.product.id),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: line.isReseller
? kAccent.withValues(alpha: 0.15)
: kSurface2,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: line.isReseller ? kAccent : kBorder,
),
),
child: Text(
line.isReseller ? 'Reseller' : 'Umum',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: line.isReseller ? kAccent : kMuted),
),
),
),
const SizedBox(width: 8),
Container(
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: kBorder),
),
child: Row(
children: [
_QtyButton(
icon: Icons.remove,
onTap: () => state.changeQty(line.product.id, -1),
),
SizedBox(
width: 32,
child: Text('${line.qty}',
textAlign: TextAlign.center,
style:
const TextStyle(fontWeight: FontWeight.w700)),
),
_QtyButton(
icon: Icons.add,
onTap: () => state.changeQty(line.product.id, 1),
),
],
),
),
],
),
));
if (i < items.length - 1) {
children.add(const Divider(height: 1, indent: 16, endIndent: 16));
}
}
children.add(const SizedBox(height: 12));
children.add(Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Subtotal', style: TextStyle(color: kMuted)),
Text(formatRupiah(state.subtotal),
style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Diskon', style: TextStyle(color: kMuted)),
SizedBox(
width: 140,
child: TextField(
keyboardType: TextInputType.number,
textAlign: TextAlign.right,
inputFormatters: [RupiahInputFormatter()],
style: const TextStyle(fontSize: 14),
onChanged: (v) =>
state.setDiscount((RupiahInputFormatter.getRawValue(v) ?? 0).toDouble()),
decoration: const InputDecoration(
isDense: true,
prefixText: 'Rp ',
contentPadding: EdgeInsets.symmetric(horizontal: 10),
),
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total',
style:
TextStyle(fontSize: 16, fontWeight: FontWeight.w800)),
Text(formatRupiah(state.total),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: kAccent)),
],
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: state.canCheckout ? onPay : null,
icon: const Icon(Icons.point_of_sale),
label: const Text('Bayar'),
style: FilledButton.styleFrom(
backgroundColor: kSuccess,
foregroundColor: const Color(0xFF06210F),
),
),
),
],
),
));
}
return SingleChildScrollView(
controller: scrollController,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
);
}
}
class _Chip extends StatelessWidget {
final String label;
final VoidCallback onTap;
const _Chip(this.label, this.onTap);
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: kBorder),
),
child: Text(label,
style: const TextStyle(
color: kAccent, fontWeight: FontWeight.w600, fontSize: 13)),
),
);
}
}
class _PaymentMethodChip extends StatelessWidget {
final String label;
final IconData icon;
final bool selected;
final VoidCallback? onTap;
const _PaymentMethodChip({
required this.label,
required this.icon,
required this.selected,
this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: selected ? kAccent.withValues(alpha: 0.18) : kSurface2,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? kAccent : kBorder,
width: selected ? 1.8 : 1,
),
),
child: Column(
children: [
Icon(icon,
size: 22,
color: selected ? kAccent : kMuted),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? kAccent : kText,
),
),
],
),
),
);
}
}
String _paymentMethodLabel(String method) {
switch (method) {
case 'transfer':
return 'Bank';
case 'qris':
return 'QRIS';
case 'split':
return 'Split Payment';
default:
return 'Tunai';
}
}
class _ReceiptRow extends StatelessWidget {
final String label;
final String value;
final bool bold;
const _ReceiptRow(this.label, this.value, this.bold);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label,
style: TextStyle(
color: bold ? kText : kMuted,
fontWeight: bold ? FontWeight.w700 : FontWeight.normal)),
Text(value,
style: TextStyle(
fontSize: bold ? 17 : 14,
fontWeight: bold ? FontWeight.w800 : FontWeight.w600,
color: bold ? kAccent : kText,
)),
],
),
);
}
}
// Avatar foto produk kasir (56px default) atau huruf inisial.
class _KasirAvatar extends StatelessWidget {
final Product product;
final double size;
final double? radius;
const _KasirAvatar({required this.product, this.size = 56, this.radius});
@override
Widget build(BuildContext context) {
final url = productImageUrl(product.imagePath);
final r = radius ?? 12;
if (url != null) {
return ClipRRect(
borderRadius: BorderRadius.circular(r),
child: Image.network(
url,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_KasirInitial(product: product, size: size, radius: r),
),
);
}
return _KasirInitial(product: product, size: size, radius: r);
}
}
class _KasirInitial extends StatelessWidget {
final Product product;
final double size;
final double radius;
const _KasirInitial(
{required this.product, required this.size, this.radius = 12});
@override
Widget build(BuildContext context) {
final initial =
product.name.isNotEmpty ? product.name[0].toUpperCase() : '?';
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: productAccent(product.name).withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(radius),
),
alignment: Alignment.center,
child: Text(
initial,
style: TextStyle(
color: productAccent(product.name),
fontWeight: FontWeight.bold,
fontSize: size * 0.42,
),
),
);
}
}
// Kartu grid kasir.
class _KasirCard extends StatelessWidget {
final Product product;
final double price;
final bool inCart;
final VoidCallback onTap;
const _KasirCard({
super.key,
required this.product,
required this.price,
required this.inCart,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: inCart ? kSuccess.withValues(alpha: 0.12) : kSurface,
borderRadius: BorderRadius.circular(kRadius),
border: Border.all(
color: inCart ? kSuccess : kBorder, width: inCart ? 1.5 : 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_KasirAvatar(product: product, size: 40),
const Spacer(),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: product.stock > 0
? kSuccess.withValues(alpha: 0.15)
: kDanger.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'Stok ${product.stock}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: product.stock > 0 ? kSuccess : kDanger,
),
),
),
],
),
const SizedBox(height: 8),
Text(
product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: kText, fontWeight: FontWeight.w600, fontSize: 13),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: kAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Text(
formatRupiah(price),
style: const TextStyle(
color: kAccent,
fontWeight: FontWeight.bold,
fontSize: 13),
),
),
],
),
),
);
}
}
// Baris list kasir: foto 56px + nama/harga/stok + tombol tambah.
class _KasirListTile extends StatelessWidget {
final Product product;
final double price;
final bool inCart;
final VoidCallback onTap;
const _KasirListTile({
super.key,
required this.product,
required this.price,
required this.inCart,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: inCart ? kSuccess.withValues(alpha: 0.12) : kSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: inCart ? kSuccess : kBorder, width: inCart ? 1.5 : 1),
),
child: Row(
children: [
_KasirAvatar(product: product, size: 56),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: kText,
fontWeight: FontWeight.w600,
fontSize: 14),
),
const SizedBox(height: 4),
Text(
formatRupiah(price),
style: const TextStyle(
color: kAccent,
fontWeight: FontWeight.bold,
fontSize: 14),
),
const SizedBox(height: 4),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: product.stock > 0
? kSuccess.withValues(alpha: 0.15)
: kDanger.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'Stok ${product.stock}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: product.stock > 0 ? kSuccess : kDanger,
),
),
),
],
),
),
const SizedBox(width: 8),
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: inCart
? kSuccess.withValues(alpha: 0.2)
: kAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
inCart ? Icons.check : Icons.add,
color: inCart ? kSuccess : kAccent,
),
),
],
),
),
);
}
}
class _ProductCard extends StatelessWidget {
final Product product;
final double displayPrice;
final bool isReseller;
final VoidCallback onTap;
const _ProductCard({
required this.product,
required this.displayPrice,
required this.isReseller,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final out = product.stock <= 0;
return AbsorbPointer(
absorbing: out,
child: Opacity(
opacity: out ? 0.5 : 1,
child: InkWell(
onTap: out ? null : onTap,
borderRadius: BorderRadius.circular(kRadius),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(kRadius),
border: Border.all(color: kBorder),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 8,
offset: Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_KasirAvatar(product: product, size: 36, radius: 18),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: out
? kDanger.withValues(alpha: 0.15)
: kSuccess.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: Text(
out ? 'Habis' : 'Stok ${product.stock}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: out ? kDanger : kSuccess,
),
),
),
],
),
const Spacer(),
Text(
product.name,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 14),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: kAccent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(
formatRupiah(displayPrice),
style: TextStyle(
color: kAccent,
fontWeight: FontWeight.w800,
fontSize: 13,
),
),
),
if (isReseller) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: kAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: const Text('Reseller',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: kAccent)),
),
],
],
),
],
),
),
),
),
);
}
}
class _QtyButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
const _QtyButton({required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(22),
child: SizedBox(
width: 44,
height: 44,
child: Icon(icon, size: 20, color: kAccent),
),
);
}
}