428 lines
13 KiB
Dart
428 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../../core/constants/app_constants.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/extensions.dart';
|
|
import '../../../core/utils/formatters.dart';
|
|
import '../../../core/widgets/empty_state.dart';
|
|
import '../../../core/widgets/primary_button.dart';
|
|
import '../../../domain/entities/cart.dart';
|
|
import '../../customer/widgets/customer_capture_sheet.dart';
|
|
import '../providers/cart_controller.dart';
|
|
import 'cart_line_tile.dart';
|
|
import 'discount_sheet.dart';
|
|
|
|
/// Always-visible bill on the right of the dashboard.
|
|
class BillingPanel extends ConsumerWidget {
|
|
const BillingPanel({super.key, this.inSheet = false});
|
|
|
|
final bool inSheet;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final cart = ref.watch(cartControllerProvider);
|
|
final controller = ref.read(cartControllerProvider.notifier);
|
|
|
|
return Container(
|
|
color: AppColors.surface,
|
|
child: Column(children: [
|
|
_Header(cart: cart, inSheet: inSheet),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: cart.isEmpty
|
|
? const EmptyState(
|
|
title: 'Cart is empty',
|
|
message: 'Scan a barcode or tap a product to begin.',
|
|
emoji: '🛒',
|
|
compact: true,
|
|
)
|
|
: ListView.separated(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.lg,
|
|
vertical: AppSpacing.md,
|
|
),
|
|
itemCount: cart.lines.length,
|
|
separatorBuilder: (_, __) =>
|
|
const SizedBox(height: AppSpacing.sm),
|
|
itemBuilder: (_, i) {
|
|
// Newest line first mirrors what the cashier just scanned.
|
|
final line = cart.lines[cart.lines.length - 1 - i];
|
|
return CartLineTile(
|
|
key: ValueKey(line.product.id),
|
|
line: line,
|
|
onIncrement: () => controller.increment(line.product.id),
|
|
onDecrement: () => controller.decrement(line.product.id),
|
|
onRemove: () => controller.removeLine(line.product.id),
|
|
onDiscount: () =>
|
|
showLineDiscountSheet(context, ref, line),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (cart.isNotEmpty) _Summary(cart: cart),
|
|
_Actions(cart: cart),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Header extends ConsumerWidget {
|
|
const _Header({required this.cart, required this.inSheet});
|
|
|
|
final Cart cart;
|
|
final bool inSheet;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final controller = ref.read(cartControllerProvider.notifier);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppSpacing.lg,
|
|
AppSpacing.md,
|
|
AppSpacing.sm,
|
|
AppSpacing.md,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
'Cart',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: context.text.titleLarge,
|
|
),
|
|
),
|
|
if (cart.isNotEmpty) ...[
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primarySurface,
|
|
borderRadius: AppRadius.brPill,
|
|
),
|
|
child: Text(
|
|
'${cart.lineCount}',
|
|
style: const TextStyle(
|
|
color: AppColors.primary,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 12.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
const Spacer(),
|
|
// Icon-only actions: labelled buttons overflowed the 380px panel.
|
|
if (controller.canUndo)
|
|
_IconAction(
|
|
icon: Icons.undo_rounded,
|
|
tooltip: 'Undo (F8)',
|
|
color: AppColors.textSecondary,
|
|
onTap: controller.undo,
|
|
),
|
|
if (cart.isNotEmpty) ...[
|
|
_IconAction(
|
|
icon: Icons.pause_circle_outline_rounded,
|
|
tooltip: 'Park bill',
|
|
color: AppColors.warning,
|
|
onTap: () async {
|
|
await controller.park();
|
|
ref.invalidate(parkedBillsProvider);
|
|
if (context.mounted) context.showSnack('Bill parked');
|
|
},
|
|
),
|
|
_IconAction(
|
|
icon: Icons.delete_outline_rounded,
|
|
tooltip: 'Clear bill',
|
|
color: AppColors.danger,
|
|
onTap: controller.clear,
|
|
),
|
|
],
|
|
if (inSheet)
|
|
_IconAction(
|
|
icon: Icons.close_rounded,
|
|
tooltip: 'Close',
|
|
color: AppColors.textSecondary,
|
|
onTap: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Compact square action used in the bill header.
|
|
class _IconAction extends StatelessWidget {
|
|
const _IconAction({
|
|
required this.icon,
|
|
required this.tooltip,
|
|
required this.color,
|
|
required this.onTap,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String tooltip;
|
|
final Color color;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: IconButton(
|
|
onPressed: onTap,
|
|
icon: Icon(icon, size: 19),
|
|
color: color,
|
|
visualDensity: VisualDensity.compact,
|
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Summary extends ConsumerWidget {
|
|
const _Summary({required this.cart});
|
|
|
|
final Cart cart;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final controller = ref.read(cartControllerProvider.notifier);
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppSpacing.xl,
|
|
AppSpacing.lg,
|
|
AppSpacing.xl,
|
|
AppSpacing.md,
|
|
),
|
|
decoration: const BoxDecoration(
|
|
border: Border(top: BorderSide(color: AppColors.divider)),
|
|
),
|
|
child: Column(children: [
|
|
if (cart.pointsEarned > 0)
|
|
Container(
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.md,
|
|
vertical: AppSpacing.sm + 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.successSurface,
|
|
borderRadius: AppRadius.brSm,
|
|
),
|
|
child: Row(children: [
|
|
const Icon(Icons.stars_rounded,
|
|
size: 16, color: AppColors.success),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Text(
|
|
'This sale earns +${cart.pointsEarned} pts',
|
|
style: const TextStyle(
|
|
color: AppColors.success,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
|
|
_Row(label: 'Subtotal', value: Formatters.money(cart.subtotal)),
|
|
|
|
if (cart.membershipDiscountAmount > 0)
|
|
_Row(
|
|
label: '${cart.customer!.tier.label} discount',
|
|
value: '-${Formatters.money(cart.membershipDiscountAmount)}',
|
|
valueColor: AppColors.success,
|
|
),
|
|
|
|
_Row(
|
|
label: 'GST',
|
|
value: Formatters.money(cart.taxAmount),
|
|
hint: cart.taxBreakdown.keys.isEmpty
|
|
? null
|
|
: cart.taxBreakdown.keys
|
|
.map((r) => '${(r * 100).toStringAsFixed(0)}%')
|
|
.join(', '),
|
|
),
|
|
|
|
InkWell(
|
|
onTap: () => showBillDiscountSheet(context, ref),
|
|
borderRadius: AppRadius.brXs,
|
|
child: _Row(
|
|
label: 'Discount',
|
|
value: cart.manualBillDiscountAmount > 0
|
|
? '-${Formatters.money(cart.manualBillDiscountAmount)}'
|
|
: '-${Formatters.money(0)}',
|
|
valueColor: cart.manualBillDiscountAmount > 0
|
|
? AppColors.success
|
|
: null,
|
|
trailingIcon: Icons.edit_outlined,
|
|
),
|
|
),
|
|
|
|
if (cart.maxRedeemablePoints > 0 || cart.pointsRedeemed > 0)
|
|
InkWell(
|
|
onTap: () => cart.pointsRedeemed > 0
|
|
? controller.clearRedemption()
|
|
: controller.redeemAllPoints(),
|
|
borderRadius: AppRadius.brXs,
|
|
child: _Row(
|
|
label: cart.pointsRedeemed > 0
|
|
? 'Points redeemed (${cart.pointsRedeemed})'
|
|
: 'Redeem ${cart.maxRedeemablePoints} points',
|
|
value: cart.pointsRedeemed > 0
|
|
? '-${Formatters.money(cart.loyaltyRedemptionValue)}'
|
|
: 'Apply',
|
|
valueColor: AppColors.primary,
|
|
trailingIcon: cart.pointsRedeemed > 0
|
|
? Icons.close_rounded
|
|
: Icons.add_rounded,
|
|
),
|
|
),
|
|
|
|
if (cart.roundOff != 0)
|
|
_Row(
|
|
label: 'Round Off',
|
|
value: '${cart.roundOff >= 0 ? '+' : ''}'
|
|
'${Formatters.money(cart.roundOff)}',
|
|
),
|
|
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: AppSpacing.md),
|
|
child: Divider(height: 1),
|
|
),
|
|
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('Total', style: context.text.titleLarge),
|
|
Text(
|
|
Formatters.money(cart.grandTotal),
|
|
style: AppTypography.money(26, color: AppColors.primary),
|
|
),
|
|
],
|
|
),
|
|
|
|
if (cart.totalSavings > 0)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: AppSpacing.xs),
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Text(
|
|
'You saved ${Formatters.money(cart.totalSavings)}',
|
|
style: const TextStyle(
|
|
color: AppColors.success,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Row extends StatelessWidget {
|
|
const _Row({
|
|
required this.label,
|
|
required this.value,
|
|
this.valueColor,
|
|
this.hint,
|
|
this.trailingIcon,
|
|
});
|
|
|
|
final String label;
|
|
final String value;
|
|
final Color? valueColor;
|
|
final String? hint;
|
|
final IconData? trailingIcon;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
|
|
child: Row(children: [
|
|
Flexible(
|
|
child: Text(
|
|
label,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
if (hint != null) ...[
|
|
const SizedBox(width: AppSpacing.xs),
|
|
Text('($hint)',
|
|
style: const TextStyle(
|
|
fontSize: 11.5,
|
|
color: AppColors.textTertiary,
|
|
)),
|
|
],
|
|
const SizedBox(width: AppSpacing.sm),
|
|
const Spacer(),
|
|
Text(
|
|
value,
|
|
style: AppTypography.money(
|
|
14.5,
|
|
weight: FontWeight.w600,
|
|
color: valueColor ?? AppColors.textPrimary,
|
|
),
|
|
),
|
|
if (trailingIcon != null) ...[
|
|
const SizedBox(width: AppSpacing.xs),
|
|
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
|
|
],
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Actions extends ConsumerWidget {
|
|
const _Actions({required this.cart});
|
|
|
|
final Cart cart;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final enabled = cart.isNotEmpty;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppSpacing.xl,
|
|
AppSpacing.sm,
|
|
AppSpacing.xl,
|
|
AppSpacing.xl,
|
|
),
|
|
child: PrimaryButton(
|
|
label: 'CHARGE',
|
|
large: true,
|
|
onPressed: enabled
|
|
? () async {
|
|
// Ask once per bill, before payment. Skipping is one tap and
|
|
// leaves the sale as walk-in.
|
|
if (ref.read(cartControllerProvider).customer == null) {
|
|
await showCustomerCaptureSheet(context);
|
|
}
|
|
if (context.mounted) context.push(AppRoutes.payment);
|
|
}
|
|
: null,
|
|
trailing: enabled
|
|
? Text(
|
|
Formatters.money(cart.grandTotal),
|
|
style: AppTypography.money(21, color: Colors.white),
|
|
)
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
}
|