From b15d94a8051de3d5cda5ddba60b5903864d6c104 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Wed, 21 Oct 2026 12:10:42 +0200 Subject: [PATCH] feat: make server own products --- docs/server-stock-source-of-truth.md | 21 +- lib/database/catalog_queries.dart | 73 +++ lib/l10n/app_en.arb | 5 + lib/l10n/app_localizations.dart | 30 ++ lib/l10n/app_localizations_en.dart | 18 + lib/l10n/app_localizations_nl.dart | 18 + lib/l10n/app_nl.arb | 5 + lib/models/remote_command.dart | 51 ++ lib/models/tab_event.dart | 4 +- lib/services/bar_tab_service.dart | 163 +++--- lib/services/remote_sync_service.dart | 80 ++- lib/viewmodels/remote_sync_view_model.dart | 7 + lib/views/bar/bar_screen_view.dart | 43 ++ lib/views/product_form_view.dart | 15 +- lib/views/product_list_view.dart | 25 +- .../widgets/managed_on_website_notice.dart | 24 + pubspec.yaml | 2 +- test/bar_tab_service_test.dart | 349 ++++++++++++ test/e2e/website_catalog_e2e_test.dart | 307 +++++++++++ test/remote_sync_service_test.dart | 501 ++++++++++++++++++ test/tab_events_test.dart | 82 ++- 21 files changed, 1717 insertions(+), 106 deletions(-) create mode 100644 lib/database/catalog_queries.dart create mode 100644 lib/views/widgets/managed_on_website_notice.dart create mode 100644 test/e2e/website_catalog_e2e_test.dart diff --git a/docs/server-stock-source-of-truth.md b/docs/server-stock-source-of-truth.md index a9568b0..d58b2f4 100644 --- a/docs/server-stock-source-of-truth.md +++ b/docs/server-stock-source-of-truth.md @@ -63,16 +63,19 @@ acknowledge and ignore. Acknowledge it like any other event; it doesn't appear i The tablet sends `stock_set` when a product is created (unless untracked), when the default products are seeded, and when its stock or tracking is changed by hand. -### 3. Catalog (`products` in the request) +### 3. Catalog: managed on the dashboard -- Keep upserting `name`, `category` and `priceInCents` per reported product. -- **Ignore `stock`** in the report once the server owns the stock (keep accepting the field; older servers use it). -- A product the server sees for the first time is created with `stock = null` (untracked). Its starting stock arrives - as a `stock_set` event in the same request. -- **Stop removing products that one tablet doesn't report**: with several tablets, tablet B not knowing a product of - tablet A must not delete A's stock. Deleting or hiding products becomes a dashboard action (out of scope here). - -Process `products` **before** the events of the same request, so a `stock_set` for a new product finds it. +- The dashboard adds, changes and archives products (archived = deleted for the tablets, kept for tabs and history). + A product added with a starting count is tracked from that moment. +- The tablet's `products` report is only used on the first sync of the venue, to take over its catalog and stock once. + After that it is ignored, also its `stock`. +- The sync response sends `catalog`: every product of the venue, + `[{ id, name, category, priceInCents, lowStockThreshold, archived }]`. The tablet adds and updates its products from + it, and hides archived ones and ones the catalog doesn't list (such as the default products of a newly paired + tablet). Leaving the key out means "the tablet manages its own catalog". +- A paired tablet can't add, rename, reprice or delete products; it still counts stock (`stock_set`) and keeps its own + product pictures until pictures are managed on the dashboard. +- A `stock_set` for a product the venue doesn't have is acknowledged and ignored. ### 4. Dashboard stock adjustments diff --git a/lib/database/catalog_queries.dart b/lib/database/catalog_queries.dart new file mode 100644 index 0000000..c339f43 --- /dev/null +++ b/lib/database/catalog_queries.dart @@ -0,0 +1,73 @@ +import 'package:drift/drift.dart'; + +import '../models/remote_command.dart'; +import 'app_database.dart'; + +/// The product catalog as the website manages it. +extension CatalogQueries on AppDatabase { + /// Makes the local products match [catalog]: new products are added, + /// changed ones updated, archived ones and products the server doesn't + /// know (such as the default products of a new tablet) are hidden. Hidden, + /// not deleted: open tabs and the history refer to them. + /// + /// The stock is not touched here: the stock rebase that follows sets it, a + /// new product starts as untracked until then. Local images are kept. + Future applyServerCatalog(List catalog) { + return transaction(() async { + final local = { + for (final row in await select(products).get()) row.id: row, + }; + + for (final product in catalog) { + final category = product.category ?? ''; + final lowStockThreshold = product.lowStockThreshold ?? 0; + final existing = local.remove(product.id); + + if (existing == null) { + await into(products).insert( + ProductsCompanion.insert( + id: product.id, + name: product.name, + category: category, + stockQuantity: 0, + lowStockThreshold: lowStockThreshold, + infiniteStock: const Value(true), + priceInCents: product.priceInCents, + active: Value(!product.archived), + ), + ); + continue; + } + + if (existing.name == product.name && + existing.category == category && + existing.priceInCents == product.priceInCents && + existing.lowStockThreshold == lowStockThreshold && + existing.active == !product.archived) { + continue; + } + await (update( + products, + )..where((row) => row.id.equals(product.id))).write( + ProductsCompanion( + name: Value(product.name), + category: Value(category), + priceInCents: Value(product.priceInCents), + lowStockThreshold: Value(lowStockThreshold), + active: Value(!product.archived), + ), + ); + } + + final unknown = [ + for (final row in local.values) + if (row.active) row.id, + ]; + if (unknown.isNotEmpty) { + await (update(products)..where((row) => row.id.isIn(unknown))).write( + const ProductsCompanion(active: Value(false)), + ); + } + }); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ae5aad..67172e8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4,6 +4,7 @@ "barTabs": "Bar Tabs", "products": "Products", "addProduct": "Add product", + "productsManagedOnWebsite": "Products are managed on the website. Here you can change the stock and the picture.", "noProductsYet": "No products yet.", "addFirstProduct": "Add your first product to start selling.", "manageProducts": "Manage products", @@ -276,6 +277,10 @@ "simulatedUpdateNotes": "Bug fixes and performance improvements.", "remoteActivity": "Remote activity", "remoteActivityHistory": "Recent remote activity", + "offline": "Offline", + "offlineDescription": "No connection to the server. The tablet keeps working: everything is sent as soon as the connection is back.", + "syncProblem": "Sync problem", + "syncProblemDescription": "Changes from the website don't reach this tablet. Error:", "syncNow": "Sync now", "syncing": "Syncing…", "lastSync": "Last sync: {time}", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c2cdd24..da0cecb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -122,6 +122,12 @@ abstract class AppLocalizations { /// **'Add product'** String get addProduct; + /// No description provided for @productsManagedOnWebsite. + /// + /// In en, this message translates to: + /// **'Products are managed on the website. Here you can change the stock and the picture.'** + String get productsManagedOnWebsite; + /// No description provided for @noProductsYet. /// /// In en, this message translates to: @@ -1640,6 +1646,30 @@ abstract class AppLocalizations { /// **'Recent remote activity'** String get remoteActivityHistory; + /// No description provided for @offline. + /// + /// In en, this message translates to: + /// **'Offline'** + String get offline; + + /// No description provided for @offlineDescription. + /// + /// In en, this message translates to: + /// **'No connection to the server. The tablet keeps working: everything is sent as soon as the connection is back.'** + String get offlineDescription; + + /// No description provided for @syncProblem. + /// + /// In en, this message translates to: + /// **'Sync problem'** + String get syncProblem; + + /// No description provided for @syncProblemDescription. + /// + /// In en, this message translates to: + /// **'Changes from the website don\'t reach this tablet. Error:'** + String get syncProblemDescription; + /// No description provided for @syncNow. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 47a400a..b3ebb35 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -20,6 +20,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get addProduct => 'Add product'; + @override + String get productsManagedOnWebsite => + 'Products are managed on the website. Here you can change the stock and the picture.'; + @override String get noProductsYet => 'No products yet.'; @@ -846,6 +850,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get remoteActivityHistory => 'Recent remote activity'; + @override + String get offline => 'Offline'; + + @override + String get offlineDescription => + 'No connection to the server. The tablet keeps working: everything is sent as soon as the connection is back.'; + + @override + String get syncProblem => 'Sync problem'; + + @override + String get syncProblemDescription => + 'Changes from the website don\'t reach this tablet. Error:'; + @override String get syncNow => 'Sync now'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index f5f7cd2..8b5b856 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -20,6 +20,10 @@ class AppLocalizationsNl extends AppLocalizations { @override String get addProduct => 'Product toevoegen'; + @override + String get productsManagedOnWebsite => + 'Producten beheer je op de website. Hier pas je de voorraad en de foto aan.'; + @override String get noProductsYet => 'Nog geen producten.'; @@ -858,6 +862,20 @@ class AppLocalizationsNl extends AppLocalizations { @override String get remoteActivityHistory => 'Recente externe activiteit'; + @override + String get offline => 'Offline'; + + @override + String get offlineDescription => + 'Geen verbinding met de server. De kassa werkt gewoon verder: alles wordt verstuurd zodra er weer verbinding is.'; + + @override + String get syncProblem => 'Synchronisatieprobleem'; + + @override + String get syncProblemDescription => + 'Wijzigingen van de website komen niet op deze tablet aan. Fout:'; + @override String get syncNow => 'Nu synchroniseren'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 3714e2f..63ac973 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -4,6 +4,7 @@ "barTabs": "Poefs", "products": "Producten", "addProduct": "Product toevoegen", + "productsManagedOnWebsite": "Producten beheer je op de website. Hier pas je de voorraad en de foto aan.", "noProductsYet": "Nog geen producten.", "addFirstProduct": "Voeg je eerste product toe om te beginnen.", "manageProducts": "Producten beheren", @@ -276,6 +277,10 @@ "simulatedUpdateNotes": "Bugfixes en prestatieverbeteringen.", "remoteActivity": "Externe activiteit", "remoteActivityHistory": "Recente externe activiteit", + "offline": "Offline", + "offlineDescription": "Geen verbinding met de server. De kassa werkt gewoon verder: alles wordt verstuurd zodra er weer verbinding is.", + "syncProblem": "Synchronisatieprobleem", + "syncProblemDescription": "Wijzigingen van de website komen niet op deze tablet aan. Fout:", "syncNow": "Nu synchroniseren", "syncing": "Bezig met synchroniseren…", "lastSync": "Laatste sync: {time}", diff --git a/lib/models/remote_command.dart b/lib/models/remote_command.dart index a493a2d..05f3a9a 100644 --- a/lib/models/remote_command.dart +++ b/lib/models/remote_command.dart @@ -157,6 +157,40 @@ class ServerStock { } } +/// A product as the website manages it. +class ServerProduct { + final String id; + final String name; + final String? category; + final int priceInCents; + + /// Null: no low stock warning. + final int? lowStockThreshold; + + /// Deleted on the website: hidden, kept for open tabs and the history. + final bool archived; + + const ServerProduct({ + required this.id, + required this.name, + this.category, + required this.priceInCents, + this.lowStockThreshold, + this.archived = false, + }); + + factory ServerProduct.fromJson(Map json) { + return ServerProduct( + id: json['id'] as String, + name: json['name'] as String, + category: json['category'] as String?, + priceInCents: json['priceInCents'] as int, + lowStockThreshold: json['lowStockThreshold'] as int?, + archived: json['archived'] as bool? ?? false, + ); + } +} + class SyncResponse { final List commands; final String? cursor; @@ -183,6 +217,10 @@ class SyncResponse { /// sales and applies [stockAdjustments] itself. final List? stock; + /// Every product of the venue, managed on the website. Null from a server + /// that doesn't manage the catalog; the tablet then manages its own. + final List? catalog; + const SyncResponse({ required this.commands, this.cursor, @@ -196,6 +234,7 @@ class SyncResponse { this.settlements = const [], this.settlementsCursor, this.stock, + this.catalog, }); factory SyncResponse.fromJson(Map json) { @@ -251,6 +290,13 @@ class SyncResponse { ], _ => null, }, + catalog: switch (json['catalog']) { + final List catalog => [ + for (final product in catalog) + ServerProduct.fromJson(product as Map), + ], + _ => null, + }, ); } @@ -391,10 +437,15 @@ class RemoteSyncEvent { final String? requestedBy; final String? message; + /// A failed sync that only lacked a connection: the bar keeps working and + /// everything is sent once it is back. + final bool offline; + const RemoteSyncEvent({ required this.type, this.tabCustomerName, this.requestedBy, this.message, + this.offline = false, }); } diff --git a/lib/models/tab_event.dart b/lib/models/tab_event.dart index b92e8b7..b8fd69a 100644 --- a/lib/models/tab_event.dart +++ b/lib/models/tab_event.dart @@ -207,7 +207,9 @@ class TabEventFactory { customerName: customerName, fields: { 'lineId': lineId, - 'productId': productId, + // Lines taken over from a tablet's own tabs have no product: the + // server then matches them by name and price, and refuses an empty id + if (productId.isNotEmpty) 'productId': productId, 'productName': productName, 'unitPriceInCents': unitPriceInCents, 'quantity': quantity, diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index fec09f4..91777db 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -461,68 +461,87 @@ class DriftBarTabService implements BarTabService { final tabRow = await _getTabRow(tabId); if (tabRow == null) throw TabNotFoundException(tabId); - // A merge on the server can leave two lines of one product at - // different prices; new drinks go on the oldest - final existingItem = - await (database.select(database.tabItems) - ..where( - (item) => - item.tabId.equals(tabId) & - item.productId.equals(product.id), - ) - ..orderBy([(item) => OrderingTerm.asc(item.createdAt)]) - ..limit(1)) - .getSingleOrNull(); - - if (existingItem != null) { - await (database.update( - database.tabItems, - )..where((item) => item.id.equals(existingItem.id))).write( - TabItemsCompanion(quantity: Value(existingItem.quantity + 1)), - ); - await _recordPurchase(existingItem.id); - // The line keeps the price it was first added at - await _recordLineChange( - tabId: tabId, - customerName: tabRow.customerName, - lineId: existingItem.id, - productId: existingItem.productId, - productName: existingItem.productName, - unitPriceInCents: existingItem.unitPriceInCents, - delta: 1, - ); - } else { - final tabItemId = _uuid.v4(); - - await database - .into(database.tabItems) - .insert( - TabItemsCompanion.insert( - id: tabItemId, - tabId: tabId, - productId: product.id, - productName: product.name, - quantity: 1, - unitPriceInCents: product.priceInCents, - createdAt: DateTime.now(), - ), - ); - await _recordPurchase(tabItemId); - await _recordLineChange( - tabId: tabId, - customerName: tabRow.customerName, - lineId: tabItemId, - productId: product.id, - productName: product.name, - unitPriceInCents: product.priceInCents, - delta: 1, - ); - } - + await _addDrinks( + tabRow, + productId: product.id, + productName: product.name, + unitPriceInCents: product.priceInCents, + quantity: 1, + ); await _incrementTabVersion(tabId); }); } + /// Puts [quantity] drinks on the tab, charged [unitPriceInCents]: on the + /// line of that product, name and price, or a new line when there is none. + /// Every new drink goes through here, so it is always charged the current + /// price: after a price or name change on the website the drinks already + /// on the tab keep what they were rung up at, and new ones start a new + /// line. Of several matching lines (a merge on the server) the oldest gets + /// them. + Future _addDrinks( + BarTabRow tabRow, { + required String productId, + required String productName, + required int unitPriceInCents, + required int quantity, + }) async { + final existingItem = + await (database.select(database.tabItems) + ..where( + (item) => + item.tabId.equals(tabRow.id) & + item.productId.equals(productId) & + item.unitPriceInCents.equals(unitPriceInCents) & + item.productName.equals(productName), + ) + ..orderBy([(item) => OrderingTerm.asc(item.createdAt)]) + ..limit(1)) + .getSingleOrNull(); + + final String lineId; + if (existingItem != null) { + lineId = existingItem.id; + await (database.update( + database.tabItems, + )..where((item) => item.id.equals(lineId))).write( + TabItemsCompanion(quantity: Value(existingItem.quantity + quantity)), + ); + } else { + lineId = _uuid.v4(); + await database + .into(database.tabItems) + .insert( + TabItemsCompanion.insert( + id: lineId, + tabId: tabRow.id, + productId: productId, + productName: productName, + quantity: quantity, + unitPriceInCents: unitPriceInCents, + createdAt: DateTime.now(), + ), + ); + } + + for (var index = 0; index < quantity; index++) { + await _recordPurchase(lineId); + } + await _recordLineChange( + tabId: tabRow.id, + customerName: tabRow.customerName, + lineId: lineId, + productId: productId, + productName: productName, + unitPriceInCents: unitPriceInCents, + delta: quantity, + ); + } + + /// Taking drinks off changes [tabItemId] itself. Adding is ringing up new + /// drinks of its product, at the current price: on this line while its + /// price and name are still current, else on the line at the current price + /// (see [_addDrinks]). Returns how many were added (positive) or taken off. @override Future adjustTabItemQuantity({ required String tabItemId, @@ -537,19 +556,31 @@ class DriftBarTabService implements BarTabService { if (item == null) return 0; + if (delta > 0) { + final tabRow = await _getTabRow(item.tabId); + if (tabRow == null) return 0; + + // Hidden products still count: the drink is on the tab already + final product = await (database.select( + database.products, + )..where((row) => row.id.equals(item.productId))).getSingleOrNull(); + await _addDrinks( + tabRow, + productId: item.productId, + productName: product?.name ?? item.productName, + unitPriceInCents: product?.priceInCents ?? item.unitPriceInCents, + quantity: delta, + ); + await _incrementTabVersion(item.tabId); + return delta; + } + final newQuantity = max(item.quantity + delta, 0); final actualDelta = newQuantity - item.quantity; if (actualDelta == 0) return 0; - if (actualDelta > 0) { - for (var index = 0; index < actualDelta; index++) { - await _recordPurchase(item.id); - } - } else { - await _removeLatestPurchases(item.id, -actualDelta); - } - + await _removeLatestPurchases(item.id, -actualDelta); await (database.update(database.tabItems) ..where((row) => row.id.equals(tabItemId))) .write(TabItemsCompanion(quantity: Value(newQuantity))); diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index 437f421..449801a 100644 --- a/lib/services/remote_sync_service.dart +++ b/lib/services/remote_sync_service.dart @@ -9,6 +9,7 @@ import 'package:http/http.dart' as http; import 'package:sentry_flutter/sentry_flutter.dart'; import '../database/app_database.dart'; +import '../database/catalog_queries.dart'; import '../database/stock_queries.dart'; import '../models/bar_tab.dart'; import '../models/payment_method.dart'; @@ -19,6 +20,16 @@ import 'kooltap_api_client.dart'; typedef _Credentials = ({String deviceId, String token}); +/// Parts of a sync response the tablet could not apply; the rest was applied. +class SyncApplyException implements Exception { + final List failures; + + const SyncApplyException(this.failures); + + @override + String toString() => failures.join('\n'); +} + typedef _CommandOutcome = ({CommandResult result, String? message}); class RemoteSyncService { @@ -225,7 +236,8 @@ class RemoteSyncService { } on RateLimitedException catch (e) { nextDelay = await _recordFailure(e, retryAfter: e.retryAfter); } catch (e, stack) { - if (!_isConnectivityError(e)) { + // A failed part was reported when it failed + if (!_isConnectivityError(e) && e is! SyncApplyException) { unawaited(Sentry.captureException(e, stackTrace: stack)); } nextDelay = await _recordFailure(e); @@ -252,7 +264,13 @@ class RemoteSyncService { debugPrint('RemoteSyncService: sync failed: $error'); _consecutiveFailures++; await _updateSyncState(success: false); - _emit(RemoteSyncEventType.syncFailed, message: error.toString()); + _eventController.add( + RemoteSyncEvent( + type: RemoteSyncEventType.syncFailed, + message: error.toString(), + offline: _isConnectivityError(error), + ), + ); return retryAfter ?? _backoff(); } @@ -293,18 +311,46 @@ class RemoteSyncService { await _clearSentEvents(response); await _markStockResultsReceived(response); - await barTabService.recordSettlements(response.settlements); + + // Each part is applied on its own: one that fails (a tab or settlement + // the tablet can't store) must not keep prices and stock from arriving + final failures = []; + final settlementsApplied = await _applyPart( + 'settlements', + failures, + () => barTabService.recordSettlements(response.settlements), + ); final serverTabs = response.tabs; if (serverTabs != null) { // After the acked events left the outbox: what is left still has to // reach the server and is replayed on top - await barTabService.applyServerState(serverTabs); + await _applyPart( + 'tabs', + failures, + () => barTabService.applyServerState(serverTabs), + ); + } + + // The website manages the products; first, so new ones get their stock + final catalog = response.catalog; + if (catalog != null) { + await _applyPart( + 'products', + failures, + () => database.applyServerCatalog(catalog), + ); } // Same rebase for the stock: the server counts every tablet's sales final serverStock = response.stock; - if (serverStock != null) await database.rebaseStock(serverStock); + if (serverStock != null) { + await _applyPart( + 'stock', + failures, + () => database.rebaseStock(serverStock), + ); + } for (final command in response.commands) { await _processCommand(command, credentials); @@ -329,15 +375,35 @@ class RemoteSyncService { } } + // Settlements that weren't stored are asked for again next time await _updateSyncState( - success: true, + success: failures.isEmpty, cursor: response.cursor, - settlementsCursor: response.settlementsCursor, + settlementsCursor: settlementsApplied ? response.settlementsCursor : null, ); + if (failures.isNotEmpty) throw SyncApplyException(failures); return appliedStock; } + /// Runs one part of applying a sync response. Returns whether it worked; a + /// failure is reported and added to [failures] instead of thrown. + Future _applyPart( + String part, + List failures, + Future Function() apply, + ) async { + try { + await apply(); + return true; + } catch (e, stack) { + debugPrint('RemoteSyncService: applying $part failed: $e'); + unawaited(Sentry.captureException(e, stackTrace: stack)); + failures.add('$part: $e'); + return false; + } + } + Future> _pendingEvents() { return (database.select(database.tabEvents) ..orderBy([ diff --git a/lib/viewmodels/remote_sync_view_model.dart b/lib/viewmodels/remote_sync_view_model.dart index 9e83d43..a3f73d6 100644 --- a/lib/viewmodels/remote_sync_view_model.dart +++ b/lib/viewmodels/remote_sync_view_model.dart @@ -35,6 +35,11 @@ class RemoteSyncViewModel extends ChangeNotifier { String? _lastError; String? get lastError => _lastError; + bool _isOffline = false; + + /// The last sync failed for lack of a connection, not because of an error. + bool get isOffline => _isOffline; + String? _venueId; String? get venueId => _venueId; @@ -109,8 +114,10 @@ class RemoteSyncViewModel extends ChangeNotifier { case RemoteSyncEventType.syncCompleted: _lastSyncAt = DateTime.now(); _lastError = null; + _isOffline = false; case RemoteSyncEventType.syncFailed: _lastError = event.message; + _isOffline = event.offline; case RemoteSyncEventType.unauthorized: _isConfigured = false; _wasRevoked = true; diff --git a/lib/views/bar/bar_screen_view.dart b/lib/views/bar/bar_screen_view.dart index 96be5c5..d9ff3b9 100644 --- a/lib/views/bar/bar_screen_view.dart +++ b/lib/views/bar/bar_screen_view.dart @@ -90,6 +90,23 @@ class _BarScreenViewState extends State { } } + void _showSyncProblem({required String title, required String description}) { + final l10n = AppLocalizations.of(context); + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: SelectableText(description), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.close), + ), + ], + ), + ); + } + void _logout() { context.read().lock(); context.read().lock(); @@ -142,6 +159,32 @@ class _BarScreenViewState extends State { icon: const Icon(Icons.cloud_off_rounded), ), ], + // A failing sync is otherwise invisible: the bar keeps working + // offline while nothing from the website arrives + if (context.watch().lastError + case final error?) ...[ + const SizedBox(width: 6), + // Offline is normal for a bar; anything else needs attention + if (context.watch().isOffline) + IconButton( + tooltip: l10n.offline, + onPressed: () => _showSyncProblem( + title: l10n.offline, + description: l10n.offlineDescription, + ), + icon: const Icon(Icons.wifi_off_rounded), + ) + else + IconButton( + tooltip: '${l10n.syncProblem}: $error', + color: Theme.of(context).colorScheme.error, + onPressed: () => _showSyncProblem( + title: l10n.syncProblem, + description: '${l10n.syncProblemDescription}\n\n$error', + ), + icon: const Icon(Icons.sync_problem_rounded), + ), + ], const SizedBox(width: 6), IconButton( tooltip: l10n.refresh, diff --git a/lib/views/product_form_view.dart b/lib/views/product_form_view.dart index b067427..f586513 100644 --- a/lib/views/product_form_view.dart +++ b/lib/views/product_form_view.dart @@ -12,6 +12,8 @@ import '../models/product.dart'; import '../utils/money.dart'; import '../utils/navigation.dart'; import '../viewmodels/product_list_view_model.dart'; +import '../viewmodels/remote_sync_view_model.dart'; +import 'widgets/managed_on_website_notice.dart'; class ProductFormView extends StatefulWidget { final String? productId; @@ -313,6 +315,9 @@ class _ProductFormViewState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final title = widget.isEditing ? l10n.editProduct : l10n.addProduct; + // A paired tablet gets its products from the website: only the stock and + // the picture are changed here + final managedOnWebsite = context.watch().isConfigured; return Scaffold( appBar: AppBar( @@ -322,7 +327,7 @@ class _ProductFormViewState extends State { onPressed: _leaveProductForm, ), actions: [ - if (widget.isEditing) + if (widget.isEditing && !managedOnWebsite) IconButton( onPressed: _delete, icon: const Icon(Icons.delete_outline), @@ -342,10 +347,15 @@ class _ProductFormViewState extends State { ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.all(24), children: [ + if (managedOnWebsite) ...[ + const ManagedOnWebsiteNotice(), + const SizedBox(height: 16), + ], _buildImagePicker(context), const SizedBox(height: 24), TextFormField( controller: _nameController, + enabled: !managedOnWebsite, decoration: InputDecoration( labelText: l10n.productName, border: OutlineInputBorder(), @@ -366,6 +376,7 @@ class _ProductFormViewState extends State { builder: (context, constraints) { return DropdownMenu( width: constraints.maxWidth, + enabled: !managedOnWebsite, initialSelection: _selectedCategory, enableFilter: true, enableSearch: true, @@ -395,6 +406,7 @@ class _ProductFormViewState extends State { const SizedBox(height: 16), TextFormField( controller: _priceController, + enabled: !managedOnWebsite, decoration: InputDecoration( labelText: l10n.price, prefixText: '€ ', @@ -455,6 +467,7 @@ class _ProductFormViewState extends State { const SizedBox(height: 16), TextFormField( controller: _lowStockController, + enabled: !managedOnWebsite, decoration: InputDecoration( labelText: l10n.lowStockThreshold, border: OutlineInputBorder(), diff --git a/lib/views/product_list_view.dart b/lib/views/product_list_view.dart index 0e61dfc..6a5fb66 100644 --- a/lib/views/product_list_view.dart +++ b/lib/views/product_list_view.dart @@ -9,6 +9,8 @@ import '../l10n/app_localizations_helpers.dart'; import '../utils/money.dart'; import '../utils/navigation.dart'; import '../viewmodels/product_list_view_model.dart'; +import '../viewmodels/remote_sync_view_model.dart'; +import 'widgets/managed_on_website_notice.dart'; class ProductListView extends StatefulWidget { const ProductListView({super.key}); @@ -31,6 +33,8 @@ class _ProductListViewState extends State { Widget build(BuildContext context) { final viewModel = context.watch(); final l10n = AppLocalizations.of(context); + // A paired tablet gets its products from the website + final managedOnWebsite = context.watch().isConfigured; return Scaffold( appBar: AppBar( @@ -41,11 +45,13 @@ class _ProductListViewState extends State { title: Text(l10n.products), ), resizeToAvoidBottomInset: false, - floatingActionButton: FloatingActionButton.extended( - onPressed: () => context.push('/products/new'), - icon: const Icon(Icons.add), - label: Text(l10n.addProduct), - ), + floatingActionButton: managedOnWebsite + ? null + : FloatingActionButton.extended( + onPressed: () => context.push('/products/new'), + icon: const Icon(Icons.add), + label: Text(l10n.addProduct), + ), body: Builder( builder: (context) { if (viewModel.isLoading) { @@ -56,16 +62,19 @@ class _ProductListViewState extends State { return Center(child: Text(l10n.errorText(viewModel.loadError))); } - if (viewModel.products.isEmpty) { + if (viewModel.products.isEmpty && !managedOnWebsite) { return Center(child: Text(l10n.noProductsYet)); } + // The notice takes the first row + final offset = managedOnWebsite ? 1 : 0; return ListView.separated( padding: const EdgeInsets.all(16), - itemCount: viewModel.products.length, + itemCount: viewModel.products.length + offset, separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { - final product = viewModel.products[index]; + if (index < offset) return const ManagedOnWebsiteNotice(); + final product = viewModel.products[index - offset]; return Card( child: ListTile( diff --git a/lib/views/widgets/managed_on_website_notice.dart b/lib/views/widgets/managed_on_website_notice.dart new file mode 100644 index 0000000..23e6451 --- /dev/null +++ b/lib/views/widgets/managed_on_website_notice.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; + +/// Tells that the products of a paired tablet are managed on the website. +class ManagedOnWebsiteNotice extends StatelessWidget { + const ManagedOnWebsiteNotice({super.key}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Card( + color: colors.secondaryContainer, + child: ListTile( + leading: Icon(Icons.language, color: colors.onSecondaryContainer), + title: Text( + AppLocalizations.of(context).productsManagedOnWebsite, + style: TextStyle(color: colors.onSecondaryContainer), + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 4966604..d6f9dd6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: kooltap description: "KoolTap: a tablet point-of-sale app for running bar tabs." publish_to: 'none' -version: 1.3.1+0 +version: 1.3.4+0 environment: sdk: ^3.12.2 diff --git a/test/bar_tab_service_test.dart b/test/bar_tab_service_test.dart index 5aaeaf4..e437719 100644 --- a/test/bar_tab_service_test.dart +++ b/test/bar_tab_service_test.dart @@ -245,6 +245,355 @@ void main() { }); }); + // The website changes prices: drinks already on a tab keep theirs, the + // next one is charged the new price + group('addProductToTab after a price or name change', () { + test( + 'a new price starts a new line; earlier drinks keep theirs', + () async { + final tab = await service.createTab(customerName: 'John'); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(priceInCents: 450), + ); + + final items = (await service.getTabById(tab.id))!.items; + expect([ + for (final item in items) (item.unitPriceInCents, item.quantity), + ], unorderedEquals([(400, 2), (450, 1)])); + expect((await service.getTabById(tab.id))!.totalInCents, 1250); + }, + ); + + test('more drinks at the new price go on the new line', () async { + final tab = await service.createTab(customerName: 'John'); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + + for (var i = 0; i < 3; i++) { + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(priceInCents: 450), + ); + } + + final items = (await service.getTabById(tab.id))!.items; + expect(items, hasLength(2)); + expect( + items.firstWhere((item) => item.unitPriceInCents == 450).quantity, + 3, + ); + }); + + test('a price going back joins the line at that price again', () async { + final tab = await service.createTab(customerName: 'John'); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(priceInCents: 300), + ); + + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + + final items = (await service.getTabById(tab.id))!.items; + expect([ + for (final item in items) (item.unitPriceInCents, item.quantity), + ], unorderedEquals([(400, 2), (300, 1)])); + }); + + test('a renamed product starts a new line under its new name', () async { + final tab = await service.createTab(customerName: 'John'); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(name: 'Pils'), + ); + + final items = (await service.getTabById(tab.id))!.items; + expect([ + for (final item in items) item.productName, + ], unorderedEquals(['Test Beer', 'Pils'])); + }); + + test('both lines count down the stock of the one product', () async { + final tab = await service.createTab(customerName: 'John'); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(priceInCents: 450), + ); + + final stored = await (database.select( + database.products, + )..where((row) => row.id.equals('prod-1'))).getSingle(); + expect(stored.stockQuantity, 98); + }); + + test('settling charges each line at its own price', () async { + final tab = await service.createTab(customerName: 'John'); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(priceInCents: 450), + ); + + await service.closeTab(tab.id); + + final closed = (await service.getClosedTabs()).single; + expect(closed.totalInCents, 850); + expect([ + for (final item in closed.items) item.unitPriceInCents, + ], unorderedEquals([400, 450])); + }); + }); + + // Foo has 2 at the old price; the website changes the price; staff tap + + // on Foo's line. The 2 stay at the old price, every new one is charged + // the new price. + group('+ on a line after a price change', () { + Future setPrice(int priceInCents, {String? name}) async { + await (database.update( + database.products, + )..where((row) => row.id.equals('prod-1'))).write( + ProductsCompanion( + priceInCents: Value(priceInCents), + name: name == null ? const Value.absent() : Value(name), + ), + ); + } + + Future fooWithTwoAtOldPrice() async { + final tab = await service.createTab(customerName: 'Foo'); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + await setPrice(450); + return tab.id; + } + + Future> lines(String tabId) async { + return [ + for (final item in (await service.getTabById(tabId))!.items) + (item.unitPriceInCents, item.quantity), + ]; + } + + Future lineAt(String tabId, int priceInCents) async { + return (await service.getTabById(tabId))!.items + .firstWhere((item) => item.unitPriceInCents == priceInCents) + .id; + } + + test( + 'the old drinks keep their price, the new one gets the new price', + () async { + final tabId = await fooWithTwoAtOldPrice(); + + final added = await service.adjustTabItemQuantity( + tabItemId: await lineAt(tabId, 400), + delta: 1, + ); + + expect(added, 1); + expect(await lines(tabId), unorderedEquals([(400, 2), (450, 1)])); + expect((await service.getTabById(tabId))!.totalInCents, 1250); + }, + ); + + test( + 'tapping + on the old line again keeps adding at the new price', + () async { + final tabId = await fooWithTwoAtOldPrice(); + final oldLine = await lineAt(tabId, 400); + + await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1); + await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1); + + expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)])); + }, + ); + + test('+ on the line at the new price adds there', () async { + final tabId = await fooWithTwoAtOldPrice(); + await service.adjustTabItemQuantity( + tabItemId: await lineAt(tabId, 400), + delta: 1, + ); + + await service.adjustTabItemQuantity( + tabItemId: await lineAt(tabId, 450), + delta: 1, + ); + + expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)])); + }); + + test('the tile and + end up on the same new line', () async { + final tabId = await fooWithTwoAtOldPrice(); + + await service.addProductToTab( + tabId: tabId, + product: createTestProduct(priceInCents: 450), + ); + await service.adjustTabItemQuantity( + tabItemId: await lineAt(tabId, 400), + delta: 1, + ); + + expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)])); + }); + + test('+ several at once adds them all at the new price', () async { + final tabId = await fooWithTwoAtOldPrice(); + + final added = await service.adjustTabItemQuantity( + tabItemId: await lineAt(tabId, 400), + delta: 3, + ); + + expect(added, 3); + expect(await lines(tabId), unorderedEquals([(400, 2), (450, 3)])); + }); + + test('− on the old line takes off a drink at the old price', () async { + final tabId = await fooWithTwoAtOldPrice(); + await service.adjustTabItemQuantity( + tabItemId: await lineAt(tabId, 400), + delta: 1, + ); + + await service.adjustTabItemQuantity( + tabItemId: await lineAt(tabId, 400), + delta: -1, + ); + + expect(await lines(tabId), unorderedEquals([(400, 1), (450, 1)])); + }); + + test('a price going back makes + add to the old line again', () async { + final tabId = await fooWithTwoAtOldPrice(); + await setPrice(400); + + await service.adjustTabItemQuantity( + tabItemId: await lineAt(tabId, 400), + delta: 1, + ); + + expect(await lines(tabId), [(400, 3)]); + }); + + test('a renamed product gets a new line under its new name', () async { + final tab = await service.createTab(customerName: 'Foo'); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + await setPrice(400, name: 'Pils'); + + await service.adjustTabItemQuantity( + tabItemId: (await service.getTabById(tab.id))!.items.single.id, + delta: 1, + ); + + final names = [ + for (final item in (await service.getTabById(tab.id))!.items) + (item.productName, item.quantity), + ]; + expect(names, unorderedEquals([('Test Beer', 1), ('Pils', 1)])); + }); + + test( + 'a hidden (archived) product is still charged its latest price', + () async { + final tabId = await fooWithTwoAtOldPrice(); + await (database.update(database.products) + ..where((row) => row.id.equals('prod-1'))) + .write(const ProductsCompanion(active: Value(false))); + + await service.adjustTabItemQuantity( + tabItemId: await lineAt(tabId, 400), + delta: 1, + ); + + expect(await lines(tabId), unorderedEquals([(400, 2), (450, 1)])); + }, + ); + + test('a line whose product is gone keeps its own price', () async { + final tab = await service.createTab(customerName: 'Foo'); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + await database.delete(database.products).go(); + + await service.adjustTabItemQuantity( + tabItemId: (await service.getTabById(tab.id))!.items.single.id, + delta: 1, + ); + + expect(await lines(tab.id), [(400, 2)]); + }); + + test('another tab without the product gets the new price', () async { + await fooWithTwoAtOldPrice(); + final bar = await service.createTab(customerName: 'Bar'); + + await service.addProductToTab( + tabId: bar.id, + product: createTestProduct(priceInCents: 450), + ); + + expect(await lines(bar.id), [(450, 1)]); + }); + + test('every drink counts down the stock of the one product', () async { + final tabId = await fooWithTwoAtOldPrice(); + + await service.adjustTabItemQuantity( + tabItemId: await lineAt(tabId, 400), + delta: 2, + ); + + final stored = await (database.select( + database.products, + )..where((row) => row.id.equals('prod-1'))).getSingle(); + expect(stored.stockQuantity, 96); + }); + }); + group('adjustTabItemQuantity', () { test('updates item quantity', () async { final tab = await service.createTab(customerName: 'John'); diff --git a/test/e2e/website_catalog_e2e_test.dart b/test/e2e/website_catalog_e2e_test.dart new file mode 100644 index 0000000..ef71c94 --- /dev/null +++ b/test/e2e/website_catalog_e2e_test.dart @@ -0,0 +1,307 @@ +// End to end against a running KoolTap server: two tablets pair, the website +// adds and changes products, the tablets sell and sync over real HTTP. +// +// Skipped unless the server is given. Use a scratch database: this pairs two +// tablets and adds products to the admin's venue. +// +// KOOLTAP_E2E_API=http://localhost:3101/api/v1 \ +// KOOLTAP_E2E_EMAIL=admin@example.com KOOLTAP_E2E_PASSWORD=... \ +// flutter test test/e2e +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/drift.dart' show driftRuntimeOptions; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/models/remote_command.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/services/kooltap_api_client.dart'; +import 'package:kooltap/services/product_service.dart'; +import 'package:kooltap/services/remote_sync_service.dart'; + +import '../helpers/memory_secure_storage.dart'; + +final _api = Platform.environment['KOOLTAP_E2E_API']; +final _skip = _api == null + ? 'Set KOOLTAP_E2E_API to run against a server' + : false; + +/// The website, through the same API the dashboard uses. +class _Website { + final String token; + final String venueId; + + _Website(this.token, this.venueId); + + static Future<_Website> login() async { + final response = await http.post( + Uri.parse('$_api/auth/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'email': Platform.environment['KOOLTAP_E2E_EMAIL'], + 'password': Platform.environment['KOOLTAP_E2E_PASSWORD'], + }), + ); + final body = _decode(response) as Map; + final user = body['user'] as Map; + return _Website(body['accessToken'] as String, user['venueId'] as String); + } + + Future call(String method, String path, [Object? body]) async { + final request = http.Request(method, Uri.parse('$_api$path')) + ..headers['Authorization'] = 'Bearer $token' + ..headers['Content-Type'] = 'application/json'; + if (body != null) request.body = jsonEncode(body); + return _decode(await http.Response.fromStream(await request.send())); + } + + Future registrationCode() async { + final body = await call('GET', '/devices/generation-code'); + return (body as Map)['code'] as String; + } + + Future> createProduct( + Map product, + ) async { + return await call('POST', '/venues/$venueId/stock/products', product) + as Map; + } + + Future changeProduct(String id, Map changes) { + return call('PATCH', '/venues/$venueId/stock/products/$id', changes); + } + + Future> tabs() async { + final body = await call('GET', '/venues/$venueId/tabs'); + return (body as Map)['tabs'] as List; + } + + static Object? _decode(http.Response response) { + if (response.statusCode >= 300) { + throw StateError('${response.statusCode}: ${response.body}'); + } + return response.body.isEmpty ? null : jsonDecode(response.body); + } +} + +/// A tablet with its own database, paired like the app pairs. +class _Tablet { + final AppDatabase database; + final DriftBarTabService tabs; + final DriftProductService products; + final RemoteSyncService sync; + final List _events = []; + + _Tablet._(this.database, this.tabs, this.products, this.sync) { + sync.events.listen(_events.add); + } + + static Future<_Tablet> pair(_Website website, String name) async { + final database = AppDatabase(NativeDatabase.memory()); + final tabs = DriftBarTabService(database: database); + final api = KoolTapApiClient(client: http.Client(), baseUrl: _api!); + final sync = RemoteSyncService( + database: database, + barTabService: tabs, + apiClient: api, + secureStorage: MemorySecureStorage(), + ); + final credentials = await api.registerDevice( + venueId: website.venueId, + deviceName: name, + registrationCode: await website.registrationCode(), + ); + await sync.setCredentials( + deviceId: credentials.deviceId, + token: credentials.token, + venueId: website.venueId, + ); + return _Tablet._( + database, + tabs, + DriftProductService(database: database), + sync, + ); + } + + /// Syncs and fails the test when the sync failed. + Future syncNow() async { + _events.clear(); + await sync.sync(); + // Sync events are delivered asynchronously + await pumpEventQueue(); + final types = _events.map((e) => e.type).toList(); + expect( + types, + contains(RemoteSyncEventType.syncCompleted), + reason: '$types ${_events.map((e) => e.message)}', + ); + } + + /// What the bar screen shows. + Future product(String name) async { + return (await products.getProducts()).singleWhere((p) => p.name == name); + } + + Future close() async { + sync.dispose(); + await database.close(); + } +} + +void main() { + late _Website website; + late _Tablet a; + late _Tablet b; + + setUpAll(() async { + if (_api == null) return; + // Two tablets, each with its own database, on purpose + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + website = await _Website.login(); + a = await _Tablet.pair(website, 'E2E tablet A'); + b = await _Tablet.pair(website, 'E2E tablet B'); + await a.syncNow(); + await b.syncNow(); + }); + + tearDownAll(() async { + if (_api == null) return; + await a.close(); + await b.close(); + }); + + // One story: the steps depend on each other and run in order + test( + 'products managed on the website, two tablets selling', + () async { + final suffix = DateTime.now().millisecondsSinceEpoch; + final colaName = 'E2E Cola $suffix'; + + // 1. The website adds a product with its stock: both tablets get it + final cola = await website.createProduct({ + 'name': colaName, + 'category': 'Frisdrank', + 'priceInCents': 250, + 'stock': 24, + 'lowStockThreshold': 5, + }); + await a.syncNow(); + await b.syncNow(); + for (final tablet in [a, b]) { + final product = await tablet.product(colaName); + expect(product.id, cola['productId']); + expect(product.priceInCents, 250); + expect(product.stockQuantity, 24); + expect(product.lowStockThreshold, 5); + } + + // 2. Tablet A sells one: tablet B sees the tab and the stock + final alice = await a.tabs.createTab(customerName: 'E2E Alice $suffix'); + await a.tabs.addProductToTab( + tabId: alice.id, + product: await a.product(colaName), + ); + await a.syncNow(); + await b.syncNow(); + expect((await b.tabs.getTabById(alice.id))!.items.single.quantity, 1); + expect((await b.product(colaName)).stockQuantity, 23); + + // 3. The website changes the price: both tablets show it + await website.changeProduct(cola['productId'] as String, { + 'priceInCents': 270, + }); + await a.syncNow(); + await b.syncNow(); + expect((await a.product(colaName)).priceInCents, 270); + expect((await b.product(colaName)).priceInCents, 270); + + // 4. The next drink is charged the new price; the first keeps its price + await b.tabs.addProductToTab( + tabId: alice.id, + product: await b.product(colaName), + ); + await b.syncNow(); + await a.syncNow(); + for (final tablet in [a, b]) { + final lines = (await tablet.tabs.getTabById(alice.id))!.items; + expect([ + for (final line in lines) (line.unitPriceInCents, line.quantity), + ], unorderedEquals([(250, 1), (270, 1)])); + expect((await tablet.product(colaName)).stockQuantity, 22); + } + + // 4b. Tablet A taps + on Alice's line at the old price: that drink is + // charged the new price too, the first one keeps the old price + final oldLine = (await a.tabs.getTabById( + alice.id, + ))!.items.firstWhere((line) => line.unitPriceInCents == 250); + await a.tabs.adjustTabItemQuantity(tabItemId: oldLine.id, delta: 1); + await a.syncNow(); + await b.syncNow(); + for (final tablet in [a, b]) { + final lines = (await tablet.tabs.getTabById(alice.id))!.items; + expect([ + for (final line in lines) (line.unitPriceInCents, line.quantity), + ], unorderedEquals([(250, 1), (270, 2)])); + expect((await tablet.product(colaName)).stockQuantity, 21); + } + final onWebsite = (await website.tabs()).singleWhere( + (t) => t['tabId'] == alice.id, + ); + expect(onWebsite['totalInCents'], 790); + + // 5. A rename reaches the tablets under the same product + final renamed = 'E2E Coca-Cola $suffix'; + await website.changeProduct(cola['productId'] as String, { + 'name': renamed, + }); + await a.syncNow(); + expect((await a.product(renamed)).id, cola['productId']); + + // 6. Archived: gone from the bar screen, still on Alice's tab + await website.changeProduct(cola['productId'] as String, { + 'archived': true, + }); + await a.syncNow(); + expect( + (await a.products.getProducts()).where( + (p) => p.id == cola['productId'], + ), + isEmpty, + ); + expect((await a.tabs.getTabById(alice.id))!.totalInCents, 790); + + // 7. The website settles Alice: both tablets show it in the history once + await website.call('POST', '/venues/${website.venueId}/commands', { + 'tabId': alice.id, + 'type': 'close_tab', + 'expectedVersion': onWebsite['version'], + 'paymentMethod': 'cash', + }); + await a.syncNow(); + await b.syncNow(); + await b.syncNow(); + for (final tablet in [a, b]) { + expect((await tablet.tabs.getTabById(alice.id))!.items, isEmpty); + final settled = (await tablet.tabs.getClosedTabs()) + .where((t) => t.originalTabId == alice.id) + .toList(); + expect(settled, hasLength(1)); + expect(settled.single.totalInCents, 790); + } + + // 8. Restored, it is back on the bar screen with the stock it had + await website.changeProduct(cola['productId'] as String, { + 'archived': false, + }); + await b.syncNow(); + expect((await b.product(renamed)).stockQuantity, 21); + }, + skip: _skip, + timeout: const Timeout(Duration(minutes: 2)), + ); +} diff --git a/test/remote_sync_service_test.dart b/test/remote_sync_service_test.dart index 27716c4..06f93f8 100644 --- a/test/remote_sync_service_test.dart +++ b/test/remote_sync_service_test.dart @@ -127,6 +127,35 @@ void main() { ]; group('sync', () { + test( + 'a lost connection is reported as offline, a server error is not', + () async { + final offline = RemoteSyncService( + database: database, + barTabService: barTabService, + apiClient: KoolTapApiClient( + client: MockClient( + (_) async => throw http.ClientException('Failed host lookup'), + ), + baseUrl: _baseUrl, + ), + secureStorage: storage, + ); + final offlineEvents = []; + offline.events.listen(offlineEvents.add); + server.syncResponse = http.Response('oops', 500); + + await offline.sync(); + await service.sync(); + await pumpEventQueue(); + + expect(offlineEvents.single.type, RemoteSyncEventType.syncFailed); + expect(offlineEvents.single.offline, isTrue); + expect(events.single.offline, isFalse); + offline.dispose(); + }, + ); + test('sends a snapshot of the open tabs and stores the cursor', () async { final tab = await barTabService.createTab(customerName: 'Alice'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); @@ -1363,6 +1392,478 @@ void main() { }); }); + group('website manages the catalog', () { + Map product( + String id, { + String name = 'Cola', + String? category = 'Frisdrank', + int price = 250, + int? lowStockThreshold, + bool archived = false, + }) { + return { + 'id': id, + 'name': name, + 'category': category, + 'priceInCents': price, + 'lowStockThreshold': lowStockThreshold, + 'archived': archived, + }; + } + + Future> rows() async { + return { + for (final row in await database.select(database.products).get()) + row.id: row, + }; + } + + test('adds, changes and hides products like the website says', () async { + await (database.update(database.products) + ..where((row) => row.id.equals('prod-1'))) + .write(const ProductsCompanion(imagePath: Value('/beer.png'))); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: 'seeded', + name: 'Seeded on this tablet', + category: 'Drinks', + stockQuantity: 24, + lowStockThreshold: 0, + priceInCents: 100, + ), + ); + server.respondWith({ + 'catalog': [ + product( + 'prod-1', + name: 'Beer', + category: null, + price: 450, + lowStockThreshold: 6, + ), + product('cola'), + product('old', name: 'Old', archived: true), + ], + 'stock': [ + {'productId': 'cola', 'stock': 24, 'countedAt': null}, + ], + }); + + await service.sync(); + + final products = await rows(); + expect( + products['prod-1'], + isA() + .having((p) => p.priceInCents, 'price', 450) + .having((p) => p.category, 'category', '') + .having((p) => p.lowStockThreshold, 'threshold', 6) + .having((p) => p.imagePath, 'image', '/beer.png') + .having((p) => p.active, 'active', isTrue), + ); + // New, and its stock arrives in the same sync + expect( + products['cola'], + isA() + .having((p) => p.name, 'name', 'Cola') + .having((p) => p.stockQuantity, 'stock', 24) + .having((p) => p.infiniteStock, 'untracked', isFalse), + ); + expect(products['old']!.active, isFalse); + expect(products['seeded']!.active, isFalse); + }); + + test('a product that comes back is shown again', () async { + server.respondWith({ + 'catalog': [product('prod-1', archived: true)], + }); + await service.sync(); + expect((await rows())['prod-1']!.active, isFalse); + + server.respondWith({ + 'catalog': [product('prod-1')], + }); + await service.sync(); + + expect((await rows())['prod-1']!.active, isTrue); + }); + + test('a server without a catalog leaves the products alone', () async { + await service.sync(); + + expect( + (await rows())['prod-1'], + isA() + .having((p) => p.name, 'name', 'Beer') + .having((p) => p.active, 'active', isTrue), + ); + }); + }); + + group('website catalog changes reach the bar', () { + late DriftProductService products; + + setUp(() { + products = DriftProductService(database: database); + }); + + Map beer({ + int price = 400, + String name = 'Beer', + bool archived = false, + int? lowStockThreshold = 10, + }) { + return { + 'id': 'prod-1', + 'name': name, + 'category': 'Drinks', + 'priceInCents': price, + 'lowStockThreshold': lowStockThreshold, + 'archived': archived, + }; + } + + List> sentLines(int request) => [ + for (final event in server.syncRequests[request]['events'] as List) + if (event['type'] == 'line_added') event as Map, + ]; + + test( + 'the next drink after a price change is charged the new price', + () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab( + tabId: tab.id, + product: (await products.getProductById('prod-1'))!, + ); + server.respondWith({ + 'catalog': [beer(price: 450)], + }); + await service.sync(); + + // What the bar screen hands to the tab: the product as stored now + final shown = (await products.getProducts()).single; + expect(shown.priceInCents, 450); + await barTabService.addProductToTab(tabId: tab.id, product: shown); + server.respondWith({}); + await service.sync(); + + final lines = (await barTabService.getTabById(tab.id))!.items; + expect([ + for (final line in lines) (line.unitPriceInCents, line.quantity), + ], unorderedEquals([(400, 1), (450, 1)])); + // Unacknowledged, so both drinks are sent again, each at its own price + expect( + [for (final line in sentLines(1)) line['unitPriceInCents']], + [400, 450], + ); + }, + ); + + test('Foo keeps 2 at the old price, + charges the new price', () async { + final tab = await barTabService.createTab(customerName: 'Foo'); + final beerNow = (await products.getProductById('prod-1'))!; + await barTabService.addProductToTab(tabId: tab.id, product: beerNow); + await barTabService.addProductToTab(tabId: tab.id, product: beerNow); + server.respondWith({ + 'catalog': [beer(price: 450)], + }); + await service.sync(); + + // Staff tap + on Foo's line + final oldLine = (await barTabService.getTabById(tab.id))!.items.single; + await barTabService.adjustTabItemQuantity( + tabItemId: oldLine.id, + delta: 1, + ); + server.respondWith({}); + await service.sync(); + + final lines = (await barTabService.getTabById(tab.id))!.items; + expect([ + for (final line in lines) (line.unitPriceInCents, line.quantity), + ], unorderedEquals([(400, 2), (450, 1)])); + expect(sentLines(1).last['unitPriceInCents'], 450); + }); + + test( + 'the products the bar screen watches change without a restart', + () async { + final emitted = >[]; + final subscription = products.watchProducts().listen( + (list) => emitted.add([for (final p in list) p.priceInCents]), + ); + await pumpEventQueue(); + + server.respondWith({ + 'catalog': [beer(price: 450)], + }); + await service.sync(); + await pumpEventQueue(); + + expect(emitted.last, [450]); + await subscription.cancel(); + }, + ); + + test( + 'an unchanged catalog writes nothing, so the screen does not redraw', + () async { + server.respondWith({ + 'catalog': [beer()], + }); + await service.sync(); + var emissions = 0; + final subscription = products.watchProducts().listen( + (_) => emissions++, + ); + await pumpEventQueue(); + + await service.sync(); + await service.sync(); + await pumpEventQueue(); + + // Only the first emission: the current list + expect(emissions, 1); + await subscription.cancel(); + }, + ); + + test( + 'a renamed product is shown under its new name, old drinks keep theirs', + () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab( + tabId: tab.id, + product: (await products.getProductById('prod-1'))!, + ); + server.respondWith({ + 'catalog': [beer(name: 'Pils')], + }); + await service.sync(); + + final shown = (await products.getProducts()).single; + expect(shown.name, 'Pils'); + await barTabService.addProductToTab(tabId: tab.id, product: shown); + + final names = [ + for (final line in (await barTabService.getTabById(tab.id))!.items) + line.productName, + ]; + expect(names, unorderedEquals(['Beer', 'Pils'])); + }, + ); + + test('a product added on the website can be sold right away', () async { + server.respondWith({ + 'catalog': [ + beer(), + { + 'id': 'web-1', + 'name': 'Ice Tea', + 'category': 'Frisdrank', + 'priceInCents': 280, + 'lowStockThreshold': 5, + 'archived': false, + }, + ], + 'stock': [ + {'productId': 'web-1', 'stock': 24, 'countedAt': null}, + ], + }); + await service.sync(); + final iceTea = (await products.getProductById('web-1'))!; + expect(iceTea.stockQuantity, 24); + expect(iceTea.infiniteStock, isFalse); + + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: iceTea); + server.respondWith({}); + await service.sync(); + + expect(sentLines(1).single, containsPair('productId', 'web-1')); + expect(sentLines(1).single, containsPair('unitPriceInCents', 280)); + expect((await products.getProductById('web-1'))!.stockQuantity, 23); + }); + + test( + 'an archived product leaves the bar screen but stays on open tabs', + () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab( + tabId: tab.id, + product: (await products.getProductById('prod-1'))!, + ); + server.respondWith({ + 'catalog': [beer(archived: true)], + }); + + await service.sync(); + + expect(await products.getProducts(), isEmpty); + expect( + (await barTabService.getTabById(tab.id))!.items.single.quantity, + 1, + ); + // The product form can still open it, e.g. from the history + expect(await products.getProductById('prod-1'), isNotNull); + }, + ); + + test('no warning level on the website is 0 on the tablet', () async { + server.respondWith({ + 'catalog': [beer(lowStockThreshold: null)], + }); + + await service.sync(); + + expect((await products.getProductById('prod-1'))!.lowStockThreshold, 0); + }); + + test('a price change leaves the lines of the server tabs alone', () async { + server.respondWith({ + 'catalog': [beer(price: 450)], + 'tabs': [ + { + 'id': 'tab-1', + 'customerName': 'Alice', + 'openedAt': '2026-09-24T20:00:00.000Z', + 'version': 2, + 'lines': [ + { + 'id': 'line-1', + 'productId': 'prod-1', + 'productName': 'Beer', + 'unitPriceInCents': 400, + 'quantity': 2, + 'units': [ + { + 'id': 'u1', + 'addedAt': '2026-09-24T20:00:00.000Z', + 'removedAt': null, + }, + { + 'id': 'u2', + 'addedAt': '2026-09-24T20:01:00.000Z', + 'removedAt': null, + }, + ], + }, + ], + }, + ], + }); + + await service.sync(); + + final tab = (await barTabService.getTabById('tab-1'))!; + expect(tab.items.single.unitPriceInCents, 400); + expect(tab.totalInCents, 800); + }); + }); + + group('a part of the response that fails', () { + Map brokenTab() { + // Two lines claiming the same unit: the database refuses the second + Map line(String id) => { + 'id': id, + 'productId': 'prod-1', + 'productName': 'Beer', + 'unitPriceInCents': 400, + 'quantity': 1, + 'units': [ + { + 'id': 'same-unit', + 'addedAt': '2026-09-24T20:00:00.000Z', + 'removedAt': null, + }, + ], + }; + return { + 'id': 'tab-1', + 'customerName': 'Alice', + 'openedAt': '2026-09-24T20:00:00.000Z', + 'version': 1, + 'lines': [line('line-1'), line('line-2')], + }; + } + + test('does not keep prices and stock from arriving', () async { + server.respondWith({ + 'tabs': [brokenTab()], + 'catalog': [ + { + 'id': 'prod-1', + 'name': 'Beer', + 'category': 'Drinks', + 'priceInCents': 450, + 'lowStockThreshold': 10, + 'archived': false, + }, + ], + 'stock': [ + {'productId': 'prod-1', 'stock': 42, 'countedAt': null}, + ], + }); + + await service.sync(); + await pumpEventQueue(); + + final beer = await (database.select( + database.products, + )..where((row) => row.id.equals('prod-1'))).getSingle(); + expect(beer.priceInCents, 450); + expect(beer.stockQuantity, 42); + }); + + test('is reported as a failed sync naming the part', () async { + server.respondWith({ + 'tabs': [brokenTab()], + }); + + await service.sync(); + await pumpEventQueue(); + + final failed = events.where( + (e) => e.type == RemoteSyncEventType.syncFailed, + ); + expect(failed.single.message, startsWith('tabs: ')); + expect(eventTypes(), isNot(contains(RemoteSyncEventType.syncCompleted))); + }); + + test('leaves the local tabs as they were', () async { + final tab = await barTabService.createTab(customerName: 'Bob'); + server.respondWith({ + 'tabs': [brokenTab()], + }); + + await service.sync(); + + expect(await barTabService.getTabById(tab.id), isNotNull); + expect(await barTabService.getTabById('tab-1'), isNull); + }); + + test('a later good response is applied normally', () async { + server.respondWith({ + 'tabs': [brokenTab()], + }); + await service.sync(); + + server.respondWith({ + 'tabs': [ + {...brokenTab(), 'lines': []}, + ], + }); + await service.sync(); + await pumpEventQueue(); + + expect(await barTabService.getTabById('tab-1'), isNotNull); + expect(eventTypes().last, RemoteSyncEventType.syncCompleted); + }); + }); + group('server holds the stock', () { Future beer() { return (database.select( diff --git a/test/tab_events_test.dart b/test/tab_events_test.dart index e71dd5a..2dc85ed 100644 --- a/test/tab_events_test.dart +++ b/test/tab_events_test.dart @@ -99,23 +99,79 @@ void main() { expect(row.id, event['id']); }); - test('adding to an existing line keeps that line\'s price', () async { + test( + 'a drink at a new price goes on a new line, with that price', + () async { + final tab = await openTab('Jan'); + await service.addProductToTab(tabId: tab.id, product: _cola); + await service.addProductToTab( + tabId: tab.id, + product: _cola.copyWith(priceInCents: 300), + ); + + final events = await outbox(); + expect(events.map((event) => event['unitPriceInCents']), [250, 300]); + expect(events.map((event) => event['productId']), ['prod-1', 'prod-1']); + // A different line, so the server doesn't put it on the old one either + expect(events[0]['lineId'], isNot(events[1]['lineId'])); + }, + ); + + test( + '+ on a line at an old price sends the new price, on a new line', + () async { + final tab = await openTab('Jan'); + await service.addProductToTab(tabId: tab.id, product: _cola); + final oldLine = await lineIdOf(tab.id); + await (database.update(database.products) + ..where((row) => row.id.equals('prod-1'))) + .write(const ProductsCompanion(priceInCents: Value(300))); + + await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1); + + final added = (await outbox()).last; + expect(added['type'], 'line_added'); + expect(added['unitPriceInCents'], 300); + expect(added['quantity'], 1); + expect(added['lineId'], isNot(oldLine)); + }, + ); + + test('a line without a product sends no productId', () async { final tab = await openTab('Jan'); - await service.addProductToTab(tabId: tab.id, product: _cola); - await service.addProductToTab( - tabId: tab.id, - product: const Product( - id: 'prod-1', - name: 'Cola', - category: 'Drinks', - stockQuantity: 100, - lowStockThreshold: 10, - priceInCents: 300, - ), + // As taken over from the tablet's own tabs: the line has no product + await database + .into(database.tabItems) + .insert( + TabItemsCompanion.insert( + id: 'seed:${tab.id}:0', + tabId: tab.id, + productId: '', + productName: 'Cola', + quantity: 1, + unitPriceInCents: 250, + createdAt: DateTime.now(), + ), + ); + + await service.adjustTabItemQuantity( + tabItemId: 'seed:${tab.id}:0', + delta: -1, ); + final event = (await outbox()).last; + expect(event['type'], 'line_removed'); + expect(event.containsKey('productId'), isFalse); + expect(event['productName'], 'Cola'); + }); + + test('a drink at the same price goes on the same line', () async { + final tab = await openTab('Jan'); + await service.addProductToTab(tabId: tab.id, product: _cola); + await service.addProductToTab(tabId: tab.id, product: _cola); + final events = await outbox(); - expect(events.map((event) => event['unitPriceInCents']), [250, 250]); + expect(events[0]['lineId'], events[1]['lineId']); }); test('changing a quantity writes the difference', () async {