import 'dart:async'; 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 '../../../app/providers.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/glass_card.dart'; import '../../../core/widgets/primary_button.dart'; import '../../../data/sync/sync_engine.dart'; import '../../../domain/entities/transaction.dart'; import '../../modules/providers/printer_settings.dart'; import '../../pos/providers/cart_controller.dart'; import '../../pos/providers/catalog_providers.dart'; import '../../sync/providers/sync_controller.dart'; import '../widgets/receipt_preview.dart'; /// Confirmation screen. /// /// The bill is written to SQLite the instant checkout completes, but is /// deliberately held back from the server for [AppConstants.postSaleResetDelay] /// — long enough to catch a mistake. Counts down and starts the next sale (and /// releases the bill to the sync engine) on its own so an unattended terminal /// never sits on a finished bill forever; "Cancel sale" inside the window /// voids it instead and hands the cart straight back. class ReceiptScreen extends ConsumerStatefulWidget { const ReceiptScreen({super.key, required this.transaction}); final SaleTransaction transaction; @override ConsumerState createState() => _ReceiptScreenState(); } class _ReceiptScreenState extends ConsumerState { late int _seconds = AppConstants.postSaleResetDelay.inSeconds; Timer? _timer; /// True while a cancellation is being written to disk — guards against a /// second tap voiding a sale that is already half-reversed. bool _voiding = false; @override void initState() { super.initState(); // Auto-print is opt-in and only fires when a printer was actually // selected, so it can never fail invisibly on a terminal with none. WidgetsBinding.instance.addPostFrameCallback((_) async { final settings = ref.read(printerSettingsProvider); if (!settings.autoPrint || !settings.hasPrinter) return; final ok = await ref.read(receiptServiceProvider).printDirect( widget.transaction, printerUrl: settings.printerUrl, ); if (!ok && mounted) { _cancelAutoReturn(); ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(const SnackBar( backgroundColor: AppColors.danger, content: Text( 'Printer did not respond — use Print to send it again.', ), ),); } }); _timer = Timer.periodic(const Duration(seconds: 1), (t) { if (!mounted) return; setState(() => _seconds--); if (_seconds <= 0) _newSale(); }); } @override void dispose() { _timer?.cancel(); super.dispose(); } void _cancelAutoReturn() { _timer?.cancel(); if (mounted) setState(() => _seconds = -1); } void _newSale() { _timer?.cancel(); // This is the one moment the bill is actually released to the server — // whether the window ran out on its own or New Sale was pressed early, // the cashier has let this bill stand. ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted); ref.read(cartControllerProvider.notifier).reset(); if (mounted) context.go(AppRoutes.pos); } /// Undoes the sale: deletes the order, puts the stock back, restores an /// attached shopper's loyalty balance, and hands the exact same cart back /// to the billing screen. Never touches the sync engine — this bill must /// never reach the server. Future _cancelSale() async { if (_voiding) return; setState(() => _voiding = true); _timer?.cancel(); final txn = widget.transaction; await ref.read(transactionRepositoryProvider).voidSale( transaction: txn, stockMovements: { for (final line in txn.cart.lines) line.product.id: line.quantity, }, ); ref.invalidate(allProductsProvider); ref.invalidate(visibleProductsProvider); ref.read(orderVersionProvider.notifier).state++; ref.read(cartControllerProvider.notifier).restore(txn.cart); if (mounted) context.go(AppRoutes.pos); } @override Widget build(BuildContext context) { final txn = widget.transaction; return Scaffold( backgroundColor: AppColors.background, body: SafeArea( child: Listener( onPointerDown: (_) => _cancelAutoReturn(), child: Padding( padding: const EdgeInsets.all(AppSpacing.xxl), child: context.isCompact ? SingleChildScrollView( child: Column(children: [ _summary(txn), const SizedBox(height: AppSpacing.lg), SizedBox( height: 480, child: ReceiptPreview(transaction: txn), ), ],), ) : Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded(flex: 5, child: _summary(txn)), const SizedBox(width: AppSpacing.xxl), Expanded( flex: 4, child: ReceiptPreview(transaction: txn), ), ], ), ), ), ), ); } Widget _summary(SaleTransaction txn) { return GlassCard( padding: const EdgeInsets.all(AppSpacing.xxxl), radius: AppRadius.xxl, shadows: AppColors.shadowMd, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: Container( width: 84, height: 84, decoration: const BoxDecoration( color: AppColors.successSurface, shape: BoxShape.circle, ), child: const Icon(Icons.check_rounded, size: 44, color: AppColors.success,), ) .animate() .scale( duration: 340.ms, curve: Curves.easeOutBack, begin: const Offset(0.5, 0.5), ) .fadeIn(), ), const SizedBox(height: AppSpacing.xl), Text('Sale complete', textAlign: TextAlign.center, style: context.text.headlineMedium,), const SizedBox(height: AppSpacing.xs), Text( '${txn.invoiceNumber} · ${Formatters.dateTime(txn.createdAt)}', textAlign: TextAlign.center, style: context.text.bodySmall, ), const SizedBox(height: AppSpacing.xxl), Container( padding: const EdgeInsets.all(AppSpacing.xl), decoration: const BoxDecoration( color: AppColors.primarySurface, borderRadius: AppRadius.brLg, ), child: Column(children: [ Text('Amount paid', style: context.text.labelMedium), Text( Formatters.money(txn.total), style: AppTypography.money(36, color: AppColors.primary), ), const SizedBox(height: AppSpacing.md), const Divider(), const SizedBox(height: AppSpacing.md), _row('Paid via', txn.paymentSummary), if (txn.changeDue > 0) _row('Change returned', Formatters.money(txn.changeDue), highlight: AppColors.success,), _row('Items', '${txn.cart.lineCount}'), if (txn.customer != null) ...[ _row('Customer', txn.customer!.name), _row('Points earned', '+${txn.pointsEarned}', highlight: AppColors.success,), if (txn.pointsRedeemed > 0) _row('Points redeemed', '-${txn.pointsRedeemed}'), ], if (txn.cart.totalSavings > 0) _row( 'Customer saved', Formatters.money(txn.cart.totalSavings), highlight: AppColors.success, ), ],), ), const SizedBox(height: AppSpacing.xxl), // Wrap, so three actions never overflow a narrow terminal. Wrap( spacing: AppSpacing.md, runSpacing: AppSpacing.md, children: [ SizedBox( width: 150, child: PrimaryButton( label: 'Print', icon: Icons.print_outlined, tone: ButtonTone.neutral, onPressed: () async { _cancelAutoReturn(); final settings = ref.read(printerSettingsProvider); final service = ref.read(receiptServiceProvider); // Straight to the roll when one is configured; otherwise // the system preview, which works with no hardware. final printed = settings.hasPrinter && await service.printDirect( txn, printerUrl: settings.printerUrl, ); if (!printed) await service.printPreview(txn); }, ), ), SizedBox( width: 170, child: PrimaryButton( label: 'WhatsApp', icon: Icons.chat_rounded, tone: txn.customer == null ? ButtonTone.neutral : ButtonTone.ghost, onPressed: txn.customer == null ? null : () async { _cancelAutoReturn(); final sent = await ref .read(receiptServiceProvider) .sendToWhatsApp(txn); if (!mounted || sent) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(const SnackBar( content: Text( 'Could not open WhatsApp on this device.', ), ),); }, ), ), SizedBox( width: 170, child: PrimaryButton( label: 'Share PDF', icon: Icons.ios_share_rounded, tone: ButtonTone.neutral, onPressed: () { _cancelAutoReturn(); ref.read(receiptServiceProvider).sharePdf(txn); }, ), ), ], ), if (txn.customer == null) const Padding( padding: EdgeInsets.only(top: AppSpacing.sm), child: Text( 'No mobile number captured, so WhatsApp is unavailable for ' 'this bill.', style: TextStyle( fontSize: 12, color: AppColors.textTertiary, ), ), ), const SizedBox(height: AppSpacing.md), PrimaryButton( label: _seconds > 0 ? 'New Sale ($_seconds)' : 'New Sale', icon: Icons.add_shopping_cart_rounded, large: true, onPressed: _voiding ? null : _newSale, ), const SizedBox(height: AppSpacing.sm), TextButton.icon( onPressed: _voiding ? null : _cancelSale, icon: _voiding ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.danger, ), ) : const Icon(Icons.undo_rounded, size: 16), label: Text(_voiding ? 'Cancelling…' : 'Cancel sale'), style: TextButton.styleFrom(foregroundColor: AppColors.danger), ), ], ), ); } Widget _row(String label, String value, {Color? highlight}) => Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: const TextStyle( fontSize: 13.5, color: AppColors.textSecondary, ),), Text( value, style: TextStyle( fontSize: 13.5, fontWeight: FontWeight.w700, color: highlight ?? AppColors.textPrimary, ), ), ], ), ); }