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 '../../../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 '../../../core/widgets/status_pill.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), _methodsCard(controller, state), ], ); final right = _tenderCard(controller, state); if (!twoColumn) { return SingleChildScrollView( padding: EdgeInsets.all(pad), child: Column( children: [left, const SizedBox(height: AppSpacing.md), right], ), ); } return Padding( padding: EdgeInsets.all(pad), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(flex: 4, child: SingleChildScrollView(child: left)), const SizedBox(width: AppSpacing.lg), Expanded(flex: 5, child: SingleChildScrollView(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, ), ), ), if (customer != null) ...[ const SizedBox(width: AppSpacing.sm), StatusPill.tier(customer.tier, dense: true), ], ], ), 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); }, ), ], ), if (state.splits.isNotEmpty) ...[ const Divider(height: AppSpacing.xxl), Row( children: [ const Expanded( child: Text( 'Split tenders', 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: [ Text(e.value.method.emoji, style: const TextStyle(fontSize: 15)), 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', ), ], ), ), ], ], ), ); } // ---------------------------------------------------------------- Tender Widget _tenderCard(PaymentController controller, PaymentState state) { return GlassCard( padding: const EdgeInsets.all(AppSpacing.lg), radius: AppRadius.xl, child: state.activeMethod.needsChange ? _cashTender(controller) : _referenceTender(controller, state), ); } Widget _cashTender(PaymentController controller) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ const Text( 'Cash received', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), ), const SizedBox(height: AppSpacing.md), 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: [ const Text('₹', style: TextStyle(fontSize: 22, color: AppColors.textTertiary)), const SizedBox(width: AppSpacing.sm), Expanded( child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text( _cashBuffer.isEmpty ? '0' : _cashBuffer, style: AppTypography.money(28), ), ), ), ], ), ), const SizedBox(height: AppSpacing.md), 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), ), for (final note in const [50, 100, 200, 500, 2000]) ActionChip( label: Text('₹$note'), onPressed: () => _setCash((double.tryParse(_cashBuffer) ?? 0) + note), ), ], ), const SizedBox(height: AppSpacing.md), _changeRow(controller.changeDue), const SizedBox(height: AppSpacing.md), Center( child: NumericKeypad( allowDecimal: true, maxWidth: 330, onKey: _appendCash, onBackspace: _backspaceCash, ), ), const SizedBox(height: AppSpacing.md), _splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0), ], ); } 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, ), ), ], ), ); } Widget _referenceTender(PaymentController controller, PaymentState state) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 20)), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( '${state.activeMethod.label} payment', overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), ), ), ], ), const SizedBox(height: AppSpacing.xxl), Center( child: Container( width: 104, height: 104, decoration: BoxDecoration( color: AppColors.primarySurface, borderRadius: AppRadius.brXl, ), alignment: Alignment.center, child: Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 46)), ), ), const SizedBox(height: AppSpacing.lg), Text( 'Charge ${Formatters.money(controller.balanceDue)} on the ' '${state.activeMethod.label.toLowerCase()} terminal', textAlign: TextAlign.center, style: const TextStyle( fontSize: 13.5, color: AppColors.textSecondary, height: 1.5, ), ), const SizedBox(height: AppSpacing.xxl), if (state.activeMethod.needsReference) 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), ), ), const SizedBox(height: AppSpacing.lg), _splitButton(controller), ], ); } Widget _splitButton(PaymentController controller, {double? amount}) { return OutlinedButton.icon( onPressed: controller.balanceDue > 0 ? () { controller.addSplit( amount: amount == null ? null : amount.clamp(0, controller.balanceDue).toDouble(), ); setState(() => _cashBuffer = ''); } : null, icon: const Icon(Icons.call_split_rounded, size: 17), label: const Text('Add as split payment'), style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)), ); } // ------------------------------------------------------------ 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: 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, ), ), ), if (state.activeMethod.needsChange) 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: [ Text(method.emoji, style: const TextStyle(fontSize: 22)), 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, ), ), ], ), ), ), ); } }