diff --git a/assets/Cha-Ching.mp3 b/assets/Cha-Ching.mp3 new file mode 100644 index 0000000..d204198 Binary files /dev/null and b/assets/Cha-Ching.mp3 differ diff --git a/docs/server-tabs-source-of-truth.md b/docs/server-tabs-source-of-truth.md index d007d18..e5a9eb7 100644 --- a/docs/server-tabs-source-of-truth.md +++ b/docs/server-tabs-source-of-truth.md @@ -21,7 +21,7 @@ Common fields (already sent): `id`, `type`, `tabId`, `customerName`, `occurredAt | `type` | Extra fields | New? | |---|---|---| -| `tab_opened` | `openedAt` | **new** | +| `tab_opened` | `openedAt`, `email?` | **new** (`email` newer, see section 6) | | `tab_renamed` | — (`customerName` is the new name) | **new** | | `tab_deleted` | — (always preceded by a `line_removed` for every line still on the tab) | **new** | | `line_added` | `lineId`, `productId`, `productName`, `unitPriceInCents`, `quantity` (≥ 1) | `lineId`, `productId` new | @@ -30,10 +30,15 @@ Common fields (already sent): `id`, `type`, `tabId`, `customerName`, `occurredAt Events created by an older app version may lack `lineId` / `productId`; match those by name (see below). +`line_added` and `line_removed` also carry `orderId`: the order the drinks belong to (drinks rung up on one tab +without a pause of 5 minutes, and without leaving the tab in between). Each unit stores it as `orderId`; units from +older app versions have none and are grouped by time (a gap of over 5 minutes starts a new order). On `line_removed` +the field is optional: present (also `null`), only units of that order count; absent, any unit of the line. + ### 2. The venue's tab state ``` -Tab { id, customerName, openedAt, version, lines: Line[] } +Tab { id, customerName, email | null, openedAt, version, lines: Line[] } Line { id, productId, productName, unitPriceInCents, quantity, units: Unit[] } Unit { id, addedAt, removedAt | null } // one per piece ever put on the line ``` @@ -48,11 +53,11 @@ Unit { id, addedAt, removedAt | null } // one per piece ever put on the lin | Event | Rule | |---|---| -| `tab_opened` | Create the tab with `tabId`, `customerName`, `openedAt` if it doesn't exist. Duplicate names are allowed (two offline tablets opening "Jan" give two tabs). | +| `tab_opened` | Create the tab with `tabId`, `customerName`, `openedAt` and `email` (absent → `null`) if it doesn't exist. Duplicate names are allowed (two offline tablets opening "Jan" give two tabs). | | `tab_renamed` | Set `customerName`. Unknown tab → ignore (acknowledge). | | `tab_deleted` | Remove the tab and its lines. Unknown tab → ignore. | -| `line_added` | If the tab doesn't exist (deleted elsewhere), **re-create it** from `tabId` + `customerName` (`openedAt = occurredAt`), so no sale is lost. Find the line; none → create it with `id = lineId` (or a new id if that id is taken). Add `quantity` units with `id = ":"` (index 0…quantity−1) and `addedAt = occurredAt`. | -| `line_removed` | Find the line; none → ignore. Mark the `min(quantity, line.quantity)` **latest** active units removed (`removedAt = occurredAt`). | +| `line_added` | If the tab doesn't exist (deleted elsewhere), **re-create it** from `tabId` + `customerName` (`openedAt = occurredAt`), so no sale is lost. Find the line; none → create it with `id = lineId` (or a new id if that id is taken). Add `quantity` units with `id = ":"` (index 0…quantity−1), `addedAt = occurredAt` and the event's `orderId`. | +| `line_removed` | Find the line; none → ignore. Mark the `min(quantity, n)` **latest** active units removed (`removedAt = occurredAt`), where `n` counts the line's active units of the event's `orderId` (all of them when the event has no `orderId`). | | `settled` | For each item: find the line; take `min(item.quantity, line.quantity)` of the **oldest** active units off it, together with the line's removed units; drop the line when nothing is left. Drinks added after the settlement (by another tablet) **stay on the tab**. Record a settlement (section 4) with id = the event's `id`. The tab itself stays open (tabs are never closed, only emptied). | The unit ids **must** follow `":"`: the tablet gives replayed units the same ids, so a sync that @@ -65,7 +70,7 @@ Stock is applied in the same transaction, see `server-stock-source-of-truth.md`. ```json { "tabs": [ - { "id": "…", "customerName": "Alice", "openedAt": "2026-10-24T20:00:00.000Z", "version": 7, + { "id": "…", "customerName": "Alice", "email": "alice@example.com", "openedAt": "2026-10-24T20:00:00.000Z", "version": 7, "lines": [ { "id": "…", "productId": "12", "productName": "Cola", "unitPriceInCents": 250, "quantity": 1, "units": [ { "id": ":0", "addedAt": "…", "removedAt": null }, @@ -116,6 +121,26 @@ Response: - **Planned tab names**: create them as tabs in the server state. Once `tabs` is sent, the app ignores `plannedTabNames` (otherwise every tablet would open them). +### 6. Customer email + +Since the Open Tab dialog, the bartender can add a customer with a name and an **optional email**. The app sends it +on the `tab_opened` event: + +```json +{ "id": "…", "type": "tab_opened", "tabId": "…", "customerName": "Alice", "occurredAt": "…", + "openedAt": "…", "email": "alice@example.com" } +``` + +- `email` is **left out** (not `null`) when none was entered, and on every event from older app versions. The app trims + it and checks it looks like `name@domain.tld`; validate again if you rely on it, and reject nothing over it (store + `null` instead of refusing the event). +- Store it on the tab and return it as `email` in `tabs` (`null` or absent when there is none). +- There is no event to change or remove an email yet; the dashboard is the place for that if needed. +- Until the server returns `email`, the tablet keeps the one entered on it (by tab id) across syncs, but other + tablets don't see it. Note that the tablet treats `null`/absent as "unknown" and keeps its own value, so clearing an + email from the dashboard won't reach a tablet that has one stored. +- The email is personal data: show it only to venue staff, and delete it with the tab. + ### Order within one sync 1. Apply the received events (once per id), oldest first; acknowledge/reject them. @@ -138,3 +163,5 @@ Response: - Settle on the dashboard with every tablet off: the tab is emptied at once; the tablets show it in History after their next sync, exactly once. - The same event id sent twice (also in two syncs) is applied once. +- Add a customer with an email on tablet A: after both synced, tablet B shows the email under the name in the Open Tab + dialog. A customer added without email has `email: null` in `tabs`. diff --git a/lib/app/router.dart b/lib/app/router.dart index 8385559..8051cc8 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -168,8 +168,7 @@ GoRouter createAppRouter({ onSet: adminPinViewModel.setPin, error: () => adminPinViewModel.error, onSuccess: () => context.go(_returnLocation(state) ?? '/admin'), - onClose: () => - context.canPop() ? context.pop() : context.go('/bar'), + onClose: () => context.canPop() ? context.pop() : context.go('/bar'), ), ), diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 8bdb188..d0bd767 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -55,6 +55,9 @@ class BarTabs extends Table { TextColumn get customerName => text()(); + /// Optional, entered when the customer was added. + TextColumn get email => text().nullable()(); + DateTimeColumn get openedAt => dateTime()(); IntColumn get version => integer().withDefault(const Constant(0))(); @@ -95,6 +98,10 @@ class TabItemPurchases extends Table { DateTimeColumn get removedAt => dateTime().nullable()(); + /// The order this drink was rung up in: drinks ordered together are shown + /// together. Null for drinks from before orders were tracked. + TextColumn get orderId => text().nullable()(); + @override Set get primaryKey => {id}; } @@ -135,6 +142,8 @@ class ClosedTabItems extends Table { DateTimeColumn get removedAt => dateTime().nullable()(); + TextColumn get orderId => text().nullable()(); + @override Set get primaryKey => {id}; } @@ -268,7 +277,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 18; + int get schemaVersion => 20; @override MigrationStrategy get migration { @@ -358,7 +367,10 @@ class AppDatabase extends _$AppDatabase { // Tabs never close (only their items are archived), so the status // and closed_at columns were never anything but 'open' and null. if (await hasTableColumn('bar_tabs', 'status')) { - await migrator.alterTable(TableMigration(barTabs)); + // `email` only arrives in v20 + await migrator.alterTable( + TableMigration(barTabs, newColumns: [barTabs.email]), + ); } await _backfillPurchaseRows(); await _deleteOrphanedPurchaseRows(); @@ -391,6 +403,25 @@ class AppDatabase extends _$AppDatabase { } await migrator.createTable(pendingImageUploads); } + + if (from < 19) { + // `id`: the table exists + if (await hasTableColumn('tab_item_purchases', 'id') && + !await hasTableColumn('tab_item_purchases', 'order_id')) { + await migrator.addColumn( + tabItemPurchases, + tabItemPurchases.orderId, + ); + } + if (await hasTableColumn('closed_tab_items', 'id') && + !await hasTableColumn('closed_tab_items', 'order_id')) { + await migrator.addColumn(closedTabItems, closedTabItems.orderId); + } + } + + if (from < 20 && !await hasTableColumn('bar_tabs', 'email')) { + await migrator.addColumn(barTabs, barTabs.email); + } }, ); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 10d76d9..b4387fe 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -663,6 +663,15 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _emailMeta = const VerificationMeta('email'); + @override + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _openedAtMeta = const VerificationMeta( 'openedAt', ); @@ -687,7 +696,13 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { defaultValue: const Constant(0), ); @override - List get $columns => [id, customerName, openedAt, version]; + List get $columns => [ + id, + customerName, + email, + openedAt, + version, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -716,6 +731,12 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { } else if (isInserting) { context.missing(_customerNameMeta); } + if (data.containsKey('email')) { + context.handle( + _emailMeta, + email.isAcceptableOrUnknown(data['email']!, _emailMeta), + ); + } if (data.containsKey('opened_at')) { context.handle( _openedAtMeta, @@ -747,6 +768,10 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { DriftSqlType.string, data['${effectivePrefix}customer_name'], )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + ), openedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}opened_at'], @@ -767,11 +792,15 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { class BarTabRow extends DataClass implements Insertable { final String id; final String customerName; + + /// Optional, entered when the customer was added. + final String? email; final DateTime openedAt; final int version; const BarTabRow({ required this.id, required this.customerName, + this.email, required this.openedAt, required this.version, }); @@ -780,6 +809,9 @@ class BarTabRow extends DataClass implements Insertable { final map = {}; map['id'] = Variable(id); map['customer_name'] = Variable(customerName); + if (!nullToAbsent || email != null) { + map['email'] = Variable(email); + } map['opened_at'] = Variable(openedAt); map['version'] = Variable(version); return map; @@ -789,6 +821,9 @@ class BarTabRow extends DataClass implements Insertable { return BarTabsCompanion( id: Value(id), customerName: Value(customerName), + email: email == null && nullToAbsent + ? const Value.absent() + : Value(email), openedAt: Value(openedAt), version: Value(version), ); @@ -802,6 +837,7 @@ class BarTabRow extends DataClass implements Insertable { return BarTabRow( id: serializer.fromJson(json['id']), customerName: serializer.fromJson(json['customerName']), + email: serializer.fromJson(json['email']), openedAt: serializer.fromJson(json['openedAt']), version: serializer.fromJson(json['version']), ); @@ -812,6 +848,7 @@ class BarTabRow extends DataClass implements Insertable { return { 'id': serializer.toJson(id), 'customerName': serializer.toJson(customerName), + 'email': serializer.toJson(email), 'openedAt': serializer.toJson(openedAt), 'version': serializer.toJson(version), }; @@ -820,11 +857,13 @@ class BarTabRow extends DataClass implements Insertable { BarTabRow copyWith({ String? id, String? customerName, + Value email = const Value.absent(), DateTime? openedAt, int? version, }) => BarTabRow( id: id ?? this.id, customerName: customerName ?? this.customerName, + email: email.present ? email.value : this.email, openedAt: openedAt ?? this.openedAt, version: version ?? this.version, ); @@ -834,6 +873,7 @@ class BarTabRow extends DataClass implements Insertable { customerName: data.customerName.present ? data.customerName.value : this.customerName, + email: data.email.present ? data.email.value : this.email, openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt, version: data.version.present ? data.version.value : this.version, ); @@ -844,6 +884,7 @@ class BarTabRow extends DataClass implements Insertable { return (StringBuffer('BarTabRow(') ..write('id: $id, ') ..write('customerName: $customerName, ') + ..write('email: $email, ') ..write('openedAt: $openedAt, ') ..write('version: $version') ..write(')')) @@ -851,13 +892,14 @@ class BarTabRow extends DataClass implements Insertable { } @override - int get hashCode => Object.hash(id, customerName, openedAt, version); + int get hashCode => Object.hash(id, customerName, email, openedAt, version); @override bool operator ==(Object other) => identical(this, other) || (other is BarTabRow && other.id == this.id && other.customerName == this.customerName && + other.email == this.email && other.openedAt == this.openedAt && other.version == this.version); } @@ -865,12 +907,14 @@ class BarTabRow extends DataClass implements Insertable { class BarTabsCompanion extends UpdateCompanion { final Value id; final Value customerName; + final Value email; final Value openedAt; final Value version; final Value rowid; const BarTabsCompanion({ this.id = const Value.absent(), this.customerName = const Value.absent(), + this.email = const Value.absent(), this.openedAt = const Value.absent(), this.version = const Value.absent(), this.rowid = const Value.absent(), @@ -878,6 +922,7 @@ class BarTabsCompanion extends UpdateCompanion { BarTabsCompanion.insert({ required String id, required String customerName, + this.email = const Value.absent(), required DateTime openedAt, this.version = const Value.absent(), this.rowid = const Value.absent(), @@ -887,6 +932,7 @@ class BarTabsCompanion extends UpdateCompanion { static Insertable custom({ Expression? id, Expression? customerName, + Expression? email, Expression? openedAt, Expression? version, Expression? rowid, @@ -894,6 +940,7 @@ class BarTabsCompanion extends UpdateCompanion { return RawValuesInsertable({ if (id != null) 'id': id, if (customerName != null) 'customer_name': customerName, + if (email != null) 'email': email, if (openedAt != null) 'opened_at': openedAt, if (version != null) 'version': version, if (rowid != null) 'rowid': rowid, @@ -903,6 +950,7 @@ class BarTabsCompanion extends UpdateCompanion { BarTabsCompanion copyWith({ Value? id, Value? customerName, + Value? email, Value? openedAt, Value? version, Value? rowid, @@ -910,6 +958,7 @@ class BarTabsCompanion extends UpdateCompanion { return BarTabsCompanion( id: id ?? this.id, customerName: customerName ?? this.customerName, + email: email ?? this.email, openedAt: openedAt ?? this.openedAt, version: version ?? this.version, rowid: rowid ?? this.rowid, @@ -925,6 +974,9 @@ class BarTabsCompanion extends UpdateCompanion { if (customerName.present) { map['customer_name'] = Variable(customerName.value); } + if (email.present) { + map['email'] = Variable(email.value); + } if (openedAt.present) { map['opened_at'] = Variable(openedAt.value); } @@ -942,6 +994,7 @@ class BarTabsCompanion extends UpdateCompanion { return (StringBuffer('BarTabsCompanion(') ..write('id: $id, ') ..write('customerName: $customerName, ') + ..write('email: $email, ') ..write('openedAt: $openedAt, ') ..write('version: $version, ') ..write('rowid: $rowid') @@ -1479,8 +1532,25 @@ class $TabItemPurchasesTable extends TabItemPurchases type: DriftSqlType.dateTime, requiredDuringInsert: false, ); + static const VerificationMeta _orderIdMeta = const VerificationMeta( + 'orderId', + ); @override - List get $columns => [id, tabItemId, purchasedAt, removedAt]; + late final GeneratedColumn orderId = GeneratedColumn( + 'order_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + tabItemId, + purchasedAt, + removedAt, + orderId, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -1523,6 +1593,12 @@ class $TabItemPurchasesTable extends TabItemPurchases removedAt.isAcceptableOrUnknown(data['removed_at']!, _removedAtMeta), ); } + if (data.containsKey('order_id')) { + context.handle( + _orderIdMeta, + orderId.isAcceptableOrUnknown(data['order_id']!, _orderIdMeta), + ); + } return context; } @@ -1548,6 +1624,10 @@ class $TabItemPurchasesTable extends TabItemPurchases DriftSqlType.dateTime, data['${effectivePrefix}removed_at'], ), + orderId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_id'], + ), ); } @@ -1563,11 +1643,16 @@ class TabItemPurchaseRow extends DataClass final String tabItemId; final DateTime purchasedAt; final DateTime? removedAt; + + /// The order this drink was rung up in: drinks ordered together are shown + /// together. Null for drinks from before orders were tracked. + final String? orderId; const TabItemPurchaseRow({ required this.id, required this.tabItemId, required this.purchasedAt, this.removedAt, + this.orderId, }); @override Map toColumns(bool nullToAbsent) { @@ -1578,6 +1663,9 @@ class TabItemPurchaseRow extends DataClass if (!nullToAbsent || removedAt != null) { map['removed_at'] = Variable(removedAt); } + if (!nullToAbsent || orderId != null) { + map['order_id'] = Variable(orderId); + } return map; } @@ -1589,6 +1677,9 @@ class TabItemPurchaseRow extends DataClass removedAt: removedAt == null && nullToAbsent ? const Value.absent() : Value(removedAt), + orderId: orderId == null && nullToAbsent + ? const Value.absent() + : Value(orderId), ); } @@ -1602,6 +1693,7 @@ class TabItemPurchaseRow extends DataClass tabItemId: serializer.fromJson(json['tabItemId']), purchasedAt: serializer.fromJson(json['purchasedAt']), removedAt: serializer.fromJson(json['removedAt']), + orderId: serializer.fromJson(json['orderId']), ); } @override @@ -1612,6 +1704,7 @@ class TabItemPurchaseRow extends DataClass 'tabItemId': serializer.toJson(tabItemId), 'purchasedAt': serializer.toJson(purchasedAt), 'removedAt': serializer.toJson(removedAt), + 'orderId': serializer.toJson(orderId), }; } @@ -1620,11 +1713,13 @@ class TabItemPurchaseRow extends DataClass String? tabItemId, DateTime? purchasedAt, Value removedAt = const Value.absent(), + Value orderId = const Value.absent(), }) => TabItemPurchaseRow( id: id ?? this.id, tabItemId: tabItemId ?? this.tabItemId, purchasedAt: purchasedAt ?? this.purchasedAt, removedAt: removedAt.present ? removedAt.value : this.removedAt, + orderId: orderId.present ? orderId.value : this.orderId, ); TabItemPurchaseRow copyWithCompanion(TabItemPurchasesCompanion data) { return TabItemPurchaseRow( @@ -1634,6 +1729,7 @@ class TabItemPurchaseRow extends DataClass ? data.purchasedAt.value : this.purchasedAt, removedAt: data.removedAt.present ? data.removedAt.value : this.removedAt, + orderId: data.orderId.present ? data.orderId.value : this.orderId, ); } @@ -1643,13 +1739,15 @@ class TabItemPurchaseRow extends DataClass ..write('id: $id, ') ..write('tabItemId: $tabItemId, ') ..write('purchasedAt: $purchasedAt, ') - ..write('removedAt: $removedAt') + ..write('removedAt: $removedAt, ') + ..write('orderId: $orderId') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, tabItemId, purchasedAt, removedAt); + int get hashCode => + Object.hash(id, tabItemId, purchasedAt, removedAt, orderId); @override bool operator ==(Object other) => identical(this, other) || @@ -1657,7 +1755,8 @@ class TabItemPurchaseRow extends DataClass other.id == this.id && other.tabItemId == this.tabItemId && other.purchasedAt == this.purchasedAt && - other.removedAt == this.removedAt); + other.removedAt == this.removedAt && + other.orderId == this.orderId); } class TabItemPurchasesCompanion extends UpdateCompanion { @@ -1665,12 +1764,14 @@ class TabItemPurchasesCompanion extends UpdateCompanion { final Value tabItemId; final Value purchasedAt; final Value removedAt; + final Value orderId; final Value rowid; const TabItemPurchasesCompanion({ this.id = const Value.absent(), this.tabItemId = const Value.absent(), this.purchasedAt = const Value.absent(), this.removedAt = const Value.absent(), + this.orderId = const Value.absent(), this.rowid = const Value.absent(), }); TabItemPurchasesCompanion.insert({ @@ -1678,6 +1779,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion { required String tabItemId, required DateTime purchasedAt, this.removedAt = const Value.absent(), + this.orderId = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), tabItemId = Value(tabItemId), @@ -1687,6 +1789,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion { Expression? tabItemId, Expression? purchasedAt, Expression? removedAt, + Expression? orderId, Expression? rowid, }) { return RawValuesInsertable({ @@ -1694,6 +1797,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion { if (tabItemId != null) 'tab_item_id': tabItemId, if (purchasedAt != null) 'purchased_at': purchasedAt, if (removedAt != null) 'removed_at': removedAt, + if (orderId != null) 'order_id': orderId, if (rowid != null) 'rowid': rowid, }); } @@ -1703,6 +1807,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion { Value? tabItemId, Value? purchasedAt, Value? removedAt, + Value? orderId, Value? rowid, }) { return TabItemPurchasesCompanion( @@ -1710,6 +1815,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion { tabItemId: tabItemId ?? this.tabItemId, purchasedAt: purchasedAt ?? this.purchasedAt, removedAt: removedAt ?? this.removedAt, + orderId: orderId ?? this.orderId, rowid: rowid ?? this.rowid, ); } @@ -1729,6 +1835,9 @@ class TabItemPurchasesCompanion extends UpdateCompanion { if (removedAt.present) { map['removed_at'] = Variable(removedAt.value); } + if (orderId.present) { + map['order_id'] = Variable(orderId.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -1742,6 +1851,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion { ..write('tabItemId: $tabItemId, ') ..write('purchasedAt: $purchasedAt, ') ..write('removedAt: $removedAt, ') + ..write('orderId: $orderId, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -2216,6 +2326,17 @@ class $ClosedTabItemsTable extends ClosedTabItems type: DriftSqlType.dateTime, requiredDuringInsert: false, ); + static const VerificationMeta _orderIdMeta = const VerificationMeta( + 'orderId', + ); + @override + late final GeneratedColumn orderId = GeneratedColumn( + 'order_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -2226,6 +2347,7 @@ class $ClosedTabItemsTable extends ClosedTabItems unitPriceInCents, purchasedAt, removedAt, + orderId, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -2308,6 +2430,12 @@ class $ClosedTabItemsTable extends ClosedTabItems removedAt.isAcceptableOrUnknown(data['removed_at']!, _removedAtMeta), ); } + if (data.containsKey('order_id')) { + context.handle( + _orderIdMeta, + orderId.isAcceptableOrUnknown(data['order_id']!, _orderIdMeta), + ); + } return context; } @@ -2349,6 +2477,10 @@ class $ClosedTabItemsTable extends ClosedTabItems DriftSqlType.dateTime, data['${effectivePrefix}removed_at'], ), + orderId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_id'], + ), ); } @@ -2368,6 +2500,7 @@ class ClosedTabItemRow extends DataClass final int unitPriceInCents; final DateTime? purchasedAt; final DateTime? removedAt; + final String? orderId; const ClosedTabItemRow({ required this.id, required this.closedTabId, @@ -2377,6 +2510,7 @@ class ClosedTabItemRow extends DataClass required this.unitPriceInCents, this.purchasedAt, this.removedAt, + this.orderId, }); @override Map toColumns(bool nullToAbsent) { @@ -2393,6 +2527,9 @@ class ClosedTabItemRow extends DataClass if (!nullToAbsent || removedAt != null) { map['removed_at'] = Variable(removedAt); } + if (!nullToAbsent || orderId != null) { + map['order_id'] = Variable(orderId); + } return map; } @@ -2410,6 +2547,9 @@ class ClosedTabItemRow extends DataClass removedAt: removedAt == null && nullToAbsent ? const Value.absent() : Value(removedAt), + orderId: orderId == null && nullToAbsent + ? const Value.absent() + : Value(orderId), ); } @@ -2427,6 +2567,7 @@ class ClosedTabItemRow extends DataClass unitPriceInCents: serializer.fromJson(json['unitPriceInCents']), purchasedAt: serializer.fromJson(json['purchasedAt']), removedAt: serializer.fromJson(json['removedAt']), + orderId: serializer.fromJson(json['orderId']), ); } @override @@ -2441,6 +2582,7 @@ class ClosedTabItemRow extends DataClass 'unitPriceInCents': serializer.toJson(unitPriceInCents), 'purchasedAt': serializer.toJson(purchasedAt), 'removedAt': serializer.toJson(removedAt), + 'orderId': serializer.toJson(orderId), }; } @@ -2453,6 +2595,7 @@ class ClosedTabItemRow extends DataClass int? unitPriceInCents, Value purchasedAt = const Value.absent(), Value removedAt = const Value.absent(), + Value orderId = const Value.absent(), }) => ClosedTabItemRow( id: id ?? this.id, closedTabId: closedTabId ?? this.closedTabId, @@ -2462,6 +2605,7 @@ class ClosedTabItemRow extends DataClass unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents, purchasedAt: purchasedAt.present ? purchasedAt.value : this.purchasedAt, removedAt: removedAt.present ? removedAt.value : this.removedAt, + orderId: orderId.present ? orderId.value : this.orderId, ); ClosedTabItemRow copyWithCompanion(ClosedTabItemsCompanion data) { return ClosedTabItemRow( @@ -2481,6 +2625,7 @@ class ClosedTabItemRow extends DataClass ? data.purchasedAt.value : this.purchasedAt, removedAt: data.removedAt.present ? data.removedAt.value : this.removedAt, + orderId: data.orderId.present ? data.orderId.value : this.orderId, ); } @@ -2494,7 +2639,8 @@ class ClosedTabItemRow extends DataClass ..write('quantity: $quantity, ') ..write('unitPriceInCents: $unitPriceInCents, ') ..write('purchasedAt: $purchasedAt, ') - ..write('removedAt: $removedAt') + ..write('removedAt: $removedAt, ') + ..write('orderId: $orderId') ..write(')')) .toString(); } @@ -2509,6 +2655,7 @@ class ClosedTabItemRow extends DataClass unitPriceInCents, purchasedAt, removedAt, + orderId, ); @override bool operator ==(Object other) => @@ -2521,7 +2668,8 @@ class ClosedTabItemRow extends DataClass other.quantity == this.quantity && other.unitPriceInCents == this.unitPriceInCents && other.purchasedAt == this.purchasedAt && - other.removedAt == this.removedAt); + other.removedAt == this.removedAt && + other.orderId == this.orderId); } class ClosedTabItemsCompanion extends UpdateCompanion { @@ -2533,6 +2681,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { final Value unitPriceInCents; final Value purchasedAt; final Value removedAt; + final Value orderId; final Value rowid; const ClosedTabItemsCompanion({ this.id = const Value.absent(), @@ -2543,6 +2692,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { this.unitPriceInCents = const Value.absent(), this.purchasedAt = const Value.absent(), this.removedAt = const Value.absent(), + this.orderId = const Value.absent(), this.rowid = const Value.absent(), }); ClosedTabItemsCompanion.insert({ @@ -2554,6 +2704,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { required int unitPriceInCents, this.purchasedAt = const Value.absent(), this.removedAt = const Value.absent(), + this.orderId = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), closedTabId = Value(closedTabId), @@ -2570,6 +2721,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { Expression? unitPriceInCents, Expression? purchasedAt, Expression? removedAt, + Expression? orderId, Expression? rowid, }) { return RawValuesInsertable({ @@ -2581,6 +2733,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { if (unitPriceInCents != null) 'unit_price_in_cents': unitPriceInCents, if (purchasedAt != null) 'purchased_at': purchasedAt, if (removedAt != null) 'removed_at': removedAt, + if (orderId != null) 'order_id': orderId, if (rowid != null) 'rowid': rowid, }); } @@ -2594,6 +2747,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { Value? unitPriceInCents, Value? purchasedAt, Value? removedAt, + Value? orderId, Value? rowid, }) { return ClosedTabItemsCompanion( @@ -2605,6 +2759,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents, purchasedAt: purchasedAt ?? this.purchasedAt, removedAt: removedAt ?? this.removedAt, + orderId: orderId ?? this.orderId, rowid: rowid ?? this.rowid, ); } @@ -2636,6 +2791,9 @@ class ClosedTabItemsCompanion extends UpdateCompanion { if (removedAt.present) { map['removed_at'] = Variable(removedAt.value); } + if (orderId.present) { + map['order_id'] = Variable(orderId.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -2653,6 +2811,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { ..write('unitPriceInCents: $unitPriceInCents, ') ..write('purchasedAt: $purchasedAt, ') ..write('removedAt: $removedAt, ') + ..write('orderId: $orderId, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -5238,6 +5397,7 @@ typedef $$BarTabsTableCreateCompanionBuilder = BarTabsCompanion Function({ required String id, required String customerName, + Value email, required DateTime openedAt, Value version, Value rowid, @@ -5246,6 +5406,7 @@ typedef $$BarTabsTableUpdateCompanionBuilder = BarTabsCompanion Function({ Value id, Value customerName, + Value email, Value openedAt, Value version, Value rowid, @@ -5293,6 +5454,11 @@ class $$BarTabsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get email => $composableBuilder( + column: $table.email, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get openedAt => $composableBuilder( column: $table.openedAt, builder: (column) => ColumnFilters(column), @@ -5348,6 +5514,11 @@ class $$BarTabsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get email => $composableBuilder( + column: $table.email, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get openedAt => $composableBuilder( column: $table.openedAt, builder: (column) => ColumnOrderings(column), @@ -5376,6 +5547,9 @@ class $$BarTabsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get email => + $composableBuilder(column: $table.email, builder: (column) => column); + GeneratedColumn get openedAt => $composableBuilder(column: $table.openedAt, builder: (column) => column); @@ -5438,12 +5612,14 @@ class $$BarTabsTableTableManager ({ Value id = const Value.absent(), Value customerName = const Value.absent(), + Value email = const Value.absent(), Value openedAt = const Value.absent(), Value version = const Value.absent(), Value rowid = const Value.absent(), }) => BarTabsCompanion( id: id, customerName: customerName, + email: email, openedAt: openedAt, version: version, rowid: rowid, @@ -5452,12 +5628,14 @@ class $$BarTabsTableTableManager ({ required String id, required String customerName, + Value email = const Value.absent(), required DateTime openedAt, Value version = const Value.absent(), Value rowid = const Value.absent(), }) => BarTabsCompanion.insert( id: id, customerName: customerName, + email: email, openedAt: openedAt, version: version, rowid: rowid, @@ -6072,6 +6250,7 @@ typedef $$TabItemPurchasesTableCreateCompanionBuilder = required String tabItemId, required DateTime purchasedAt, Value removedAt, + Value orderId, Value rowid, }); typedef $$TabItemPurchasesTableUpdateCompanionBuilder = @@ -6080,6 +6259,7 @@ typedef $$TabItemPurchasesTableUpdateCompanionBuilder = Value tabItemId, Value purchasedAt, Value removedAt, + Value orderId, Value rowid, }); @@ -6138,6 +6318,11 @@ class $$TabItemPurchasesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get orderId => $composableBuilder( + column: $table.orderId, + builder: (column) => ColumnFilters(column), + ); + $$TabItemsTableFilterComposer get tabItemId { final $$TabItemsTableFilterComposer composer = $composerBuilder( composer: this, @@ -6186,6 +6371,11 @@ class $$TabItemPurchasesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get orderId => $composableBuilder( + column: $table.orderId, + builder: (column) => ColumnOrderings(column), + ); + $$TabItemsTableOrderingComposer get tabItemId { final $$TabItemsTableOrderingComposer composer = $composerBuilder( composer: this, @@ -6230,6 +6420,9 @@ class $$TabItemPurchasesTableAnnotationComposer GeneratedColumn get removedAt => $composableBuilder(column: $table.removedAt, builder: (column) => column); + GeneratedColumn get orderId => + $composableBuilder(column: $table.orderId, builder: (column) => column); + $$TabItemsTableAnnotationComposer get tabItemId { final $$TabItemsTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -6288,12 +6481,14 @@ class $$TabItemPurchasesTableTableManager Value tabItemId = const Value.absent(), Value purchasedAt = const Value.absent(), Value removedAt = const Value.absent(), + Value orderId = const Value.absent(), Value rowid = const Value.absent(), }) => TabItemPurchasesCompanion( id: id, tabItemId: tabItemId, purchasedAt: purchasedAt, removedAt: removedAt, + orderId: orderId, rowid: rowid, ), createCompanionCallback: @@ -6302,12 +6497,14 @@ class $$TabItemPurchasesTableTableManager required String tabItemId, required DateTime purchasedAt, Value removedAt = const Value.absent(), + Value orderId = const Value.absent(), Value rowid = const Value.absent(), }) => TabItemPurchasesCompanion.insert( id: id, tabItemId: tabItemId, purchasedAt: purchasedAt, removedAt: removedAt, + orderId: orderId, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -6597,6 +6794,7 @@ typedef $$ClosedTabItemsTableCreateCompanionBuilder = required int unitPriceInCents, Value purchasedAt, Value removedAt, + Value orderId, Value rowid, }); typedef $$ClosedTabItemsTableUpdateCompanionBuilder = @@ -6609,6 +6807,7 @@ typedef $$ClosedTabItemsTableUpdateCompanionBuilder = Value unitPriceInCents, Value purchasedAt, Value removedAt, + Value orderId, Value rowid, }); @@ -6660,6 +6859,11 @@ class $$ClosedTabItemsTableFilterComposer column: $table.removedAt, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get orderId => $composableBuilder( + column: $table.orderId, + builder: (column) => ColumnFilters(column), + ); } class $$ClosedTabItemsTableOrderingComposer @@ -6710,6 +6914,11 @@ class $$ClosedTabItemsTableOrderingComposer column: $table.removedAt, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get orderId => $composableBuilder( + column: $table.orderId, + builder: (column) => ColumnOrderings(column), + ); } class $$ClosedTabItemsTableAnnotationComposer @@ -6752,6 +6961,9 @@ class $$ClosedTabItemsTableAnnotationComposer GeneratedColumn get removedAt => $composableBuilder(column: $table.removedAt, builder: (column) => column); + + GeneratedColumn get orderId => + $composableBuilder(column: $table.orderId, builder: (column) => column); } class $$ClosedTabItemsTableTableManager @@ -6799,6 +7011,7 @@ class $$ClosedTabItemsTableTableManager Value unitPriceInCents = const Value.absent(), Value purchasedAt = const Value.absent(), Value removedAt = const Value.absent(), + Value orderId = const Value.absent(), Value rowid = const Value.absent(), }) => ClosedTabItemsCompanion( id: id, @@ -6809,6 +7022,7 @@ class $$ClosedTabItemsTableTableManager unitPriceInCents: unitPriceInCents, purchasedAt: purchasedAt, removedAt: removedAt, + orderId: orderId, rowid: rowid, ), createCompanionCallback: @@ -6821,6 +7035,7 @@ class $$ClosedTabItemsTableTableManager required int unitPriceInCents, Value purchasedAt = const Value.absent(), Value removedAt = const Value.absent(), + Value orderId = const Value.absent(), Value rowid = const Value.absent(), }) => ClosedTabItemsCompanion.insert( id: id, @@ -6831,6 +7046,7 @@ class $$ClosedTabItemsTableTableManager unitPriceInCents: unitPriceInCents, purchasedAt: purchasedAt, removedAt: removedAt, + orderId: orderId, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c46122c..ae756da 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -125,6 +125,14 @@ "incorrectPin": "Incorrect PIN.", "tooManyPinAttempts": "Too many attempts. Try again later.", "removed": "Removed", + "removedCount": "{count} removed", + "@removedCount": {"placeholders": {"count": {"type": "int"}}}, + "orderStartedToday": "{time} today", + "@orderStartedToday": {"placeholders": {"time": {"type": "String"}}}, + "orderStartedYesterday": "{time} yesterday", + "@orderStartedYesterday": {"placeholders": {"time": {"type": "String"}}}, + "orderStartedOn": "{date} {time}", + "@orderStartedOn": {"placeholders": {"date": {"type": "String"}, "time": {"type": "String"}}}, "enterPrice": "Enter a price.", "enterValidPrice": "Enter a valid price.", "enterValidStock": "Enter a valid stock amount.", @@ -365,5 +373,11 @@ "creditsEveryoneAtTheBar": "Everyone at the bar", "creditsDrinkResponsibly": "Drink responsibly. Mostly.", "lazyPinRemark": "🤨 Really?", - "rainbowRoad": "Rainbow road 🌈" + "rainbowRoad": "Rainbow road 🌈", + "newCustomer": "New customer", + "emailOptional": "Email (optional)", + "invalidEmail": "Enter a valid email address", + "allTabsOpen": "Every tab is already open", + "noTabsFound": "No tabs found", + "nameRequired": "Enter a name" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ee08895..8f530eb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -800,6 +800,30 @@ abstract class AppLocalizations { /// **'Removed'** String get removed; + /// No description provided for @removedCount. + /// + /// In en, this message translates to: + /// **'{count} removed'** + String removedCount(int count); + + /// No description provided for @orderStartedToday. + /// + /// In en, this message translates to: + /// **'{time} today'** + String orderStartedToday(String time); + + /// No description provided for @orderStartedYesterday. + /// + /// In en, this message translates to: + /// **'{time} yesterday'** + String orderStartedYesterday(String time); + + /// No description provided for @orderStartedOn. + /// + /// In en, this message translates to: + /// **'{date} {time}'** + String orderStartedOn(String date, String time); + /// No description provided for @enterPrice. /// /// In en, this message translates to: @@ -2089,6 +2113,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Rainbow road 🌈'** String get rainbowRoad; + + /// No description provided for @newCustomer. + /// + /// In en, this message translates to: + /// **'New customer'** + String get newCustomer; + + /// No description provided for @emailOptional. + /// + /// In en, this message translates to: + /// **'Email (optional)'** + String get emailOptional; + + /// No description provided for @invalidEmail. + /// + /// In en, this message translates to: + /// **'Enter a valid email address'** + String get invalidEmail; + + /// No description provided for @allTabsOpen. + /// + /// In en, this message translates to: + /// **'Every tab is already open'** + String get allTabsOpen; + + /// No description provided for @noTabsFound. + /// + /// In en, this message translates to: + /// **'No tabs found'** + String get noTabsFound; + + /// No description provided for @nameRequired. + /// + /// In en, this message translates to: + /// **'Enter a name'** + String get nameRequired; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3cd253e..b5a4e06 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -393,6 +393,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get removed => 'Removed'; + @override + String removedCount(int count) { + return '$count removed'; + } + + @override + String orderStartedToday(String time) { + return '$time today'; + } + + @override + String orderStartedYesterday(String time) { + return '$time yesterday'; + } + + @override + String orderStartedOn(String date, String time) { + return '$date $time'; + } + @override String get enterPrice => 'Enter a price.'; @@ -1110,4 +1130,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get rainbowRoad => 'Rainbow road 🌈'; + + @override + String get newCustomer => 'New customer'; + + @override + String get emailOptional => 'Email (optional)'; + + @override + String get invalidEmail => 'Enter a valid email address'; + + @override + String get allTabsOpen => 'Every tab is already open'; + + @override + String get noTabsFound => 'No tabs found'; + + @override + String get nameRequired => 'Enter a name'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index c90bdfa..78f65e5 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -334,7 +334,7 @@ class AppLocalizationsNl extends AppLocalizations { String get total => 'Totaal'; @override - String get closeTab => 'Poef sluiten'; + String get closeTab => 'Poef afsluiten'; @override String productStockSummary(String category, String price, int stock) { @@ -397,6 +397,26 @@ class AppLocalizationsNl extends AppLocalizations { @override String get removed => 'Verwijderd'; + @override + String removedCount(int count) { + return '$count verwijderd'; + } + + @override + String orderStartedToday(String time) { + return '$time vandaag'; + } + + @override + String orderStartedYesterday(String time) { + return '$time gisteren'; + } + + @override + String orderStartedOn(String date, String time) { + return '$date $time'; + } + @override String get enterPrice => 'Voer een prijs in.'; @@ -441,7 +461,7 @@ class AppLocalizationsNl extends AppLocalizations { String get couldNotCloseTab => 'Poef kon niet worden gesloten.'; @override - String get closingTab => 'Poef sluiten…'; + String get closingTab => 'Poef afsluiten…'; @override String get slideToConfirmClosing => 'Schuif om het sluiten te bevestigen'; @@ -977,7 +997,7 @@ class AppLocalizationsNl extends AppLocalizations { } @override - String get remoteCommandCloseTab => 'Poef sluiten via dashboard'; + String get remoteCommandCloseTab => 'Poef afsluiten via dashboard'; @override String remoteCommandUnknown(String type) { @@ -1124,4 +1144,22 @@ class AppLocalizationsNl extends AppLocalizations { @override String get rainbowRoad => 'Regenboogweg 🌈'; + + @override + String get newCustomer => 'Nieuwe klant'; + + @override + String get emailOptional => 'E-mail (optioneel)'; + + @override + String get invalidEmail => 'Geef een geldig e-mailadres in'; + + @override + String get allTabsOpen => 'Alle poefs zijn al open'; + + @override + String get noTabsFound => 'Geen poefs gevonden'; + + @override + String get nameRequired => 'Geef een naam in'; } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index e28bf8f..0123686 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -104,7 +104,7 @@ "@tabItemCount": {"placeholders": {"count": {"type": "int"}}}, "tapProductsToAdd": "Tik op producten om ze toe te voegen", "total": "Totaal", - "closeTab": "Poef sluiten", + "closeTab": "Poef afsluiten", "productStockSummary": "{category} • {price} • Voorraad: {stock}", "@productStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}, "stock": {"type": "int"}}}, "infiniteStock": "Oneindige voorraad", @@ -125,6 +125,10 @@ "incorrectPin": "Onjuiste PIN.", "tooManyPinAttempts": "Te veel pogingen. Probeer het later opnieuw.", "removed": "Verwijderd", + "removedCount": "{count} verwijderd", + "orderStartedToday": "{time} vandaag", + "orderStartedYesterday": "{time} gisteren", + "orderStartedOn": "{date} {time}", "enterPrice": "Voer een prijs in.", "enterValidPrice": "Voer een geldige prijs in.", "enterValidStock": "Voer een geldige voorraad in.", @@ -140,7 +144,7 @@ "currentTotal": "Huidig totaal: {total}", "@currentTotal": {"placeholders": {"total": {"type": "String"}}}, "couldNotCloseTab": "Poef kon niet worden gesloten.", - "closingTab": "Poef sluiten…", + "closingTab": "Poef afsluiten…", "slideToConfirmClosing": "Schuif om het sluiten te bevestigen", "errorScreen": "Foutscherm", "unexpectedError": "Er is een onverwachte fout opgetreden.\nProbeer de app opnieuw te starten.", @@ -307,7 +311,7 @@ "justNow": "zonet", "minutesAgo": "{count, plural, =1{1 minuut geleden} other{{count} minuten geleden}}", "hoursAgo": "{count, plural, =1{1 uur geleden} other{{count} uur geleden}}", - "remoteCommandCloseTab": "Poef sluiten via dashboard", + "remoteCommandCloseTab": "Poef afsluiten via dashboard", "remoteCommandUnknown": "Onbekende opdracht ({type})", "commandCompleted": "Uitgevoerd", "commandConflict": "Conflict", @@ -353,5 +357,11 @@ "creditsEveryoneAtTheBar": "Iedereen aan de toog", "creditsDrinkResponsibly": "Drink met mate. Meestal.", "lazyPinRemark": "🤨 Echt?", - "rainbowRoad": "Regenboogweg 🌈" + "rainbowRoad": "Regenboogweg 🌈", + "newCustomer": "Nieuwe klant", + "emailOptional": "E-mail (optioneel)", + "invalidEmail": "Geef een geldig e-mailadres in", + "allTabsOpen": "Alle poefs zijn al open", + "noTabsFound": "Geen poefs gevonden", + "nameRequired": "Geef een naam in" } diff --git a/lib/models/bar_tab.dart b/lib/models/bar_tab.dart index 87065fc..b503343 100644 --- a/lib/models/bar_tab.dart +++ b/lib/models/bar_tab.dart @@ -5,6 +5,7 @@ import 'tab_item.dart'; class BarTab { final String id; final String customerName; + final String? email; final DateTime openedAt; final int version; final List items; @@ -12,6 +13,7 @@ class BarTab { const BarTab({ required this.id, required this.customerName, + this.email, required this.openedAt, this.version = 0, required this.items, @@ -31,11 +33,19 @@ class BarTab { other is BarTab && id == other.id && customerName == other.customerName && + email == other.email && openedAt == other.openedAt && version == other.version && listEquals(items, other.items); @override int get hashCode => - Object.hash(id, customerName, openedAt, version, Object.hashAll(items)); + Object.hash( + id, + customerName, + email, + openedAt, + version, + Object.hashAll(items), + ); } diff --git a/lib/models/closed_tab_item.dart b/lib/models/closed_tab_item.dart index eea896f..8f90d0e 100644 --- a/lib/models/closed_tab_item.dart +++ b/lib/models/closed_tab_item.dart @@ -7,6 +7,7 @@ class ClosedTabItem { final int unitPriceInCents; final DateTime? purchasedAt; final DateTime? removedAt; + final String? orderId; ClosedTabItem({ required this.id, @@ -17,6 +18,7 @@ class ClosedTabItem { required this.unitPriceInCents, this.purchasedAt, this.removedAt, + this.orderId, }); bool get isRemoved => removedAt != null; @@ -34,7 +36,8 @@ class ClosedTabItem { quantity == other.quantity && unitPriceInCents == other.unitPriceInCents && purchasedAt == other.purchasedAt && - removedAt == other.removedAt; + removedAt == other.removedAt && + orderId == other.orderId; @override int get hashCode => Object.hash( @@ -46,5 +49,6 @@ class ClosedTabItem { unitPriceInCents, purchasedAt, removedAt, + orderId, ); } diff --git a/lib/models/server_tab.dart b/lib/models/server_tab.dart index e0b3a23..e2a4fbf 100644 --- a/lib/models/server_tab.dart +++ b/lib/models/server_tab.dart @@ -8,7 +8,15 @@ class ServerUnit { final DateTime addedAt; final DateTime? removedAt; - const ServerUnit({required this.id, required this.addedAt, this.removedAt}); + /// The order it was rung up in; null for drinks from older app versions. + final String? orderId; + + const ServerUnit({ + required this.id, + required this.addedAt, + this.removedAt, + this.orderId, + }); factory ServerUnit.fromJson(Map json) { return ServerUnit( @@ -18,6 +26,7 @@ class ServerUnit { final String value => DateTime.parse(value).toLocal(), _ => null, }, + orderId: json['orderId'] as String?, ); } } @@ -58,6 +67,7 @@ class ServerTabLine { class ServerTab { final String id; final String customerName; + final String? email; final DateTime openedAt; final int version; final List lines; @@ -65,6 +75,7 @@ class ServerTab { const ServerTab({ required this.id, required this.customerName, + this.email, required this.openedAt, required this.version, this.lines = const [], @@ -74,6 +85,7 @@ class ServerTab { return ServerTab( id: json['id'] as String, customerName: json['customerName'] as String, + email: json['email'] as String?, openedAt: DateTime.parse(json['openedAt'] as String).toLocal(), version: json['version'] as int? ?? 0, lines: [ diff --git a/lib/models/tab_event.dart b/lib/models/tab_event.dart index a039740..d2bb2dc 100644 --- a/lib/models/tab_event.dart +++ b/lib/models/tab_event.dart @@ -47,6 +47,10 @@ class TabEvent { const TabEvent({required this.id, required this.payload}); } +/// The order a removal takes drinks off: `orderId`, or (when null) the +/// drinks rung up before orders were tracked. +typedef OrderRef = ({String? orderId}); + /// One settled line in a `settled` event. typedef SettledLine = ({ String productId, @@ -66,12 +70,16 @@ class TabEventFactory { required String tabId, required String customerName, required DateTime openedAt, + String? email, }) { return _event( TabEventType.tabOpened, tabId: tabId, customerName: customerName, - fields: {'openedAt': openedAt.toUtc().toIso8601String()}, + fields: { + 'openedAt': openedAt.toUtc().toIso8601String(), + 'email': ?email, + }, ); } @@ -96,7 +104,8 @@ class TabEventFactory { } /// [quantity] is how many pieces were added; always positive. [lineId] is - /// the local line, so the server can keep its id. + /// the local line, so the server can keep its id. [orderId] is the order + /// they are part of. TabEvent lineAdded({ required String tabId, required String customerName, @@ -105,6 +114,7 @@ class TabEventFactory { required String productName, required int unitPriceInCents, required int quantity, + required String? orderId, }) { return _lineEvent( TabEventType.lineAdded, @@ -115,10 +125,13 @@ class TabEventFactory { productName: productName, unitPriceInCents: unitPriceInCents, quantity: quantity, + order: (orderId: orderId), ); } - /// [quantity] is how many pieces were removed; always positive. + /// [quantity] is how many pieces were removed; always positive. With + /// [order] they are taken off that order only; without, the latest drinks + /// of the line (as before orders). TabEvent lineRemoved({ required String tabId, required String customerName, @@ -127,6 +140,7 @@ class TabEventFactory { required String productName, required int unitPriceInCents, required int quantity, + OrderRef? order, }) { return _lineEvent( TabEventType.lineRemoved, @@ -137,6 +151,7 @@ class TabEventFactory { productName: productName, unitPriceInCents: unitPriceInCents, quantity: quantity, + order: order, ); } @@ -246,6 +261,7 @@ class TabEventFactory { required String productName, required int unitPriceInCents, required int quantity, + required OrderRef? order, }) { if (quantity < 1) { throw ArgumentError.value(quantity, 'quantity', 'Must be at least 1'); @@ -263,6 +279,9 @@ class TabEventFactory { 'productName': productName, 'unitPriceInCents': unitPriceInCents, 'quantity': quantity, + // Also when null: a removal then only takes drinks without an order, + // where a removal without `orderId` takes the latest of the line + if (order != null) 'orderId': order.orderId, }, ); } diff --git a/lib/models/tab_item_purchase.dart b/lib/models/tab_item_purchase.dart index f1a39b4..96c8abf 100644 --- a/lib/models/tab_item_purchase.dart +++ b/lib/models/tab_item_purchase.dart @@ -4,11 +4,15 @@ class TabItemPurchase { final DateTime purchasedAt; final DateTime? removedAt; + /// The order it was rung up in; null for drinks from before orders. + final String? orderId; + const TabItemPurchase({ required this.id, required this.tabItemId, required this.purchasedAt, this.removedAt, + this.orderId, }); bool get isRemoved => removedAt != null; @@ -20,8 +24,10 @@ class TabItemPurchase { id == other.id && tabItemId == other.tabItemId && purchasedAt == other.purchasedAt && - removedAt == other.removedAt; + removedAt == other.removedAt && + orderId == other.orderId; @override - int get hashCode => Object.hash(id, tabItemId, purchasedAt, removedAt); + int get hashCode => + Object.hash(id, tabItemId, purchasedAt, removedAt, orderId); } diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index 3a28894..65f9ccc 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -50,23 +50,29 @@ abstract class BarTabService { Future getTabById(String id); - Future createTab({required String customerName}); + Future createTab({required String customerName, String? email}); Future renameTab({required String tabId, required String customerName}); /// Deletes an open tab and returns its items to inventory. Future deleteTab(String tabId); + /// Rings up one [product] as part of order [orderId]; a new order when + /// null. Future addProductToTab({ required String tabId, required Product product, + String? orderId, }); - /// Returns the delta that was actually applied, which is smaller than - /// [delta] when it would push the quantity below zero. + /// Adds drinks to [order] (a new order when null), or takes drinks of + /// [order] off (when null: the latest of the line). Returns the delta that + /// was actually applied, which is smaller than [delta] when there are fewer + /// drinks to take off. Future adjustTabItemQuantity({ required String tabItemId, required int delta, + OrderRef? order, }); /// Archives the tab's current items into history and clears them. @@ -119,6 +125,7 @@ class DriftBarTabService implements BarTabService { tabItemId: row.tabItemId, purchasedAt: row.purchasedAt, removedAt: row.removedAt, + orderId: row.orderId, ); } @@ -138,6 +145,7 @@ class DriftBarTabService implements BarTabService { return BarTab( id: row.id, customerName: row.customerName, + email: row.email, openedAt: row.openedAt, version: row.version, items: items, @@ -154,6 +162,7 @@ class DriftBarTabService implements BarTabService { unitPriceInCents: row.unitPriceInCents, purchasedAt: row.purchasedAt, removedAt: row.removedAt, + orderId: row.orderId, ); } @@ -173,11 +182,16 @@ class DriftBarTabService implements BarTabService { if (tabId != null) query.where(database.barTabs.id.equals(tabId)); + // Times are stored to the second: drinks rung up within one second keep + // the order they were added in (rowid), so a tab's rows never swap query.orderBy([ OrderingTerm.desc(database.barTabs.openedAt), OrderingTerm.asc(database.tabItems.createdAt), + OrderingTerm.asc(const CustomExpression('"tab_items"."rowid"')), OrderingTerm.asc(database.tabItemPurchases.purchasedAt), - OrderingTerm.asc(database.tabItemPurchases.id), + OrderingTerm.asc( + const CustomExpression('"tab_item_purchases"."rowid"'), + ), ]); return query; @@ -224,31 +238,63 @@ class DriftBarTabService implements BarTabService { )..where((tab) => tab.id.equals(tabId))).getSingleOrNull(); } - Future _recordPurchase(String tabItemId) async { + /// [id] is the one the server gives the unit: `:`. + Future _recordPurchase( + String tabItemId, + String orderId, { + required String id, + }) async { await database .into(database.tabItemPurchases) .insert( TabItemPurchasesCompanion.insert( - id: _uuid.v4(), + id: id, tabItemId: tabItemId, purchasedAt: DateTime.now(), + orderId: Value(orderId), ), ); } - /// Marks the most recent active purchases as removed, keeping them in the - /// audit trail. + /// The active purchases of [tabItemId] a removal may take off: those of + /// [order] when given, as the server does; else all of them (events from + /// before orders). + Future> _removablePurchases( + String tabItemId, + OrderRef? order, + ) { + return (database.select(database.tabItemPurchases)..where( + (purchase) => + purchase.tabItemId.equals(tabItemId) & + purchase.removedAt.isNull() & + switch (order) { + null => const Constant(true), + (orderId: null) => purchase.orderId.isNull(), + (orderId: final String id) => purchase.orderId.equals(id), + }, + )) + .get(); + } + + /// Marks the most recent active purchases (of [order], see + /// [_removablePurchases]) as removed, keeping them in the audit trail. Future _removeLatestPurchases( String tabItemId, int count, { DateTime? removedAt, + OrderRef? order, }) async { final latest = await (database.select(database.tabItemPurchases) ..where( (purchase) => purchase.tabItemId.equals(tabItemId) & - purchase.removedAt.isNull(), + purchase.removedAt.isNull() & + switch (order) { + null => const Constant(true), + (orderId: null) => purchase.orderId.isNull(), + (orderId: final String id) => purchase.orderId.equals(id), + }, ) ..orderBy([ (purchase) => OrderingTerm.desc(purchase.purchasedAt), @@ -303,11 +349,13 @@ class DriftBarTabService implements BarTabService { ); } + /// Returns the id of the event. + /// /// Queues a `line_added` ([delta] > 0) or `line_removed` ([delta] < 0) /// event and moves the stock of [productId], the product the line was /// created from, the other way. Every line event goes through here, so a /// line event and its stock change can never drift apart. - Future _recordLineChange({ + Future _recordLineChange({ required String tabId, required String customerName, required String lineId, @@ -315,22 +363,32 @@ class DriftBarTabService implements BarTabService { required String productName, required int unitPriceInCents, required int delta, + required OrderRef? order, }) async { - final lineEvent = delta > 0 - ? eventFactory.lineAdded - : eventFactory.lineRemoved; - await _recordEvent( - lineEvent( - tabId: tabId, - customerName: customerName, - lineId: lineId, - productId: productId, - productName: productName, - unitPriceInCents: unitPriceInCents, - quantity: delta.abs(), - ), - ); + final event = delta > 0 + ? eventFactory.lineAdded( + tabId: tabId, + customerName: customerName, + lineId: lineId, + productId: productId, + productName: productName, + unitPriceInCents: unitPriceInCents, + quantity: delta, + orderId: order?.orderId, + ) + : eventFactory.lineRemoved( + tabId: tabId, + customerName: customerName, + lineId: lineId, + productId: productId, + productName: productName, + unitPriceInCents: unitPriceInCents, + quantity: -delta, + order: order, + ); + await _recordEvent(event); await database.changeTrackedStock(productId, -delta); + return event.id; } Future _incrementTabVersion(String tabId) async { @@ -377,9 +435,16 @@ class DriftBarTabService implements BarTabService { } @override - Future createTab({required String customerName}) async { + Future createTab({ + required String customerName, + String? email, + }) async { final id = _uuid.v4(); final name = _validateCustomerName(customerName); + final trimmedEmail = email?.trim(); + final cleanEmail = trimmedEmail == null || trimmedEmail.isEmpty + ? null + : trimmedEmail; final openedAt = DateTime.now(); await database.transaction(() async { @@ -389,6 +454,7 @@ class DriftBarTabService implements BarTabService { BarTabsCompanion.insert( id: id, customerName: name, + email: Value(cleanEmail), openedAt: openedAt, version: const Value(1), ), @@ -398,6 +464,7 @@ class DriftBarTabService implements BarTabService { tabId: id, customerName: name, openedAt: openedAt, + email: cleanEmail, ), ); }); @@ -434,6 +501,7 @@ class DriftBarTabService implements BarTabService { if (tab == null) return; for (final item in tab.items) { + // Without an order: everything on the line, whatever its order await _recordLineChange( tabId: tab.id, customerName: tab.customerName, @@ -442,6 +510,7 @@ class DriftBarTabService implements BarTabService { productName: item.productName, unitPriceInCents: item.unitPriceInCents, delta: -item.quantity, + order: null, ); } await _recordEvent( @@ -456,6 +525,7 @@ class DriftBarTabService implements BarTabService { Future addProductToTab({ required String tabId, required Product product, + String? orderId, }) async { await database.transaction(() async { final tabRow = await _getTabRow(tabId); @@ -467,6 +537,7 @@ class DriftBarTabService implements BarTabService { productName: product.name, unitPriceInCents: product.priceInCents, quantity: 1, + orderId: orderId ?? _uuid.v4(), ); await _incrementTabVersion(tabId); }); @@ -485,6 +556,7 @@ class DriftBarTabService implements BarTabService { required String productName, required int unitPriceInCents, required int quantity, + required String orderId, }) async { final existingItem = await (database.select(database.tabItems) @@ -524,10 +596,7 @@ class DriftBarTabService implements BarTabService { ); } - for (var index = 0; index < quantity; index++) { - await _recordPurchase(lineId); - } - await _recordLineChange( + final eventId = await _recordLineChange( tabId: tabRow.id, customerName: tabRow.customerName, lineId: lineId, @@ -535,7 +604,13 @@ class DriftBarTabService implements BarTabService { productName: productName, unitPriceInCents: unitPriceInCents, delta: quantity, + order: (orderId: orderId), ); + // The ids the server gives these units, so they are recognised after a + // sync + for (var index = 0; index < quantity; index++) { + await _recordPurchase(lineId, orderId, id: '$eventId:$index'); + } } /// Taking drinks off changes [tabItemId] itself. Adding is ringing up new @@ -546,6 +621,7 @@ class DriftBarTabService implements BarTabService { Future adjustTabItemQuantity({ required String tabItemId, required int delta, + OrderRef? order, }) async { if (delta == 0) return 0; @@ -570,17 +646,23 @@ class DriftBarTabService implements BarTabService { productName: product?.name ?? item.productName, unitPriceInCents: product?.priceInCents ?? item.unitPriceInCents, quantity: delta, + orderId: order?.orderId ?? _uuid.v4(), ); await _incrementTabVersion(item.tabId); return delta; } - final newQuantity = max(item.quantity + delta, 0); - final actualDelta = newQuantity - item.quantity; + // Only the drinks of the order can be taken off; without an order the + // line's quantity counts, as before orders + final available = order == null + ? item.quantity + : (await _removablePurchases(item.id, order)).length; + final actualDelta = -min(-delta, available); + final newQuantity = item.quantity + actualDelta; if (actualDelta == 0) return 0; - await _removeLatestPurchases(item.id, -actualDelta); + await _removeLatestPurchases(item.id, -actualDelta, order: order); await (database.update(database.tabItems) ..where((row) => row.id.equals(tabItemId))) .write(TabItemsCompanion(quantity: Value(newQuantity))); @@ -594,6 +676,7 @@ class DriftBarTabService implements BarTabService { productName: item.productName, unitPriceInCents: item.unitPriceInCents, delta: actualDelta, + order: order, ); await _incrementTabVersion(item.tabId); @@ -681,6 +764,7 @@ class DriftBarTabService implements BarTabService { unitPriceInCents: item.unitPriceInCents, purchasedAt: Value(purchase.purchasedAt), removedAt: Value(purchase.removedAt), + orderId: Value(purchase.orderId), ), ]); }); @@ -703,7 +787,9 @@ class DriftBarTabService implements BarTabService { ) ..orderBy([ (item) => OrderingTerm.desc(item.purchasedAt), - (item) => OrderingTerm.desc(item.id), + // Newest first also within one second: the order they were + // stored in, reversed + (item) => OrderingTerm.desc(const CustomExpression('rowid')), ])) .get(); @@ -787,12 +873,25 @@ class DriftBarTabService implements BarTabService { @override Future applyServerState(List tabs) async { await database.transaction(() async { + // A server without orders sends units back without one: they keep the + // order they were rung up in here, so the tab doesn't regroup + final knownOrders = { + for (final purchase in await (database.select( + database.tabItemPurchases, + )..where((row) => row.orderId.isNotNull())).get()) + purchase.id: purchase.orderId!, + }; + + // A server that doesn't know emails yet would wipe the ones entered + // here + final knownEmails = {}; for (final tab in await database.select(database.barTabs).get()) { + if (tab.email case final email?) knownEmails[tab.id] = email; await _deleteTabWithItems(tab.id); } for (final tab in tabs) { - await _insertServerTab(tab); + await _insertServerTab(tab, knownOrders, knownEmails[tab.id]); } // Read here, so changes made while the sync was in flight are included @@ -808,13 +907,18 @@ class DriftBarTabService implements BarTabService { }); } - Future _insertServerTab(ServerTab tab) async { + Future _insertServerTab( + ServerTab tab, + Map knownOrders, + String? knownEmail, + ) async { await database .into(database.barTabs) .insert( BarTabsCompanion.insert( id: tab.id, customerName: tab.customerName, + email: Value(tab.email ?? knownEmail), openedAt: tab.openedAt, version: Value(tab.version), ), @@ -848,6 +952,7 @@ class DriftBarTabService implements BarTabService { tabItemId: line.id, purchasedAt: unit.addedAt, removedAt: Value(unit.removedAt), + orderId: Value(unit.orderId ?? knownOrders[unit.id]), ), ]); }); @@ -871,7 +976,12 @@ class DriftBarTabService implements BarTabService { final openedAt = DateTime.tryParse( event['openedAt'] as String? ?? '', )?.toLocal(); - await _ensureTab(tabId, customerName, openedAt ?? occurredAt); + await _ensureTab( + tabId, + customerName, + openedAt ?? occurredAt, + email: event['email'] as String?, + ); case TabEventType.tabRenamed: await (database.update(database.barTabs) ..where((tab) => tab.id.equals(tabId))) @@ -885,9 +995,20 @@ class DriftBarTabService implements BarTabService { final quantity = event['quantity'] as int? ?? 0; if (line == null || quantity <= 0) return; - final removed = min(quantity, line.quantity); + // Events from before orders have no `orderId`: the latest of the line + final OrderRef? order = event.containsKey('orderId') + ? (orderId: event['orderId'] as String?) + : null; + final removable = await _removablePurchases(line.id, order); + final removed = min(quantity, removable.length); + if (removed == 0) return; await _setLineQuantity(line.id, line.quantity - removed); - await _removeLatestPurchases(line.id, removed, removedAt: occurredAt); + await _removeLatestPurchases( + line.id, + removed, + removedAt: occurredAt, + order: order, + ); case TabEventType.settled: for (final item in event['items'] as List? ?? []) { await _replaySettledLine(tabId, item as Map); @@ -947,6 +1068,7 @@ class DriftBarTabService implements BarTabService { id: '${event['id']}:$index', tabItemId: line!.id, purchasedAt: occurredAt, + orderId: Value(event['orderId'] as String?), ), ]); }); @@ -998,14 +1120,16 @@ class DriftBarTabService implements BarTabService { Future _ensureTab( String tabId, String customerName, - DateTime openedAt, - ) async { + DateTime openedAt, { + String? email, + }) async { await database .into(database.barTabs) .insert( BarTabsCompanion.insert( id: tabId, customerName: customerName, + email: Value(email), openedAt: openedAt, version: const Value(1), ), @@ -1096,6 +1220,7 @@ class DriftBarTabService implements BarTabService { unitPriceInCents: line.unitPriceInCents, purchasedAt: Value(unit.addedAt), removedAt: Value(unit.removedAt), + orderId: Value(unit.orderId), ), ]); }); diff --git a/lib/services/kooltap_api_client.dart b/lib/services/kooltap_api_client.dart index de97667..edde031 100644 --- a/lib/services/kooltap_api_client.dart +++ b/lib/services/kooltap_api_client.dart @@ -60,9 +60,7 @@ class KoolTapApiClient { this.appVersion, }) : _client = client ?? http.Client(); - Map get _versionHeader => { - 'X-App-Version': ?appVersion, - }; + Map get _versionHeader => {'X-App-Version': ?appVersion}; static const _timeout = Duration(seconds: 15); static const _defaultRetryAfter = Duration(seconds: 60); diff --git a/lib/utils/history_grouping.dart b/lib/utils/history_grouping.dart deleted file mode 100644 index 77b9d7b..0000000 --- a/lib/utils/history_grouping.dart +++ /dev/null @@ -1,48 +0,0 @@ -import '../models/closed_tab_item.dart'; - -class ClosedTabItemDayGroup { - final DateTime day; - final List items; - - const ClosedTabItemDayGroup({required this.day, required this.items}); - - int get itemCount => items.fold( - 0, - (total, item) => total + (item.isRemoved ? 0 : item.quantity), - ); -} - -List groupClosedTabItemsByDay( - Iterable items, { - required DateTime fallbackDate, -}) { - final groups = >{}; - - for (final item in items) { - final localPurchasedAt = (item.purchasedAt ?? fallbackDate).toLocal(); - final day = DateTime( - localPurchasedAt.year, - localPurchasedAt.month, - localPurchasedAt.day, - ); - groups.putIfAbsent(day, () => []).add(item); - } - - for (final group in groups.values) { - group.sort((a, b) { - final aTime = (a.purchasedAt ?? fallbackDate).toLocal(); - final bTime = (b.purchasedAt ?? fallbackDate).toLocal(); - final byTime = bTime.compareTo(aTime); - if (byTime != 0) return byTime; - - return b.id.compareTo(a.id); - }); - } - - final sortedGroups = groups.entries.toList() - ..sort((a, b) => b.key.compareTo(a.key)); - - return sortedGroups - .map((entry) => ClosedTabItemDayGroup(day: entry.key, items: entry.value)) - .toList(); -} diff --git a/lib/utils/tab_orders.dart b/lib/utils/tab_orders.dart new file mode 100644 index 0000000..5c38eeb --- /dev/null +++ b/lib/utils/tab_orders.dart @@ -0,0 +1,219 @@ +import 'package:collection/collection.dart'; +import 'package:intl/intl.dart'; + +import '../l10n/app_localizations.dart'; +import '../models/bar_tab.dart'; +import '../models/closed_tab.dart'; +import '../models/closed_tab_item.dart'; +import '../models/tab_item.dart'; + +/// Drinks rung up without an order (before orders were tracked) that follow +/// each other this closely form one order, as on the server. +const orderGap = Duration(minutes: 5); + +/// One drink, for grouping: [line] is what it is shown as, [lineKey] which +/// drinks are counted on one row. +class OrderDrink { + final L line; + final Object lineKey; + final String id; + final DateTime addedAt; + final String? orderId; + final bool isRemoved; + + const OrderDrink({ + required this.line, + required this.lineKey, + required this.id, + required this.addedAt, + required this.orderId, + required this.isRemoved, + }); +} + +/// The drinks of one line within an order. +class TabOrderRow { + final L line; + + /// Still on the tab (or paid for, in history). + final int quantity; + + /// Taken off again. + final int removed; + + const TabOrderRow({ + required this.line, + required this.quantity, + required this.removed, + }); +} + +/// Drinks ordered together, shown under one "12:32 today" divider. +class TabOrder { + /// Null for drinks from before orders, grouped by time instead. + final String? orderId; + + /// When its first drink was rung up. + final DateTime startedAt; + final List> rows; + + const TabOrder({ + required this.orderId, + required this.startedAt, + required this.rows, + }); + + int get itemCount => rows.fold(0, (total, row) => total + row.quantity); +} + +/// Splits [drinks] into their orders, oldest first. Drinks rung up at the +/// same moment keep the order they are given in. +List> groupOrders(Iterable> drinks) { + final sorted = drinks.toList(); + mergeSort(sorted, compare: (a, b) => a.addedAt.compareTo(b.addedAt)); + + final orders = <_OrderBuilder>[]; + final byId = >{}; + _OrderBuilder? unordered; + DateTime? unorderedLastAt; + + for (final drink in sorted) { + final _OrderBuilder order; + final orderId = drink.orderId; + if (orderId != null) { + order = byId.putIfAbsent(orderId, () { + final created = _OrderBuilder(orderId, drink.addedAt); + orders.add(created); + return created; + }); + } else { + if (unordered == null || + drink.addedAt.difference(unorderedLastAt!) > orderGap) { + unordered = _OrderBuilder(null, drink.addedAt); + orders.add(unordered); + } + unorderedLastAt = drink.addedAt; + order = unordered; + } + order.add(drink); + } + + // Started in the order of their first drink: already oldest first + return [for (final order in orders) order.build()]; +} + +class _OrderBuilder { + final String? orderId; + final DateTime startedAt; + final _rows = {}; + + _OrderBuilder(this.orderId, this.startedAt); + + void add(OrderDrink drink) { + final row = _rows[drink.lineKey]; + _rows[drink.lineKey] = ( + line: row?.line ?? drink.line, + quantity: (row?.quantity ?? 0) + (drink.isRemoved ? 0 : 1), + removed: (row?.removed ?? 0) + (drink.isRemoved ? 1 : 0), + ); + } + + TabOrder build() => TabOrder( + orderId: orderId, + startedAt: startedAt, + rows: [ + for (final row in _rows.values) + TabOrderRow( + line: row.line, + quantity: row.quantity, + removed: row.removed, + ), + ], + ); +} + +/// The orders of an open tab. Without [includeRemoved] only what is still on +/// it: rows and orders whose drinks were all taken off again are left out. +/// Drinks of an item without purchase records count as ordered when the tab +/// was opened. +List> ordersOfTab(BarTab tab, {bool includeRemoved = false}) { + final orders = groupOrders([ + for (final item in tab.items) + if (item.purchases.isEmpty) + for (var index = 0; index < item.quantity; index++) + OrderDrink( + line: item, + lineKey: item.id, + id: '${item.id}-$index', + addedAt: tab.openedAt, + orderId: null, + isRemoved: false, + ) + else + for (final purchase in item.purchases) + OrderDrink( + line: item, + lineKey: item.id, + id: purchase.id, + addedAt: purchase.purchasedAt, + orderId: purchase.orderId, + isRemoved: purchase.isRemoved, + ), + ]); + if (includeRemoved) return orders; + + return [ + for (final order in orders) + if (order.itemCount > 0) + TabOrder( + orderId: order.orderId, + startedAt: order.startedAt, + rows: [ + for (final row in order.rows) + if (row.quantity > 0) row, + ], + ), + ]; +} + +/// The orders of a paid tab, with the drinks taken off before paying. +List> ordersOfClosedTab(ClosedTab tab) { + return groupOrders([ + // The items come newest first; drinks of the same moment keep their + // order, so start from the oldest + for (final item in tab.items.reversed) + for (var index = 0; index < item.quantity; index++) + OrderDrink( + line: item, + lineKey: (item.productId, item.productName, item.unitPriceInCents), + id: '${item.id}-$index', + addedAt: item.purchasedAt ?? tab.closedAt, + orderId: item.orderId, + isRemoved: item.isRemoved, + ), + ]); +} + +/// "12:32 today", "12:32 yesterday", or the date with the time. +String orderTimeLabel( + AppLocalizations l10n, + String locale, + DateTime startedAt, { + DateTime? now, +}) { + final local = startedAt.toLocal(); + final today = now ?? DateTime.now(); + final day = DateTime(local.year, local.month, local.day); + final days = DateTime( + today.year, + today.month, + today.day, + ).difference(day).inDays; + final time = DateFormat.Hm(locale).format(local); + + return switch (days) { + 0 => l10n.orderStartedToday(time), + 1 => l10n.orderStartedYesterday(time), + _ => l10n.orderStartedOn(DateFormat.yMMMd(locale).format(local), time), + }; +} diff --git a/lib/viewmodels/bar_screen_view_model.dart b/lib/viewmodels/bar_screen_view_model.dart index fdd8d1a..83fa83a 100644 --- a/lib/viewmodels/bar_screen_view_model.dart +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:uuid/uuid.dart'; import '../models/app_error.dart'; import '../models/bar_tab.dart'; @@ -15,9 +16,20 @@ import '../services/bar_tab_service.dart'; /// up without manual reloads. Actions report to Sentry and rethrow; the view /// decides how to tell the user. class BarScreenViewModel extends ChangeNotifier { - final BarTabService barTabService; + /// Drinks rung up on the same tab within this time of the previous one are + /// one order; a later drink starts a new order. + static const orderTimeout = Duration(minutes: 5); - BarScreenViewModel({required this.barTabService}); + final BarTabService barTabService; + final DateTime Function() _now; + final _uuid = const Uuid(); + + BarScreenViewModel({required this.barTabService, DateTime Function()? now}) + : _now = now ?? DateTime.now; + + /// The order being rung up: selecting a tab (again) or a pause of + /// [orderTimeout] starts a new one. + ({String tabId, String orderId, DateTime lastAddedAt})? _order; StreamSubscription>? _subscription; List _tabs = []; @@ -25,7 +37,30 @@ class BarScreenViewModel extends ChangeNotifier { bool _hasLoaded = false; AppError? _loadError; - List get tabs => _tabs; + /// Tabs opened on this tablet that have no drinks yet. Only kept in memory: + /// after a restart the list starts empty again. + final Set _openedTabIds = {}; + + /// Every tab the venue knows, open or not. + List get allTabs => _tabs; + + /// The tabs on the bar screen: opened here, with drinks on them, or + /// selected. + List get tabs => [ + for (final tab in _tabs) + if (_isOpen(tab)) tab, + ]; + + /// The tabs that can be opened from the Open Tab dialog. + List get closedTabs => [ + for (final tab in _tabs) + if (!_isOpen(tab)) tab, + ]; + + bool _isOpen(BarTab tab) => + tab.itemCount > 0 || + _openedTabIds.contains(tab.id) || + tab.id == _selectedTabId; String? get selectedTabId => _selectedTabId; @@ -55,11 +90,22 @@ class BarScreenViewModel extends ChangeNotifier { } void _onTabs(List tabs) { + // A tab whose drinks are all gone (settled elsewhere, or taken off) is + // closed again; while it is selected it stays on screen + final previousCounts = {for (final tab in _tabs) tab.id: tab.itemCount}; + for (final tab in tabs) { + if ((previousCounts[tab.id] ?? 0) > 0 && tab.itemCount == 0) { + _openedTabIds.remove(tab.id); + } + } + _tabs = tabs; _hasLoaded = true; _loadError = null; - if (selectedTab == null) _selectedTabId = tabs.firstOrNull?.id; + final ids = {for (final tab in tabs) tab.id}; + _openedTabIds.retainAll(ids); + if (!ids.contains(_selectedTabId)) _selectedTabId = null; notifyListeners(); } @@ -75,13 +121,44 @@ class BarScreenViewModel extends ChangeNotifier { void selectTab(String tabId) { _selectedTabId = tabId; + _order = null; notifyListeners(); } - Future createTab(String customerName) { + /// Leaves no tab selected, so products can't be added by accident. + void deselectTab() { + _selectedTabId = null; + _order = null; + notifyListeners(); + } + + /// The order a drink rung up on [tabId] now is part of. + String _orderFor(String tabId) { + final now = _now(); + final order = _order; + final orderId = + order != null && + order.tabId == tabId && + now.difference(order.lastAddedAt) < orderTimeout + ? order.orderId + : _uuid.v4(); + _order = (tabId: tabId, orderId: orderId, lastAddedAt: now); + return orderId; + } + + /// Puts [tabId] on the bar screen and selects it. + void openTab(String tabId) { + _openedTabIds.add(tabId); + selectTab(tabId); + } + + Future createTab(String customerName, {String? email}) { return _guard('createTab', () async { - final tab = await barTabService.createTab(customerName: customerName); - selectTab(tab.id); + final tab = await barTabService.createTab( + customerName: customerName, + email: email, + ); + openTab(tab.id); }); } @@ -102,17 +179,30 @@ class BarScreenViewModel extends ChangeNotifier { await _guard( 'addProductToSelectedTab', - () => barTabService.addProductToTab(tabId: tab.id, product: product), + () => barTabService.addProductToTab( + tabId: tab.id, + product: product, + orderId: _orderFor(tab.id), + ), ); } - Future changeItemQuantity(TabItem item, int delta) async { + /// More drinks go into the current order; taking drinks off takes them off + /// order [orderId], the one they are shown in. + Future changeItemQuantity( + TabItem item, + int delta, + String? orderId, + ) async { if (delta == 0) return; await _guard( 'changeItemQuantity', - () => - barTabService.adjustTabItemQuantity(tabItemId: item.id, delta: delta), + () => barTabService.adjustTabItemQuantity( + tabItemId: item.id, + delta: delta, + order: (orderId: delta > 0 ? _orderFor(item.tabId) : orderId), + ), ); } @@ -126,6 +216,9 @@ class BarScreenViewModel extends ChangeNotifier { 'closeSelectedTab', () => barTabService.closeTab(tab.id, paymentMethod: paymentMethod), ); + // A paid tab goes back to the Open Tab dialog + _openedTabIds.remove(tab.id); + deselectTab(); } Future _guard(String action, Future Function() body) async { diff --git a/lib/views/admin_screen_view.dart b/lib/views/admin_screen_view.dart index a4b2b55..3755055 100644 --- a/lib/views/admin_screen_view.dart +++ b/lib/views/admin_screen_view.dart @@ -8,7 +8,6 @@ import '../models/remote_command.dart'; import '../utils/navigation.dart'; import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/remote_sync_view_model.dart'; -import 'dialogs/new_tab_dialog.dart'; class AdminScreenView extends StatelessWidget { const AdminScreenView({super.key}); @@ -19,16 +18,6 @@ class AdminScreenView extends StatelessWidget { adminPinViewModel.lock(); } - Future _openNewTab(BuildContext context) async { - final l10n = AppLocalizations.of(context); - final name = await showNewTabDialog(context); - - if (name == null || !context.mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.tabOpened(name)))); - } - Future _confirmUnlink(BuildContext context) async { final l10n = AppLocalizations.of(context); final confirmed = await showDialog( @@ -94,12 +83,6 @@ class AdminScreenView extends StatelessWidget { _AdminSection( title: l10n.adminTools, children: [ - _AdminTile( - icon: Icons.person_add_alt_1_outlined, - title: l10n.openNewTab, - subtitle: l10n.openNewTabDescription, - onTap: () => _openNewTab(context), - ), _AdminTile( icon: Icons.inventory_2_outlined, title: l10n.products, diff --git a/lib/views/bar/bar_screen_view.dart b/lib/views/bar/bar_screen_view.dart index 48cfc09..88fe25f 100644 --- a/lib/views/bar/bar_screen_view.dart +++ b/lib/views/bar/bar_screen_view.dart @@ -18,6 +18,7 @@ import '../../viewmodels/remote_sync_view_model.dart'; import '../../viewmodels/settings_view_model.dart'; import '../dialogs/close_tab_dialog.dart'; import '../dialogs/edit_tab_dialog.dart'; +import '../dialogs/open_tab_dialog.dart'; import '../widgets/venue_title.dart'; import 'product_grid.dart'; import 'tab_panel.dart'; @@ -30,8 +31,11 @@ class BarScreenView extends StatefulWidget { } class _BarScreenViewState extends State { + static const _idleTimeout = Duration(minutes: 5); + bool _showSearch = false; late final StreamSubscription _remoteCloseSubscription; + Timer? _idleTimer; @override void initState() { @@ -41,6 +45,7 @@ class _BarScreenViewState extends State { .read() .tabsClosedRemotely .listen(_showRemoteClose); + _restartIdleTimer(); WidgetsBinding.instance.addPostFrameCallback((_) { UpdateChecker.check(context); @@ -50,9 +55,19 @@ class _BarScreenViewState extends State { @override void dispose() { _remoteCloseSubscription.cancel(); + _idleTimer?.cancel(); super.dispose(); } + /// A tab left open after the bar goes quiet is easy to add to by mistake, + /// so it is put back in the list. + void _restartIdleTimer() { + _idleTimer?.cancel(); + _idleTimer = Timer(_idleTimeout, () { + if (mounted) context.read().deselectTab(); + }); + } + void _showRemoteClose(RemoteSyncEvent event) { if (!mounted) return; @@ -91,6 +106,16 @@ class _BarScreenViewState extends State { } } + Future _openTab() async { + final l10n = AppLocalizations.of(context); + final name = await showOpenTabDialog(context); + + if (name == null || !mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.tabOpened(name)))); + } + void _showSyncProblem({required String title, required String description}) { final l10n = AppLocalizations.of(context); showDialog( @@ -151,150 +176,162 @@ class _BarScreenViewState extends State { (viewModel) => viewModel.venueName, ); - return Scaffold( - appBar: AppBar( - title: VenueTitle( - text: venueName == null || venueName.trim().isEmpty - ? l10n.barTabs - : venueName, - ), - actions: [ - IconButton( - tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs, - isSelected: _showSearch, - onPressed: () => setState(() => _showSearch = !_showSearch), - icon: const Icon(Icons.search_rounded), - selectedIcon: const Icon(Icons.search_off_rounded), + // Any touch counts as activity, wherever it lands + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => _restartIdleTimer(), + child: Scaffold( + appBar: AppBar( + title: VenueTitle( + text: venueName == null || venueName.trim().isEmpty + ? l10n.barTabs + : venueName, ), - const SizedBox(width: 6), - IconButton( - tooltip: l10n.admin, - onPressed: () => context.push('/admin'), - icon: const Icon(Icons.admin_panel_settings_outlined), - ), - if (kDebugMode) ...[ - const SizedBox(width: 6), - _DebugShortcut( - tooltip: l10n.manageProducts, - icon: Icons.inventory_2_outlined, - location: '/products', + actions: [ + IconButton( + tooltip: l10n.openTab, + onPressed: _openTab, + icon: const Icon(Icons.person_add_alt_1_outlined), ), - const SizedBox(width: 6), - _DebugShortcut( - tooltip: l10n.tabHistory, - icon: Icons.history_rounded, - location: '/history', - ), - ], - if (context.watch().wasRevoked) ...[ const SizedBox(width: 6), IconButton( - tooltip: l10n.deviceAccessRevoked, - color: Theme.of(context).colorScheme.error, + tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs, + isSelected: _showSearch, + onPressed: () => setState(() => _showSearch = !_showSearch), + icon: const Icon(Icons.search_rounded), + selectedIcon: const Icon(Icons.search_off_rounded), + ), + const SizedBox(width: 6), + IconButton( + tooltip: l10n.admin, onPressed: () => context.push('/admin'), - icon: const Icon(Icons.cloud_off_rounded), + icon: const Icon(Icons.admin_panel_settings_outlined), ), - ], - if (context.watch().updateRequired - case final message?) ...[ - const SizedBox(width: 6), - IconButton( - tooltip: l10n.appUpdateRequired, - color: Theme.of(context).colorScheme.error, - onPressed: () => _showUpdateRequired(message), - icon: const Icon(Icons.system_update_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), + if (kDebugMode) ...[ + const SizedBox(width: 6), + _DebugShortcut( + tooltip: l10n.manageProducts, + icon: Icons.inventory_2_outlined, + location: '/products', ), - ], - const SizedBox(width: 6), - IconButton( - tooltip: l10n.refresh, - onPressed: () => context.read().syncNow(), - icon: const Icon(Icons.refresh_rounded), - ), - const SizedBox(width: 6), - IconButton( - tooltip: l10n.settings, - onPressed: () => context.push('/settings'), - icon: const Icon(Icons.settings), - ), - if (context.watch().isPinSet) ...[ - const SizedBox(width: 6), - IconButton( - tooltip: l10n.logout, - onPressed: _logout, - icon: const Icon(Icons.logout), - ), - ], - ], - actionsPadding: const EdgeInsets.symmetric(horizontal: 8), - ), - resizeToAvoidBottomInset: false, - body: Builder( - builder: (context) { - if (viewModel.isLoading) { - return const Center( - child: CircularProgressIndicator(strokeWidth: 2.5), - ); - } - - if (viewModel.loadError != null) { - return _LoadError(message: l10n.errorText(viewModel.loadError)); - } - - return Row( - children: [ - Expanded( - flex: 2, - child: ProductGrid( - products: productsViewModel.products, - rows: settings.barGridRows, - hasSelectedTab: viewModel.selectedTab != null, - onProductTap: _addProduct, - ), - ), - Container(width: 1, color: Theme.of(context).dividerColor), - Expanded( - child: TabPanel( - tabs: viewModel.tabs, - selectedTab: viewModel.selectedTab, - selectedTabId: viewModel.selectedTabId, - showSearch: _showSearch, - onTabSelected: viewModel.selectTab, - onItemQuantityChanged: viewModel.changeItemQuantity, - onCloseTabPressed: () => confirmCloseTab(context), - onTabEdited: (tab) => showEditTabDialog(context, tab), - onTabDeleted: (tab) => confirmDeleteTab(context, tab), - ), + const SizedBox(width: 6), + _DebugShortcut( + tooltip: l10n.tabHistory, + icon: Icons.history_rounded, + location: '/history', ), ], - ); - }, + if (context.watch().wasRevoked) ...[ + const SizedBox(width: 6), + IconButton( + tooltip: l10n.deviceAccessRevoked, + color: Theme.of(context).colorScheme.error, + onPressed: () => context.push('/admin'), + icon: const Icon(Icons.cloud_off_rounded), + ), + ], + if (context.watch().updateRequired + case final message?) ...[ + const SizedBox(width: 6), + IconButton( + tooltip: l10n.appUpdateRequired, + color: Theme.of(context).colorScheme.error, + onPressed: () => _showUpdateRequired(message), + icon: const Icon(Icons.system_update_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, + onPressed: () => context.read().syncNow(), + icon: const Icon(Icons.refresh_rounded), + ), + const SizedBox(width: 6), + IconButton( + tooltip: l10n.settings, + onPressed: () => context.push('/settings'), + icon: const Icon(Icons.settings), + ), + if (context.watch().isPinSet) ...[ + const SizedBox(width: 6), + IconButton( + tooltip: l10n.logout, + onPressed: _logout, + icon: const Icon(Icons.logout), + ), + ], + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 8), + ), + resizeToAvoidBottomInset: false, + body: Builder( + builder: (context) { + if (viewModel.isLoading) { + return const Center( + child: CircularProgressIndicator(strokeWidth: 2.5), + ); + } + + if (viewModel.loadError != null) { + return _LoadError(message: l10n.errorText(viewModel.loadError)); + } + + return Row( + children: [ + Expanded( + flex: 2, + child: ProductGrid( + products: productsViewModel.products, + rows: settings.barGridRows, + hasSelectedTab: viewModel.selectedTab != null, + onProductTap: _addProduct, + ), + ), + Container(width: 1, color: Theme.of(context).dividerColor), + Expanded( + child: TabPanel( + tabs: viewModel.tabs, + selectedTab: viewModel.selectedTab, + selectedTabId: viewModel.selectedTabId, + showSearch: _showSearch, + onTabSelected: viewModel.selectTab, + onTabDeselected: viewModel.deselectTab, + onItemQuantityChanged: viewModel.changeItemQuantity, + onCloseTabPressed: () => confirmCloseTab(context), + onTabEdited: (tab) => showEditTabDialog(context, tab), + onTabDeleted: (tab) => confirmDeleteTab(context, tab), + ), + ), + ], + ); + }, + ), ), ); } diff --git a/lib/views/bar/selected_tab_details.dart b/lib/views/bar/selected_tab_details.dart index b3739f5..050c737 100644 --- a/lib/views/bar/selected_tab_details.dart +++ b/lib/views/bar/selected_tab_details.dart @@ -4,10 +4,17 @@ import '../../l10n/app_localizations.dart'; import '../../models/bar_tab.dart'; import '../../models/tab_item.dart'; import '../../utils/money.dart'; +import '../../utils/tab_orders.dart'; +import '../widgets/order_divider.dart'; + +/// Changes how many of [item] are on the tab: [delta] > 0 rings up more, +/// [delta] < 0 takes drinks of order [orderId] off. +typedef ItemQuantityChanged = + Future Function(TabItem item, int delta, String? orderId); class SelectedTabDetails extends StatelessWidget { final BarTab tab; - final Future Function(TabItem item, int delta) onItemQuantityChanged; + final ItemQuantityChanged onItemQuantityChanged; final VoidCallback onCloseTabPressed; const SelectedTabDetails({ @@ -44,17 +51,11 @@ class SelectedTabDetails extends StatelessWidget { ], ), ) - : ListView.separated( - itemCount: tab.items.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) { - final item = tab.items[index]; - - return TabItemRow( - item: item, - onQuantityChanged: onItemQuantityChanged, - ); - }, + : _OrderList( + // Another tab starts with its own orders folded + key: ValueKey(tab.id), + orders: ordersOfTab(tab), + onItemQuantityChanged: onItemQuantityChanged, ), ), const SizedBox(height: 8), @@ -102,13 +103,82 @@ class SelectedTabDetails extends StatelessWidget { } } +/// The tab's orders, oldest first, each under the time it started. +class _OrderList extends StatefulWidget { + final List> orders; + final ItemQuantityChanged onItemQuantityChanged; + + const _OrderList({ + super.key, + required this.orders, + required this.onItemQuantityChanged, + }); + + @override + State<_OrderList> createState() => _OrderListState(); +} + +/// The current (latest) order is open, earlier ones are folded away under +/// their time and total; tapping a header flips it. +class _OrderListState extends State<_OrderList> { + /// Orders opened or closed by hand, by [_key]. + final _toggled = {}; + + String _key(TabOrder order) => + order.orderId ?? order.startedAt.toIso8601String(); + + @override + Widget build(BuildContext context) { + final orders = widget.orders; + final children = []; + for (final (orderIndex, order) in orders.indexed) { + final key = _key(order); + final expanded = _toggled[key] ?? orderIndex == orders.length - 1; + children.add( + OrderDivider( + key: ValueKey('order-$key'), + startedAt: order.startedAt, + totalInCents: order.rows.fold( + 0, + (total, row) => total + row.quantity * row.line.unitPriceInCents, + ), + expanded: expanded, + onTap: () => setState(() => _toggled[key] = !expanded), + ), + ); + if (!expanded) continue; + + for (final (index, row) in order.rows.indexed) { + if (index > 0) children.add(const Divider(height: 1)); + children.add( + TabItemRow( + key: ValueKey('${order.orderId ?? order.startedAt}-${row.line.id}'), + item: row.line, + quantity: row.quantity, + orderId: order.orderId, + onQuantityChanged: widget.onItemQuantityChanged, + ), + ); + } + } + + return ListView(children: children); + } +} + class TabItemRow extends StatelessWidget { final TabItem item; - final Future Function(TabItem item, int delta) onQuantityChanged; + + /// How many of [item] are in this order. + final int quantity; + final String? orderId; + final ItemQuantityChanged onQuantityChanged; const TabItemRow({ super.key, required this.item, + required this.quantity, + required this.orderId, required this.onQuantityChanged, }); @@ -116,7 +186,7 @@ class TabItemRow extends StatelessWidget { final l10n = AppLocalizations.of(context); try { - await onQuantityChanged(item, delta); + await onQuantityChanged(item, delta, orderId); } catch (e) { if (!context.mounted) return; ScaffoldMessenger.of( @@ -145,7 +215,7 @@ class TabItemRow extends StatelessWidget { ), const SizedBox(height: 2), Text( - '${item.quantity} × ${item.unitPriceInCents.asEuro}', + '$quantity × ${item.unitPriceInCents.asEuro}', style: Theme.of(context).textTheme.bodySmall, ), ], @@ -167,7 +237,7 @@ class TabItemRow extends StatelessWidget { SizedBox( width: 22, child: Text( - '${item.quantity}', + '$quantity', textAlign: TextAlign.center, style: const TextStyle(fontWeight: FontWeight.w700), ), @@ -183,7 +253,7 @@ class TabItemRow extends StatelessWidget { SizedBox( width: 72, child: Text( - item.lineTotalInCents.asEuro, + (quantity * item.unitPriceInCents).asEuro, textAlign: TextAlign.end, style: const TextStyle(fontWeight: FontWeight.w700), ), diff --git a/lib/views/bar/tab_panel.dart b/lib/views/bar/tab_panel.dart index 4c7fcc7..0292815 100644 --- a/lib/views/bar/tab_panel.dart +++ b/lib/views/bar/tab_panel.dart @@ -7,7 +7,6 @@ import 'package:flutter_slidable/flutter_slidable.dart'; import '../../l10n/app_localizations.dart'; import '../../models/bar_tab.dart'; -import '../../models/tab_item.dart'; import '../../utils/easter_eggs.dart'; import '../../utils/money.dart'; import '../../utils/name_index.dart'; @@ -42,7 +41,8 @@ class TabPanel extends StatefulWidget { final String? selectedTabId; final bool showSearch; final ValueChanged onTabSelected; - final Future Function(TabItem item, int delta) onItemQuantityChanged; + final VoidCallback onTabDeselected; + final ItemQuantityChanged onItemQuantityChanged; final VoidCallback onCloseTabPressed; final Future Function(BarTab) onTabEdited; final Future Function(BarTab) onTabDeleted; @@ -54,6 +54,7 @@ class TabPanel extends StatefulWidget { required this.selectedTabId, required this.showSearch, required this.onTabSelected, + required this.onTabDeselected, required this.onItemQuantityChanged, required this.onCloseTabPressed, required this.onTabEdited, @@ -77,13 +78,23 @@ class _TabPanelState extends State { /// The names-list offset the selected tile was last lined up at. double? _attachedOffset; - @override - void initState() { - super.initState(); + /// The first row of the names list, measured for [_rowExtent]. + final GlobalKey _firstRowKey = GlobalKey(); - // The row height is only known once the list is laid out + /// The height of every row in the names list, gap included. Measured, as + /// a short list can't tell it from its scroll range. + double? _rowExtent; + + /// The row height is only known once the list is laid out (and changes + /// with the text size). + void _measureRow() { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() {}); + if (!mounted) return; + final box = _firstRowKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + if (box.size.height != _rowExtent) { + setState(() => _rowExtent = box.size.height); + } }); } @@ -99,11 +110,12 @@ class _TabPanelState extends State { if (tab == null || _focus == _PanelFocus.names) return null; final index = tabs.indexWhere((candidate) => candidate.id == tab.id); - final extent = _tabExtent(tabs.length); + final extent = _rowExtent; if (index == -1 || extent == null) return null; final offset = (index + 1) * extent - _tabRowGap - viewportHeight; - return offset < 0 ? null : offset; + // Rounding can leave a sliver below zero + return offset < -0.5 ? null : max(offset, 0); } void _lineUpSelectedTab(double? offset) { @@ -152,12 +164,12 @@ class _TabPanelState extends State { _searchQuery = ''; } - // Adding products means working on the selected tab + // Adding products, or opening a tab from the Open Tab dialog, means + // working on the selected tab final tab = widget.selectedTab; final oldTab = oldWidget.selectedTab; if (tab != null && - tab.id == oldTab?.id && - tab.itemCount != oldTab?.itemCount) { + (tab.id != oldTab?.id || tab.itemCount != oldTab?.itemCount)) { _focus = _PanelFocus.items; } } @@ -169,14 +181,6 @@ class _TabPanelState extends State { super.dispose(); } - /// Every tab row has the same height, so it follows from the scroll range. - double? _tabExtent(int tabCount) { - if (!_tabsScrollController.hasClients || tabCount == 0) return null; - - final position = _tabsScrollController.position; - return (position.maxScrollExtent + position.viewportDimension) / tabCount; - } - /// Scrolls to the first tab at or after [letter], so a letter without names /// lands on the next one. void _jumpToLetter(String letter, List tabs) { @@ -186,8 +190,8 @@ class _TabPanelState extends State { setState(() => _touchedLetter = letter); } - final extent = _tabExtent(tabs.length); - if (extent == null) return; + final extent = _rowExtent; + if (extent == null || !_tabsScrollController.hasClients) return; final position = _tabsScrollController.position; final start = indexLetters.indexOf(letter); @@ -205,8 +209,8 @@ class _TabPanelState extends State { String? _visibleLetter(List tabs) { if (tabs.isEmpty) return null; - final extent = _tabExtent(tabs.length); - final index = extent == null + final extent = _rowExtent; + final index = extent == null || !_tabsScrollController.hasClients ? 0 : (_tabsScrollController.offset / extent).round().clamp( 0, @@ -226,6 +230,7 @@ class _TabPanelState extends State { _searchQuery.toLowerCase(), ); }).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName)); + _measureRow(); return DecoratedBox( decoration: BoxDecoration(color: scheme.surfaceContainerLow), @@ -236,7 +241,7 @@ class _TabPanelState extends State { children: [ ListenableBuilder( listenable: _tabsScrollController, - builder: (context, child) => _LetterIndexBar( + builder: (context, child) => LetterIndexBar( availableLetters: { for (final tab in filteredTabs) indexLetterOf(tab.customerName), @@ -262,11 +267,30 @@ class _TabPanelState extends State { ? _PanelFocus.balanced : _focus; final searchHeight = widget.showSearch ? _searchHeight : 0.0; - final namesHeight = switch (focus) { - _PanelFocus.balanced => min(246.0, available / 2), - _PanelFocus.names => available - collapsedItems, - _PanelFocus.items => collapsedNames + searchHeight, - }; + // Without a selected tab there are no items to show, so + // the names get the whole panel + final hasSelection = widget.selectedTab != null; + final selectedIndex = filteredTabs.indexWhere( + (tab) => tab.id == widget.selectedTabId, + ); + final rowExtent = _rowExtent; + final namesHeight = !hasSelection + ? constraints.maxHeight + : switch (focus) { + _PanelFocus.balanced => min(246.0, available / 2), + _PanelFocus.names => available - collapsedItems, + // A tile near the top can't scroll down to its + // items, so the names shrink up to it instead + _PanelFocus.items => + (selectedIndex == -1 || rowExtent == null + ? collapsedNames + : min( + collapsedNames, + (selectedIndex + 1) * rowExtent - + _tabRowGap, + )) + + searchHeight, + }; final attachOffset = _attachOffset( filteredTabs, @@ -335,12 +359,19 @@ class _TabPanelState extends State { }, child: OpenTabsList( controller: _tabsScrollController, + firstRowKey: _firstRowKey, tabs: filteredTabs, selectedTabId: widget.selectedTabId, attachedTabId: attached ? widget.selectedTabId : null, onTabSelected: (id) { + // Tapping the open tab again closes it + if (id == widget.selectedTabId) { + _focusOn(_PanelFocus.names); + widget.onTabDeselected(); + return; + } _focusOn(_PanelFocus.items); widget.onTabSelected(id); }, @@ -352,7 +383,8 @@ class _TabPanelState extends State { ], ), ), - if (!attached) const Divider(height: dividerHeight), + if (!attached && hasSelection) + const Divider(height: dividerHeight), Expanded( child: Listener( onPointerDown: (_) => _focusOn(_PanelFocus.items), @@ -434,6 +466,9 @@ class _TabPanelState extends State { class OpenTabsList extends StatelessWidget { final ScrollController? controller; + + /// Put on the first row, so its height can be measured. + final Key? firstRowKey; final List tabs; final String? selectedTabId; @@ -446,6 +481,7 @@ class OpenTabsList extends StatelessWidget { const OpenTabsList({ super.key, this.controller, + this.firstRowKey, required this.tabs, required this.selectedTabId, this.attachedTabId, @@ -477,6 +513,7 @@ class OpenTabsList extends StatelessWidget { final attached = tab.id == attachedTabId; return _TabRow( + key: index == 0 ? firstRowKey : null, child: ClipRRect( borderRadius: attached ? _attachedTileRadius @@ -520,7 +557,7 @@ class OpenTabsList extends StatelessWidget { class _TabRow extends StatelessWidget { final Widget child; - const _TabRow({required this.child}); + const _TabRow({super.key, required this.child}); @override Widget build(BuildContext context) { @@ -603,14 +640,16 @@ class _TabCard extends StatelessWidget { } } -class _LetterIndexBar extends StatefulWidget { +/// The A–Z strip beside a list of names: tap or slide to jump to a letter. +class LetterIndexBar extends StatefulWidget { final Set availableLetters; final String? activeLetter; final bool isTouched; final ValueChanged onLetterSelected; final VoidCallback onReleased; - const _LetterIndexBar({ + const LetterIndexBar({ + super.key, required this.availableLetters, required this.activeLetter, required this.isTouched, @@ -619,10 +658,10 @@ class _LetterIndexBar extends StatefulWidget { }); @override - State<_LetterIndexBar> createState() => _LetterIndexBarState(); + State createState() => _LetterIndexBarState(); } -class _LetterIndexBarState extends State<_LetterIndexBar> +class _LetterIndexBarState extends State with SingleTickerProviderStateMixin { static final _zIndex = indexLetters.indexOf('Z'); diff --git a/lib/views/dialogs/close_tab_dialog.dart b/lib/views/dialogs/close_tab_dialog.dart index 5f2dbc4..688994e 100644 --- a/lib/views/dialogs/close_tab_dialog.dart +++ b/lib/views/dialogs/close_tab_dialog.dart @@ -1,3 +1,6 @@ +import 'dart:math'; + +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:provider/provider.dart'; @@ -6,6 +9,7 @@ import '../../l10n/app_localizations.dart'; import '../../models/payment_method.dart'; import '../../utils/money.dart'; import '../../viewmodels/bar_screen_view_model.dart'; +import '../widgets/money_rain.dart'; import '../widgets/slide_confirm.dart'; Future confirmCloseTab(BuildContext context) async { @@ -73,6 +77,9 @@ Future confirmCloseTab(BuildContext context) async { try { await viewModel.closeSelectedTab(paymentMethod: paymentMethod); + _playChaChing(); + // Make it rain, but only once in a hundred tabs. + if (context.mounted && Random().nextInt(25) == 0) showMoneyRain(context); } catch (_) { if (!context.mounted) return; ScaffoldMessenger.of( @@ -81,6 +88,16 @@ Future confirmCloseTab(BuildContext context) async { } } +final _chaChingPlayer = AudioPlayer(); + +/// Plays the till sound once a tab is paid; a sound that fails to play never +/// gets in the way of closing the tab. +void _playChaChing() { + _chaChingPlayer + .play(AssetSource('Cha-Ching.mp3')) + .catchError((Object e) => debugPrint('Could not play cha-ching: $e')); +} + const _payconiqQrAsset = 'assets/payconiq/qr.png'; /// Shows the Payconiq QR code for the customer to scan. Resolves to true diff --git a/lib/views/dialogs/new_tab_dialog.dart b/lib/views/dialogs/new_tab_dialog.dart deleted file mode 100644 index 3ae8eab..0000000 --- a/lib/views/dialogs/new_tab_dialog.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../l10n/app_localizations.dart'; -import '../../viewmodels/bar_screen_view_model.dart'; -import 'duplicate_tab_name_dialog.dart'; - -/// Returns the name of the opened tab, or null when nothing was opened. -Future showNewTabDialog(BuildContext context) async { - final l10n = AppLocalizations.of(context); - final controller = TextEditingController(); - - final name = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: Text(l10n.openNewTab), - content: TextField( - controller: controller, - autofocus: true, - decoration: InputDecoration(labelText: l10n.customerGroupName), - onSubmitted: (value) { - Navigator.of(dialogContext).pop(value); - }, - ), - actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text(l10n.cancel), - ), - FilledButton( - onPressed: () { - Navigator.of(dialogContext).pop(controller.text); - }, - child: Text(l10n.openTab), - ), - ], - ); - }, - ); - - final trimmed = name?.trim() ?? ''; - if (trimmed.isEmpty || !context.mounted) return null; - - final viewModel = context.read(); - if (viewModel.hasOpenTabNamed(trimmed) && - !await confirmDuplicateTabName(context, trimmed)) { - return null; - } - - try { - await viewModel.createTab(trimmed); - return trimmed; - } catch (_) { - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage))); - } - return null; - } -} diff --git a/lib/views/dialogs/open_tab_dialog.dart b/lib/views/dialogs/open_tab_dialog.dart new file mode 100644 index 0000000..ec511f6 --- /dev/null +++ b/lib/views/dialogs/open_tab_dialog.dart @@ -0,0 +1,328 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/bar_tab.dart'; +import '../../utils/name_index.dart'; +import '../../viewmodels/bar_screen_view_model.dart'; +import '../bar/tab_panel.dart'; +import 'duplicate_tab_name_dialog.dart'; + +/// What the Open Tab dialog was closed with. +sealed class _Choice {} + +class _ExistingTab extends _Choice { + final BarTab tab; + + _ExistingTab(this.tab); +} + +class _NewCustomer extends _Choice { + /// The search text, as a start for the name. + final String name; + + _NewCustomer(this.name); +} + +/// Lets the bartender pick a tab that isn't open yet, or add a new customer. +/// Returns the name of the opened tab, or null when nothing was opened. +Future showOpenTabDialog(BuildContext context) async { + final viewModel = context.read(); + + final choice = await showDialog<_Choice>( + context: context, + builder: (dialogContext) => _OpenTabDialog(viewModel: viewModel), + ); + + if (!context.mounted) return null; + + switch (choice) { + case null: + return null; + case _ExistingTab(:final tab): + viewModel.openTab(tab.id); + return tab.customerName; + case _NewCustomer(:final name): + return _addCustomer(context, initialName: name); + } +} + +class _OpenTabDialog extends StatefulWidget { + final BarScreenViewModel viewModel; + + const _OpenTabDialog({required this.viewModel}); + + @override + State<_OpenTabDialog> createState() => _OpenTabDialogState(); +} + +/// Every row is this tall, so the letter index knows where each name is. +const _rowExtent = 64.0; + +class _OpenTabDialogState extends State<_OpenTabDialog> { + final ScrollController _scrollController = ScrollController(); + String _query = ''; + + /// The letter under the finger while the index bar is pressed. + String? _touchedLetter; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + /// Scrolls to the first tab at or after [letter], so a letter without names + /// lands on the next one. + void _jumpToLetter(String letter, List tabs) { + if (letter != _touchedLetter) { + HapticFeedback.selectionClick(); + setState(() => _touchedLetter = letter); + } + if (!_scrollController.hasClients) return; + + final position = _scrollController.position; + final start = indexLetters.indexOf(letter); + final index = tabs.indexWhere( + (tab) => indexLetters.indexOf(indexLetterOf(tab.customerName)) >= start, + ); + + _scrollController.jumpTo( + index == -1 + ? position.maxScrollExtent + : min(index * _rowExtent, position.maxScrollExtent), + ); + } + + String? _visibleLetter(List tabs) { + if (tabs.isEmpty) return null; + final offset = _scrollController.hasClients ? _scrollController.offset : 0; + final index = (offset / _rowExtent).round().clamp(0, tabs.length - 1); + return indexLetterOf(tabs[index].customerName); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final scheme = Theme.of(context).colorScheme; + + return AlertDialog( + title: Text(l10n.openTab), + content: SizedBox( + width: 420, + height: 480, + child: Column( + children: [ + TextField( + onChanged: (value) => setState(() => _query = value), + decoration: InputDecoration( + hintText: l10n.searchByName, + prefixIcon: const Icon(Icons.search_rounded, size: 20), + isDense: true, + ), + ), + const SizedBox(height: 12), + Expanded( + // Tabs opened or closed elsewhere while the dialog is up + child: ListenableBuilder( + listenable: widget.viewModel, + builder: (context, _) { + final query = _query.trim().toLowerCase(); + final available = widget.viewModel.closedTabs; + final tabs = + available + .where( + (tab) => + tab.customerName.toLowerCase().contains(query), + ) + .toList() + ..sort( + (a, b) => + compareByIndex(a.customerName, b.customerName), + ); + + if (tabs.isEmpty) { + return Center( + child: Text( + available.isEmpty ? l10n.allTabsOpen : l10n.noTabsFound, + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + final list = ListView.builder( + controller: _scrollController, + itemCount: tabs.length, + itemExtent: _rowExtent, + itemBuilder: (context, index) { + final tab = tabs[index]; + + return ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + leading: CircleAvatar( + backgroundColor: scheme.primary.withValues( + alpha: 0.12, + ), + foregroundColor: scheme.primary, + child: Text(indexLetterOf(tab.customerName)), + ), + title: Text( + tab.customerName, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + subtitle: tab.email == null + ? null + : Text( + tab.email!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => + Navigator.of(context).pop(_ExistingTab(tab)), + ); + }, + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListenableBuilder( + listenable: _scrollController, + builder: (context, _) => LetterIndexBar( + availableLetters: { + for (final tab in tabs) + indexLetterOf(tab.customerName), + }, + activeLetter: _touchedLetter ?? _visibleLetter(tabs), + isTouched: _touchedLetter != null, + onLetterSelected: (letter) => + _jumpToLetter(letter, tabs), + onReleased: () => + setState(() => _touchedLetter = null), + ), + ), + const SizedBox(width: 8), + Expanded(child: list), + ], + ); + }, + ), + ), + ], + ), + ), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton.icon( + onPressed: () => + Navigator.of(context).pop(_NewCustomer(_query.trim())), + icon: const Icon(Icons.person_add_alt_1_outlined), + label: Text(l10n.newCustomer), + ), + ], + ); + } +} + +final _emailPattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); + +/// Asks for a name and an optional email, then opens a tab for them. +Future _addCustomer( + BuildContext context, { + required String initialName, +}) async { + final l10n = AppLocalizations.of(context); + final nameController = TextEditingController(text: initialName); + final emailController = TextEditingController(); + final formKey = GlobalKey(); + + final customer = await showDialog<({String name, String? email})>( + context: context, + builder: (dialogContext) { + void submit() { + if (!formKey.currentState!.validate()) return; + final email = emailController.text.trim(); + Navigator.of(dialogContext).pop(( + name: nameController.text.trim(), + email: email.isEmpty ? null : email, + )); + } + + return AlertDialog( + title: Text(l10n.newCustomer), + content: SizedBox( + width: 420, + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: nameController, + autofocus: true, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: l10n.customerGroupName, + ), + validator: (value) => + (value ?? '').trim().isEmpty ? l10n.nameRequired : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + decoration: InputDecoration(labelText: l10n.emailOptional), + validator: (value) { + final email = (value ?? '').trim(); + return email.isEmpty || _emailPattern.hasMatch(email) + ? null + : l10n.invalidEmail; + }, + onFieldSubmitted: (_) => submit(), + ), + ], + ), + ), + ), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.cancel), + ), + FilledButton(onPressed: submit, child: Text(l10n.openTab)), + ], + ); + }, + ); + + if (customer == null || !context.mounted) return null; + + final viewModel = context.read(); + if (viewModel.hasOpenTabNamed(customer.name) && + !await confirmDuplicateTabName(context, customer.name)) { + return null; + } + + try { + await viewModel.createTab(customer.name, email: customer.email); + return customer.name; + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage))); + } + return null; + } +} diff --git a/lib/views/widgets/closed_tab_card.dart b/lib/views/widgets/closed_tab_card.dart index 9fc09d8..2b6785f 100644 --- a/lib/views/widgets/closed_tab_card.dart +++ b/lib/views/widgets/closed_tab_card.dart @@ -6,8 +6,9 @@ import '../../models/closed_tab.dart'; import '../../models/closed_tab_item.dart'; import '../../models/payment_method.dart'; import '../../utils/easter_eggs.dart'; -import '../../utils/history_grouping.dart'; import '../../utils/money.dart'; +import '../../utils/tab_orders.dart'; +import 'order_history_list.dart'; class ClosedTabCard extends StatefulWidget { final ClosedTab closedTab; @@ -35,10 +36,6 @@ class _ClosedTabCardState extends State { final l10n = AppLocalizations.of(context); final locale = Localizations.localeOf(context).toLanguageTag(); final dateFormat = DateFormat.yMMMd(locale).add_jm(); - final dayGroups = groupClosedTabItemsByDay( - closedTab.items, - fallbackDate: closedTab.closedAt, - ); final scheme = Theme.of(context).colorScheme; return Container( @@ -159,16 +156,12 @@ class _ClosedTabCardState extends State { children: [ const Divider(height: 1), const SizedBox(height: 8), - for (var index = 0; index < dayGroups.length; index++) - _ClosedTabDayDropdown( - key: ValueKey( - '${dayGroups[index].items.first.closedTabId}-' - '${dayGroups[index].day.toIso8601String()}', - ), - group: dayGroups[index], - fallbackDate: closedTab.closedAt, - initiallyExpanded: index == 0, - ), + OrderHistoryList( + orders: ordersOfClosedTab(closedTab), + productName: (ClosedTabItem item) => item.productName, + unitPriceInCents: (ClosedTabItem item) => + item.unitPriceInCents, + ), ], ), ), @@ -179,213 +172,3 @@ class _ClosedTabCardState extends State { ); } } - -class _ClosedTabDayDropdown extends StatefulWidget { - final ClosedTabItemDayGroup group; - final DateTime fallbackDate; - final bool initiallyExpanded; - - const _ClosedTabDayDropdown({ - super.key, - required this.group, - required this.fallbackDate, - required this.initiallyExpanded, - }); - - @override - State<_ClosedTabDayDropdown> createState() => _ClosedTabDayDropdownState(); -} - -class _ClosedTabDayDropdownState extends State<_ClosedTabDayDropdown> { - late bool _expanded = widget.initiallyExpanded; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final locale = Localizations.localeOf(context).toLanguageTag(); - final dayFormat = DateFormat.yMMMMd(locale); - final scheme = Theme.of(context).colorScheme; - - return Padding( - padding: const EdgeInsets.only(top: 8), - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - decoration: BoxDecoration( - color: _expanded - ? scheme.primary.withValues(alpha: 0.08) - : scheme.onSurface.withValues(alpha: 0.035), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: _expanded - ? scheme.primary.withValues(alpha: 0.28) - : scheme.onSurface.withValues(alpha: 0.08), - ), - ), - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - Material( - color: Colors.transparent, - child: InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Row( - children: [ - Container( - width: 30, - height: 30, - decoration: BoxDecoration( - color: scheme.primary.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(9), - ), - child: Icon( - Icons.calendar_today_rounded, - size: 16, - color: scheme.primary, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dayFormat.format(widget.group.day), - style: const TextStyle( - fontWeight: FontWeight.w700, - ), - ), - Text( - l10n.tabItemCount(widget.group.itemCount), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - Icon( - _expanded - ? Icons.keyboard_arrow_up_rounded - : Icons.keyboard_arrow_down_rounded, - color: _expanded - ? scheme.primary - : scheme.onSurface.withValues(alpha: 0.5), - ), - ], - ), - ), - ), - ), - AnimatedSize( - duration: const Duration(milliseconds: 160), - curve: Curves.easeOut, - child: _expanded - ? Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: Column( - children: [ - Divider( - height: 1, - color: scheme.primary.withValues(alpha: 0.18), - ), - for (final item in widget.group.items) - _ClosedTabItemRow( - item: item, - purchasedAt: - item.purchasedAt ?? widget.fallbackDate, - ), - ], - ), - ) - : const SizedBox.shrink(), - ), - ], - ), - ), - ); - } -} - -class _ClosedTabItemRow extends StatelessWidget { - final ClosedTabItem item; - final DateTime purchasedAt; - - const _ClosedTabItemRow({required this.item, required this.purchasedAt}); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final locale = Localizations.localeOf(context).toLanguageTag(); - final timeFormat = DateFormat.jm(locale); - final unitPrice = NumberFormat.simpleCurrency( - locale: locale, - ).format(item.unitPriceInCents / 100); - final lineTotal = NumberFormat.simpleCurrency( - locale: locale, - ).format(item.lineTotalInCents / 100); - final scheme = Theme.of(context).colorScheme; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.productName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - color: item.isRemoved - ? scheme.onSurface.withValues(alpha: 0.5) - : scheme.onSurface, - decoration: item.isRemoved - ? TextDecoration.lineThrough - : null, - ), - ), - Text( - timeFormat.format(purchasedAt), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - if (item.isRemoved) - Text( - l10n.removed, - style: TextStyle( - color: scheme.error, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ) - else ...[ - Text( - '${item.quantity} × $unitPrice', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(width: 12), - SizedBox( - width: 64, - child: Text( - lineTotal, - textAlign: TextAlign.end, - style: TextStyle( - fontWeight: FontWeight.w700, - color: scheme.onSurface, - ), - ), - ), - ], - ], - ), - ); - } -} diff --git a/lib/views/widgets/money_rain.dart b/lib/views/widgets/money_rain.dart new file mode 100644 index 0000000..e45fd7f --- /dev/null +++ b/lib/views/widgets/money_rain.dart @@ -0,0 +1,135 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +const _emojis = ['💲', '🤑', '💸', '💰', '💵', '💶', '💷']; +const _dropCount = 160; +const _duration = Duration(seconds: 3); + +/// Lets money emojis rain down over the whole screen for three seconds, for +/// when a tab gets paid. Taps go straight through to the app underneath. +void showMoneyRain(BuildContext context) { + final overlay = Overlay.of(context, rootOverlay: true); + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (_) => _MoneyRain(onDone: () => entry.remove()), + ); + overlay.insert(entry); +} + +class _MoneyRain extends StatefulWidget { + final VoidCallback onDone; + + const _MoneyRain({required this.onDone}); + + @override + State<_MoneyRain> createState() => _MoneyRainState(); +} + +class _MoneyRainState extends State<_MoneyRain> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: _duration, + )..forward().whenComplete(widget.onDone); + + final _drops = List.generate(_dropCount, (_) => _Drop.random(Random())); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: CustomPaint( + size: Size.infinite, + painter: _MoneyRainPainter(_controller, _drops), + ), + ); + } +} + +/// One falling emoji. Times are fractions of the whole rain, so every drop +/// has left the screen by the time the rain ends. +class _Drop { + final int emoji; + final double x; + final double start; + final double fall; + final double size; + final double spin; + final double wobble; + + const _Drop({ + required this.emoji, + required this.x, + required this.start, + required this.fall, + required this.size, + required this.spin, + required this.wobble, + }); + + factory _Drop.random(Random random) { + final fall = 0.3 + random.nextDouble() * 0.17; + return _Drop( + emoji: random.nextInt(_emojis.length), + x: random.nextDouble(), + start: random.nextDouble() * (1 - fall), + fall: fall, + size: 28 + random.nextDouble() * 36, + spin: (random.nextDouble() - 0.5) * 4 * pi, + wobble: random.nextDouble() * 2 * pi, + ); + } +} + +class _MoneyRainPainter extends CustomPainter { + static const _baseSize = 64.0; + + final Animation animation; + final List<_Drop> drops; + final List _glyphs = [ + for (final emoji in _emojis) + TextPainter( + text: TextSpan( + text: emoji, + style: const TextStyle(fontSize: _baseSize), + ), + textDirection: TextDirection.ltr, + )..layout(), + ]; + + _MoneyRainPainter(this.animation, this.drops) : super(repaint: animation); + + @override + void paint(Canvas canvas, Size size) { + final t = animation.value; + + for (final drop in drops) { + final progress = (t - drop.start) / drop.fall; + if (progress < 0 || progress > 1) continue; + + final glyph = _glyphs[drop.emoji]; + final scale = drop.size / _baseSize; + final dx = + drop.x * size.width + sin(progress * 3 * pi + drop.wobble) * 24; + final dy = -drop.size + progress * (size.height + drop.size * 2); + + canvas + ..save() + ..translate(dx, dy) + ..rotate(drop.spin * progress) + ..scale(scale); + glyph.paint(canvas, Offset(-glyph.width / 2, -glyph.height / 2)); + canvas.restore(); + } + } + + @override + bool shouldRepaint(_MoneyRainPainter oldDelegate) => + oldDelegate.drops != drops; +} diff --git a/lib/views/widgets/open_tab_history_card.dart b/lib/views/widgets/open_tab_history_card.dart index cfb9bd7..91dbfc9 100644 --- a/lib/views/widgets/open_tab_history_card.dart +++ b/lib/views/widgets/open_tab_history_card.dart @@ -3,9 +3,11 @@ import 'package:intl/intl.dart'; import '../../l10n/app_localizations.dart'; import '../../models/bar_tab.dart'; -import '../../models/tab_item_purchase.dart'; +import '../../models/tab_item.dart'; import '../../utils/easter_eggs.dart'; import '../../utils/money.dart'; +import '../../utils/tab_orders.dart'; +import 'order_history_list.dart'; class OpenTabHistoryCard extends StatefulWidget { final BarTab tab; @@ -19,42 +21,6 @@ class OpenTabHistoryCard extends StatefulWidget { class _OpenTabHistoryCardState extends State { bool _expanded = false; - List<_OpenPurchase> _purchases(BarTab tab) { - final purchases = <_OpenPurchase>[]; - - for (final item in tab.items) { - final records = item.purchases.isEmpty - ? List.generate( - item.quantity, - (index) => TabItemPurchase( - id: '${item.id}-$index', - tabItemId: item.id, - purchasedAt: tab.openedAt, - ), - ) - : item.purchases; - - purchases.addAll( - records.map( - (purchase) => _OpenPurchase( - productName: item.productName, - unitPriceInCents: item.unitPriceInCents, - purchasedAt: purchase.purchasedAt, - isRemoved: purchase.isRemoved, - ), - ), - ); - } - - purchases.sort((a, b) { - final byTime = b.purchasedAt.compareTo(a.purchasedAt); - if (byTime != 0) return byTime; - - return b.productName.compareTo(a.productName); - }); - return purchases; - } - @override Widget build(BuildContext context) { final tab = widget.tab; @@ -163,9 +129,12 @@ class _OpenTabHistoryCardState extends State { children: [ const Divider(height: 1), const SizedBox(height: 8), - ..._purchases( - tab, - ).map((purchase) => _OpenPurchaseRow(purchase: purchase)), + OrderHistoryList( + orders: ordersOfTab(tab, includeRemoved: true), + productName: (TabItem item) => item.productName, + unitPriceInCents: (TabItem item) => + item.unitPriceInCents, + ), ], ), ), @@ -178,81 +147,3 @@ class _OpenTabHistoryCardState extends State { ); } } - -class _OpenPurchase { - final String productName; - final int unitPriceInCents; - final DateTime purchasedAt; - final bool isRemoved; - - const _OpenPurchase({ - required this.productName, - required this.unitPriceInCents, - required this.purchasedAt, - required this.isRemoved, - }); -} - -class _OpenPurchaseRow extends StatelessWidget { - final _OpenPurchase purchase; - - const _OpenPurchaseRow({required this.purchase}); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final locale = Localizations.localeOf(context).toLanguageTag(); - final dateFormat = DateFormat.yMMMd(locale).add_jm(); - final unitPrice = NumberFormat.simpleCurrency( - locale: locale, - ).format(purchase.unitPriceInCents / 100); - final scheme = Theme.of(context).colorScheme; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - purchase.productName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - color: purchase.isRemoved - ? scheme.onSurface.withValues(alpha: 0.5) - : scheme.onSurface, - decoration: purchase.isRemoved - ? TextDecoration.lineThrough - : null, - ), - ), - Text( - dateFormat.format(purchase.purchasedAt), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - if (purchase.isRemoved) - Text( - l10n.removed, - style: TextStyle( - color: scheme.error, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ) - else - Text( - '1 × $unitPrice', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ); - } -} diff --git a/lib/views/widgets/order_divider.dart b/lib/views/widgets/order_divider.dart new file mode 100644 index 0000000..6c87f23 --- /dev/null +++ b/lib/views/widgets/order_divider.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../utils/money.dart'; +import '../../utils/tab_orders.dart'; + +/// The line above the drinks of one order: "—— 12:32 today ——". With +/// [totalInCents] it adds what the order costs; with [onTap] it opens and +/// closes the order, a chevron showing whether it is [expanded]. +class OrderDivider extends StatelessWidget { + final DateTime startedAt; + final int? totalInCents; + final bool expanded; + final VoidCallback? onTap; + + const OrderDivider({ + super.key, + required this.startedAt, + this.totalInCents, + this.expanded = true, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final scheme = Theme.of(context).colorScheme; + final line = Expanded( + child: Divider(color: scheme.onSurface.withValues(alpha: 0.18)), + ); + final label = [ + orderTimeLabel(l10n, locale, startedAt), + if (totalInCents != null) totalInCents!.asEuro, + ].join(' – '); + final style = Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w700, + color: scheme.onSurface.withValues(alpha: 0.6), + ); + + final row = Padding( + padding: const EdgeInsets.only(top: 6, bottom: 2), + child: Row( + children: [ + line, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: style), + if (onTap != null) ...[ + const SizedBox(width: 2), + Icon( + expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 18, + color: style?.color, + ), + ], + ], + ), + ), + line, + ], + ), + ); + + if (onTap == null) return row; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: row, + ); + } +} diff --git a/lib/views/widgets/order_history_list.dart b/lib/views/widgets/order_history_list.dart new file mode 100644 index 0000000..f885501 --- /dev/null +++ b/lib/views/widgets/order_history_list.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../utils/money.dart'; +import '../../utils/tab_orders.dart'; +import 'order_divider.dart'; + +/// The drinks of a tab in history, per order: "2 × Duvel € 8,00", with the +/// drinks taken off again struck through. +class OrderHistoryList extends StatelessWidget { + final List> orders; + final String Function(L line) productName; + final int Function(L line) unitPriceInCents; + + const OrderHistoryList({ + super.key, + required this.orders, + required this.productName, + required this.unitPriceInCents, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final order in orders) ...[ + OrderDivider(startedAt: order.startedAt), + for (final row in order.rows) ...[ + if (row.quantity > 0) + _HistoryRow( + name: productName(row.line), + quantity: row.quantity, + unitPriceInCents: unitPriceInCents(row.line), + ), + if (row.removed > 0) + _HistoryRow( + name: productName(row.line), + quantity: row.removed, + unitPriceInCents: unitPriceInCents(row.line), + removed: true, + ), + ], + ], + ], + ); + } +} + +class _HistoryRow extends StatelessWidget { + final String name; + final int quantity; + final int unitPriceInCents; + final bool removed; + + const _HistoryRow({ + required this.name, + required this.quantity, + required this.unitPriceInCents, + this.removed = false, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final scheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + children: [ + Expanded( + child: Text( + '$quantity × $name', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + color: removed + ? scheme.onSurface.withValues(alpha: 0.5) + : scheme.onSurface, + decoration: removed ? TextDecoration.lineThrough : null, + ), + ), + ), + const SizedBox(width: 12), + if (removed) + Text( + l10n.removedCount(quantity), + style: TextStyle( + color: scheme.error, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ) + else + Text( + (quantity * unitPriceInCents).asEuro, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 499475a..046a6c3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.dev" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.dev" + source: hosted + version: "4.4.1" boolean_selector: dependency: transitive description: @@ -970,6 +1026,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 + url: "https://pub.dev" + source: hosted + version: "3.5.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6799079..0eb7584 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.4.0+1 +version: 1.4.4+0 environment: sdk: ^3.12.2 @@ -13,6 +13,7 @@ dependencies: flutter_localizations: sdk: flutter + audioplayers: ^6.8.1 collection: ^1.19.1 crypto: ^3.0.0 cupertino_icons: ^1.0.8 @@ -50,6 +51,7 @@ flutter: uses-material-design: true assets: + - assets/Cha-Ching.mp3 - assets/icons/payconic.svg - assets/payconiq/ - assets/products/ diff --git a/test/bar_screen_view_model_test.dart b/test/bar_screen_view_model_test.dart index c33041a..eaf2401 100644 --- a/test/bar_screen_view_model_test.dart +++ b/test/bar_screen_view_model_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:kooltap/models/bar_tab.dart'; +import 'package:kooltap/models/payment_method.dart'; import 'package:kooltap/models/tab_item.dart'; import 'package:kooltap/services/bar_tab_service.dart'; import 'package:kooltap/viewmodels/bar_screen_view_model.dart'; @@ -9,12 +10,22 @@ import 'package:mocktail/mocktail.dart'; class MockBarTabService extends Mock implements BarTabService {} -BarTab _tab(String id) { +BarTab _tab(String id, {int drinks = 0}) { return BarTab( id: id, customerName: id, openedAt: DateTime(2026), - items: const [], + items: [ + if (drinks > 0) + TabItem( + id: '$id-item', + tabId: id, + productId: 'prod-1', + productName: 'Beer', + quantity: drinks, + unitPriceInCents: 400, + ), + ], ); } @@ -38,7 +49,7 @@ void main() { await openTabs.close(); }); - test('shows tabs from the stream and selects the first one', () async { + test('starts with no open tabs and nothing selected', () async { viewModel.ensureLoaded(); expect(viewModel.isLoading, true); @@ -46,8 +57,62 @@ void main() { await pumpEventQueue(); expect(viewModel.isLoading, false); - expect(viewModel.tabs.map((tab) => tab.id), ['a', 'b']); - expect(viewModel.selectedTabId, 'a'); + expect(viewModel.tabs, isEmpty); + expect(viewModel.closedTabs.map((tab) => tab.id), ['a', 'b']); + expect(viewModel.selectedTabId, isNull); + }); + + test('tabs with drinks on them are open', () async { + viewModel.ensureLoaded(); + openTabs.add([_tab('a', drinks: 2), _tab('b')]); + await pumpEventQueue(); + + expect(viewModel.tabs.map((tab) => tab.id), ['a']); + expect(viewModel.closedTabs.map((tab) => tab.id), ['b']); + }); + + test('opening a tab shows and selects it', () async { + viewModel.ensureLoaded(); + openTabs.add([_tab('a'), _tab('b')]); + await pumpEventQueue(); + + viewModel.openTab('b'); + viewModel.deselectTab(); + + expect(viewModel.tabs.map((tab) => tab.id), ['b']); + expect(viewModel.closedTabs.map((tab) => tab.id), ['a']); + }); + + test('a tab settled elsewhere goes back to the closed tabs', () async { + viewModel.ensureLoaded(); + openTabs.add([_tab('a', drinks: 2)]); + await pumpEventQueue(); + viewModel.openTab('a'); + viewModel.deselectTab(); + + openTabs.add([_tab('a')]); + await pumpEventQueue(); + + expect(viewModel.tabs, isEmpty); + expect(viewModel.closedTabs.map((tab) => tab.id), ['a']); + }); + + test('closing the selected tab moves it back and deselects', () async { + when( + () => barTabService.closeTab( + 'a', + paymentMethod: PaymentMethod.cash, + ), + ).thenAnswer((_) async {}); + viewModel.ensureLoaded(); + openTabs.add([_tab('a'), _tab('b')]); + await pumpEventQueue(); + viewModel.openTab('a'); + + await viewModel.closeSelectedTab(); + + expect(viewModel.selectedTabId, isNull); + expect(viewModel.tabs, isEmpty); }); test('keeps the selection while that tab still exists', () async { @@ -62,20 +127,17 @@ void main() { expect(viewModel.selectedTabId, 'b'); }); - test( - 'falls back to the first tab when the selected one disappears', - () async { - viewModel.ensureLoaded(); - openTabs.add([_tab('a'), _tab('b')]); - await pumpEventQueue(); - viewModel.selectTab('b'); + test('selects nothing when the selected tab disappears', () async { + viewModel.ensureLoaded(); + openTabs.add([_tab('a'), _tab('b')]); + await pumpEventQueue(); + viewModel.selectTab('b'); - openTabs.add([_tab('a')]); - await pumpEventQueue(); + openTabs.add([_tab('a')]); + await pumpEventQueue(); - expect(viewModel.selectedTabId, 'a'); - }, - ); + expect(viewModel.selectedTabId, isNull); + }); test('exposes a load error when the stream fails', () async { viewModel.ensureLoaded(); @@ -97,11 +159,15 @@ void main() { ); when( - () => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1), + () => barTabService.adjustTabItemQuantity( + tabItemId: 'item-1', + delta: 1, + order: any(named: 'order'), + ), ).thenAnswer((_) async => throw const TabNotFoundException('tab-1')); await expectLater( - viewModel.changeItemQuantity(item, 1), + viewModel.changeItemQuantity(item, 1, null), throwsA(isA()), ); @@ -110,7 +176,7 @@ void main() { test('selects a newly created tab', () async { when( - () => barTabService.createTab(customerName: 'New'), + () => barTabService.createTab(customerName: 'New', email: null), ).thenAnswer((_) async => _tab('new')); await viewModel.createTab('New'); diff --git a/test/database_migration_test.dart b/test/database_migration_test.dart index 4f22964..3edca03 100644 --- a/test/database_migration_test.dart +++ b/test/database_migration_test.dart @@ -103,10 +103,16 @@ void main() { expect((await database.select(database.products).get()), hasLength(1)); }); - test('gives products no server picture yet and an empty upload queue', () async { - final product = await database.select(database.products).getSingle(); + test( + 'gives products no server picture yet and an empty upload queue', + () async { + final product = await database.select(database.products).getSingle(); - expect(product.imageId, isNull); - expect(await database.select(database.pendingImageUploads).get(), isEmpty); - }); + expect(product.imageId, isNull); + expect( + await database.select(database.pendingImageUploads).get(), + isEmpty, + ); + }, + ); } diff --git a/test/history_grouping_test.dart b/test/history_grouping_test.dart deleted file mode 100644 index de51ea0..0000000 --- a/test/history_grouping_test.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltap/models/closed_tab_item.dart'; -import 'package:kooltap/utils/history_grouping.dart'; - -void main() { - test('groups items by purchase day with newest day first', () { - final items = [ - _closedItem('older', DateTime(2026, 8, 1, 22)), - _closedItem('newer-b', DateTime(2026, 8, 3, 18)), - _closedItem('newer-a', DateTime(2026, 8, 3, 10)), - ]; - - final groups = groupClosedTabItemsByDay( - items, - fallbackDate: DateTime(2026, 8, 4), - ); - - expect(groups, hasLength(2)); - expect(groups.first.day, DateTime(2026, 8, 3)); - expect(groups.first.items.map((item) => item.id), ['newer-b', 'newer-a']); - expect(groups.last.day, DateTime(2026, 8, 1)); - }); -} - -ClosedTabItem _closedItem(String id, DateTime purchasedAt) { - return ClosedTabItem( - id: id, - closedTabId: 'closed-tab', - productId: 'product-$id', - productName: id, - quantity: 1, - unitPriceInCents: 100, - purchasedAt: purchasedAt, - ); -} diff --git a/test/money_rain_test.dart b/test/money_rain_test.dart new file mode 100644 index 0000000..fdc2fc6 --- /dev/null +++ b/test/money_rain_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltap/views/widgets/money_rain.dart'; + +void main() { + testWidgets('money rain covers the screen for three seconds', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => showMoneyRain(context), + child: const Text('Pay'), + ), + ), + ), + ); + + await tester.tap(find.text('Pay')); + await tester.pump(); + expect(find.byType(IgnorePointer), findsWidgets); + + await tester.pump(const Duration(milliseconds: 1500)); + final painted = find.byWidgetPredicate( + (widget) => widget is CustomPaint && widget.size == Size.infinite, + ); + expect(painted, findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1600)); + await tester.pump(); + expect(painted, findsNothing); + }); +} diff --git a/test/tab_events_test.dart b/test/tab_events_test.dart index c77c3f6..91e4c65 100644 --- a/test/tab_events_test.dart +++ b/test/tab_events_test.dart @@ -694,6 +694,7 @@ void main() { productName: 'Cola', unitPriceInCents: 250, quantity: 2, + orderId: 'order-1', ); expect(event.payload['occurredAt'], '2026-10-24T21:41:03.120Z'); @@ -723,6 +724,7 @@ class _FixedIdEventFactory extends TabEventFactory { required String productName, required int unitPriceInCents, required int quantity, + required String? orderId, }) { final event = super.lineAdded( tabId: tabId, @@ -732,6 +734,7 @@ class _FixedIdEventFactory extends TabEventFactory { productName: productName, unitPriceInCents: unitPriceInCents, quantity: quantity, + orderId: orderId, ); return TabEvent(id: 'fixed', payload: {...event.payload, 'id': 'fixed'}); } diff --git a/test/tab_orders_test.dart b/test/tab_orders_test.dart new file mode 100644 index 0000000..8c0d0d6 --- /dev/null +++ b/test/tab_orders_test.dart @@ -0,0 +1,509 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart' hide Table; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/l10n/app_localizations.dart'; +import 'package:kooltap/models/bar_tab.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/models/server_tab.dart'; +import 'package:kooltap/models/tab_item.dart'; +import 'package:kooltap/models/tab_item_purchase.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/utils/money.dart'; +import 'package:kooltap/utils/tab_orders.dart'; +import 'package:kooltap/viewmodels/bar_screen_view_model.dart'; +import 'package:kooltap/views/bar/selected_tab_details.dart'; +import 'package:mocktail/mocktail.dart'; + +const _duvel = Product( + id: 'duvel', + name: 'Duvel', + category: 'Drinks', + stockQuantity: 100, + lowStockThreshold: 10, + priceInCents: 400, +); + +const _cola = Product( + id: 'cola', + name: 'Cola', + category: 'Drinks', + stockQuantity: 100, + lowStockThreshold: 10, + priceInCents: 250, +); + +class MockBarTabService extends Mock implements BarTabService {} + +OrderDrink _drink( + String name, + DateTime at, { + String? orderId, + bool removed = false, +}) { + return OrderDrink( + line: name, + lineKey: name, + id: '$name-${at.toIso8601String()}-$removed', + addedAt: at, + orderId: orderId, + isRemoved: removed, + ); +} + +List> _summary(List> orders) => [ + for (final order in orders) + [ + order.orderId, + order.startedAt, + [for (final row in order.rows) '${row.quantity}x ${row.line}'], + ], +]; + +void main() { + final t1232 = DateTime(2026, 10, 28, 12, 32); + final t1310 = DateTime(2026, 10, 28, 13, 10); + + group('groupOrders', () { + test('shows each order under the time it started, oldest first', () { + final orders = groupOrders([ + _drink('Duvel', t1310, orderId: 'o2'), + _drink('Duvel', t1232, orderId: 'o1'), + _drink('Duvel', t1232, orderId: 'o1'), + _drink('Cola', t1232.add(const Duration(minutes: 1)), orderId: 'o1'), + _drink('Beer', t1310.add(const Duration(minutes: 1)), orderId: 'o2'), + ]); + + expect(_summary(orders), [ + [ + 'o1', + t1232, + ['2x Duvel', '1x Cola'], + ], + [ + 'o2', + t1310, + ['1x Duvel', '1x Beer'], + ], + ]); + }); + + test('groups drinks without an order by time: over 5 minutes apart is ' + 'a new order', () { + final orders = groupOrders([ + _drink('Duvel', t1232), + _drink('Cola', t1232.add(const Duration(minutes: 4))), + _drink('Beer', t1310), + ]); + + expect(_summary(orders), [ + [ + null, + t1232, + ['1x Duvel', '1x Cola'], + ], + [ + null, + t1310, + ['1x Beer'], + ], + ]); + }); + + test('counts drinks taken off again apart', () { + final orders = groupOrders([ + _drink('Duvel', t1232, orderId: 'o1'), + _drink('Duvel', t1232, orderId: 'o1', removed: true), + ]); + + expect(orders.single.rows.single.quantity, 1); + expect(orders.single.rows.single.removed, 1); + }); + }); + + test('orderTimeLabel says today, yesterday or the date', () async { + await initializeDateFormatting('en'); + final l10n = lookupAppLocalizations(const Locale('en')); + final now = DateTime(2026, 10, 28, 14); + + expect(orderTimeLabel(l10n, 'en', t1232, now: now), '12:32 today'); + expect( + orderTimeLabel( + l10n, + 'en', + t1232.subtract(const Duration(days: 1)), + now: now, + ), + '12:32 yesterday', + ); + expect( + orderTimeLabel(l10n, 'en', DateTime(2026, 10, 20, 9, 5), now: now), + 'Oct 20, 2026 09:05', + ); + }); + + group('DriftBarTabService orders', () { + late AppDatabase database; + late DriftBarTabService service; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + service = DriftBarTabService(database: database); + for (final product in [_duvel, _cola]) { + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: product.id, + name: product.name, + category: product.category, + stockQuantity: product.stockQuantity, + lowStockThreshold: product.lowStockThreshold, + priceInCents: product.priceInCents, + ), + ); + } + }); + + tearDown(() => database.close()); + + Future>> outbox() async { + final rows = + await (database.select(database.tabEvents)..orderBy([ + (row) => OrderingTerm.asc(const CustomExpression('rowid')), + ])) + .get(); + return [ + for (final row in rows) jsonDecode(row.payload) as Map, + ]; + } + + /// 2 Duvel and 1 Cola in order o1, then 1 Duvel in o2. + Future ringUp() async { + final tab = await service.createTab(customerName: 'Bram'); + for (final (product, orderId) in [ + (_duvel, 'o1'), + (_duvel, 'o1'), + (_cola, 'o1'), + (_duvel, 'o2'), + ]) { + // Taps are never at the same moment + await Future.delayed(const Duration(milliseconds: 5)); + await service.addProductToTab( + tabId: tab.id, + product: product, + orderId: orderId, + ); + } + return (await service.getTabById(tab.id))!; + } + + test( + 'keeps the order of every drink and sends it with the event', + () async { + final tab = await ringUp(); + + expect( + [ + for (final order in ordersOfTab(tab)) + [ + order.orderId, + [ + for (final row in order.rows) + '${row.quantity}x ${row.line.productName}', + ], + ], + ], + [ + [ + 'o1', + ['2x Duvel', '1x Cola'], + ], + [ + 'o2', + ['1x Duvel'], + ], + ], + ); + expect((await outbox()).skip(1).map((e) => e['orderId']), [ + 'o1', + 'o1', + 'o1', + 'o2', + ]); + }, + ); + + test('takes a drink off the order it is shown in', () async { + final tab = await ringUp(); + final duvel = tab.items.firstWhere((item) => item.productId == 'duvel'); + + final applied = await service.adjustTabItemQuantity( + tabItemId: duvel.id, + delta: -5, + order: (orderId: 'o2'), + ); + + expect(applied, -1); + final orders = ordersOfTab((await service.getTabById(tab.id))!); + expect(orders.map((order) => order.orderId), ['o1']); + expect((await outbox()).last, containsPair('orderId', 'o2')); + }); + + test('deleting a tab takes every line off whatever its orders', () async { + final tab = await ringUp(); + + await service.deleteTab(tab.id); + + final removals = (await outbox()).where( + (e) => e['type'] == 'line_removed', + ); + // Without an order: the server takes everything on the line off + expect([ + for (final e in removals) + '${e['productName']} ${e.containsKey('orderId')} ${e['quantity']}', + ], unorderedEquals(['Duvel false 3', 'Cola false 1'])); + }); + + test('a paid tab keeps its orders in history', () async { + final tab = await ringUp(); + + await service.closeTab(tab.id); + + final closed = (await service.getClosedTabs()).single; + expect(ordersOfClosedTab(closed).map((order) => order.orderId), [ + 'o1', + 'o2', + ]); + }); + + test( + 'replays a removal on the order it was made on, after a sync', + () async { + final openedAt = DateTime(2026, 10, 28, 12); + ServerUnit unit(String id, String orderId, int minutes) => ServerUnit( + id: id, + addedAt: openedAt.add(Duration(minutes: minutes)), + orderId: orderId, + ); + final serverTab = ServerTab( + id: 'tab-1', + customerName: 'Bram', + openedAt: openedAt, + version: 3, + lines: [ + ServerTabLine( + id: 'line-1', + productId: 'duvel', + productName: 'Duvel', + unitPriceInCents: 400, + quantity: 2, + units: [unit('u1', 'o1', 0), unit('u2', 'o2', 40)], + ), + ], + ); + await service.applyServerState([serverTab]); + final tab = (await service.getTabById('tab-1'))!; + await service.adjustTabItemQuantity( + tabItemId: tab.items.single.id, + delta: -1, + order: (orderId: 'o1'), + ); + + // The server has not seen the removal yet: it is replayed on top + await service.applyServerState([serverTab]); + + final orders = ordersOfTab((await service.getTabById('tab-1'))!); + expect(orders.map((order) => order.orderId), ['o2']); + }, + ); + + test('a drink keeps its order when a server without orders sends it ' + 'back', () async { + final tab = await ringUp(); + final events = await outbox(); + // The server applied every event and knows nothing of orders + await database.delete(database.tabEvents).go(); + final serverTab = ServerTab( + id: tab.id, + customerName: tab.customerName, + openedAt: tab.openedAt, + version: tab.version, + lines: [ + for (final item in tab.items) + ServerTabLine( + id: item.id, + productId: item.productId, + productName: item.productName, + unitPriceInCents: item.unitPriceInCents, + quantity: item.quantity, + units: [ + for (final event in events) + if (event['lineId'] == item.id) + ServerUnit( + id: '${event['id']}:0', + addedAt: DateTime.parse( + event['occurredAt'] as String, + ).toLocal(), + ), + ], + ), + ], + ); + + await service.applyServerState([serverTab]); + + final orders = ordersOfTab((await service.getTabById(tab.id))!); + expect(orders.map((order) => order.orderId), ['o1', 'o2']); + }); + }); + + group('SelectedTabDetails', () { + BarTab tabWith(List<(String product, String orderId, DateTime at)> drinks) { + final purchases = >{}; + for (final (index, (product, orderId, at)) in drinks.indexed) { + purchases + .putIfAbsent(product, () => []) + .add( + TabItemPurchase( + id: 'p$index', + tabItemId: product, + purchasedAt: at, + orderId: orderId, + ), + ); + } + return BarTab( + id: 'tab-1', + customerName: 'Bram', + openedAt: t1232, + items: [ + for (final MapEntry(key: product, value: units) in purchases.entries) + TabItem( + id: product, + tabId: 'tab-1', + productId: product, + productName: product, + quantity: units.length, + unitPriceInCents: 400, + purchases: units, + ), + ], + ); + } + + testWidgets('folds earlier orders under their time and total', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SelectedTabDetails( + tab: tabWith([ + ( + 'Duvel', + 'o1', + DateTime.now().subtract(const Duration(hours: 1)), + ), + ( + 'Duvel', + 'o1', + DateTime.now().subtract(const Duration(hours: 1)), + ), + ('Beer', 'o2', DateTime.now()), + ]), + onItemQuantityChanged: (_, _, _) async {}, + onCloseTabPressed: () {}, + ), + ), + ), + ); + + // The earlier order shows its total only; the current one its drinks + expect(find.textContaining('– ${800.asEuro}'), findsOneWidget); + expect(find.text('Duvel'), findsNothing); + expect(find.text('Beer'), findsOneWidget); + + await tester.tap(find.textContaining('– ${800.asEuro}')); + await tester.pump(); + + expect(find.text('Duvel'), findsOneWidget); + }); + }); + + group('BarScreenViewModel orders', () { + late MockBarTabService barTabService; + late StreamController> openTabs; + late DateTime now; + late BarScreenViewModel viewModel; + final orderIds = []; + + setUp(() async { + barTabService = MockBarTabService(); + openTabs = StreamController>.broadcast(); + when( + () => barTabService.watchOpenTabs(), + ).thenAnswer((_) => openTabs.stream); + orderIds.clear(); + when( + () => barTabService.addProductToTab( + tabId: any(named: 'tabId'), + product: any(named: 'product'), + orderId: any(named: 'orderId'), + ), + ).thenAnswer((invocation) async { + orderIds.add(invocation.namedArguments[#orderId] as String?); + }); + + now = DateTime(2026, 10, 28, 12, 32); + viewModel = BarScreenViewModel( + barTabService: barTabService, + now: () => now, + ); + viewModel.ensureLoaded(); + openTabs.add([ + BarTab(id: 'a', customerName: 'Bram', openedAt: now, items: const []), + BarTab(id: 'b', customerName: 'Jan', openedAt: now, items: const []), + ]); + await pumpEventQueue(); + viewModel.openTab('a'); + }); + + setUpAll(() => registerFallbackValue(_duvel)); + + tearDown(() async { + viewModel.dispose(); + await openTabs.close(); + }); + + test('drinks within 5 minutes of each other are one order', () async { + await viewModel.addProductToSelectedTab(_duvel); + now = now.add(const Duration(minutes: 4)); + await viewModel.addProductToSelectedTab(_cola); + now = now.add(const Duration(minutes: 5)); + await viewModel.addProductToSelectedTab(_duvel); + + expect(orderIds[0], orderIds[1]); + expect(orderIds[2], isNot(orderIds[1])); + }); + + test('closing the tab and coming back starts a new order', () async { + await viewModel.addProductToSelectedTab(_duvel); + viewModel.deselectTab(); + viewModel.selectTab('a'); + await viewModel.addProductToSelectedTab(_duvel); + viewModel.selectTab('b'); + viewModel.selectTab('a'); + await viewModel.addProductToSelectedTab(_duvel); + + expect(orderIds.toSet(), hasLength(3)); + }); + }); +} diff --git a/test/tab_panel_index_test.dart b/test/tab_panel_index_test.dart index 725eba5..0779d77 100644 --- a/test/tab_panel_index_test.dart +++ b/test/tab_panel_index_test.dart @@ -45,6 +45,7 @@ void main() { WidgetTester tester, { bool showSearch = false, BarTab? selectedTab, + List? panelTabs, }) async { tester.view.physicalSize = const Size(500, 800); tester.view.devicePixelRatio = 1; @@ -56,12 +57,13 @@ void main() { supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: TabPanel( - tabs: tabs, + tabs: panelTabs ?? tabs, selectedTab: selectedTab, selectedTabId: selectedTab?.id, showSearch: showSearch, onTabSelected: (_) {}, - onItemQuantityChanged: (_, _) async {}, + onTabDeselected: () {}, + onItemQuantityChanged: (_, _, _) async {}, onCloseTabPressed: () {}, onTabEdited: (_) async {}, onTabDeleted: (_) async {}, @@ -92,7 +94,7 @@ void main() { await tester.tap( find.descendant( of: find.byWidgetPredicate( - (widget) => widget.runtimeType.toString() == '_LetterIndexBar', + (widget) => widget.runtimeType.toString() == 'LetterIndexBar', ), matching: find.text(letter), ), @@ -190,6 +192,40 @@ void main() { ); }); + for (final (description, name, count) in [ + ('the first name', 'anna', null), + ('a name in a short list', 'Bram', 3), + ]) { + testWidgets('lines up $description on top of its items', ( + tester, + ) async { + final panelTabs = count == null + ? tabs + : (tabs.toList()..sort( + (a, b) => compareByIndex(a.customerName, b.customerName), + )) + .take(count) + .toList(); + final tab = panelTabs.firstWhere((tab) => tab.customerName == name); + + // Selected from outside, as the Open Tab dialog does + await pumpPanel(tester, panelTabs: panelTabs); + await pumpPanel(tester, panelTabs: panelTabs, selectedTab: tab); + await tester.pumpAndSettle(); + + final list = tester.getRect(find.byType(OpenTabsList)); + final details = tester.getRect(find.byType(SelectedTabDetails)); + final tile = tester.getRect( + find + .ancestor(of: find.text(name), matching: find.byType(ClipRRect)) + .first, + ); + + expect(tile.bottom, closeTo(list.bottom, 1)); + expect(details.top - tile.bottom, inInclusiveRange(0, 20)); + }); + } + testWidgets('browsing the names shows the divider again', (tester) async { final marc = tabs.firstWhere((tab) => tab.customerName == 'Marc'); await pumpPanel(tester, selectedTab: marc); @@ -221,7 +257,8 @@ void main() { }); testWidgets('a letter without names goes to the next one', (tester) async { - await pumpPanel(tester); + // A selected tab keeps the list short enough to scroll Rik to the top + await pumpPanel(tester, selectedTab: tabs.first); await tapLetter(tester, 'Q'); expect(firstVisibleName(tester), 'Rik');