pos changes
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user