import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; import '../database/app_database.dart'; import '../database/catalog_queries.dart'; import '../database/stock_queries.dart'; import '../models/product.dart'; import '../models/tab_event.dart'; export '../database/stock_queries.dart' show ProductNotFoundException; abstract class ProductService { /// Active products, re-emitted whenever the products table changes. Stream> watchProducts(); Future> getProducts(); Future getProductById(String id); Future createProduct({ required String name, required String category, required int stockQuantity, required int lowStockThreshold, bool infiniteStock = false, required int priceInCents, required String? imagePath, }); Future updateProduct(Product product); Future deleteProduct(String id); Future decreaseStock(String productId, int amount); Future increaseStock(String productId, int amount); } class DriftProductService implements ProductService { final AppDatabase database; final TabEventFactory eventFactory; final _uuid = const Uuid(); DriftProductService({required this.database, TabEventFactory? eventFactory}) : eventFactory = eventFactory ?? TabEventFactory(); Product _mapRowToProduct(ProductRow row) { return Product( id: row.id, name: row.name, category: row.category, stockQuantity: row.stockQuantity, lowStockThreshold: row.lowStockThreshold, infiniteStock: row.infiniteStock, priceInCents: row.priceInCents, imagePath: row.imagePath, active: row.active, ); } SimpleSelectStatement<$ProductsTable, ProductRow> _activeProductsQuery() { return database.select(database.products) ..where((product) => product.active.equals(true)) ..orderBy([(product) => OrderingTerm.asc(product.name)]); } @override Stream> watchProducts() { return _activeProductsQuery().map(_mapRowToProduct).watch(); } @override Future> getProducts() { return _activeProductsQuery().map(_mapRowToProduct).get(); } @override Future getProductById(String id) async { final query = database.select(database.products) ..where((product) => product.id.equals(id)); final row = await query.getSingleOrNull(); if (row == null) { return null; } return _mapRowToProduct(row); } /// Queues the new product for the server (which takes this tablet's id), its /// starting stock as a count, and its picture for upload. @override Future createProduct({ required String name, required String category, required int stockQuantity, required int lowStockThreshold, bool infiniteStock = false, required int priceInCents, required String? imagePath, }) async { final id = _uuid.v4(); await database.transaction(() async { await database .into(database.products) .insert( ProductsCompanion.insert( id: id, name: name, category: category, stockQuantity: stockQuantity, lowStockThreshold: lowStockThreshold, infiniteStock: Value(infiniteStock), priceInCents: priceInCents, imagePath: Value(imagePath), ), ); await database.queueEvent( eventFactory.productCreated( productId: id, fields: { 'name': name, 'category': _serverCategory(category), 'priceInCents': priceInCents, 'lowStockThreshold': lowStockThreshold, }, ), ); // The server starts a product it doesn't know as untracked if (!infiniteStock) { await database.queueEvent( eventFactory.stockSet(productId: id, quantity: stockQuantity), ); } if (imagePath != null) await database.queueImageUpload(id, imagePath); }); } /// Queues what changed for the server: the changed fields, a changed stock /// or tracking as a count, and a new picture for upload. @override Future updateProduct(Product product) async { await database.transaction(() async { final current = await (database.select( database.products, )..where((row) => row.id.equals(product.id))).getSingleOrNull(); await (database.update( database.products, )..where((row) => row.id.equals(product.id))).write( ProductsCompanion( name: Value(product.name), category: Value(product.category), stockQuantity: Value(product.stockQuantity), lowStockThreshold: Value(product.lowStockThreshold), infiniteStock: Value(product.infiniteStock), priceInCents: Value(product.priceInCents), imagePath: Value(product.imagePath), active: Value(product.active), ), ); if (current == null) return; final fields = { if (current.name != product.name) 'name': product.name, if (current.category != product.category) 'category': _serverCategory(product.category), if (current.priceInCents != product.priceInCents) 'priceInCents': product.priceInCents, if (current.lowStockThreshold != product.lowStockThreshold) 'lowStockThreshold': product.lowStockThreshold, }; if (fields.isNotEmpty) { await database.queueEvent( eventFactory.productUpdated(productId: product.id, fields: fields), ); } final stockChanged = product.infiniteStock ? !current.infiniteStock : current.infiniteStock || current.stockQuantity != product.stockQuantity; if (stockChanged) { await database.queueEvent( eventFactory.stockSet( productId: product.id, quantity: product.infiniteStock ? null : product.stockQuantity, ), ); } final picture = product.imagePath; if (picture != null && picture != current.imagePath) { await database.queueImageUpload(product.id, picture); } }); } /// Only hidden, since open tabs and history still refer to it; the server /// archives it for every tablet. @override Future deleteProduct(String id) async { await database.transaction(() async { await (database.update(database.products) ..where((product) => product.id.equals(id))) .write(const ProductsCompanion(active: Value(false))); await database.queueEvent(eventFactory.productArchived(productId: id)); }); } /// The server has no category for an empty one. static String? _serverCategory(String category) { final trimmed = category.trim(); return trimmed.isEmpty ? null : trimmed; } @override Future decreaseStock(String productId, int amount) { return database.decreaseStock(productId, amount); } @override Future increaseStock(String productId, int amount) { return database.increaseStock(productId, amount); } }