1128 lines
38 KiB
Dart
1128 lines
38 KiB
Dart
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,
|
||
};
|