Files
nearle_pos/lib/presentation/pos/providers/cart_controller.dart

381 lines
12 KiB
Dart

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<Cart> {
CartController({
required ProductRepository products,
required TransactionRepository transactions,
required SoundService sound,
required this.onFeedback,
List<Promo> 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<Promo> _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<Cart> _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<void> 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<void> park({String? label}) async {
if (state.isEmpty) return;
await _transactions.park(ParkedBill(
id: _uuid.v4(),
cart: state,
parkedAt: DateTime.now(),
label: label,
),);
reset();
}
Future<void> 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<void> 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<CartLine> _replace(CartLine updated) => [
for (final l in state.lines)
if (l.product.id == updated.product.id) updated else l,
];
}
// ----------------------------------------------------------------- Providers
final scanFeedbackProvider = StateProvider<ScanFeedback?>((ref) => null);
final cartControllerProvider =
StateNotifierProvider<CartController, Cart>((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<double>((ref) => ref.watch(cartControllerProvider).grandTotal);
final cartItemCountProvider =
Provider<int>((ref) => ref.watch(cartControllerProvider).lineCount);
final parkedBillsProvider = FutureProvider<List<ParkedBill>>(
(ref) => ref.watch(transactionRepositoryProvider).parkedBills(),
);