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 createState() => _KasirScreenState(); } class _KasirScreenState extends State { 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(); 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(); final isInitialProductsLoading = state.productsLoading && !state.dataReady; 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>( context: context, builder: (ctx) { 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: () => 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: () { 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: 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 (context.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( 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: isInitialProductsLoading ? const _ProductsLoading() : products.isEmpty ? state.productsError != null ? _EmptyState( icon: Icons.cloud_off_outlined, title: 'Gagal memuat produk', subtitle: 'Periksa koneksi lalu coba lagi. Detail: ${state.productsError}', actionLabel: 'Coba lagi', ) : _EmptyState( icon: state.products.isEmpty ? Icons.inventory_2_outlined : Icons.search_off, title: state.products.isEmpty ? 'Belum ada produk' : 'Tidak ditemukan', subtitle: state.products.isEmpty ? 'Tambahkan produk lewat tab Produk.' : 'Coba kata kunci lain.', ) : _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 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 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.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(); 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 _scaleAnim; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 180), vsync: this, ); _scaleAnim = Tween(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(); 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(); final items = state.cart; final children = [ 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( 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 _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), ), ); } } class _EmptyState extends StatelessWidget { final IconData icon; final String title; final String subtitle; final String? actionLabel; const _EmptyState({ required this.icon, required this.title, required this.subtitle, this.actionLabel, }); @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 56, color: kMuted), const SizedBox(height: 16), Text( title, style: const TextStyle( color: kText, fontSize: 16, fontWeight: FontWeight.w700, ), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( subtitle, style: const TextStyle(color: kMuted, fontSize: 13), textAlign: TextAlign.center, ), if (actionLabel != null) ...[ const SizedBox(height: 16), OutlinedButton.icon( onPressed: () => context.read().retryProducts(), icon: const Icon(Icons.refresh), label: Text(actionLabel!), ), ], ], ), ), ); } } class _ProductsLoading extends StatelessWidget { const _ProductsLoading(); @override Widget build(BuildContext context) { return ListView.separated( padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), itemCount: 6, separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, i) { return Container( height: 72, decoration: BoxDecoration( color: kSurface2, borderRadius: BorderRadius.circular(12), border: Border.all(color: kBorder), ), child: Row( children: [ Container( width: 56, height: 56, margin: const EdgeInsets.all(8), decoration: BoxDecoration( color: kBorder, borderRadius: BorderRadius.circular(8), ), ), const Expanded( child: Center( child: SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2, color: kAccent, ), ), ), ), ], ), ); }, ); } }