feat: make server own products

This commit is contained in:
2026-10-21 12:10:42 +02:00
parent 93acfe15a9
commit b15d94a805
21 changed files with 1717 additions and 106 deletions
+12 -9
View File
@@ -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 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. 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. - The dashboard adds, changes and archives products (archived = deleted for the tablets, kept for tabs and history).
- **Ignore `stock`** in the report once the server owns the stock (keep accepting the field; older servers use it). A product added with a starting count is tracked from that moment.
- A product the server sees for the first time is created with `stock = null` (untracked). Its starting stock arrives - The tablet's `products` report is only used on the first sync of the venue, to take over its catalog and stock once.
as a `stock_set` event in the same request. After that it is ignored, also its `stock`.
- **Stop removing products that one tablet doesn't report**: with several tablets, tablet B not knowing a product of - The sync response sends `catalog`: every product of the venue,
tablet A must not delete A's stock. Deleting or hiding products becomes a dashboard action (out of scope here). `[{ 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
Process `products` **before** the events of the same request, so a `stock_set` for a new product finds it. 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 ### 4. Dashboard stock adjustments
+73
View File
@@ -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<void> applyServerCatalog(List<ServerProduct> 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)),
);
}
});
}
}
+5
View File
@@ -4,6 +4,7 @@
"barTabs": "Bar Tabs", "barTabs": "Bar Tabs",
"products": "Products", "products": "Products",
"addProduct": "Add product", "addProduct": "Add product",
"productsManagedOnWebsite": "Products are managed on the website. Here you can change the stock and the picture.",
"noProductsYet": "No products yet.", "noProductsYet": "No products yet.",
"addFirstProduct": "Add your first product to start selling.", "addFirstProduct": "Add your first product to start selling.",
"manageProducts": "Manage products", "manageProducts": "Manage products",
@@ -276,6 +277,10 @@
"simulatedUpdateNotes": "Bug fixes and performance improvements.", "simulatedUpdateNotes": "Bug fixes and performance improvements.",
"remoteActivity": "Remote activity", "remoteActivity": "Remote activity",
"remoteActivityHistory": "Recent 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", "syncNow": "Sync now",
"syncing": "Syncing…", "syncing": "Syncing…",
"lastSync": "Last sync: {time}", "lastSync": "Last sync: {time}",
+30
View File
@@ -122,6 +122,12 @@ abstract class AppLocalizations {
/// **'Add product'** /// **'Add product'**
String get addProduct; 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. /// No description provided for @noProductsYet.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1640,6 +1646,30 @@ abstract class AppLocalizations {
/// **'Recent remote activity'** /// **'Recent remote activity'**
String get remoteActivityHistory; 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. /// No description provided for @syncNow.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+18
View File
@@ -20,6 +20,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get addProduct => 'Add product'; 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 @override
String get noProductsYet => 'No products yet.'; String get noProductsYet => 'No products yet.';
@@ -846,6 +850,20 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get remoteActivityHistory => 'Recent remote activity'; 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 @override
String get syncNow => 'Sync now'; String get syncNow => 'Sync now';
+18
View File
@@ -20,6 +20,10 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get addProduct => 'Product toevoegen'; 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 @override
String get noProductsYet => 'Nog geen producten.'; String get noProductsYet => 'Nog geen producten.';
@@ -858,6 +862,20 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get remoteActivityHistory => 'Recente externe activiteit'; 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 @override
String get syncNow => 'Nu synchroniseren'; String get syncNow => 'Nu synchroniseren';
+5
View File
@@ -4,6 +4,7 @@
"barTabs": "Poefs", "barTabs": "Poefs",
"products": "Producten", "products": "Producten",
"addProduct": "Product toevoegen", "addProduct": "Product toevoegen",
"productsManagedOnWebsite": "Producten beheer je op de website. Hier pas je de voorraad en de foto aan.",
"noProductsYet": "Nog geen producten.", "noProductsYet": "Nog geen producten.",
"addFirstProduct": "Voeg je eerste product toe om te beginnen.", "addFirstProduct": "Voeg je eerste product toe om te beginnen.",
"manageProducts": "Producten beheren", "manageProducts": "Producten beheren",
@@ -276,6 +277,10 @@
"simulatedUpdateNotes": "Bugfixes en prestatieverbeteringen.", "simulatedUpdateNotes": "Bugfixes en prestatieverbeteringen.",
"remoteActivity": "Externe activiteit", "remoteActivity": "Externe activiteit",
"remoteActivityHistory": "Recente 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", "syncNow": "Nu synchroniseren",
"syncing": "Bezig met synchroniseren…", "syncing": "Bezig met synchroniseren…",
"lastSync": "Laatste sync: {time}", "lastSync": "Laatste sync: {time}",
+51
View File
@@ -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<String, dynamic> 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 { class SyncResponse {
final List<RemoteCommand> commands; final List<RemoteCommand> commands;
final String? cursor; final String? cursor;
@@ -183,6 +217,10 @@ class SyncResponse {
/// sales and applies [stockAdjustments] itself. /// sales and applies [stockAdjustments] itself.
final List<ServerStock>? stock; final List<ServerStock>? 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<ServerProduct>? catalog;
const SyncResponse({ const SyncResponse({
required this.commands, required this.commands,
this.cursor, this.cursor,
@@ -196,6 +234,7 @@ class SyncResponse {
this.settlements = const [], this.settlements = const [],
this.settlementsCursor, this.settlementsCursor,
this.stock, this.stock,
this.catalog,
}); });
factory SyncResponse.fromJson(Map<String, dynamic> json) { factory SyncResponse.fromJson(Map<String, dynamic> json) {
@@ -251,6 +290,13 @@ class SyncResponse {
], ],
_ => null, _ => null,
}, },
catalog: switch (json['catalog']) {
final List<dynamic> catalog => [
for (final product in catalog)
ServerProduct.fromJson(product as Map<String, dynamic>),
],
_ => null,
},
); );
} }
@@ -391,10 +437,15 @@ class RemoteSyncEvent {
final String? requestedBy; final String? requestedBy;
final String? message; 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({ const RemoteSyncEvent({
required this.type, required this.type,
this.tabCustomerName, this.tabCustomerName,
this.requestedBy, this.requestedBy,
this.message, this.message,
this.offline = false,
}); });
} }
+3 -1
View File
@@ -207,7 +207,9 @@ class TabEventFactory {
customerName: customerName, customerName: customerName,
fields: { fields: {
'lineId': lineId, '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, 'productName': productName,
'unitPriceInCents': unitPriceInCents, 'unitPriceInCents': unitPriceInCents,
'quantity': quantity, 'quantity': quantity,
+97 -66
View File
@@ -461,68 +461,87 @@ class DriftBarTabService implements BarTabService {
final tabRow = await _getTabRow(tabId); final tabRow = await _getTabRow(tabId);
if (tabRow == null) throw TabNotFoundException(tabId); if (tabRow == null) throw TabNotFoundException(tabId);
// A merge on the server can leave two lines of one product at await _addDrinks(
// different prices; new drinks go on the oldest tabRow,
final existingItem = productId: product.id,
await (database.select(database.tabItems) productName: product.name,
..where( unitPriceInCents: product.priceInCents,
(item) => quantity: 1,
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 _incrementTabVersion(tabId); 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<void> _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 @override
Future<int> adjustTabItemQuantity({ Future<int> adjustTabItemQuantity({
required String tabItemId, required String tabItemId,
@@ -537,19 +556,31 @@ class DriftBarTabService implements BarTabService {
if (item == null) return 0; 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 newQuantity = max(item.quantity + delta, 0);
final actualDelta = newQuantity - item.quantity; final actualDelta = newQuantity - item.quantity;
if (actualDelta == 0) return 0; if (actualDelta == 0) return 0;
if (actualDelta > 0) { await _removeLatestPurchases(item.id, -actualDelta);
for (var index = 0; index < actualDelta; index++) {
await _recordPurchase(item.id);
}
} else {
await _removeLatestPurchases(item.id, -actualDelta);
}
await (database.update(database.tabItems) await (database.update(database.tabItems)
..where((row) => row.id.equals(tabItemId))) ..where((row) => row.id.equals(tabItemId)))
.write(TabItemsCompanion(quantity: Value(newQuantity))); .write(TabItemsCompanion(quantity: Value(newQuantity)));
+73 -7
View File
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import '../database/app_database.dart'; import '../database/app_database.dart';
import '../database/catalog_queries.dart';
import '../database/stock_queries.dart'; import '../database/stock_queries.dart';
import '../models/bar_tab.dart'; import '../models/bar_tab.dart';
import '../models/payment_method.dart'; import '../models/payment_method.dart';
@@ -19,6 +20,16 @@ import 'kooltap_api_client.dart';
typedef _Credentials = ({String deviceId, String token}); 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<String> failures;
const SyncApplyException(this.failures);
@override
String toString() => failures.join('\n');
}
typedef _CommandOutcome = ({CommandResult result, String? message}); typedef _CommandOutcome = ({CommandResult result, String? message});
class RemoteSyncService { class RemoteSyncService {
@@ -225,7 +236,8 @@ class RemoteSyncService {
} on RateLimitedException catch (e) { } on RateLimitedException catch (e) {
nextDelay = await _recordFailure(e, retryAfter: e.retryAfter); nextDelay = await _recordFailure(e, retryAfter: e.retryAfter);
} catch (e, stack) { } 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)); unawaited(Sentry.captureException(e, stackTrace: stack));
} }
nextDelay = await _recordFailure(e); nextDelay = await _recordFailure(e);
@@ -252,7 +264,13 @@ class RemoteSyncService {
debugPrint('RemoteSyncService: sync failed: $error'); debugPrint('RemoteSyncService: sync failed: $error');
_consecutiveFailures++; _consecutiveFailures++;
await _updateSyncState(success: false); 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(); return retryAfter ?? _backoff();
} }
@@ -293,18 +311,46 @@ class RemoteSyncService {
await _clearSentEvents(response); await _clearSentEvents(response);
await _markStockResultsReceived(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 = <String>[];
final settlementsApplied = await _applyPart(
'settlements',
failures,
() => barTabService.recordSettlements(response.settlements),
);
final serverTabs = response.tabs; final serverTabs = response.tabs;
if (serverTabs != null) { if (serverTabs != null) {
// After the acked events left the outbox: what is left still has to // After the acked events left the outbox: what is left still has to
// reach the server and is replayed on top // 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 // Same rebase for the stock: the server counts every tablet's sales
final serverStock = response.stock; 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) { for (final command in response.commands) {
await _processCommand(command, credentials); await _processCommand(command, credentials);
@@ -329,15 +375,35 @@ class RemoteSyncService {
} }
} }
// Settlements that weren't stored are asked for again next time
await _updateSyncState( await _updateSyncState(
success: true, success: failures.isEmpty,
cursor: response.cursor, cursor: response.cursor,
settlementsCursor: response.settlementsCursor, settlementsCursor: settlementsApplied ? response.settlementsCursor : null,
); );
if (failures.isNotEmpty) throw SyncApplyException(failures);
return appliedStock; 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<bool> _applyPart(
String part,
List<String> failures,
Future<void> 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<List<TabEventRow>> _pendingEvents() { Future<List<TabEventRow>> _pendingEvents() {
return (database.select(database.tabEvents) return (database.select(database.tabEvents)
..orderBy([ ..orderBy([
@@ -35,6 +35,11 @@ class RemoteSyncViewModel extends ChangeNotifier {
String? _lastError; String? _lastError;
String? get lastError => _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? _venueId;
String? get venueId => _venueId; String? get venueId => _venueId;
@@ -109,8 +114,10 @@ class RemoteSyncViewModel extends ChangeNotifier {
case RemoteSyncEventType.syncCompleted: case RemoteSyncEventType.syncCompleted:
_lastSyncAt = DateTime.now(); _lastSyncAt = DateTime.now();
_lastError = null; _lastError = null;
_isOffline = false;
case RemoteSyncEventType.syncFailed: case RemoteSyncEventType.syncFailed:
_lastError = event.message; _lastError = event.message;
_isOffline = event.offline;
case RemoteSyncEventType.unauthorized: case RemoteSyncEventType.unauthorized:
_isConfigured = false; _isConfigured = false;
_wasRevoked = true; _wasRevoked = true;
+43
View File
@@ -90,6 +90,23 @@ class _BarScreenViewState extends State<BarScreenView> {
} }
} }
void _showSyncProblem({required String title, required String description}) {
final l10n = AppLocalizations.of(context);
showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: SelectableText(description),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(l10n.close),
),
],
),
);
}
void _logout() { void _logout() {
context.read<PinLockViewModel>().lock(); context.read<PinLockViewModel>().lock();
context.read<AdminPinViewModel>().lock(); context.read<AdminPinViewModel>().lock();
@@ -142,6 +159,32 @@ class _BarScreenViewState extends State<BarScreenView> {
icon: const Icon(Icons.cloud_off_rounded), 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<RemoteSyncViewModel>().lastError
case final error?) ...[
const SizedBox(width: 6),
// Offline is normal for a bar; anything else needs attention
if (context.watch<RemoteSyncViewModel>().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), const SizedBox(width: 6),
IconButton( IconButton(
tooltip: l10n.refresh, tooltip: l10n.refresh,
+14 -1
View File
@@ -12,6 +12,8 @@ import '../models/product.dart';
import '../utils/money.dart'; import '../utils/money.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../viewmodels/product_list_view_model.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 { class ProductFormView extends StatefulWidget {
final String? productId; final String? productId;
@@ -313,6 +315,9 @@ class _ProductFormViewState extends State<ProductFormView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final title = widget.isEditing ? l10n.editProduct : l10n.addProduct; 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<RemoteSyncViewModel>().isConfigured;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -322,7 +327,7 @@ class _ProductFormViewState extends State<ProductFormView> {
onPressed: _leaveProductForm, onPressed: _leaveProductForm,
), ),
actions: [ actions: [
if (widget.isEditing) if (widget.isEditing && !managedOnWebsite)
IconButton( IconButton(
onPressed: _delete, onPressed: _delete,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
@@ -342,10 +347,15 @@ class _ProductFormViewState extends State<ProductFormView> {
ScrollViewKeyboardDismissBehavior.onDrag, ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
children: [ children: [
if (managedOnWebsite) ...[
const ManagedOnWebsiteNotice(),
const SizedBox(height: 16),
],
_buildImagePicker(context), _buildImagePicker(context),
const SizedBox(height: 24), const SizedBox(height: 24),
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
enabled: !managedOnWebsite,
decoration: InputDecoration( decoration: InputDecoration(
labelText: l10n.productName, labelText: l10n.productName,
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -366,6 +376,7 @@ class _ProductFormViewState extends State<ProductFormView> {
builder: (context, constraints) { builder: (context, constraints) {
return DropdownMenu<String>( return DropdownMenu<String>(
width: constraints.maxWidth, width: constraints.maxWidth,
enabled: !managedOnWebsite,
initialSelection: _selectedCategory, initialSelection: _selectedCategory,
enableFilter: true, enableFilter: true,
enableSearch: true, enableSearch: true,
@@ -395,6 +406,7 @@ class _ProductFormViewState extends State<ProductFormView> {
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _priceController, controller: _priceController,
enabled: !managedOnWebsite,
decoration: InputDecoration( decoration: InputDecoration(
labelText: l10n.price, labelText: l10n.price,
prefixText: '€ ', prefixText: '€ ',
@@ -455,6 +467,7 @@ class _ProductFormViewState extends State<ProductFormView> {
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _lowStockController, controller: _lowStockController,
enabled: !managedOnWebsite,
decoration: InputDecoration( decoration: InputDecoration(
labelText: l10n.lowStockThreshold, labelText: l10n.lowStockThreshold,
border: OutlineInputBorder(), border: OutlineInputBorder(),
+17 -8
View File
@@ -9,6 +9,8 @@ import '../l10n/app_localizations_helpers.dart';
import '../utils/money.dart'; import '../utils/money.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../viewmodels/product_list_view_model.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 { class ProductListView extends StatefulWidget {
const ProductListView({super.key}); const ProductListView({super.key});
@@ -31,6 +33,8 @@ class _ProductListViewState extends State<ProductListView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final viewModel = context.watch<ProductListViewModel>(); final viewModel = context.watch<ProductListViewModel>();
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
// A paired tablet gets its products from the website
final managedOnWebsite = context.watch<RemoteSyncViewModel>().isConfigured;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -41,11 +45,13 @@ class _ProductListViewState extends State<ProductListView> {
title: Text(l10n.products), title: Text(l10n.products),
), ),
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
floatingActionButton: FloatingActionButton.extended( floatingActionButton: managedOnWebsite
onPressed: () => context.push('/products/new'), ? null
icon: const Icon(Icons.add), : FloatingActionButton.extended(
label: Text(l10n.addProduct), onPressed: () => context.push('/products/new'),
), icon: const Icon(Icons.add),
label: Text(l10n.addProduct),
),
body: Builder( body: Builder(
builder: (context) { builder: (context) {
if (viewModel.isLoading) { if (viewModel.isLoading) {
@@ -56,16 +62,19 @@ class _ProductListViewState extends State<ProductListView> {
return Center(child: Text(l10n.errorText(viewModel.loadError))); return Center(child: Text(l10n.errorText(viewModel.loadError)));
} }
if (viewModel.products.isEmpty) { if (viewModel.products.isEmpty && !managedOnWebsite) {
return Center(child: Text(l10n.noProductsYet)); return Center(child: Text(l10n.noProductsYet));
} }
// The notice takes the first row
final offset = managedOnWebsite ? 1 : 0;
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: viewModel.products.length, itemCount: viewModel.products.length + offset,
separatorBuilder: (_, _) => const SizedBox(height: 8), separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final product = viewModel.products[index]; if (index < offset) return const ManagedOnWebsiteNotice();
final product = viewModel.products[index - offset];
return Card( return Card(
child: ListTile( child: ListTile(
@@ -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),
),
),
);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ name: kooltap
description: "KoolTap: a tablet point-of-sale app for running bar tabs." description: "KoolTap: a tablet point-of-sale app for running bar tabs."
publish_to: 'none' publish_to: 'none'
version: 1.3.1+0 version: 1.3.4+0
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2
+349
View File
@@ -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<void> 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<String> 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<List<(int, int)>> lines(String tabId) async {
return [
for (final item in (await service.getTabById(tabId))!.items)
(item.unitPriceInCents, item.quantity),
];
}
Future<String> 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', () { group('adjustTabItemQuantity', () {
test('updates item quantity', () async { test('updates item quantity', () async {
final tab = await service.createTab(customerName: 'John'); final tab = await service.createTab(customerName: 'John');
+307
View File
@@ -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<String, dynamic>;
final user = body['user'] as Map<String, dynamic>;
return _Website(body['accessToken'] as String, user['venueId'] as String);
}
Future<Object?> 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<String> registrationCode() async {
final body = await call('GET', '/devices/generation-code');
return (body as Map<String, dynamic>)['code'] as String;
}
Future<Map<String, dynamic>> createProduct(
Map<String, Object?> product,
) async {
return await call('POST', '/venues/$venueId/stock/products', product)
as Map<String, dynamic>;
}
Future<void> changeProduct(String id, Map<String, Object?> changes) {
return call('PATCH', '/venues/$venueId/stock/products/$id', changes);
}
Future<List<dynamic>> tabs() async {
final body = await call('GET', '/venues/$venueId/tabs');
return (body as Map<String, dynamic>)['tabs'] as List<dynamic>;
}
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<RemoteSyncEvent> _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<void> 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> product(String name) async {
return (await products.getProducts()).singleWhere((p) => p.name == name);
}
Future<void> 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)),
);
}
+501
View File
@@ -127,6 +127,35 @@ void main() {
]; ];
group('sync', () { 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 = <RemoteSyncEvent>[];
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 { test('sends a snapshot of the open tabs and stores the cursor', () async {
final tab = await barTabService.createTab(customerName: 'Alice'); final tab = await barTabService.createTab(customerName: 'Alice');
await barTabService.addProductToTab(tabId: tab.id, product: _beer); await barTabService.addProductToTab(tabId: tab.id, product: _beer);
@@ -1363,6 +1392,478 @@ void main() {
}); });
}); });
group('website manages the catalog', () {
Map<String, dynamic> 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<Map<String, ProductRow>> 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<ProductRow>()
.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<ProductRow>()
.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<ProductRow>()
.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<String, dynamic> 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<Map<String, dynamic>> sentLines(int request) => [
for (final event in server.syncRequests[request]['events'] as List)
if (event['type'] == 'line_added') event as Map<String, dynamic>,
];
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 = <List<int>>[];
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<String, dynamic> brokenTab() {
// Two lines claiming the same unit: the database refuses the second
Map<String, dynamic> 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': <Object>[]},
],
});
await service.sync();
await pumpEventQueue();
expect(await barTabService.getTabById('tab-1'), isNotNull);
expect(eventTypes().last, RemoteSyncEventType.syncCompleted);
});
});
group('server holds the stock', () { group('server holds the stock', () {
Future<ProductRow> beer() { Future<ProductRow> beer() {
return (database.select( return (database.select(
+69 -13
View File
@@ -99,23 +99,79 @@ void main() {
expect(row.id, event['id']); 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'); final tab = await openTab('Jan');
await service.addProductToTab(tabId: tab.id, product: _cola); // As taken over from the tablet's own tabs: the line has no product
await service.addProductToTab( await database
tabId: tab.id, .into(database.tabItems)
product: const Product( .insert(
id: 'prod-1', TabItemsCompanion.insert(
name: 'Cola', id: 'seed:${tab.id}:0',
category: 'Drinks', tabId: tab.id,
stockQuantity: 100, productId: '',
lowStockThreshold: 10, productName: 'Cola',
priceInCents: 300, 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(); 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 { test('changing a quantity writes the difference', () async {