import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../../../app/providers.dart'; import '../../../core/constants/app_constants.dart'; import '../../../core/services/sound_service.dart'; import '../../../domain/entities/cart.dart'; import '../../../domain/entities/customer.dart'; import '../../../domain/entities/product.dart'; import '../../../domain/entities/promo.dart'; import '../../../domain/entities/transaction.dart'; import '../../../domain/repositories/product_repository.dart'; import '../../../domain/repositories/transaction_repository.dart'; import '../../../domain/services/promo_engine.dart'; /// Transient feedback for the scan toast — never a blocking dialog. enum ScanOutcome { added, incremented, notFound, outOfStock } class ScanFeedback { const ScanFeedback({ required this.outcome, required this.stamp, this.product, this.message, }); final ScanOutcome outcome; final DateTime stamp; final Product? product; final String? message; bool get isSuccess => outcome == ScanOutcome.added || outcome == ScanOutcome.incremented; } /// Owns the live bill. /// /// All mutations funnel through here so that scanner input, product taps and /// keyboard shortcuts share one code path and one set of guarantees. class CartController extends StateNotifier { CartController({ required ProductRepository products, required TransactionRepository transactions, required SoundService sound, required this.onFeedback, List promos = const [], DateTime Function()? clock, }) : _products = products, _transactions = transactions, _sound = sound, _promos = promos, _now = clock ?? DateTime.now, super(Cart.empty); final ProductRepository _products; final TransactionRepository _transactions; final SoundService _sound; final void Function(ScanFeedback) onFeedback; /// Campaigns live right now. Re-evaluated after every change to the bill, /// because whether one fires depends on what is in it. final List _promos; final DateTime Function() _now; static const _uuid = Uuid(); /// Applies the campaign rules to [next] and stores the result. /// /// Every mutation goes through here rather than assigning `state` directly, /// so a promo cannot be left applied after the line that earned it is /// removed — which is how a shopper gets a discount for an item they put /// back. void _commit(Cart next) { state = next.copyWith( appliedPromos: PromoEngine.evaluate( cart: next, promos: _promos, at: _now(), ), ); } /// Snapshots for undo — capped so memory can't grow unbounded on a terminal /// that runs for days. final List _undoStack = []; static const int _maxUndo = 25; bool get canUndo => _undoStack.isNotEmpty; void _push() { _undoStack.add(state); if (_undoStack.length > _maxUndo) _undoStack.removeAt(0); } void undo() { if (_undoStack.isEmpty) return; state = _undoStack.removeLast(); } // ------------------------------------------------------------ Line items /// Adds a product, merging into the existing line when already present. void addProduct(Product product, {double quantity = 1}) { if (product.isOutOfStock) { _sound.scanError(); onFeedback(ScanFeedback( outcome: ScanOutcome.outOfStock, stamp: DateTime.now(), product: product, message: '${product.name} is out of stock', ),); return; } _push(); final existing = state.lineFor(product.id); final requested = (existing?.quantity ?? 0) + quantity; if (requested > product.stock) { _undoStack.removeLast(); _sound.scanError(); onFeedback(ScanFeedback( outcome: ScanOutcome.outOfStock, stamp: DateTime.now(), product: product, message: 'Only ${product.stock.toStringAsFixed(0)} ' '${product.unit.symbol} left', ),); return; } if (existing == null) { _commit(state.copyWith(lines: [ ...state.lines, CartLine( product: product, quantity: quantity, addedAt: DateTime.now(), ), ],),); } else { _commit(state.copyWith( lines: _replace(existing.copyWith(quantity: requested)), ),); } _clampRedemption(); _sound.scanSuccess(); onFeedback(ScanFeedback( outcome: existing == null ? ScanOutcome.added : ScanOutcome.incremented, stamp: DateTime.now(), product: product, ),); } /// Scanner entry point. Resolves the barcode and adds it with no dialogs. Future scanBarcode(String code) async { final product = await _products.findByBarcode(code); if (product == null) { // Deliberately not awaited — the beep must never delay the next scan. unawaited(_sound.scanError()); onFeedback(ScanFeedback( outcome: ScanOutcome.notFound, stamp: DateTime.now(), message: 'No product for barcode $code', ),); return; } addProduct(product); } void setQuantity(String productId, double quantity) { final line = state.lineFor(productId); if (line == null) return; if (quantity <= 0) { removeLine(productId); return; } final capped = quantity .clamp(0, AppConstants.maxCartQuantityPerLine.toDouble()) .toDouble(); if (capped > line.product.stock) { _sound.scanError(); onFeedback(ScanFeedback( outcome: ScanOutcome.outOfStock, stamp: DateTime.now(), product: line.product, message: 'Only ${line.product.stock.toStringAsFixed(0)} in stock', ),); return; } _push(); _commit(state.copyWith(lines: _replace(line.copyWith(quantity: capped)))); _clampRedemption(); } void increment(String productId, {double by = 1}) { final line = state.lineFor(productId); if (line == null) return; setQuantity(productId, line.quantity + by); } void decrement(String productId, {double by = 1}) { final line = state.lineFor(productId); if (line == null) return; setQuantity(productId, line.quantity - by); } void removeLine(String productId) { if (!state.contains(productId)) return; _push(); _commit(state.copyWith( lines: state.lines.where((l) => l.product.id != productId).toList(), ),); _clampRedemption(); } void applyLineDiscount(String productId, Discount discount) { final line = state.lineFor(productId); if (line == null) return; _push(); _commit(state.copyWith(lines: _replace(line.copyWith(discount: discount)))); _clampRedemption(); } // ------------------------------------------------------------ Bill level void applyBillDiscount(Discount discount) { _push(); _commit(state.copyWith(billDiscount: discount)); _clampRedemption(); } void clearBillDiscount() => applyBillDiscount(Discount.none); void attachCustomer(Customer? customer) { _push(); _commit( customer == null ? state.copyWith(clearCustomer: true, pointsRedeemed: 0) : state.copyWith(customer: customer), ); _clampRedemption(); } void redeemPoints(int points) { final max = state.maxRedeemablePoints; _push(); state = state.copyWith(pointsRedeemed: points.clamp(0, max)); } void redeemAllPoints() => redeemPoints(state.maxRedeemablePoints); void clearRedemption() => redeemPoints(0); void setNote(String? note) => state = state.copyWith(note: note); /// Keeps redemption legal after the bill shrinks below the redeemed value. void _clampRedemption() { if (state.pointsRedeemed == 0) return; final max = state.maxRedeemablePoints; if (state.pointsRedeemed > max) { state = state.copyWith(pointsRedeemed: max); } } // --------------------------------------------------------------- Session void clear() { _push(); state = Cart.empty; } /// Starts a brand new sale, dropping undo history and the customer. void reset() { _undoStack.clear(); state = Cart.empty; } /// Keeps the customer attached for a follow-up bill. void resetKeepingCustomer() { _undoStack.clear(); state = Cart(customer: state.customer); } // ---------------------------------------------------------- Parked bills Future park({String? label}) async { if (state.isEmpty) return; await _transactions.park(ParkedBill( id: _uuid.v4(), cart: state, parkedAt: DateTime.now(), label: label, ),); reset(); } Future resume(ParkedBill bill) async { // Resuming a bill on top of an already-active cart would otherwise // silently overwrite whatever was already there — picking a second // parked bill while the first one's items are still sitting in the // cart, unsaved. Same hole startNewSale closes for the header button; // this closes it here by parking what's active first. if (state.isNotEmpty) { await park(label: 'Auto-parked — replaced by resuming another bill'); } await _transactions.removeParked(bill.id); _undoStack.clear(); // Re-evaluated rather than restored: a campaign that has since ended must // not be honoured just because the bill was parked while it was running. _commit(bill.cart); } /// What the header's "New Sale" button actually calls. /// /// A cashier can always start over — that's the whole point of the escape /// hatch — but silently wiping a non-empty cart here would be the exact /// same hole the removal PIN closes: scan an item, then abandon the cart /// instead of removing that one line, and it disappears just the same, /// with nobody having approved anything. So a non-empty cart is parked, /// not discarded — visible and resumable from Parked bills — and only an /// already-empty cart takes the plain reset path. Future startNewSale() async { if (state.isNotEmpty) { await park(label: 'Auto-parked — new sale started with items still in cart'); return; } reset(); } /// Puts a cart back exactly as it was just before a sale that has since /// been voided, so cancelling a completed bill doesn't make the cashier /// re-scan everything. Promos are re-evaluated for the same reason /// [resume] re-evaluates them, not restored verbatim. void restore(Cart cart) { _undoStack.clear(); _commit(cart); } List _replace(CartLine updated) => [ for (final l in state.lines) if (l.product.id == updated.product.id) updated else l, ]; } // ----------------------------------------------------------------- Providers final scanFeedbackProvider = StateProvider((ref) => null); final cartControllerProvider = StateNotifierProvider((ref) { return CartController( products: ref.watch(productRepositoryProvider), transactions: ref.watch(transactionRepositoryProvider), sound: ref.watch(soundServiceProvider), // Watched, so editing a campaign in Settings takes effect at the till // without a restart. An empty list until they load is correct — no promo // is safer than a stale one. promos: ref.watch(activePromosProvider).value ?? const [], onFeedback: (feedback) => ref.read(scanFeedbackProvider.notifier).state = feedback, ); }); /// Convenience selectors — each rebuilds only the widget that needs it. final cartTotalProvider = Provider((ref) => ref.watch(cartControllerProvider).grandTotal); final cartItemCountProvider = Provider((ref) => ref.watch(cartControllerProvider).lineCount); final parkedBillsProvider = FutureProvider>( (ref) => ref.watch(transactionRepositoryProvider).parkedBills(), );