1765 lines
72 KiB
Dart
1765 lines
72 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) {
|
|
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<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 _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),
|
|
),
|
|
);
|
|
}
|
|
}
|