import 'dart:convert'; import 'package:drift/drift.dart'; import '../models/remote_command.dart'; import '../models/tab_event.dart'; import 'app_database.dart'; /// A product as the catalog and this tablet's unsent changes make it. class _Product { String name; String category; int priceInCents; int lowStockThreshold; bool active; String? imageId; String? imagePath; _Product(ProductRow row) : name = row.name, category = row.category, priceInCents = row.priceInCents, lowStockThreshold = row.lowStockThreshold, active = row.active, imageId = row.imageId, imagePath = row.imagePath; _Product.fromServer(ServerProduct product) : name = product.name, category = product.category ?? '', priceInCents = product.priceInCents, lowStockThreshold = product.lowStockThreshold ?? 0, active = !product.archived, imageId = product.imageId, imagePath = null; bool sameAs(ProductRow row) => name == row.name && category == row.category && priceInCents == row.priceInCents && lowStockThreshold == row.lowStockThreshold && active == row.active && imageId == row.imageId && imagePath == row.imagePath; ProductsCompanion toCompanion() => ProductsCompanion( name: Value(name), category: Value(category), priceInCents: Value(priceInCents), lowStockThreshold: Value(lowStockThreshold), active: Value(active), imageId: Value(imageId), imagePath: Value(imagePath), ); /// Applies the fields of a `product_created` / `product_updated` event. void apply(Map fields) { if (fields['name'] case final String value) name = value; if (fields.containsKey('category')) { category = fields['category'] as String? ?? ''; } if (fields['priceInCents'] case final int value) priceInCents = value; if (fields.containsKey('lowStockThreshold')) { lowStockThreshold = fields['lowStockThreshold'] as int? ?? 0; } if (fields.containsKey('imageId')) imageId = fields['imageId'] as String?; } } /// The product catalog: the website's, with this tablet's changes on top. extension CatalogQueries on AppDatabase { /// Makes the local products match [catalog], then replays this tablet's /// product changes the server hasn't applied yet on top, so an offline /// edit stays visible until the server has it (the same idea as the tabs). /// /// New products are added; archived ones, and products the server doesn't /// know and this tablet didn't create (such as the default products of a /// new tablet), are hidden: open tabs and the history refer to them. /// /// Pictures: a changed one keeps showing the old file until /// [ProductImageSync] downloaded the new one; a picture waiting to be /// uploaded from this tablet is left alone. The stock is not touched here: /// the stock rebase that follows sets it. Future applyServerCatalog(List catalog) { return transaction(() async { final rows = { for (final row in await select(products).get()) row.id: row, }; final uploading = { for (final row in await select(pendingImageUploads).get()) row.productId, }; final desired = {for (final row in rows.values) row.id: _Product(row)}; final listed = {}; for (final product in catalog) { listed.add(product.id); final local = desired[product.id]; final next = _Product.fromServer(product); if (local == null) { desired[product.id] = next; continue; } if (uploading.contains(product.id)) { next ..imageId = local.imageId ..imagePath = local.imagePath; } else if (next.imageId != null && next.imageId == local.imageId) { next.imagePath = local.imagePath; } else if (next.imageId != null) { // Until the new picture is downloaded, the old one is better than none next.imagePath = local.imagePath; } desired[product.id] = next; } for (final entry in desired.entries) { if (!listed.contains(entry.key)) entry.value.active = false; } for (final event in await _pendingProductEvents()) { final productId = event['productId'] as String?; final local = productId == null ? null : desired[productId]; if (local == null) continue; switch (TabEventType.fromValue(event['type'] as String? ?? '')) { case TabEventType.productCreated || TabEventType.productUpdated: local.apply(event['product'] as Map? ?? {}); if (event['type'] == TabEventType.productCreated.value) { local.active = true; } case TabEventType.productArchived: local.active = false; default: break; } } for (final MapEntry(key: id, value: product) in desired.entries) { final row = rows[id]; if (row == null) { await into(products).insert( product.toCompanion().copyWith( id: Value(id), // Untracked until the stock rebase sets its stock stockQuantity: const Value(0), infiniteStock: const Value(true), ), ); } else if (!product.sameAs(row)) { await (update( products, )..where((r) => r.id.equals(id))).write(product.toCompanion()); } } }); } /// Queues a picture taken on this tablet for upload; a newer picture of the /// same product replaces one still waiting. Future queueImageUpload(String productId, String localPath) async { await into(pendingImageUploads).insertOnConflictUpdate( PendingImageUploadsCompanion.insert( productId: productId, localPath: localPath, createdAt: DateTime.now(), ), ); } Future>> _pendingProductEvents() async { final types = TabEventType.products.map((type) => type.value).toSet(); final rows = await (select(tabEvents)..orderBy([ (row) => OrderingTerm.asc(row.createdAt), (row) => OrderingTerm.asc(const CustomExpression('rowid')), ])) .get(); return [ for (final row in rows) if (jsonDecode(row.payload) case final Map event when types.contains(event['type'])) event, ]; } }