Files
nearle_pos/lib/presentation/payment/screens/payment_screen.dart

702 lines
24 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 '../../../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<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),
_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: _amountTender(controller, state),
);
}
/// 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
/// gets that field below the keypad.
Widget _amountTender(PaymentController controller, PaymentState state) {
final cash = state.activeMethod.needsChange;
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,
),
),
),
],
),
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),
),
// 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),
],
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),
],
);
}
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 _splitButton(PaymentController controller, {double? amount}) {
return OutlinedButton.icon(
onPressed: controller.balanceDue > 0
? () {
controller.addSplit(
amount: 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: 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: [
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,
),
),
],
),
),
),
);
}
}