Bills were persisted correctly but read back wrong. The read path rebuilt a cart from its lines alone, dropping bill-level discounts and loyalty, so every figure derived from a stored bill was overstated: the upload payload, the day archive and the shift report. A discounted 529 bill read back as 620. Money and data integrity - order_dao: restore bill_discount and points_redeemed when rebuilding a cart; keep the reconstruction tier-less so the membership discount is not applied twice. Trust the recorded total and points via SaleTransaction.storedTotal. - checkout_sale + order_dao.commitSale: write the bill, its stock movement and the loyalty update in one transaction. Previously a failure part-way through left a persisted bill the cashier believed had failed, inviting a duplicate. - checkout_sale: re-check every line against live stock. A parked bill resumed after its stock was sold passed validation and oversold. - catalogue_dao: allocate the invoice sequence in one transaction; the previous read-modify-write could hand two sales the same number and fail UNIQUE. - local_store: replay unsynced sales after a catalogue import, so a mid-shift re-import cannot restore stock that has already been sold. - payment_controller: stamp the signed-in operator on the bill instead of the hardcoded seed session, and pass the terminal id through. - cart: reconcile per-slab GST against the bill total so the parts sum to the whole on a tax invoice. Sync and reporting - sync_repository: drain unsynced bills in a loop rather than silently capping at one page; stop on rejection so rejected rows cannot loop forever. - sync_log_dao (new): persist the sync history to the sync_log table, which the schema already defined but nothing used. It was in memory, so the only record that bills had been uploaded died at restart. - Scope shift reports by cashier. day_archive is re-keyed to (business_date, cashier_name) so a till stays settleable after its bills are uploaded and deleted. Schema v4 with a migration that carries v3 rows across. Input and UI - barcode_service: consume machine-paced keystrokes so a scan cannot also land in the focused field, and raise the bar to 60ms/char while a text field has focus so typing a mobile number is not read as a scan. Clock and focus check injected so the behaviour is testable. - primary_button: make the label flexible; label plus trailing total overflowed the Charge button by up to 131px. - app_router: redirect instead of null-casting when the receipt route is entered without its transaction. - customer_repository: reduce the search query to digits so a punctuated mobile number matches. Cleanup - Remove TransactionRepository.save, CustomerRepository.recordSale and OrderDao.insertOrder, all superseded by commitSale. - dart fix across the tree; 251 analyzer issues down to 3 info-level. Tests: 23 passing / 15 failing -> 90 passing. Fixed the two defects that broke the existing suite (containsAll type argument, reset() needing a catalogue) and deleted the leftover template test. Added coverage for the order round trip, the day archive after a real sync, stock safety, checkout atomicity, the v3->v4 migration, scanner-versus-human input, and an app-level smoke test that renders every module. Note: bills already uploaded with a discount went up overstated. This stops it happening again but does not correct historical server data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
728 lines
24 KiB
Dart
728 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: state.activeMethod.needsChange
|
|
? _cashTender(controller)
|
|
: _referenceTender(controller, state),
|
|
);
|
|
}
|
|
|
|
Widget _cashTender(PaymentController controller) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text(
|
|
'Cash received',
|
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.lg,
|
|
vertical: AppSpacing.md,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceAlt,
|
|
borderRadius: AppRadius.brLg,
|
|
border: Border.all(color: AppColors.border),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Text('₹',
|
|
style:
|
|
TextStyle(fontSize: 22, color: AppColors.textTertiary),),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Expanded(
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
_cashBuffer.isEmpty ? '0' : _cashBuffer,
|
|
style: AppTypography.money(28),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
Wrap(
|
|
spacing: AppSpacing.sm,
|
|
runSpacing: AppSpacing.sm,
|
|
children: [
|
|
ActionChip(
|
|
avatar: const Icon(Icons.done_all_rounded, size: 15),
|
|
label: const Text('Exact'),
|
|
onPressed: () => _setCash(controller.balanceDue),
|
|
),
|
|
for (final note in const [50, 100, 200, 500, 2000])
|
|
ActionChip(
|
|
label: Text('₹$note'),
|
|
onPressed: () =>
|
|
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
_changeRow(controller.changeDue),
|
|
const SizedBox(height: AppSpacing.md),
|
|
Center(
|
|
child: NumericKeypad(
|
|
allowDecimal: true,
|
|
maxWidth: 330,
|
|
onKey: _appendCash,
|
|
onBackspace: _backspaceCash,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _changeRow(double change) {
|
|
final active = change > 0;
|
|
return AnimatedContainer(
|
|
duration: AppMotion.normal,
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
decoration: BoxDecoration(
|
|
color: active ? AppColors.successSurface : AppColors.surfaceAlt,
|
|
borderRadius: AppRadius.brMd,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
active
|
|
? Icons.currency_exchange_rounded
|
|
: Icons.info_outline_rounded,
|
|
size: 18,
|
|
color: active ? AppColors.success : AppColors.textTertiary,
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Expanded(
|
|
child: Text(
|
|
'Change to return',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 13.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: active ? AppColors.success : AppColors.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Text(
|
|
Formatters.money(change),
|
|
style: AppTypography.money(
|
|
20,
|
|
color: active ? AppColors.success : AppColors.textTertiary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _referenceTender(PaymentController controller, PaymentState state) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(state.activeMethod.emoji,
|
|
style: const TextStyle(fontSize: 20),),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Expanded(
|
|
child: Text(
|
|
'${state.activeMethod.label} payment',
|
|
overflow: TextOverflow.ellipsis,
|
|
style:
|
|
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.xxl),
|
|
Center(
|
|
child: Container(
|
|
width: 104,
|
|
height: 104,
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.primarySurface,
|
|
borderRadius: AppRadius.brXl,
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(state.activeMethod.emoji,
|
|
style: const TextStyle(fontSize: 46),),
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
Text(
|
|
'Charge ${Formatters.money(controller.balanceDue)} on the '
|
|
'${state.activeMethod.label.toLowerCase()} terminal',
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 13.5,
|
|
color: AppColors.textSecondary,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.xxl),
|
|
if (state.activeMethod.needsReference)
|
|
TextField(
|
|
onChanged: controller.setReference,
|
|
decoration: InputDecoration(
|
|
labelText: switch (state.activeMethod) {
|
|
PaymentMethod.card => 'Approval code',
|
|
PaymentMethod.upi => 'UPI transaction ID',
|
|
PaymentMethod.giftCard => 'Gift card number',
|
|
_ => 'Reference',
|
|
},
|
|
prefixIcon: const Icon(Icons.tag_rounded),
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
_splitButton(controller),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _splitButton(PaymentController controller, {double? amount}) {
|
|
return OutlinedButton.icon(
|
|
onPressed: controller.balanceDue > 0
|
|
? () {
|
|
controller.addSplit(
|
|
amount: amount?.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,
|
|
),
|
|
),
|
|
),
|
|
if (state.activeMethod.needsChange)
|
|
TextButton(
|
|
onPressed: () => _setCash(controller.balanceDue),
|
|
style: TextButton.styleFrom(
|
|
minimumSize: const Size(0, 30),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.md,
|
|
),
|
|
),
|
|
child: const Text('Exact',
|
|
style: TextStyle(fontSize: 12.5),),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
PrimaryButton(
|
|
label: 'Complete Sale',
|
|
icon: Icons.check_circle_outline_rounded,
|
|
large: true,
|
|
tone: ButtonTone.success,
|
|
busy: state.isProcessing,
|
|
onPressed: canComplete ? _confirm : null,
|
|
trailing: Text(
|
|
Formatters.money(total),
|
|
style: AppTypography.money(19, color: Colors.white),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _mini(String label, String value) => Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
Text(value, style: AppTypography.money(13.5)),
|
|
],
|
|
);
|
|
}
|
|
|
|
class _MethodTile extends StatelessWidget {
|
|
const _MethodTile({
|
|
required this.method,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
final PaymentMethod method;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: selected ? AppColors.primary : AppColors.surfaceAlt,
|
|
borderRadius: AppRadius.brMd,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: AppRadius.brMd,
|
|
child: AnimatedContainer(
|
|
duration: AppMotion.fast,
|
|
width: 104,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.sm,
|
|
vertical: AppSpacing.md,
|
|
),
|
|
decoration: BoxDecoration(
|
|
borderRadius: AppRadius.brMd,
|
|
border: Border.all(
|
|
color: selected ? AppColors.primary : AppColors.border,
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(method.emoji, style: const TextStyle(fontSize: 22)),
|
|
const SizedBox(height: AppSpacing.xs),
|
|
Text(
|
|
method.label,
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: selected ? Colors.white : AppColors.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|