import '../../domain/entities/customer.dart'; import '../../domain/entities/product.dart'; import '../../domain/entities/sync_event.dart'; import '../../domain/entities/transaction.dart'; import 'seed_data.dart'; /// On-terminal storage. /// /// The terminal starts with an **empty catalogue**: nothing can be billed /// until the cashier imports products. Everything after that — sales, parked /// bills, queued events — lives here and survives without a connection. /// /// Swapping this for Hive or SQLite changes nothing above the data layer. class LocalStore { LocalStore._(); static final LocalStore instance = LocalStore._(); final Map _products = {}; final Map _customers = {}; final List _transactions = []; final List _parked = []; final List _events = []; int _invoiceSequence = 0; DateTime? _lastImportAt; String? _catalogueRevision; /// Nothing to seed — the catalogue arrives via import. Future init() async {} /// Test helper. Clears everything and optionally loads the demo catalogue /// so fixtures don't have to run an import first. Future reset({bool withCatalogue = false}) async { _products.clear(); _customers.clear(); _transactions.clear(); _parked.clear(); _events.clear(); _invoiceSequence = 0; _lastImportAt = null; _catalogueRevision = null; if (withCatalogue) { importCatalogue( products: SeedData.products(), customers: SeedData.customers(), revision: 'seed', at: DateTime.now(), ); } } // -------------------------------------------------------------- Catalogue /// True once a catalogue has been pulled. The POS refuses to bill until so. bool get hasCatalogue => _products.isNotEmpty; DateTime? get lastImportAt => _lastImportAt; String? get catalogueRevision => _catalogueRevision; /// Replaces the catalogue wholesale. /// /// Stock levels already adjusted by local sales are preserved for products /// that survive the re-import, so importing mid-shift does not resurrect /// stock that has been sold. void importCatalogue({ required List products, required List customers, required String revision, required DateTime at, }) { final priorStock = { for (final p in _products.values) p.id: p.stock, }; _products ..clear() ..addEntries(products.map((p) { final held = priorStock[p.id]; return MapEntry(p.id, held == null ? p : p.copyWith(stock: held)); })); // Locally registered customers must not be wiped by a server pull. for (final c in customers) { _customers.putIfAbsent(c.id, () => c); } _lastImportAt = at; _catalogueRevision = revision; } // --------------------------------------------------------------- Products List get products => _products.values.toList(growable: false); void putProduct(Product p) => _products[p.id] = p; Product? productById(String id) => _products[id]; // -------------------------------------------------------------- Customers List get customers => _customers.values.toList(growable: false); void putCustomer(Customer c) => _customers[c.id] = c; Customer? customerById(String id) => _customers[id]; // ----------------------------------------------------------- Transactions List get transactions => List.unmodifiable(_transactions.reversed); void addTransaction(SaleTransaction t) => _transactions.add(t); int nextInvoiceSequence() => ++_invoiceSequence; // ------------------------------------------------------------ Parked bills List get parked => List.unmodifiable(_parked); void addParked(ParkedBill b) => _parked.add(b); void removeParked(String id) => _parked.removeWhere((b) => b.id == id); // ------------------------------------------------------------ Sync events List get events => List.unmodifiable(_events.reversed); void addEvent(SyncEvent e) => _events.add(e); /// Updates in place. Events are never removed, so a failed push stays /// visible and retryable. void updateEvent(SyncEvent e) { final i = _events.indexWhere((x) => x.id == e.id); if (i >= 0) _events[i] = e; } bool get hasUnsyncedEvents => _events.any((e) => e.status != SyncStatus.synced); }