import 'dart:async'; import 'package:flutter/material.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_layout.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 'admin_pin_dialog.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: () => _removeLine( context, ref, controller, line.product.id, ), onDiscount: () => showLineDiscountSheet(context, ref, line), ); }, ), ), if (cart.isNotEmpty) _Summary(cart: cart), _Actions(cart: cart), ],), ); } } /// Once an item is on the bill, taking it back off needs an admin's /// approval — a cashier can always start an entirely new sale instead. This /// is the one gate both removal paths (a single line, or the whole cart) go /// through, so they can never drift out of sync with each other. Future _removeLine( BuildContext context, WidgetRef ref, CartController controller, String productId, ) async { final ok = await requireAdminPin( context, ref, reason: 'Removing a scanned item from the bill needs admin approval.', ); if (ok) controller.removeLine(productId); } Future _clearCart( BuildContext context, WidgetRef ref, CartController controller, ) async { final ok = await requireAdminPin( context, ref, reason: 'Clearing the whole bill needs admin approval.', ); if (ok) controller.clear(); } 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); // Same fixed height as the page header on the left, so the two bars // line up on one visual line instead of the cart title floating lower. final contentPadding = PosLayout.of(context).contentPadding; return Container( constraints: const BoxConstraints(minHeight: AppSizes.headerHeight), padding: EdgeInsets.symmetric(horizontal: contentPadding), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text( 'Cart', overflow: TextOverflow.ellipsis, style: context.text.titleLarge, ), ), if (cart.isNotEmpty) ...[ Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), decoration: const BoxDecoration( color: AppColors.primarySurface, borderRadius: AppRadius.brPill, ), child: Text( '${cart.lineCount}', style: const TextStyle( color: AppColors.primary, fontWeight: FontWeight.w700, fontSize: 12.5, ), ), ), ], SizedBox(), SizedBox(), Spacer(), // Icon-only actions: labelled buttons overflowed the 380px panel. // Grouped tight with even spacing, flush against the same right // edge the header buttons on the left use. Row( mainAxisSize: MainAxisSize.min, children: [ 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: () => _clearCart(context, ref, controller), ), ], 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: const 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, ), // Named individually rather than lumped into one "Promotions" line: // a shopper who came in for a specific offer needs to see it applied, // and a cashier being asked "did the weekend deal come off?" needs to // answer without opening a report. for (final applied in cart.appliedPromos) _Row( label: applied.promo.name, value: '-${Formatters.money(applied.amount)}', 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( mainAxisAlignment: MainAxisAlignment.spaceBetween, 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) ...[ 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); } // Navigation result is not needed here. if (context.mounted) unawaited(context.push(AppRoutes.payment)); } : null, trailing: enabled ? Text( Formatters.money(cart.grandTotal), style: AppTypography.money(21, color: Colors.white), ) : null, ), ); } }