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 createState() => _PaymentScreenState(); } class _PaymentScreenState extends ConsumerState { 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 _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 _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 []; 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(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, };