Files
app-grosir/lib/screens/kasir_screen.dart
2026-07-23 22:12:32 +07:00

1451 lines
59 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 '../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;
@override
void dispose() {
_searchDebounce?.cancel();
_search.dispose();
super.dispose();
}
@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: 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,
),
),
),
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: products.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.search_off, size: 48, color: kMuted),
SizedBox(height: 8),
Text('Produk tidak ditemukan',
style: TextStyle(color: kMuted)),
],
),
)
: RefreshIndicator(
onRefresh: () async {
context.read<AppState>().recordManualRefresh();
await context.read<AppState>().refresh();
},
color: kAccent,
child: GridView.builder(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.1,
),
itemCount: products.length,
itemBuilder: (context, i) => _ProductCard(
product: products[i],
displayPrice:
isReseller ? products[i].priceReseller : products[i].price,
isReseller: isReseller,
onTap: () {
HapticFeedback.lightImpact();
state.addToCart(products[i]);
},
),
),
),
),
if (state.cart.isNotEmpty)
_CartBar(
onTap: () => _openCartSheet(context, state),
itemCount: state.cart.fold(0, (s, c) => s + c.qty),
),
],
);
}
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) {
return StatefulBuilder(
builder: (context, setDialog) {
final isPayingRef = [false];
DateTime? payingStart;
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);
}
} 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) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
contentPadding: const EdgeInsets.all(20),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircleAvatar(
radius: 26,
backgroundColor: kSuccess,
child: Icon(Icons.check, color: Colors.white, size: 30),
),
const SizedBox(height: 10),
const Text('Pembayaran Berhasil',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(
'${formatTanggal(sale.date)} ${formatWaktu(sale.date)}',
style: const TextStyle(color: kMuted, fontSize: 12),
),
const SizedBox(height: 4),
Text('#${sale.id.toUpperCase()}',
style: const TextStyle(color: kMuted, fontSize: 11)),
const Divider(height: 24),
...sale.items.map((it) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text('${it.name} x${it.qty}',
style: const TextStyle(fontSize: 14)),
),
Text(formatRupiah(it.subtotal),
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: it.isReseller
? kAccent.withValues(alpha: 0.15)
: kMuted.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
it.isReseller ? 'Reseller' : 'Umum',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: it.isReseller ? kAccent : kMuted,
),
),
),
],
),
)),
const SizedBox(height: 4),
Text(
'Kasir: ${context.read<AppState>().getProfileName(sale.userId)}',
style: const TextStyle(color: kMuted, fontSize: 12),
),
if (sale.discount > 0)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
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: 24),
_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: 16),
const Text('Terima kasih!',
style: TextStyle(color: kMuted)),
],
),
),
actions: [
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Tutup'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
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: context.read<AppState>().getProfileName(sale.userId),
whatsapp: prefs.getString('store_whatsapp'),
notes: prefs.getString('store_notes'),
);
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text(err == null
? 'Struk dikirim ke printer'
: 'Gagal cetak: $err'),
backgroundColor: err == null ? kSuccess : kDanger,
),
);
}
},
icon: const Icon(Icons.print_outlined),
label: const Text('Cetak Struk'),
),
),
],
);
},
);
}
}
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: [
CircleAvatar(
radius: 18,
backgroundColor: kAccent.withValues(alpha: 0.15),
child: Text(
line.product.name.isNotEmpty
? line.product.name[0].toUpperCase()
: '?',
style: TextStyle(
color: kAccent,
fontWeight: FontWeight.w700,
fontSize: 13),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(line.product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600)),
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,
)),
],
),
);
}
}
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: [
CircleAvatar(
radius: 18,
backgroundColor: kAccent.withValues(alpha: 0.15),
child: Text(
product.name.isNotEmpty
? product.name[0].toUpperCase()
: '?',
style: TextStyle(
color: kAccent,
fontWeight: FontWeight.w700,
),
),
),
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),
),
);
}
}