import '../../domain/entities/customer.dart'; import '../../domain/entities/product.dart'; import '../../domain/entities/sync_event.dart'; import '../local/app_database.dart'; import '../local/catalogue_dao.dart'; import '../local/order_dao.dart'; import '../local/sync_log_dao.dart'; /// Terminal-side storage facade. /// /// SQLite is the source of truth. The catalogue is additionally held in memory /// because barcode resolution happens on every scan and the product grid reads /// it constantly — but every write goes to disk first, so nothing depends on /// the process staying alive. class LocalStore { LocalStore._(); static final LocalStore instance = LocalStore._(); late CatalogueDao catalogue; late OrderDao orders; late SyncLogDao syncLog; final Map _products = {}; final Map _customers = {}; final List _syncEvents = []; DateTime? _lastImportAt; String? _catalogueRevision; int _unsyncedOrders = 0; bool _ready = false; bool get isReady => _ready; /// Opens the database and loads the catalogue into memory. Future init({String? databasePath, bool inMemory = false}) async { if (inMemory) { await AppDatabase.instance.openInMemory(); } else { await AppDatabase.instance.open(overridePath: databasePath); } catalogue = CatalogueDao(AppDatabase.instance.db); orders = OrderDao(AppDatabase.instance.db); syncLog = SyncLogDao(AppDatabase.instance.db); await hydrate(); _ready = true; } /// Re-reads cached state from disk. Called on start and after an import. Future hydrate() async { _products ..clear() ..addEntries( (await catalogue.allProducts()).map((p) => MapEntry(p.id, p)), ); _customers ..clear() ..addEntries( (await catalogue.allCustomers()).map((c) => MapEntry(c.id, c)), ); final stamp = await catalogue.meta(MetaKeys.lastImportAt); _lastImportAt = stamp == null ? null : DateTime.fromMillisecondsSinceEpoch(int.parse(stamp)); _catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision); _unsyncedOrders = await orders.unsyncedCount(); _syncEvents ..clear() ..addAll(await syncLog.recent()); } // ---------------------------------------------------------------- Sync log /// Newest first. Backed by the table, so it survives a restart. List get syncEvents => List.unmodifiable(_syncEvents); Future appendSyncEvent(SyncEvent event) async { await syncLog.insert(event); await syncLog.trim(); _syncEvents.insert(0, event); } /// Test helper: wipes every table and reloads. Future reset({bool withCatalogue = false}) async { if (!_ready) await init(inMemory: true); await AppDatabase.instance.clear(); if (withCatalogue) { // Imported here rather than at the top of the file so the seed data is // only pulled in by tests and the simulated remote source. await catalogue.replaceCatalogue( products: _seedProducts(), customers: _seedCustomers(), ); await catalogue.setMeta( MetaKeys.lastImportAt, '${DateTime.now().millisecondsSinceEpoch}', ); await catalogue.setMeta(MetaKeys.catalogueRevision, 'seed'); } await hydrate(); } // -------------------------------------------------------------- Catalogue /// True once products exist on this terminal. Billing is gated on it. bool get hasCatalogue => _products.isNotEmpty; DateTime? get lastImportAt => _lastImportAt; String? get catalogueRevision => _catalogueRevision; int get unsyncedOrders => _unsyncedOrders; Future importCatalogue({ required List products, required List customers, required String revision, required DateTime at, }) async { await catalogue.replaceCatalogue(products: products, customers: customers); // The server's stock figure predates any sale this terminal has made but // not yet uploaded, so those units would reappear on the shelf. Replay them // before anyone can bill against the inflated count. final committed = await orders.unsyncedStockCommitments(); if (committed.isNotEmpty) { await catalogue.decrementStock(committed); } await catalogue.setMeta( MetaKeys.lastImportAt, '${at.millisecondsSinceEpoch}', ); await catalogue.setMeta(MetaKeys.catalogueRevision, revision); await hydrate(); } // --------------------------------------------------------------- Products List get products => _products.values.toList(growable: false); Product? productById(String id) => _products[id]; Product? productByBarcode(String barcode) { final needle = barcode.trim(); for (final p in _products.values) { if (p.barcode == needle && p.isActive) return p; } return null; } /// Write-through: disk first, then the cache. Future putProduct(Product p) async { await catalogue.upsertProduct(p); _products[p.id] = p; } Future applyStockMovement(Map quantities) async { await catalogue.decrementStock(quantities); cacheStockMovement(quantities); } /// Mirrors a stock decrement already written to disk into the memory cache. /// Used after a sale is committed as part of a larger transaction. void cacheStockMovement(Map quantities) { quantities.forEach((id, qty) { final p = _products[id]; if (p == null) return; _products[id] = p.copyWith(stock: (p.stock - qty).clamp(0, double.infinity)); }); } // -------------------------------------------------------------- Customers List get customers => _customers.values.toList(growable: false); Customer? customerById(String id) => _customers[id]; Future putCustomer(Customer c) async { await catalogue.upsertCustomer(c); _customers[c.id] = c; } /// Mirrors a customer already written to disk into the memory cache. void cacheCustomer(Customer c) => _customers[c.id] = c; // ----------------------------------------------------------------- Orders /// Refreshes the cached unsynced tally after a write or a sync. Future refreshUnsyncedCount() async { _unsyncedOrders = await orders.unsyncedCount(); return _unsyncedOrders; } // ------------------------------------------------------------- Seed data // Kept behind these hooks so production code never reaches for them. static List Function() _seedProducts = () => const []; static List Function() _seedCustomers = () => const []; /// Lets the simulated remote source and tests supply demo rows. static void registerSeed({ required List Function() products, required List Function() customers, }) { _seedProducts = products; _seedCustomers = customers; } }