second commit
This commit is contained in:
307
lib/presentation/pos/providers/cart_controller.dart
Normal file
307
lib/presentation/pos/providers/cart_controller.dart
Normal file
@@ -0,0 +1,307 @@
|
||||
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/transaction.dart';
|
||||
import '../../../domain/repositories/product_repository.dart';
|
||||
import '../../../domain/repositories/transaction_repository.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,
|
||||
}) : _products = products,
|
||||
_transactions = transactions,
|
||||
_sound = sound,
|
||||
super(Cart.empty);
|
||||
|
||||
final ProductRepository _products;
|
||||
final TransactionRepository _transactions;
|
||||
final SoundService _sound;
|
||||
final void Function(ScanFeedback) onFeedback;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// 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) {
|
||||
state = state.copyWith(lines: [
|
||||
...state.lines,
|
||||
CartLine(
|
||||
product: product,
|
||||
quantity: quantity,
|
||||
addedAt: DateTime.now(),
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
state = 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) {
|
||||
_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();
|
||||
state = 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();
|
||||
state = 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();
|
||||
state = state.copyWith(lines: _replace(line.copyWith(discount: discount)));
|
||||
_clampRedemption();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Bill level
|
||||
void applyBillDiscount(Discount discount) {
|
||||
_push();
|
||||
state = state.copyWith(billDiscount: discount);
|
||||
_clampRedemption();
|
||||
}
|
||||
|
||||
void clearBillDiscount() => applyBillDiscount(Discount.none);
|
||||
|
||||
void attachCustomer(Customer? customer) {
|
||||
_push();
|
||||
state = 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 {
|
||||
await _transactions.removeParked(bill.id);
|
||||
_undoStack.clear();
|
||||
state = bill.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),
|
||||
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(),
|
||||
);
|
||||
44
lib/presentation/pos/providers/catalog_providers.dart
Normal file
44
lib/presentation/pos/providers/catalog_providers.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../domain/entities/product.dart';
|
||||
|
||||
/// `null` means the "All" chip is selected.
|
||||
final selectedCategoryProvider =
|
||||
StateProvider<ProductCategory?>((ref) => null);
|
||||
|
||||
final searchQueryProvider = StateProvider<String>((ref) => '');
|
||||
|
||||
final allProductsProvider = FutureProvider<List<Product>>(
|
||||
(ref) => ref.watch(productRepositoryProvider).getAll(),
|
||||
);
|
||||
|
||||
/// The grid's data source: category filter and search applied together.
|
||||
final visibleProductsProvider = FutureProvider<List<Product>>((ref) async {
|
||||
final repo = ref.watch(productRepositoryProvider);
|
||||
final query = ref.watch(searchQueryProvider);
|
||||
final category = ref.watch(selectedCategoryProvider);
|
||||
|
||||
final base = query.trim().isEmpty
|
||||
? await repo.getAll()
|
||||
: await repo.search(query);
|
||||
|
||||
if (category == null) return base;
|
||||
return base.where((p) => p.category == category).toList();
|
||||
});
|
||||
|
||||
/// Counts per category for the chip badges.
|
||||
final categoryCountsProvider =
|
||||
FutureProvider<Map<ProductCategory, int>>((ref) async {
|
||||
final products = await ref.watch(allProductsProvider.future);
|
||||
final map = <ProductCategory, int>{};
|
||||
for (final p in products) {
|
||||
map[p.category] = (map[p.category] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
final lowStockProductsProvider = FutureProvider<List<Product>>((ref) async {
|
||||
final products = await ref.watch(allProductsProvider.future);
|
||||
return products.where((p) => p.isLowStock || p.isOutOfStock).toList();
|
||||
});
|
||||
44
lib/presentation/pos/providers/navigation_provider.dart
Normal file
44
lib/presentation/pos/providers/navigation_provider.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// The modules a cashier needs. Deliberately excludes analytics — this
|
||||
/// terminal is for billing, not back-office reporting.
|
||||
enum PosModule {
|
||||
pos('Point of Sale', 'POS', Icons.point_of_sale_rounded, NavSection.billing),
|
||||
customers('Customers', 'Customers', Icons.people_alt_rounded,
|
||||
NavSection.billing),
|
||||
|
||||
productImport('Product Import', 'Product Import',
|
||||
Icons.cloud_download_rounded, NavSection.catalogue),
|
||||
promos('Promotions', 'Promo', Icons.sell_rounded, NavSection.catalogue),
|
||||
|
||||
events('Events', 'Events', Icons.sync_rounded, NavSection.session),
|
||||
settings('Settings', 'Settings', Icons.settings_rounded, NavSection.session);
|
||||
|
||||
const PosModule(this.title, this.label, this.icon, this.section);
|
||||
|
||||
/// Long form, shown in the page header.
|
||||
final String title;
|
||||
|
||||
/// Short form, shown in the sidebar.
|
||||
final String label;
|
||||
|
||||
final IconData icon;
|
||||
final NavSection section;
|
||||
}
|
||||
|
||||
/// Groups the navigation into labelled blocks.
|
||||
enum NavSection {
|
||||
billing('Billing'),
|
||||
catalogue('Catalogue'),
|
||||
session('Session');
|
||||
|
||||
const NavSection(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
List<PosModule> get modules =>
|
||||
PosModule.values.where((m) => m.section == this).toList();
|
||||
}
|
||||
|
||||
final activeModuleProvider = StateProvider<PosModule>((ref) => PosModule.pos);
|
||||
Reference in New Issue
Block a user