Files
nearle_pos/lib/presentation/payment/screens/payment_screen.dart
2026-08-07 15:31:20 +05:30

1128 lines
38 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/providers.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/glass_card.dart';
import '../../../core/widgets/numeric_keypad.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/promo.dart';
import '../../../domain/entities/transaction.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../../pos/providers/cart_controller.dart';
import '../providers/payment_controller.dart';
/// Tender capture and sale completion.
///
/// Customer identification happens here rather than at the start of the sale:
/// a mobile number is all that is asked for, and the step can be skipped.
class PaymentScreen extends ConsumerStatefulWidget {
const PaymentScreen({super.key});
/// Below this the two columns stack into one scrolling page.
static const double twoColumnAbove = 1080;
@override
ConsumerState<PaymentScreen> createState() => _PaymentScreenState();
}
class _PaymentScreenState extends ConsumerState<PaymentScreen> {
String _cashBuffer = '';
void _syncCash() {
ref
.read(paymentControllerProvider.notifier)
.setCashTendered(double.tryParse(_cashBuffer) ?? 0);
}
void _appendCash(String d) {
if (d == '.' && _cashBuffer.contains('.')) return;
if (_cashBuffer.length >= 8) return;
setState(() => _cashBuffer += d);
_syncCash();
}
void _backspaceCash() {
if (_cashBuffer.isEmpty) return;
setState(
() => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1),);
_syncCash();
}
void _setCash(double value) {
setState(() => _cashBuffer = value.toStringAsFixed(0));
_syncCash();
}
Future<void> _confirm() async {
final result = await ref.read(paymentControllerProvider.notifier).confirm();
if (result == null || !mounted) return;
ref.read(cartControllerProvider.notifier).reset();
context.go(AppRoutes.receipt, extra: result.transaction);
}
@override
Widget build(BuildContext context) {
final cart = ref.watch(cartControllerProvider);
final state = ref.watch(paymentControllerProvider);
final controller = ref.read(paymentControllerProvider.notifier);
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: const Text('Payment'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => context.pop(),
tooltip: 'Back to bill',
),
),
body: LayoutBuilder(
builder: (context, constraints) {
final twoColumn = constraints.maxWidth >= PaymentScreen.twoColumnAbove;
final pad =
constraints.maxWidth < 700 ? AppSpacing.lg : AppSpacing.xxl;
final left = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_amountCard(controller, state, cart.grandTotal, cart.lineCount),
const SizedBox(height: AppSpacing.md),
_customerCard(),
const SizedBox(height: AppSpacing.md),
// Only builds anything when a campaign is on the bill or one is
// within reach, so a shop running none sees no empty card.
_offersCard(),
_methodsCard(controller, state),
const SizedBox(height: AppSpacing.md),
// Carries the rest of the column's height, and answers the
// question a customer asks at the counter — what am I paying
// for — without going back to the bill.
_summaryCard(),
],
);
final right = _tenderCard(controller, state);
if (!twoColumn) {
return SingleChildScrollView(
padding: EdgeInsets.all(pad),
child: Column(
children: [left, const SizedBox(height: AppSpacing.md), right],
),
);
}
// One scroll view over both columns, equal flex, centred and capped.
//
// Two independent scrollers with a 4:5 split were what made this read
// as lopsided: the columns started at different widths, ended at
// different heights, and the whole thing sat against the top-left of
// a much larger window. The minimum height fills the viewport so the
// pair sits in the middle of the screen instead of clinging to the
// top edge, and the cap stops the cards stretching into bands on a
// wide till display.
final minHeight = constraints.maxHeight.isFinite
? (constraints.maxHeight - pad * 2).clamp(0.0, double.infinity)
: 0.0;
return SingleChildScrollView(
padding: EdgeInsets.all(pad),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minHeight),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1340),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: left),
const SizedBox(width: AppSpacing.lg),
Expanded(child: right),
],
),
),
),
),
);
},
),
bottomNavigationBar: _bottomBar(controller, state, cart.grandTotal),
);
}
// ------------------------------------------------------------ Amount due
Widget _amountCard(
PaymentController controller,
PaymentState state,
double total,
int lineCount,
) {
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.xl),
radius: AppRadius.xl,
tinted: true,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Amount due',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
letterSpacing: 0.4,
),
),
const SizedBox(height: AppSpacing.xs),
// Scales instead of clipping when the amount runs long.
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
Formatters.money(controller.balanceDue),
style: AppTypography.money(38, color: AppColors.primary),
),
),
const SizedBox(height: AppSpacing.md),
Wrap(
alignment: WrapAlignment.center,
spacing: AppSpacing.lg,
runSpacing: AppSpacing.xs,
children: [
_mini('Items', '$lineCount'),
_mini('Bill total', Formatters.money(total)),
if (state.settled > 0)
_mini('Settled', Formatters.money(state.settled)),
],
),
],
),
);
}
// -------------------------------------------------------------- Customer
Widget _customerCard() {
final cart = ref.watch(cartControllerProvider);
final customer = cart.customer;
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl,
onTap: () => showCustomerCaptureSheet(context),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: customer == null
? AppColors.surfaceAlt
: AppColors.primarySurface,
child: customer == null
? const Icon(Icons.person_add_alt_1_outlined,
size: 19, color: AppColors.textSecondary,)
: Text(
Formatters.initials(customer.name),
style: const TextStyle(
color: AppColors.primary,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Flexible(
child: Text(
customer?.name ?? 'Walk-in customer',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 1),
Text(
customer == null
? 'Add a mobile number for loyalty — or skip'
: '${Formatters.mobile(customer.mobile)} · earns '
'+${cart.pointsEarned} pts',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
TextButton(
onPressed: () => showCustomerCaptureSheet(context),
style: TextButton.styleFrom(
minimumSize: const Size(0, 38),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
),
child: Text(customer == null ? 'Add' : 'Change'),
),
],
),
);
}
// --------------------------------------------------------------- Methods
Widget _methodsCard(PaymentController controller, PaymentState state) {
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Payment method',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: AppSpacing.md),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
for (final m in PaymentMethod.values)
if (m != PaymentMethod.loyalty)
_MethodTile(
method: m,
selected: state.activeMethod == m,
onTap: () {
setState(() => _cashBuffer = '');
controller.selectMethod(m);
},
),
],
),
],
),
);
}
// ---------------------------------------------------------------- Tender
Widget _tenderCard(PaymentController controller, PaymentState state) {
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl,
child: _amountTender(controller, state),
);
}
/// Amount entry for whichever method is active.
///
/// Every method works the same way — a keypad, an Exact shortcut and an
/// explicit amount — rather than cash alone asking what was received while
/// card/UPI/wallet silently assumed the full balance. Cash additionally gets
/// denomination shortcuts and a change-due row, since only cash can be
/// over-tendered; a method that captures a reference (card, UPI, gift card)
/// gets that field below the keypad.
Widget _amountTender(PaymentController controller, PaymentState state) {
final cash = state.activeMethod.needsChange;
return LayoutBuilder(
builder: (context, box) {
// Wide enough to stand the shortcuts beside the keypad instead of
// above it. A centred 330px keypad in a 560px column was the other
// half of the lopsided look — the space beside it did nothing.
final sideBySide = cash && box.maxWidth >= 500;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(methodIcon(state.activeMethod),
size: 20, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
cash
? 'Cash received'
: '${state.activeMethod.label} amount received',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: AppSpacing.md),
_amountField(),
const SizedBox(height: AppSpacing.md),
if (!sideBySide) ...[
_shortcutWrap(controller, cash),
const SizedBox(height: AppSpacing.md),
],
if (cash) ...[
_changeRow(controller.changeDue),
const SizedBox(height: AppSpacing.md),
],
if (sideBySide)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _shortcutColumn(controller)),
const SizedBox(width: AppSpacing.lg),
SizedBox(width: 296, child: _keypad()),
],
)
else
Center(child: _keypad()),
if (state.activeMethod.needsReference) ...[
const SizedBox(height: AppSpacing.md),
TextField(
onChanged: controller.setReference,
decoration: InputDecoration(
labelText: switch (state.activeMethod) {
PaymentMethod.card => 'Approval code',
PaymentMethod.upi => 'UPI transaction ID',
PaymentMethod.giftCard => 'Gift card number',
_ => 'Reference',
},
prefixIcon: const Icon(Icons.tag_rounded),
),
),
],
_partPaymentAction(controller, state),
_receivedSoFar(controller, state),
],
);
},
);
}
Widget _keypad() => NumericKeypad(
allowDecimal: true,
maxWidth: 330,
onKey: _appendCash,
onBackspace: _backspaceCash,
);
/// The typed amount.
///
/// The symbol sits in the same run as the digits and in the same style. It
/// used to be a separate, smaller, grey glyph, which rendered as a mismatched
/// mark floating beside the number rather than part of it.
Widget _amountField() {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
'${_cashBuffer.isEmpty ? '0' : _cashBuffer}',
style: AppTypography.money(30),
),
),
),
],
),
);
}
static const List<int> _notes = [50, 100, 200, 500, 2000];
/// Shortcuts above the keypad, for the narrow layout.
Widget _shortcutWrap(PaymentController controller, bool cash) {
return Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
ActionChip(
avatar: const Icon(Icons.done_all_rounded, size: 15),
label: const Text('Exact'),
onPressed: () => _setCash(controller.balanceDue),
),
// Denomination shortcuts only make sense for physical notes.
if (cash)
for (final note in _notes)
ActionChip(
label: Text('$note'),
onPressed: () =>
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
),
],
);
}
/// Shortcuts beside the keypad, for the wide layout.
Widget _shortcutColumn(PaymentController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.tonalIcon(
onPressed: () => _setCash(controller.balanceDue),
icon: const Icon(Icons.done_all_rounded, size: 17),
label: Text('Exact ${Formatters.money(controller.balanceDue)}'),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 48),
backgroundColor: AppColors.primarySurface,
foregroundColor: AppColors.primary,
),
),
const SizedBox(height: AppSpacing.sm),
for (final note in _notes) ...[
OutlinedButton(
onPressed: () =>
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 48),
foregroundColor: AppColors.textPrimary,
),
child: Text('+ ₹$note'),
),
if (note != _notes.last) const SizedBox(height: AppSpacing.sm),
],
],
);
}
Widget _changeRow(double change) {
final active = change > 0;
return AnimatedContainer(
duration: AppMotion.normal,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: active ? AppColors.successSurface : AppColors.surfaceAlt,
borderRadius: AppRadius.brMd,
),
child: Row(
children: [
Icon(
active
? Icons.currency_exchange_rounded
: Icons.info_outline_rounded,
size: 18,
color: active ? AppColors.success : AppColors.textTertiary,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'Change to return',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: active ? AppColors.success : AppColors.textSecondary,
),
),
),
const SizedBox(width: AppSpacing.sm),
Text(
Formatters.money(change),
style: AppTypography.money(
20,
color: active ? AppColors.success : AppColors.textTertiary,
),
),
],
),
);
}
/// Takes part of the bill on the current method.
///
/// This is the old "Add as split payment" button, and it stages exactly the
/// same tender — but it no longer asks the cashier to know what a split is,
/// or to press it for a payment that is not one. It appears only when the
/// typed amount is genuinely short of the balance, and says what it will do
/// in the customer's terms: take this much now, leave that much to pay.
Widget _partPaymentAction(
PaymentController controller,
PaymentState state,
) {
final entered = double.tryParse(_cashBuffer) ?? 0;
final due = controller.balanceDue;
final short = entered > 0.009 && entered < due - 0.009;
if (!short) return const SizedBox.shrink();
final rest = due - entered;
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.tonalIcon(
onPressed: () {
controller.addSplit(amount: entered);
setState(() => _cashBuffer = '');
},
icon: const Icon(Icons.add_rounded, size: 18),
label: Text(
'Take ${Formatters.money(entered)} by '
'${state.activeMethod.label}',
overflow: TextOverflow.ellipsis,
),
style: FilledButton.styleFrom(minimumSize: const Size(0, 48)),
),
const SizedBox(height: AppSpacing.xs),
Text(
'${Formatters.money(rest)} left to pay — pick another method for '
'the rest.',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.4,
),
),
],
),
);
}
/// Tenders already staged against this bill, and what is still outstanding.
Widget _receivedSoFar(PaymentController controller, PaymentState state) {
if (state.splits.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Divider(height: 1),
const SizedBox(height: AppSpacing.md),
Row(
children: [
const Expanded(
child: Text(
'Received so far',
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
),
),
TextButton(
onPressed: controller.clearSplits,
style: TextButton.styleFrom(
foregroundColor: AppColors.danger,
minimumSize: const Size(0, 32),
),
child: const Text('Clear'),
),
],
),
for (final e in state.splits.asMap().entries)
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: Row(
children: [
Icon(methodIcon(e.value.method),
size: 17, color: AppColors.textSecondary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
e.value.method.label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13.5),
),
),
Text(Formatters.money(e.value.amount),
style: AppTypography.money(13.5),),
IconButton(
onPressed: () => controller.removeSplit(e.key),
icon: const Icon(Icons.close_rounded, size: 16),
color: AppColors.textTertiary,
constraints:
const BoxConstraints(minWidth: 30, minHeight: 30),
padding: EdgeInsets.zero,
tooltip: 'Remove tender',
),
],
),
),
const SizedBox(height: AppSpacing.xs),
Row(
children: [
const Expanded(
child: Text(
'Still to pay',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
Text(
Formatters.money(controller.balanceDue),
style: AppTypography.money(
15,
color: controller.balanceDue > 0
? AppColors.warning
: AppColors.success,
),
),
],
),
],
),
);
}
// --------------------------------------------------------------- Offers
/// Campaigns on this bill, and the nearest one that is not on it yet.
///
/// The engine already applies everything that qualifies, silently — the
/// shopper only ever saw a discount line. Two things were missing at the
/// counter: a cashier could not answer "did the weekend offer come off?"
/// without opening the promo module, and nobody could see that a bill was a
/// few rupees short of one. The near-miss rows are the point of this card:
/// a minimum-bill campaign is worth nothing if the person paying is never
/// told they are close to it.
///
/// Read-only. Nothing here applies or removes a campaign — that stays with
/// [PromoEngine], so the till cannot be talked into a discount by hand.
Widget _offersCard() {
final cart = ref.watch(cartControllerProvider);
final promos = ref.watch(activePromosProvider).value ?? const <Promo>[];
final applied = cart.appliedPromos;
final appliedIds = applied.map((a) => a.promo.id).toSet();
final now = DateTime.now();
// Live today, not already firing, and gated only by a bill minimum this
// cart has not reached. A campaign that fails for any other reason —
// wrong category, wrong product, wrong day — is not "nearly earned" and
// saying so would be a false promise.
final withinReach = promos
.where((p) =>
p.isLiveAt(now) &&
!appliedIds.contains(p.id) &&
p.minBillValue > 0 &&
cart.subtotal < p.minBillValue,)
.toList()
..sort((a, b) => a.minBillValue.compareTo(b.minBillValue));
if (applied.isEmpty && withinReach.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.md),
child: GlassCard(
padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.sell_outlined,
size: 18, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
const Expanded(
child: Text(
'Offers',
style:
TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
if (applied.isNotEmpty)
Text(
' ${Formatters.money(
applied.fold<double>(0, (sum, a) => sum + a.amount),
)}',
style: AppTypography.money(14, color: AppColors.success),
),
],
),
const SizedBox(height: AppSpacing.md),
for (final a in applied)
_offerRow(
icon: Icons.check_circle_rounded,
tone: AppColors.success,
title: a.promo.name,
subtitle: a.promo.summary,
trailing: ' ${Formatters.money(a.amount)}',
),
// Two is the useful number: the next one to reach and the one
// after it. A full list turns a payment screen into a catalogue
// of things the shopper is not getting.
for (final p in withinReach.take(2))
_offerRow(
icon: Icons.lock_open_rounded,
tone: AppColors.warning,
title: p.name,
subtitle: '${p.summary} \u00b7 add '
'${Formatters.money(p.minBillValue - cart.subtotal)} more '
'to reach ${Formatters.money(p.minBillValue)}',
),
],
),
),
);
}
Widget _offerRow({
required IconData icon,
required Color tone,
required String title,
required String subtitle,
String? trailing,
}) =>
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 17, color: tone),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
height: 1.4,
),
),
],
),
),
if (trailing != null) ...[
const SizedBox(width: AppSpacing.sm),
Text(trailing, style: AppTypography.money(13.5, color: tone)),
],
],
),
);
// --------------------------------------------------------- Bill summary
/// What the amount due is made of.
Widget _summaryCard() {
final cart = ref.watch(cartControllerProvider);
final discounts = cart.lineDiscountTotal + cart.billDiscountTotal;
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Bill summary',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: AppSpacing.md),
_summaryRow('Subtotal', Formatters.money(cart.subtotal)),
if (discounts > 0)
_summaryRow(
'Discounts',
' ${Formatters.money(discounts)}',
tone: AppColors.success,
),
if (cart.loyaltyRedemptionValue > 0)
_summaryRow(
'Points redeemed',
' ${Formatters.money(cart.loyaltyRedemptionValue)}',
tone: AppColors.success,
),
if (cart.roundOff != 0)
_summaryRow('Round off', Formatters.money(cart.roundOff)),
const Divider(height: AppSpacing.xl),
_summaryRow(
'Total',
Formatters.money(cart.grandTotal),
strong: true,
),
const SizedBox(height: AppSpacing.xs),
// Prices are GST-inclusive, so this is a breakdown of the total
// rather than another line added to it — said plainly, because a
// customer reading a tax figure will otherwise try to add it on.
Text(
'Includes GST ${Formatters.money(cart.taxAmount)} '
'(CGST ${Formatters.money(cart.cgst)} + '
'SGST ${Formatters.money(cart.sgst)})',
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
height: 1.45,
),
),
],
),
);
}
Widget _summaryRow(
String label,
String value, {
Color? tone,
bool strong = false,
}) =>
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: strong ? 14.5 : 13.5,
fontWeight: strong ? FontWeight.w600 : FontWeight.w400,
color: strong
? AppColors.textPrimary
: (tone ?? AppColors.textSecondary),
),
),
),
Text(
value,
style: AppTypography.money(
strong ? 17 : 13.5,
color: tone ?? (strong ? AppColors.primary : null),
),
),
],
),
);
// ------------------------------------------------------------ Bottom bar
Widget _bottomBar(
PaymentController controller,
PaymentState state,
double total,
) {
// A cash sale cannot complete until the cashier says what was handed over.
final canComplete = controller.canConfirm;
final reason = controller.blockedReason;
return SafeArea(
child: Container(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
AppSpacing.md,
AppSpacing.xxl,
AppSpacing.lg,
),
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.border)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (state.error != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
margin: const EdgeInsets.only(bottom: AppSpacing.md),
decoration: const BoxDecoration(
color: AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(
children: [
const Icon(Icons.error_outline_rounded,
color: AppColors.danger, size: 18,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
state.error!,
style: const TextStyle(
color: AppColors.danger,
fontSize: 13,
),
),
),
],
),
).animate().shake(duration: 300.ms, hz: 3),
if (reason != null && state.error == null)
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Row(
children: [
const Icon(Icons.info_outline_rounded,
size: 15, color: AppColors.textTertiary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
reason,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textTertiary,
),
),
),
TextButton(
onPressed: () => _setCash(controller.balanceDue),
style: TextButton.styleFrom(
minimumSize: const Size(0, 30),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
),
child: const Text('Exact',
style: TextStyle(fontSize: 12.5),),
),
],
),
),
PrimaryButton(
label: 'Complete Sale',
icon: Icons.check_circle_outline_rounded,
large: true,
tone: ButtonTone.success,
busy: state.isProcessing,
onPressed: canComplete ? _confirm : null,
trailing: Text(
Formatters.money(total),
style: AppTypography.money(19, color: Colors.white),
),
),
],
),
),
);
}
Widget _mini(String label, String value) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
),
),
Text(value, style: AppTypography.money(13.5)),
],
);
}
class _MethodTile extends StatelessWidget {
const _MethodTile({
required this.method,
required this.selected,
required this.onTap,
});
final PaymentMethod method;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: selected ? AppColors.primary : AppColors.surfaceAlt,
borderRadius: AppRadius.brMd,
child: InkWell(
onTap: onTap,
borderRadius: AppRadius.brMd,
child: AnimatedContainer(
duration: AppMotion.fast,
width: 104,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
borderRadius: AppRadius.brMd,
border: Border.all(
color: selected ? AppColors.primary : AppColors.border,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
methodIcon(method),
size: 22,
color: selected ? Colors.white : AppColors.textSecondary,
),
const SizedBox(height: AppSpacing.xs),
Text(
method.label,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: selected ? Colors.white : AppColors.textPrimary,
),
),
],
),
),
),
);
}
}
/// A flat icon per tender type.
///
/// These were emoji — 💵 for cash, 💳 for card — which render as small
/// photographic pictures on most platforms and as a fallback box on some. Next
/// to Material iconography everywhere else on the screen they read as clip
/// art pasted into the UI rather than part of it, and the cash one in
/// particular looked like a picture of American banknotes on a rupee till.
IconData methodIcon(PaymentMethod method) => switch (method) {
PaymentMethod.cash => Icons.payments_outlined,
PaymentMethod.card => Icons.credit_card_rounded,
PaymentMethod.upi => Icons.qr_code_2_rounded,
PaymentMethod.wallet => Icons.account_balance_wallet_outlined,
PaymentMethod.giftCard => Icons.card_giftcard_rounded,
PaymentMethod.loyalty => Icons.stars_rounded,
};