1451 lines
59 KiB
Dart
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),
|
|
),
|
|
);
|
|
}
|
|
}
|