pos changes

This commit is contained in:
2026-08-06 19:26:53 +05:30
parent cb065a0f69
commit eebd10da6d
42 changed files with 3451 additions and 2531 deletions

View File

@@ -3,6 +3,7 @@ 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';
@@ -12,6 +13,7 @@ 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/promo.dart';
import '../../../domain/entities/transaction.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../../pos/providers/cart_controller.dart';
@@ -97,7 +99,15 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
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(),
],
);
@@ -112,15 +122,36 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
);
}
return Padding(
// 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: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 4, child: SingleChildScrollView(child: left)),
const SizedBox(width: AppSpacing.lg),
Expanded(flex: 5, child: SingleChildScrollView(child: right)),
],
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),
],
),
),
),
),
);
},
@@ -289,57 +320,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
),
],
),
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',
),
],
),
),
],
],
),
);
@@ -354,118 +334,191 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
);
}
/// Amount entry for whichever method is active. Every method works the
/// same way now — 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
/// chips and a change-due row, since only cash can be over-tendered; a
/// method that captures a reference (card, UPI, gift card) additionally
/// 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: [
Row(
children: [
Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 20)),
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,
),
),
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,
),
],
),
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
child: Text('+ ₹$note'),
),
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),
),
// Denomination shortcuts only make sense for physical notes.
if (cash)
for (final note in const [50, 100, 200, 500, 2000])
ActionChip(
label: Text('$note'),
onPressed: () =>
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
),
],
),
if (cash) ...[
const SizedBox(height: AppSpacing.md),
_changeRow(controller.changeDue),
if (note != _notes.last) const SizedBox(height: AppSpacing.sm),
],
const SizedBox(height: AppSpacing.md),
Center(
child: NumericKeypad(
allowDecimal: true,
maxWidth: 330,
onKey: _appendCash,
onBackspace: _backspaceCash,
),
),
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),
),
),
],
const SizedBox(height: AppSpacing.md),
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
],
);
}
@@ -513,22 +566,380 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
);
}
Widget _splitButton(PaymentController controller, {double? amount}) {
return OutlinedButton.icon(
onPressed: controller.balanceDue > 0
? () {
controller.addSplit(
amount: amount?.clamp(0, controller.balanceDue).toDouble(),
);
/// 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 = '');
}
: 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)),
},
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,
@@ -679,7 +1090,11 @@ class _MethodTile extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(method.emoji, style: const TextStyle(fontSize: 22)),
Icon(
methodIcon(method),
size: 22,
color: selected ? Colors.white : AppColors.textSecondary,
),
const SizedBox(height: AppSpacing.xs),
Text(
method.label,
@@ -699,3 +1114,19 @@ class _MethodTile extends StatelessWidget {
);
}
}
/// 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,
};