feat: New Open Tab dialog with search and optional customer email. Drinks are now grouped per order. Payments trigger cha-ching and money rain.

This commit is contained in:
2026-10-28 02:11:32 +01:00
parent 1aee0541c4
commit 29ceb51177
41 changed files with 2771 additions and 812 deletions
Binary file not shown.
+33 -6
View File
@@ -21,7 +21,7 @@ Common fields (already sent): `id`, `type`, `tabId`, `customerName`, `occurredAt
| `type` | Extra fields | New? | | `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_renamed` | — (`customerName` is the new name) | **new** |
| `tab_deleted` | — (always preceded by a `line_removed` for every line still on the tab) | **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 | | `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). 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 ### 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[] } Line { id, productId, productName, unitPriceInCents, quantity, units: Unit[] }
Unit { id, addedAt, removedAt | null } // one per piece ever put on the line 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 | | 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_renamed` | Set `customerName`. Unknown tab → ignore (acknowledge). |
| `tab_deleted` | Remove the tab and its lines. Unknown tab → ignore. | | `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 = "<event id>:<index>"` (index 0…quantity−1) and `addedAt = 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 = "<event id>:<index>"` (index 0…quantity−1), `addedAt = occurredAt` and the event's `orderId`. |
| `line_removed` | Find the line; none → ignore. Mark the `min(quantity, line.quantity)` **latest** active units removed (`removedAt = occurredAt`). | | `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). | | `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 `"<event id>:<index>"`: the tablet gives replayed units the same ids, so a sync that The unit ids **must** follow `"<event id>:<index>"`: 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 ```json
{ {
"tabs": [ "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": [ "lines": [
{ "id": "…", "productId": "12", "productName": "Cola", "unitPriceInCents": 250, "quantity": 1, { "id": "…", "productId": "12", "productName": "Cola", "unitPriceInCents": 250, "quantity": 1,
"units": [ { "id": "<event id>:0", "addedAt": "…", "removedAt": null }, "units": [ { "id": "<event 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 - **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). `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 ### Order within one sync
1. Apply the received events (once per id), oldest first; acknowledge/reject them. 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 - 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. next sync, exactly once.
- The same event id sent twice (also in two syncs) is applied 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`.
+1 -2
View File
@@ -168,8 +168,7 @@ GoRouter createAppRouter({
onSet: adminPinViewModel.setPin, onSet: adminPinViewModel.setPin,
error: () => adminPinViewModel.error, error: () => adminPinViewModel.error,
onSuccess: () => context.go(_returnLocation(state) ?? '/admin'), onSuccess: () => context.go(_returnLocation(state) ?? '/admin'),
onClose: () => onClose: () => context.canPop() ? context.pop() : context.go('/bar'),
context.canPop() ? context.pop() : context.go('/bar'),
), ),
), ),
+33 -2
View File
@@ -55,6 +55,9 @@ class BarTabs extends Table {
TextColumn get customerName => text()(); TextColumn get customerName => text()();
/// Optional, entered when the customer was added.
TextColumn get email => text().nullable()();
DateTimeColumn get openedAt => dateTime()(); DateTimeColumn get openedAt => dateTime()();
IntColumn get version => integer().withDefault(const Constant(0))(); IntColumn get version => integer().withDefault(const Constant(0))();
@@ -95,6 +98,10 @@ class TabItemPurchases extends Table {
DateTimeColumn get removedAt => dateTime().nullable()(); 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 @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@@ -135,6 +142,8 @@ class ClosedTabItems extends Table {
DateTimeColumn get removedAt => dateTime().nullable()(); DateTimeColumn get removedAt => dateTime().nullable()();
TextColumn get orderId => text().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@@ -268,7 +277,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 18; int get schemaVersion => 20;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@@ -358,7 +367,10 @@ class AppDatabase extends _$AppDatabase {
// Tabs never close (only their items are archived), so the status // Tabs never close (only their items are archived), so the status
// and closed_at columns were never anything but 'open' and null. // and closed_at columns were never anything but 'open' and null.
if (await hasTableColumn('bar_tabs', 'status')) { 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 _backfillPurchaseRows();
await _deleteOrphanedPurchaseRows(); await _deleteOrphanedPurchaseRows();
@@ -391,6 +403,25 @@ class AppDatabase extends _$AppDatabase {
} }
await migrator.createTable(pendingImageUploads); 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);
}
}, },
); );
} }
+224 -8
View File
@@ -663,6 +663,15 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
type: DriftSqlType.string, type: DriftSqlType.string,
requiredDuringInsert: true, requiredDuringInsert: true,
); );
static const VerificationMeta _emailMeta = const VerificationMeta('email');
@override
late final GeneratedColumn<String> email = GeneratedColumn<String>(
'email',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _openedAtMeta = const VerificationMeta( static const VerificationMeta _openedAtMeta = const VerificationMeta(
'openedAt', 'openedAt',
); );
@@ -687,7 +696,13 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
defaultValue: const Constant(0), defaultValue: const Constant(0),
); );
@override @override
List<GeneratedColumn> get $columns => [id, customerName, openedAt, version]; List<GeneratedColumn> get $columns => [
id,
customerName,
email,
openedAt,
version,
];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@override @override
@@ -716,6 +731,12 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
} else if (isInserting) { } else if (isInserting) {
context.missing(_customerNameMeta); context.missing(_customerNameMeta);
} }
if (data.containsKey('email')) {
context.handle(
_emailMeta,
email.isAcceptableOrUnknown(data['email']!, _emailMeta),
);
}
if (data.containsKey('opened_at')) { if (data.containsKey('opened_at')) {
context.handle( context.handle(
_openedAtMeta, _openedAtMeta,
@@ -747,6 +768,10 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
DriftSqlType.string, DriftSqlType.string,
data['${effectivePrefix}customer_name'], data['${effectivePrefix}customer_name'],
)!, )!,
email: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}email'],
),
openedAt: attachedDatabase.typeMapping.read( openedAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, DriftSqlType.dateTime,
data['${effectivePrefix}opened_at'], data['${effectivePrefix}opened_at'],
@@ -767,11 +792,15 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
class BarTabRow extends DataClass implements Insertable<BarTabRow> { class BarTabRow extends DataClass implements Insertable<BarTabRow> {
final String id; final String id;
final String customerName; final String customerName;
/// Optional, entered when the customer was added.
final String? email;
final DateTime openedAt; final DateTime openedAt;
final int version; final int version;
const BarTabRow({ const BarTabRow({
required this.id, required this.id,
required this.customerName, required this.customerName,
this.email,
required this.openedAt, required this.openedAt,
required this.version, required this.version,
}); });
@@ -780,6 +809,9 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
final map = <String, Expression>{}; final map = <String, Expression>{};
map['id'] = Variable<String>(id); map['id'] = Variable<String>(id);
map['customer_name'] = Variable<String>(customerName); map['customer_name'] = Variable<String>(customerName);
if (!nullToAbsent || email != null) {
map['email'] = Variable<String>(email);
}
map['opened_at'] = Variable<DateTime>(openedAt); map['opened_at'] = Variable<DateTime>(openedAt);
map['version'] = Variable<int>(version); map['version'] = Variable<int>(version);
return map; return map;
@@ -789,6 +821,9 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
return BarTabsCompanion( return BarTabsCompanion(
id: Value(id), id: Value(id),
customerName: Value(customerName), customerName: Value(customerName),
email: email == null && nullToAbsent
? const Value.absent()
: Value(email),
openedAt: Value(openedAt), openedAt: Value(openedAt),
version: Value(version), version: Value(version),
); );
@@ -802,6 +837,7 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
return BarTabRow( return BarTabRow(
id: serializer.fromJson<String>(json['id']), id: serializer.fromJson<String>(json['id']),
customerName: serializer.fromJson<String>(json['customerName']), customerName: serializer.fromJson<String>(json['customerName']),
email: serializer.fromJson<String?>(json['email']),
openedAt: serializer.fromJson<DateTime>(json['openedAt']), openedAt: serializer.fromJson<DateTime>(json['openedAt']),
version: serializer.fromJson<int>(json['version']), version: serializer.fromJson<int>(json['version']),
); );
@@ -812,6 +848,7 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
return <String, dynamic>{ return <String, dynamic>{
'id': serializer.toJson<String>(id), 'id': serializer.toJson<String>(id),
'customerName': serializer.toJson<String>(customerName), 'customerName': serializer.toJson<String>(customerName),
'email': serializer.toJson<String?>(email),
'openedAt': serializer.toJson<DateTime>(openedAt), 'openedAt': serializer.toJson<DateTime>(openedAt),
'version': serializer.toJson<int>(version), 'version': serializer.toJson<int>(version),
}; };
@@ -820,11 +857,13 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
BarTabRow copyWith({ BarTabRow copyWith({
String? id, String? id,
String? customerName, String? customerName,
Value<String?> email = const Value.absent(),
DateTime? openedAt, DateTime? openedAt,
int? version, int? version,
}) => BarTabRow( }) => BarTabRow(
id: id ?? this.id, id: id ?? this.id,
customerName: customerName ?? this.customerName, customerName: customerName ?? this.customerName,
email: email.present ? email.value : this.email,
openedAt: openedAt ?? this.openedAt, openedAt: openedAt ?? this.openedAt,
version: version ?? this.version, version: version ?? this.version,
); );
@@ -834,6 +873,7 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
customerName: data.customerName.present customerName: data.customerName.present
? data.customerName.value ? data.customerName.value
: this.customerName, : this.customerName,
email: data.email.present ? data.email.value : this.email,
openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt, openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt,
version: data.version.present ? data.version.value : this.version, version: data.version.present ? data.version.value : this.version,
); );
@@ -844,6 +884,7 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
return (StringBuffer('BarTabRow(') return (StringBuffer('BarTabRow(')
..write('id: $id, ') ..write('id: $id, ')
..write('customerName: $customerName, ') ..write('customerName: $customerName, ')
..write('email: $email, ')
..write('openedAt: $openedAt, ') ..write('openedAt: $openedAt, ')
..write('version: $version') ..write('version: $version')
..write(')')) ..write(')'))
@@ -851,13 +892,14 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
} }
@override @override
int get hashCode => Object.hash(id, customerName, openedAt, version); int get hashCode => Object.hash(id, customerName, email, openedAt, version);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
(other is BarTabRow && (other is BarTabRow &&
other.id == this.id && other.id == this.id &&
other.customerName == this.customerName && other.customerName == this.customerName &&
other.email == this.email &&
other.openedAt == this.openedAt && other.openedAt == this.openedAt &&
other.version == this.version); other.version == this.version);
} }
@@ -865,12 +907,14 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
class BarTabsCompanion extends UpdateCompanion<BarTabRow> { class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
final Value<String> id; final Value<String> id;
final Value<String> customerName; final Value<String> customerName;
final Value<String?> email;
final Value<DateTime> openedAt; final Value<DateTime> openedAt;
final Value<int> version; final Value<int> version;
final Value<int> rowid; final Value<int> rowid;
const BarTabsCompanion({ const BarTabsCompanion({
this.id = const Value.absent(), this.id = const Value.absent(),
this.customerName = const Value.absent(), this.customerName = const Value.absent(),
this.email = const Value.absent(),
this.openedAt = const Value.absent(), this.openedAt = const Value.absent(),
this.version = const Value.absent(), this.version = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
@@ -878,6 +922,7 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
BarTabsCompanion.insert({ BarTabsCompanion.insert({
required String id, required String id,
required String customerName, required String customerName,
this.email = const Value.absent(),
required DateTime openedAt, required DateTime openedAt,
this.version = const Value.absent(), this.version = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
@@ -887,6 +932,7 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
static Insertable<BarTabRow> custom({ static Insertable<BarTabRow> custom({
Expression<String>? id, Expression<String>? id,
Expression<String>? customerName, Expression<String>? customerName,
Expression<String>? email,
Expression<DateTime>? openedAt, Expression<DateTime>? openedAt,
Expression<int>? version, Expression<int>? version,
Expression<int>? rowid, Expression<int>? rowid,
@@ -894,6 +940,7 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
return RawValuesInsertable({ return RawValuesInsertable({
if (id != null) 'id': id, if (id != null) 'id': id,
if (customerName != null) 'customer_name': customerName, if (customerName != null) 'customer_name': customerName,
if (email != null) 'email': email,
if (openedAt != null) 'opened_at': openedAt, if (openedAt != null) 'opened_at': openedAt,
if (version != null) 'version': version, if (version != null) 'version': version,
if (rowid != null) 'rowid': rowid, if (rowid != null) 'rowid': rowid,
@@ -903,6 +950,7 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
BarTabsCompanion copyWith({ BarTabsCompanion copyWith({
Value<String>? id, Value<String>? id,
Value<String>? customerName, Value<String>? customerName,
Value<String?>? email,
Value<DateTime>? openedAt, Value<DateTime>? openedAt,
Value<int>? version, Value<int>? version,
Value<int>? rowid, Value<int>? rowid,
@@ -910,6 +958,7 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
return BarTabsCompanion( return BarTabsCompanion(
id: id ?? this.id, id: id ?? this.id,
customerName: customerName ?? this.customerName, customerName: customerName ?? this.customerName,
email: email ?? this.email,
openedAt: openedAt ?? this.openedAt, openedAt: openedAt ?? this.openedAt,
version: version ?? this.version, version: version ?? this.version,
rowid: rowid ?? this.rowid, rowid: rowid ?? this.rowid,
@@ -925,6 +974,9 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
if (customerName.present) { if (customerName.present) {
map['customer_name'] = Variable<String>(customerName.value); map['customer_name'] = Variable<String>(customerName.value);
} }
if (email.present) {
map['email'] = Variable<String>(email.value);
}
if (openedAt.present) { if (openedAt.present) {
map['opened_at'] = Variable<DateTime>(openedAt.value); map['opened_at'] = Variable<DateTime>(openedAt.value);
} }
@@ -942,6 +994,7 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
return (StringBuffer('BarTabsCompanion(') return (StringBuffer('BarTabsCompanion(')
..write('id: $id, ') ..write('id: $id, ')
..write('customerName: $customerName, ') ..write('customerName: $customerName, ')
..write('email: $email, ')
..write('openedAt: $openedAt, ') ..write('openedAt: $openedAt, ')
..write('version: $version, ') ..write('version: $version, ')
..write('rowid: $rowid') ..write('rowid: $rowid')
@@ -1479,8 +1532,25 @@ class $TabItemPurchasesTable extends TabItemPurchases
type: DriftSqlType.dateTime, type: DriftSqlType.dateTime,
requiredDuringInsert: false, requiredDuringInsert: false,
); );
static const VerificationMeta _orderIdMeta = const VerificationMeta(
'orderId',
);
@override @override
List<GeneratedColumn> get $columns => [id, tabItemId, purchasedAt, removedAt]; late final GeneratedColumn<String> orderId = GeneratedColumn<String>(
'order_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> get $columns => [
id,
tabItemId,
purchasedAt,
removedAt,
orderId,
];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@override @override
@@ -1523,6 +1593,12 @@ class $TabItemPurchasesTable extends TabItemPurchases
removedAt.isAcceptableOrUnknown(data['removed_at']!, _removedAtMeta), removedAt.isAcceptableOrUnknown(data['removed_at']!, _removedAtMeta),
); );
} }
if (data.containsKey('order_id')) {
context.handle(
_orderIdMeta,
orderId.isAcceptableOrUnknown(data['order_id']!, _orderIdMeta),
);
}
return context; return context;
} }
@@ -1548,6 +1624,10 @@ class $TabItemPurchasesTable extends TabItemPurchases
DriftSqlType.dateTime, DriftSqlType.dateTime,
data['${effectivePrefix}removed_at'], 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 String tabItemId;
final DateTime purchasedAt; final DateTime purchasedAt;
final DateTime? removedAt; 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({ const TabItemPurchaseRow({
required this.id, required this.id,
required this.tabItemId, required this.tabItemId,
required this.purchasedAt, required this.purchasedAt,
this.removedAt, this.removedAt,
this.orderId,
}); });
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -1578,6 +1663,9 @@ class TabItemPurchaseRow extends DataClass
if (!nullToAbsent || removedAt != null) { if (!nullToAbsent || removedAt != null) {
map['removed_at'] = Variable<DateTime>(removedAt); map['removed_at'] = Variable<DateTime>(removedAt);
} }
if (!nullToAbsent || orderId != null) {
map['order_id'] = Variable<String>(orderId);
}
return map; return map;
} }
@@ -1589,6 +1677,9 @@ class TabItemPurchaseRow extends DataClass
removedAt: removedAt == null && nullToAbsent removedAt: removedAt == null && nullToAbsent
? const Value.absent() ? const Value.absent()
: Value(removedAt), : Value(removedAt),
orderId: orderId == null && nullToAbsent
? const Value.absent()
: Value(orderId),
); );
} }
@@ -1602,6 +1693,7 @@ class TabItemPurchaseRow extends DataClass
tabItemId: serializer.fromJson<String>(json['tabItemId']), tabItemId: serializer.fromJson<String>(json['tabItemId']),
purchasedAt: serializer.fromJson<DateTime>(json['purchasedAt']), purchasedAt: serializer.fromJson<DateTime>(json['purchasedAt']),
removedAt: serializer.fromJson<DateTime?>(json['removedAt']), removedAt: serializer.fromJson<DateTime?>(json['removedAt']),
orderId: serializer.fromJson<String?>(json['orderId']),
); );
} }
@override @override
@@ -1612,6 +1704,7 @@ class TabItemPurchaseRow extends DataClass
'tabItemId': serializer.toJson<String>(tabItemId), 'tabItemId': serializer.toJson<String>(tabItemId),
'purchasedAt': serializer.toJson<DateTime>(purchasedAt), 'purchasedAt': serializer.toJson<DateTime>(purchasedAt),
'removedAt': serializer.toJson<DateTime?>(removedAt), 'removedAt': serializer.toJson<DateTime?>(removedAt),
'orderId': serializer.toJson<String?>(orderId),
}; };
} }
@@ -1620,11 +1713,13 @@ class TabItemPurchaseRow extends DataClass
String? tabItemId, String? tabItemId,
DateTime? purchasedAt, DateTime? purchasedAt,
Value<DateTime?> removedAt = const Value.absent(), Value<DateTime?> removedAt = const Value.absent(),
Value<String?> orderId = const Value.absent(),
}) => TabItemPurchaseRow( }) => TabItemPurchaseRow(
id: id ?? this.id, id: id ?? this.id,
tabItemId: tabItemId ?? this.tabItemId, tabItemId: tabItemId ?? this.tabItemId,
purchasedAt: purchasedAt ?? this.purchasedAt, purchasedAt: purchasedAt ?? this.purchasedAt,
removedAt: removedAt.present ? removedAt.value : this.removedAt, removedAt: removedAt.present ? removedAt.value : this.removedAt,
orderId: orderId.present ? orderId.value : this.orderId,
); );
TabItemPurchaseRow copyWithCompanion(TabItemPurchasesCompanion data) { TabItemPurchaseRow copyWithCompanion(TabItemPurchasesCompanion data) {
return TabItemPurchaseRow( return TabItemPurchaseRow(
@@ -1634,6 +1729,7 @@ class TabItemPurchaseRow extends DataClass
? data.purchasedAt.value ? data.purchasedAt.value
: this.purchasedAt, : this.purchasedAt,
removedAt: data.removedAt.present ? data.removedAt.value : this.removedAt, 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('id: $id, ')
..write('tabItemId: $tabItemId, ') ..write('tabItemId: $tabItemId, ')
..write('purchasedAt: $purchasedAt, ') ..write('purchasedAt: $purchasedAt, ')
..write('removedAt: $removedAt') ..write('removedAt: $removedAt, ')
..write('orderId: $orderId')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@override @override
int get hashCode => Object.hash(id, tabItemId, purchasedAt, removedAt); int get hashCode =>
Object.hash(id, tabItemId, purchasedAt, removedAt, orderId);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
@@ -1657,7 +1755,8 @@ class TabItemPurchaseRow extends DataClass
other.id == this.id && other.id == this.id &&
other.tabItemId == this.tabItemId && other.tabItemId == this.tabItemId &&
other.purchasedAt == this.purchasedAt && other.purchasedAt == this.purchasedAt &&
other.removedAt == this.removedAt); other.removedAt == this.removedAt &&
other.orderId == this.orderId);
} }
class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> { class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
@@ -1665,12 +1764,14 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
final Value<String> tabItemId; final Value<String> tabItemId;
final Value<DateTime> purchasedAt; final Value<DateTime> purchasedAt;
final Value<DateTime?> removedAt; final Value<DateTime?> removedAt;
final Value<String?> orderId;
final Value<int> rowid; final Value<int> rowid;
const TabItemPurchasesCompanion({ const TabItemPurchasesCompanion({
this.id = const Value.absent(), this.id = const Value.absent(),
this.tabItemId = const Value.absent(), this.tabItemId = const Value.absent(),
this.purchasedAt = const Value.absent(), this.purchasedAt = const Value.absent(),
this.removedAt = const Value.absent(), this.removedAt = const Value.absent(),
this.orderId = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
}); });
TabItemPurchasesCompanion.insert({ TabItemPurchasesCompanion.insert({
@@ -1678,6 +1779,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
required String tabItemId, required String tabItemId,
required DateTime purchasedAt, required DateTime purchasedAt,
this.removedAt = const Value.absent(), this.removedAt = const Value.absent(),
this.orderId = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
}) : id = Value(id), }) : id = Value(id),
tabItemId = Value(tabItemId), tabItemId = Value(tabItemId),
@@ -1687,6 +1789,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
Expression<String>? tabItemId, Expression<String>? tabItemId,
Expression<DateTime>? purchasedAt, Expression<DateTime>? purchasedAt,
Expression<DateTime>? removedAt, Expression<DateTime>? removedAt,
Expression<String>? orderId,
Expression<int>? rowid, Expression<int>? rowid,
}) { }) {
return RawValuesInsertable({ return RawValuesInsertable({
@@ -1694,6 +1797,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
if (tabItemId != null) 'tab_item_id': tabItemId, if (tabItemId != null) 'tab_item_id': tabItemId,
if (purchasedAt != null) 'purchased_at': purchasedAt, if (purchasedAt != null) 'purchased_at': purchasedAt,
if (removedAt != null) 'removed_at': removedAt, if (removedAt != null) 'removed_at': removedAt,
if (orderId != null) 'order_id': orderId,
if (rowid != null) 'rowid': rowid, if (rowid != null) 'rowid': rowid,
}); });
} }
@@ -1703,6 +1807,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
Value<String>? tabItemId, Value<String>? tabItemId,
Value<DateTime>? purchasedAt, Value<DateTime>? purchasedAt,
Value<DateTime?>? removedAt, Value<DateTime?>? removedAt,
Value<String?>? orderId,
Value<int>? rowid, Value<int>? rowid,
}) { }) {
return TabItemPurchasesCompanion( return TabItemPurchasesCompanion(
@@ -1710,6 +1815,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
tabItemId: tabItemId ?? this.tabItemId, tabItemId: tabItemId ?? this.tabItemId,
purchasedAt: purchasedAt ?? this.purchasedAt, purchasedAt: purchasedAt ?? this.purchasedAt,
removedAt: removedAt ?? this.removedAt, removedAt: removedAt ?? this.removedAt,
orderId: orderId ?? this.orderId,
rowid: rowid ?? this.rowid, rowid: rowid ?? this.rowid,
); );
} }
@@ -1729,6 +1835,9 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
if (removedAt.present) { if (removedAt.present) {
map['removed_at'] = Variable<DateTime>(removedAt.value); map['removed_at'] = Variable<DateTime>(removedAt.value);
} }
if (orderId.present) {
map['order_id'] = Variable<String>(orderId.value);
}
if (rowid.present) { if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value); map['rowid'] = Variable<int>(rowid.value);
} }
@@ -1742,6 +1851,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
..write('tabItemId: $tabItemId, ') ..write('tabItemId: $tabItemId, ')
..write('purchasedAt: $purchasedAt, ') ..write('purchasedAt: $purchasedAt, ')
..write('removedAt: $removedAt, ') ..write('removedAt: $removedAt, ')
..write('orderId: $orderId, ')
..write('rowid: $rowid') ..write('rowid: $rowid')
..write(')')) ..write(')'))
.toString(); .toString();
@@ -2216,6 +2326,17 @@ class $ClosedTabItemsTable extends ClosedTabItems
type: DriftSqlType.dateTime, type: DriftSqlType.dateTime,
requiredDuringInsert: false, requiredDuringInsert: false,
); );
static const VerificationMeta _orderIdMeta = const VerificationMeta(
'orderId',
);
@override
late final GeneratedColumn<String> orderId = GeneratedColumn<String>(
'order_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
@override @override
List<GeneratedColumn> get $columns => [ List<GeneratedColumn> get $columns => [
id, id,
@@ -2226,6 +2347,7 @@ class $ClosedTabItemsTable extends ClosedTabItems
unitPriceInCents, unitPriceInCents,
purchasedAt, purchasedAt,
removedAt, removedAt,
orderId,
]; ];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@@ -2308,6 +2430,12 @@ class $ClosedTabItemsTable extends ClosedTabItems
removedAt.isAcceptableOrUnknown(data['removed_at']!, _removedAtMeta), removedAt.isAcceptableOrUnknown(data['removed_at']!, _removedAtMeta),
); );
} }
if (data.containsKey('order_id')) {
context.handle(
_orderIdMeta,
orderId.isAcceptableOrUnknown(data['order_id']!, _orderIdMeta),
);
}
return context; return context;
} }
@@ -2349,6 +2477,10 @@ class $ClosedTabItemsTable extends ClosedTabItems
DriftSqlType.dateTime, DriftSqlType.dateTime,
data['${effectivePrefix}removed_at'], 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 int unitPriceInCents;
final DateTime? purchasedAt; final DateTime? purchasedAt;
final DateTime? removedAt; final DateTime? removedAt;
final String? orderId;
const ClosedTabItemRow({ const ClosedTabItemRow({
required this.id, required this.id,
required this.closedTabId, required this.closedTabId,
@@ -2377,6 +2510,7 @@ class ClosedTabItemRow extends DataClass
required this.unitPriceInCents, required this.unitPriceInCents,
this.purchasedAt, this.purchasedAt,
this.removedAt, this.removedAt,
this.orderId,
}); });
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -2393,6 +2527,9 @@ class ClosedTabItemRow extends DataClass
if (!nullToAbsent || removedAt != null) { if (!nullToAbsent || removedAt != null) {
map['removed_at'] = Variable<DateTime>(removedAt); map['removed_at'] = Variable<DateTime>(removedAt);
} }
if (!nullToAbsent || orderId != null) {
map['order_id'] = Variable<String>(orderId);
}
return map; return map;
} }
@@ -2410,6 +2547,9 @@ class ClosedTabItemRow extends DataClass
removedAt: removedAt == null && nullToAbsent removedAt: removedAt == null && nullToAbsent
? const Value.absent() ? const Value.absent()
: Value(removedAt), : Value(removedAt),
orderId: orderId == null && nullToAbsent
? const Value.absent()
: Value(orderId),
); );
} }
@@ -2427,6 +2567,7 @@ class ClosedTabItemRow extends DataClass
unitPriceInCents: serializer.fromJson<int>(json['unitPriceInCents']), unitPriceInCents: serializer.fromJson<int>(json['unitPriceInCents']),
purchasedAt: serializer.fromJson<DateTime?>(json['purchasedAt']), purchasedAt: serializer.fromJson<DateTime?>(json['purchasedAt']),
removedAt: serializer.fromJson<DateTime?>(json['removedAt']), removedAt: serializer.fromJson<DateTime?>(json['removedAt']),
orderId: serializer.fromJson<String?>(json['orderId']),
); );
} }
@override @override
@@ -2441,6 +2582,7 @@ class ClosedTabItemRow extends DataClass
'unitPriceInCents': serializer.toJson<int>(unitPriceInCents), 'unitPriceInCents': serializer.toJson<int>(unitPriceInCents),
'purchasedAt': serializer.toJson<DateTime?>(purchasedAt), 'purchasedAt': serializer.toJson<DateTime?>(purchasedAt),
'removedAt': serializer.toJson<DateTime?>(removedAt), 'removedAt': serializer.toJson<DateTime?>(removedAt),
'orderId': serializer.toJson<String?>(orderId),
}; };
} }
@@ -2453,6 +2595,7 @@ class ClosedTabItemRow extends DataClass
int? unitPriceInCents, int? unitPriceInCents,
Value<DateTime?> purchasedAt = const Value.absent(), Value<DateTime?> purchasedAt = const Value.absent(),
Value<DateTime?> removedAt = const Value.absent(), Value<DateTime?> removedAt = const Value.absent(),
Value<String?> orderId = const Value.absent(),
}) => ClosedTabItemRow( }) => ClosedTabItemRow(
id: id ?? this.id, id: id ?? this.id,
closedTabId: closedTabId ?? this.closedTabId, closedTabId: closedTabId ?? this.closedTabId,
@@ -2462,6 +2605,7 @@ class ClosedTabItemRow extends DataClass
unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents, unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents,
purchasedAt: purchasedAt.present ? purchasedAt.value : this.purchasedAt, purchasedAt: purchasedAt.present ? purchasedAt.value : this.purchasedAt,
removedAt: removedAt.present ? removedAt.value : this.removedAt, removedAt: removedAt.present ? removedAt.value : this.removedAt,
orderId: orderId.present ? orderId.value : this.orderId,
); );
ClosedTabItemRow copyWithCompanion(ClosedTabItemsCompanion data) { ClosedTabItemRow copyWithCompanion(ClosedTabItemsCompanion data) {
return ClosedTabItemRow( return ClosedTabItemRow(
@@ -2481,6 +2625,7 @@ class ClosedTabItemRow extends DataClass
? data.purchasedAt.value ? data.purchasedAt.value
: this.purchasedAt, : this.purchasedAt,
removedAt: data.removedAt.present ? data.removedAt.value : this.removedAt, 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('quantity: $quantity, ')
..write('unitPriceInCents: $unitPriceInCents, ') ..write('unitPriceInCents: $unitPriceInCents, ')
..write('purchasedAt: $purchasedAt, ') ..write('purchasedAt: $purchasedAt, ')
..write('removedAt: $removedAt') ..write('removedAt: $removedAt, ')
..write('orderId: $orderId')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@@ -2509,6 +2655,7 @@ class ClosedTabItemRow extends DataClass
unitPriceInCents, unitPriceInCents,
purchasedAt, purchasedAt,
removedAt, removedAt,
orderId,
); );
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -2521,7 +2668,8 @@ class ClosedTabItemRow extends DataClass
other.quantity == this.quantity && other.quantity == this.quantity &&
other.unitPriceInCents == this.unitPriceInCents && other.unitPriceInCents == this.unitPriceInCents &&
other.purchasedAt == this.purchasedAt && other.purchasedAt == this.purchasedAt &&
other.removedAt == this.removedAt); other.removedAt == this.removedAt &&
other.orderId == this.orderId);
} }
class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> { class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
@@ -2533,6 +2681,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
final Value<int> unitPriceInCents; final Value<int> unitPriceInCents;
final Value<DateTime?> purchasedAt; final Value<DateTime?> purchasedAt;
final Value<DateTime?> removedAt; final Value<DateTime?> removedAt;
final Value<String?> orderId;
final Value<int> rowid; final Value<int> rowid;
const ClosedTabItemsCompanion({ const ClosedTabItemsCompanion({
this.id = const Value.absent(), this.id = const Value.absent(),
@@ -2543,6 +2692,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
this.unitPriceInCents = const Value.absent(), this.unitPriceInCents = const Value.absent(),
this.purchasedAt = const Value.absent(), this.purchasedAt = const Value.absent(),
this.removedAt = const Value.absent(), this.removedAt = const Value.absent(),
this.orderId = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
}); });
ClosedTabItemsCompanion.insert({ ClosedTabItemsCompanion.insert({
@@ -2554,6 +2704,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
required int unitPriceInCents, required int unitPriceInCents,
this.purchasedAt = const Value.absent(), this.purchasedAt = const Value.absent(),
this.removedAt = const Value.absent(), this.removedAt = const Value.absent(),
this.orderId = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
}) : id = Value(id), }) : id = Value(id),
closedTabId = Value(closedTabId), closedTabId = Value(closedTabId),
@@ -2570,6 +2721,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
Expression<int>? unitPriceInCents, Expression<int>? unitPriceInCents,
Expression<DateTime>? purchasedAt, Expression<DateTime>? purchasedAt,
Expression<DateTime>? removedAt, Expression<DateTime>? removedAt,
Expression<String>? orderId,
Expression<int>? rowid, Expression<int>? rowid,
}) { }) {
return RawValuesInsertable({ return RawValuesInsertable({
@@ -2581,6 +2733,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
if (unitPriceInCents != null) 'unit_price_in_cents': unitPriceInCents, if (unitPriceInCents != null) 'unit_price_in_cents': unitPriceInCents,
if (purchasedAt != null) 'purchased_at': purchasedAt, if (purchasedAt != null) 'purchased_at': purchasedAt,
if (removedAt != null) 'removed_at': removedAt, if (removedAt != null) 'removed_at': removedAt,
if (orderId != null) 'order_id': orderId,
if (rowid != null) 'rowid': rowid, if (rowid != null) 'rowid': rowid,
}); });
} }
@@ -2594,6 +2747,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
Value<int>? unitPriceInCents, Value<int>? unitPriceInCents,
Value<DateTime?>? purchasedAt, Value<DateTime?>? purchasedAt,
Value<DateTime?>? removedAt, Value<DateTime?>? removedAt,
Value<String?>? orderId,
Value<int>? rowid, Value<int>? rowid,
}) { }) {
return ClosedTabItemsCompanion( return ClosedTabItemsCompanion(
@@ -2605,6 +2759,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents, unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents,
purchasedAt: purchasedAt ?? this.purchasedAt, purchasedAt: purchasedAt ?? this.purchasedAt,
removedAt: removedAt ?? this.removedAt, removedAt: removedAt ?? this.removedAt,
orderId: orderId ?? this.orderId,
rowid: rowid ?? this.rowid, rowid: rowid ?? this.rowid,
); );
} }
@@ -2636,6 +2791,9 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
if (removedAt.present) { if (removedAt.present) {
map['removed_at'] = Variable<DateTime>(removedAt.value); map['removed_at'] = Variable<DateTime>(removedAt.value);
} }
if (orderId.present) {
map['order_id'] = Variable<String>(orderId.value);
}
if (rowid.present) { if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value); map['rowid'] = Variable<int>(rowid.value);
} }
@@ -2653,6 +2811,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
..write('unitPriceInCents: $unitPriceInCents, ') ..write('unitPriceInCents: $unitPriceInCents, ')
..write('purchasedAt: $purchasedAt, ') ..write('purchasedAt: $purchasedAt, ')
..write('removedAt: $removedAt, ') ..write('removedAt: $removedAt, ')
..write('orderId: $orderId, ')
..write('rowid: $rowid') ..write('rowid: $rowid')
..write(')')) ..write(')'))
.toString(); .toString();
@@ -5238,6 +5397,7 @@ typedef $$BarTabsTableCreateCompanionBuilder =
BarTabsCompanion Function({ BarTabsCompanion Function({
required String id, required String id,
required String customerName, required String customerName,
Value<String?> email,
required DateTime openedAt, required DateTime openedAt,
Value<int> version, Value<int> version,
Value<int> rowid, Value<int> rowid,
@@ -5246,6 +5406,7 @@ typedef $$BarTabsTableUpdateCompanionBuilder =
BarTabsCompanion Function({ BarTabsCompanion Function({
Value<String> id, Value<String> id,
Value<String> customerName, Value<String> customerName,
Value<String?> email,
Value<DateTime> openedAt, Value<DateTime> openedAt,
Value<int> version, Value<int> version,
Value<int> rowid, Value<int> rowid,
@@ -5293,6 +5454,11 @@ class $$BarTabsTableFilterComposer
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
); );
ColumnFilters<String> get email => $composableBuilder(
column: $table.email,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get openedAt => $composableBuilder( ColumnFilters<DateTime> get openedAt => $composableBuilder(
column: $table.openedAt, column: $table.openedAt,
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
@@ -5348,6 +5514,11 @@ class $$BarTabsTableOrderingComposer
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
); );
ColumnOrderings<String> get email => $composableBuilder(
column: $table.email,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get openedAt => $composableBuilder( ColumnOrderings<DateTime> get openedAt => $composableBuilder(
column: $table.openedAt, column: $table.openedAt,
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
@@ -5376,6 +5547,9 @@ class $$BarTabsTableAnnotationComposer
builder: (column) => column, builder: (column) => column,
); );
GeneratedColumn<String> get email =>
$composableBuilder(column: $table.email, builder: (column) => column);
GeneratedColumn<DateTime> get openedAt => GeneratedColumn<DateTime> get openedAt =>
$composableBuilder(column: $table.openedAt, builder: (column) => column); $composableBuilder(column: $table.openedAt, builder: (column) => column);
@@ -5438,12 +5612,14 @@ class $$BarTabsTableTableManager
({ ({
Value<String> id = const Value.absent(), Value<String> id = const Value.absent(),
Value<String> customerName = const Value.absent(), Value<String> customerName = const Value.absent(),
Value<String?> email = const Value.absent(),
Value<DateTime> openedAt = const Value.absent(), Value<DateTime> openedAt = const Value.absent(),
Value<int> version = const Value.absent(), Value<int> version = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
}) => BarTabsCompanion( }) => BarTabsCompanion(
id: id, id: id,
customerName: customerName, customerName: customerName,
email: email,
openedAt: openedAt, openedAt: openedAt,
version: version, version: version,
rowid: rowid, rowid: rowid,
@@ -5452,12 +5628,14 @@ class $$BarTabsTableTableManager
({ ({
required String id, required String id,
required String customerName, required String customerName,
Value<String?> email = const Value.absent(),
required DateTime openedAt, required DateTime openedAt,
Value<int> version = const Value.absent(), Value<int> version = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
}) => BarTabsCompanion.insert( }) => BarTabsCompanion.insert(
id: id, id: id,
customerName: customerName, customerName: customerName,
email: email,
openedAt: openedAt, openedAt: openedAt,
version: version, version: version,
rowid: rowid, rowid: rowid,
@@ -6072,6 +6250,7 @@ typedef $$TabItemPurchasesTableCreateCompanionBuilder =
required String tabItemId, required String tabItemId,
required DateTime purchasedAt, required DateTime purchasedAt,
Value<DateTime?> removedAt, Value<DateTime?> removedAt,
Value<String?> orderId,
Value<int> rowid, Value<int> rowid,
}); });
typedef $$TabItemPurchasesTableUpdateCompanionBuilder = typedef $$TabItemPurchasesTableUpdateCompanionBuilder =
@@ -6080,6 +6259,7 @@ typedef $$TabItemPurchasesTableUpdateCompanionBuilder =
Value<String> tabItemId, Value<String> tabItemId,
Value<DateTime> purchasedAt, Value<DateTime> purchasedAt,
Value<DateTime?> removedAt, Value<DateTime?> removedAt,
Value<String?> orderId,
Value<int> rowid, Value<int> rowid,
}); });
@@ -6138,6 +6318,11 @@ class $$TabItemPurchasesTableFilterComposer
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
); );
ColumnFilters<String> get orderId => $composableBuilder(
column: $table.orderId,
builder: (column) => ColumnFilters(column),
);
$$TabItemsTableFilterComposer get tabItemId { $$TabItemsTableFilterComposer get tabItemId {
final $$TabItemsTableFilterComposer composer = $composerBuilder( final $$TabItemsTableFilterComposer composer = $composerBuilder(
composer: this, composer: this,
@@ -6186,6 +6371,11 @@ class $$TabItemPurchasesTableOrderingComposer
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
); );
ColumnOrderings<String> get orderId => $composableBuilder(
column: $table.orderId,
builder: (column) => ColumnOrderings(column),
);
$$TabItemsTableOrderingComposer get tabItemId { $$TabItemsTableOrderingComposer get tabItemId {
final $$TabItemsTableOrderingComposer composer = $composerBuilder( final $$TabItemsTableOrderingComposer composer = $composerBuilder(
composer: this, composer: this,
@@ -6230,6 +6420,9 @@ class $$TabItemPurchasesTableAnnotationComposer
GeneratedColumn<DateTime> get removedAt => GeneratedColumn<DateTime> get removedAt =>
$composableBuilder(column: $table.removedAt, builder: (column) => column); $composableBuilder(column: $table.removedAt, builder: (column) => column);
GeneratedColumn<String> get orderId =>
$composableBuilder(column: $table.orderId, builder: (column) => column);
$$TabItemsTableAnnotationComposer get tabItemId { $$TabItemsTableAnnotationComposer get tabItemId {
final $$TabItemsTableAnnotationComposer composer = $composerBuilder( final $$TabItemsTableAnnotationComposer composer = $composerBuilder(
composer: this, composer: this,
@@ -6288,12 +6481,14 @@ class $$TabItemPurchasesTableTableManager
Value<String> tabItemId = const Value.absent(), Value<String> tabItemId = const Value.absent(),
Value<DateTime> purchasedAt = const Value.absent(), Value<DateTime> purchasedAt = const Value.absent(),
Value<DateTime?> removedAt = const Value.absent(), Value<DateTime?> removedAt = const Value.absent(),
Value<String?> orderId = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
}) => TabItemPurchasesCompanion( }) => TabItemPurchasesCompanion(
id: id, id: id,
tabItemId: tabItemId, tabItemId: tabItemId,
purchasedAt: purchasedAt, purchasedAt: purchasedAt,
removedAt: removedAt, removedAt: removedAt,
orderId: orderId,
rowid: rowid, rowid: rowid,
), ),
createCompanionCallback: createCompanionCallback:
@@ -6302,12 +6497,14 @@ class $$TabItemPurchasesTableTableManager
required String tabItemId, required String tabItemId,
required DateTime purchasedAt, required DateTime purchasedAt,
Value<DateTime?> removedAt = const Value.absent(), Value<DateTime?> removedAt = const Value.absent(),
Value<String?> orderId = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
}) => TabItemPurchasesCompanion.insert( }) => TabItemPurchasesCompanion.insert(
id: id, id: id,
tabItemId: tabItemId, tabItemId: tabItemId,
purchasedAt: purchasedAt, purchasedAt: purchasedAt,
removedAt: removedAt, removedAt: removedAt,
orderId: orderId,
rowid: rowid, rowid: rowid,
), ),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
@@ -6597,6 +6794,7 @@ typedef $$ClosedTabItemsTableCreateCompanionBuilder =
required int unitPriceInCents, required int unitPriceInCents,
Value<DateTime?> purchasedAt, Value<DateTime?> purchasedAt,
Value<DateTime?> removedAt, Value<DateTime?> removedAt,
Value<String?> orderId,
Value<int> rowid, Value<int> rowid,
}); });
typedef $$ClosedTabItemsTableUpdateCompanionBuilder = typedef $$ClosedTabItemsTableUpdateCompanionBuilder =
@@ -6609,6 +6807,7 @@ typedef $$ClosedTabItemsTableUpdateCompanionBuilder =
Value<int> unitPriceInCents, Value<int> unitPriceInCents,
Value<DateTime?> purchasedAt, Value<DateTime?> purchasedAt,
Value<DateTime?> removedAt, Value<DateTime?> removedAt,
Value<String?> orderId,
Value<int> rowid, Value<int> rowid,
}); });
@@ -6660,6 +6859,11 @@ class $$ClosedTabItemsTableFilterComposer
column: $table.removedAt, column: $table.removedAt,
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
); );
ColumnFilters<String> get orderId => $composableBuilder(
column: $table.orderId,
builder: (column) => ColumnFilters(column),
);
} }
class $$ClosedTabItemsTableOrderingComposer class $$ClosedTabItemsTableOrderingComposer
@@ -6710,6 +6914,11 @@ class $$ClosedTabItemsTableOrderingComposer
column: $table.removedAt, column: $table.removedAt,
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
); );
ColumnOrderings<String> get orderId => $composableBuilder(
column: $table.orderId,
builder: (column) => ColumnOrderings(column),
);
} }
class $$ClosedTabItemsTableAnnotationComposer class $$ClosedTabItemsTableAnnotationComposer
@@ -6752,6 +6961,9 @@ class $$ClosedTabItemsTableAnnotationComposer
GeneratedColumn<DateTime> get removedAt => GeneratedColumn<DateTime> get removedAt =>
$composableBuilder(column: $table.removedAt, builder: (column) => column); $composableBuilder(column: $table.removedAt, builder: (column) => column);
GeneratedColumn<String> get orderId =>
$composableBuilder(column: $table.orderId, builder: (column) => column);
} }
class $$ClosedTabItemsTableTableManager class $$ClosedTabItemsTableTableManager
@@ -6799,6 +7011,7 @@ class $$ClosedTabItemsTableTableManager
Value<int> unitPriceInCents = const Value.absent(), Value<int> unitPriceInCents = const Value.absent(),
Value<DateTime?> purchasedAt = const Value.absent(), Value<DateTime?> purchasedAt = const Value.absent(),
Value<DateTime?> removedAt = const Value.absent(), Value<DateTime?> removedAt = const Value.absent(),
Value<String?> orderId = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
}) => ClosedTabItemsCompanion( }) => ClosedTabItemsCompanion(
id: id, id: id,
@@ -6809,6 +7022,7 @@ class $$ClosedTabItemsTableTableManager
unitPriceInCents: unitPriceInCents, unitPriceInCents: unitPriceInCents,
purchasedAt: purchasedAt, purchasedAt: purchasedAt,
removedAt: removedAt, removedAt: removedAt,
orderId: orderId,
rowid: rowid, rowid: rowid,
), ),
createCompanionCallback: createCompanionCallback:
@@ -6821,6 +7035,7 @@ class $$ClosedTabItemsTableTableManager
required int unitPriceInCents, required int unitPriceInCents,
Value<DateTime?> purchasedAt = const Value.absent(), Value<DateTime?> purchasedAt = const Value.absent(),
Value<DateTime?> removedAt = const Value.absent(), Value<DateTime?> removedAt = const Value.absent(),
Value<String?> orderId = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
}) => ClosedTabItemsCompanion.insert( }) => ClosedTabItemsCompanion.insert(
id: id, id: id,
@@ -6831,6 +7046,7 @@ class $$ClosedTabItemsTableTableManager
unitPriceInCents: unitPriceInCents, unitPriceInCents: unitPriceInCents,
purchasedAt: purchasedAt, purchasedAt: purchasedAt,
removedAt: removedAt, removedAt: removedAt,
orderId: orderId,
rowid: rowid, rowid: rowid,
), ),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
+15 -1
View File
@@ -125,6 +125,14 @@
"incorrectPin": "Incorrect PIN.", "incorrectPin": "Incorrect PIN.",
"tooManyPinAttempts": "Too many attempts. Try again later.", "tooManyPinAttempts": "Too many attempts. Try again later.",
"removed": "Removed", "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.", "enterPrice": "Enter a price.",
"enterValidPrice": "Enter a valid price.", "enterValidPrice": "Enter a valid price.",
"enterValidStock": "Enter a valid stock amount.", "enterValidStock": "Enter a valid stock amount.",
@@ -365,5 +373,11 @@
"creditsEveryoneAtTheBar": "Everyone at the bar", "creditsEveryoneAtTheBar": "Everyone at the bar",
"creditsDrinkResponsibly": "Drink responsibly. Mostly.", "creditsDrinkResponsibly": "Drink responsibly. Mostly.",
"lazyPinRemark": "🤨 Really?", "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"
} }
+60
View File
@@ -800,6 +800,30 @@ abstract class AppLocalizations {
/// **'Removed'** /// **'Removed'**
String get 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. /// No description provided for @enterPrice.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2089,6 +2113,42 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Rainbow road 🌈'** /// **'Rainbow road 🌈'**
String get rainbowRoad; 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 class _AppLocalizationsDelegate
+38
View File
@@ -393,6 +393,26 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get removed => 'Removed'; 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 @override
String get enterPrice => 'Enter a price.'; String get enterPrice => 'Enter a price.';
@@ -1110,4 +1130,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get rainbowRoad => 'Rainbow road 🌈'; 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';
} }
+41 -3
View File
@@ -334,7 +334,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get total => 'Totaal'; String get total => 'Totaal';
@override @override
String get closeTab => 'Poef sluiten'; String get closeTab => 'Poef afsluiten';
@override @override
String productStockSummary(String category, String price, int stock) { String productStockSummary(String category, String price, int stock) {
@@ -397,6 +397,26 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get removed => 'Verwijderd'; 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 @override
String get enterPrice => 'Voer een prijs in.'; String get enterPrice => 'Voer een prijs in.';
@@ -441,7 +461,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get couldNotCloseTab => 'Poef kon niet worden gesloten.'; String get couldNotCloseTab => 'Poef kon niet worden gesloten.';
@override @override
String get closingTab => 'Poef sluiten…'; String get closingTab => 'Poef afsluiten…';
@override @override
String get slideToConfirmClosing => 'Schuif om het sluiten te bevestigen'; String get slideToConfirmClosing => 'Schuif om het sluiten te bevestigen';
@@ -977,7 +997,7 @@ class AppLocalizationsNl extends AppLocalizations {
} }
@override @override
String get remoteCommandCloseTab => 'Poef sluiten via dashboard'; String get remoteCommandCloseTab => 'Poef afsluiten via dashboard';
@override @override
String remoteCommandUnknown(String type) { String remoteCommandUnknown(String type) {
@@ -1124,4 +1144,22 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get rainbowRoad => 'Regenboogweg 🌈'; 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';
} }
+14 -4
View File
@@ -104,7 +104,7 @@
"@tabItemCount": {"placeholders": {"count": {"type": "int"}}}, "@tabItemCount": {"placeholders": {"count": {"type": "int"}}},
"tapProductsToAdd": "Tik op producten om ze toe te voegen", "tapProductsToAdd": "Tik op producten om ze toe te voegen",
"total": "Totaal", "total": "Totaal",
"closeTab": "Poef sluiten", "closeTab": "Poef afsluiten",
"productStockSummary": "{category} • {price} • Voorraad: {stock}", "productStockSummary": "{category} • {price} • Voorraad: {stock}",
"@productStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}, "stock": {"type": "int"}}}, "@productStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}, "stock": {"type": "int"}}},
"infiniteStock": "Oneindige voorraad", "infiniteStock": "Oneindige voorraad",
@@ -125,6 +125,10 @@
"incorrectPin": "Onjuiste PIN.", "incorrectPin": "Onjuiste PIN.",
"tooManyPinAttempts": "Te veel pogingen. Probeer het later opnieuw.", "tooManyPinAttempts": "Te veel pogingen. Probeer het later opnieuw.",
"removed": "Verwijderd", "removed": "Verwijderd",
"removedCount": "{count} verwijderd",
"orderStartedToday": "{time} vandaag",
"orderStartedYesterday": "{time} gisteren",
"orderStartedOn": "{date} {time}",
"enterPrice": "Voer een prijs in.", "enterPrice": "Voer een prijs in.",
"enterValidPrice": "Voer een geldige prijs in.", "enterValidPrice": "Voer een geldige prijs in.",
"enterValidStock": "Voer een geldige voorraad in.", "enterValidStock": "Voer een geldige voorraad in.",
@@ -140,7 +144,7 @@
"currentTotal": "Huidig totaal: {total}", "currentTotal": "Huidig totaal: {total}",
"@currentTotal": {"placeholders": {"total": {"type": "String"}}}, "@currentTotal": {"placeholders": {"total": {"type": "String"}}},
"couldNotCloseTab": "Poef kon niet worden gesloten.", "couldNotCloseTab": "Poef kon niet worden gesloten.",
"closingTab": "Poef sluiten…", "closingTab": "Poef afsluiten…",
"slideToConfirmClosing": "Schuif om het sluiten te bevestigen", "slideToConfirmClosing": "Schuif om het sluiten te bevestigen",
"errorScreen": "Foutscherm", "errorScreen": "Foutscherm",
"unexpectedError": "Er is een onverwachte fout opgetreden.\nProbeer de app opnieuw te starten.", "unexpectedError": "Er is een onverwachte fout opgetreden.\nProbeer de app opnieuw te starten.",
@@ -307,7 +311,7 @@
"justNow": "zonet", "justNow": "zonet",
"minutesAgo": "{count, plural, =1{1 minuut geleden} other{{count} minuten geleden}}", "minutesAgo": "{count, plural, =1{1 minuut geleden} other{{count} minuten geleden}}",
"hoursAgo": "{count, plural, =1{1 uur geleden} other{{count} uur 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})", "remoteCommandUnknown": "Onbekende opdracht ({type})",
"commandCompleted": "Uitgevoerd", "commandCompleted": "Uitgevoerd",
"commandConflict": "Conflict", "commandConflict": "Conflict",
@@ -353,5 +357,11 @@
"creditsEveryoneAtTheBar": "Iedereen aan de toog", "creditsEveryoneAtTheBar": "Iedereen aan de toog",
"creditsDrinkResponsibly": "Drink met mate. Meestal.", "creditsDrinkResponsibly": "Drink met mate. Meestal.",
"lazyPinRemark": "🤨 Echt?", "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"
} }
+11 -1
View File
@@ -5,6 +5,7 @@ import 'tab_item.dart';
class BarTab { class BarTab {
final String id; final String id;
final String customerName; final String customerName;
final String? email;
final DateTime openedAt; final DateTime openedAt;
final int version; final int version;
final List<TabItem> items; final List<TabItem> items;
@@ -12,6 +13,7 @@ class BarTab {
const BarTab({ const BarTab({
required this.id, required this.id,
required this.customerName, required this.customerName,
this.email,
required this.openedAt, required this.openedAt,
this.version = 0, this.version = 0,
required this.items, required this.items,
@@ -31,11 +33,19 @@ class BarTab {
other is BarTab && other is BarTab &&
id == other.id && id == other.id &&
customerName == other.customerName && customerName == other.customerName &&
email == other.email &&
openedAt == other.openedAt && openedAt == other.openedAt &&
version == other.version && version == other.version &&
listEquals(items, other.items); listEquals(items, other.items);
@override @override
int get hashCode => int get hashCode =>
Object.hash(id, customerName, openedAt, version, Object.hashAll(items)); Object.hash(
id,
customerName,
email,
openedAt,
version,
Object.hashAll(items),
);
} }
+5 -1
View File
@@ -7,6 +7,7 @@ class ClosedTabItem {
final int unitPriceInCents; final int unitPriceInCents;
final DateTime? purchasedAt; final DateTime? purchasedAt;
final DateTime? removedAt; final DateTime? removedAt;
final String? orderId;
ClosedTabItem({ ClosedTabItem({
required this.id, required this.id,
@@ -17,6 +18,7 @@ class ClosedTabItem {
required this.unitPriceInCents, required this.unitPriceInCents,
this.purchasedAt, this.purchasedAt,
this.removedAt, this.removedAt,
this.orderId,
}); });
bool get isRemoved => removedAt != null; bool get isRemoved => removedAt != null;
@@ -34,7 +36,8 @@ class ClosedTabItem {
quantity == other.quantity && quantity == other.quantity &&
unitPriceInCents == other.unitPriceInCents && unitPriceInCents == other.unitPriceInCents &&
purchasedAt == other.purchasedAt && purchasedAt == other.purchasedAt &&
removedAt == other.removedAt; removedAt == other.removedAt &&
orderId == other.orderId;
@override @override
int get hashCode => Object.hash( int get hashCode => Object.hash(
@@ -46,5 +49,6 @@ class ClosedTabItem {
unitPriceInCents, unitPriceInCents,
purchasedAt, purchasedAt,
removedAt, removedAt,
orderId,
); );
} }
+13 -1
View File
@@ -8,7 +8,15 @@ class ServerUnit {
final DateTime addedAt; final DateTime addedAt;
final DateTime? removedAt; 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<String, dynamic> json) { factory ServerUnit.fromJson(Map<String, dynamic> json) {
return ServerUnit( return ServerUnit(
@@ -18,6 +26,7 @@ class ServerUnit {
final String value => DateTime.parse(value).toLocal(), final String value => DateTime.parse(value).toLocal(),
_ => null, _ => null,
}, },
orderId: json['orderId'] as String?,
); );
} }
} }
@@ -58,6 +67,7 @@ class ServerTabLine {
class ServerTab { class ServerTab {
final String id; final String id;
final String customerName; final String customerName;
final String? email;
final DateTime openedAt; final DateTime openedAt;
final int version; final int version;
final List<ServerTabLine> lines; final List<ServerTabLine> lines;
@@ -65,6 +75,7 @@ class ServerTab {
const ServerTab({ const ServerTab({
required this.id, required this.id,
required this.customerName, required this.customerName,
this.email,
required this.openedAt, required this.openedAt,
required this.version, required this.version,
this.lines = const [], this.lines = const [],
@@ -74,6 +85,7 @@ class ServerTab {
return ServerTab( return ServerTab(
id: json['id'] as String, id: json['id'] as String,
customerName: json['customerName'] as String, customerName: json['customerName'] as String,
email: json['email'] as String?,
openedAt: DateTime.parse(json['openedAt'] as String).toLocal(), openedAt: DateTime.parse(json['openedAt'] as String).toLocal(),
version: json['version'] as int? ?? 0, version: json['version'] as int? ?? 0,
lines: [ lines: [
+22 -3
View File
@@ -47,6 +47,10 @@ class TabEvent {
const TabEvent({required this.id, required this.payload}); 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. /// One settled line in a `settled` event.
typedef SettledLine = ({ typedef SettledLine = ({
String productId, String productId,
@@ -66,12 +70,16 @@ class TabEventFactory {
required String tabId, required String tabId,
required String customerName, required String customerName,
required DateTime openedAt, required DateTime openedAt,
String? email,
}) { }) {
return _event( return _event(
TabEventType.tabOpened, TabEventType.tabOpened,
tabId: tabId, tabId: tabId,
customerName: customerName, 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 /// [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({ TabEvent lineAdded({
required String tabId, required String tabId,
required String customerName, required String customerName,
@@ -105,6 +114,7 @@ class TabEventFactory {
required String productName, required String productName,
required int unitPriceInCents, required int unitPriceInCents,
required int quantity, required int quantity,
required String? orderId,
}) { }) {
return _lineEvent( return _lineEvent(
TabEventType.lineAdded, TabEventType.lineAdded,
@@ -115,10 +125,13 @@ class TabEventFactory {
productName: productName, productName: productName,
unitPriceInCents: unitPriceInCents, unitPriceInCents: unitPriceInCents,
quantity: quantity, 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({ TabEvent lineRemoved({
required String tabId, required String tabId,
required String customerName, required String customerName,
@@ -127,6 +140,7 @@ class TabEventFactory {
required String productName, required String productName,
required int unitPriceInCents, required int unitPriceInCents,
required int quantity, required int quantity,
OrderRef? order,
}) { }) {
return _lineEvent( return _lineEvent(
TabEventType.lineRemoved, TabEventType.lineRemoved,
@@ -137,6 +151,7 @@ class TabEventFactory {
productName: productName, productName: productName,
unitPriceInCents: unitPriceInCents, unitPriceInCents: unitPriceInCents,
quantity: quantity, quantity: quantity,
order: order,
); );
} }
@@ -246,6 +261,7 @@ class TabEventFactory {
required String productName, required String productName,
required int unitPriceInCents, required int unitPriceInCents,
required int quantity, required int quantity,
required OrderRef? order,
}) { }) {
if (quantity < 1) { if (quantity < 1) {
throw ArgumentError.value(quantity, 'quantity', 'Must be at least 1'); throw ArgumentError.value(quantity, 'quantity', 'Must be at least 1');
@@ -263,6 +279,9 @@ class TabEventFactory {
'productName': productName, 'productName': productName,
'unitPriceInCents': unitPriceInCents, 'unitPriceInCents': unitPriceInCents,
'quantity': quantity, '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,
}, },
); );
} }
+8 -2
View File
@@ -4,11 +4,15 @@ class TabItemPurchase {
final DateTime purchasedAt; final DateTime purchasedAt;
final DateTime? removedAt; final DateTime? removedAt;
/// The order it was rung up in; null for drinks from before orders.
final String? orderId;
const TabItemPurchase({ const TabItemPurchase({
required this.id, required this.id,
required this.tabItemId, required this.tabItemId,
required this.purchasedAt, required this.purchasedAt,
this.removedAt, this.removedAt,
this.orderId,
}); });
bool get isRemoved => removedAt != null; bool get isRemoved => removedAt != null;
@@ -20,8 +24,10 @@ class TabItemPurchase {
id == other.id && id == other.id &&
tabItemId == other.tabItemId && tabItemId == other.tabItemId &&
purchasedAt == other.purchasedAt && purchasedAt == other.purchasedAt &&
removedAt == other.removedAt; removedAt == other.removedAt &&
orderId == other.orderId;
@override @override
int get hashCode => Object.hash(id, tabItemId, purchasedAt, removedAt); int get hashCode =>
Object.hash(id, tabItemId, purchasedAt, removedAt, orderId);
} }
+165 -40
View File
@@ -50,23 +50,29 @@ abstract class BarTabService {
Future<BarTab?> getTabById(String id); Future<BarTab?> getTabById(String id);
Future<BarTab> createTab({required String customerName}); Future<BarTab> createTab({required String customerName, String? email});
Future<void> renameTab({required String tabId, required String customerName}); Future<void> renameTab({required String tabId, required String customerName});
/// Deletes an open tab and returns its items to inventory. /// Deletes an open tab and returns its items to inventory.
Future<void> deleteTab(String tabId); Future<void> deleteTab(String tabId);
/// Rings up one [product] as part of order [orderId]; a new order when
/// null.
Future<void> addProductToTab({ Future<void> addProductToTab({
required String tabId, required String tabId,
required Product product, required Product product,
String? orderId,
}); });
/// Returns the delta that was actually applied, which is smaller than /// Adds drinks to [order] (a new order when null), or takes drinks of
/// [delta] when it would push the quantity below zero. /// [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<int> adjustTabItemQuantity({ Future<int> adjustTabItemQuantity({
required String tabItemId, required String tabItemId,
required int delta, required int delta,
OrderRef? order,
}); });
/// Archives the tab's current items into history and clears them. /// Archives the tab's current items into history and clears them.
@@ -119,6 +125,7 @@ class DriftBarTabService implements BarTabService {
tabItemId: row.tabItemId, tabItemId: row.tabItemId,
purchasedAt: row.purchasedAt, purchasedAt: row.purchasedAt,
removedAt: row.removedAt, removedAt: row.removedAt,
orderId: row.orderId,
); );
} }
@@ -138,6 +145,7 @@ class DriftBarTabService implements BarTabService {
return BarTab( return BarTab(
id: row.id, id: row.id,
customerName: row.customerName, customerName: row.customerName,
email: row.email,
openedAt: row.openedAt, openedAt: row.openedAt,
version: row.version, version: row.version,
items: items, items: items,
@@ -154,6 +162,7 @@ class DriftBarTabService implements BarTabService {
unitPriceInCents: row.unitPriceInCents, unitPriceInCents: row.unitPriceInCents,
purchasedAt: row.purchasedAt, purchasedAt: row.purchasedAt,
removedAt: row.removedAt, removedAt: row.removedAt,
orderId: row.orderId,
); );
} }
@@ -173,11 +182,16 @@ class DriftBarTabService implements BarTabService {
if (tabId != null) query.where(database.barTabs.id.equals(tabId)); 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([ query.orderBy([
OrderingTerm.desc(database.barTabs.openedAt), OrderingTerm.desc(database.barTabs.openedAt),
OrderingTerm.asc(database.tabItems.createdAt), OrderingTerm.asc(database.tabItems.createdAt),
OrderingTerm.asc(const CustomExpression<int>('"tab_items"."rowid"')),
OrderingTerm.asc(database.tabItemPurchases.purchasedAt), OrderingTerm.asc(database.tabItemPurchases.purchasedAt),
OrderingTerm.asc(database.tabItemPurchases.id), OrderingTerm.asc(
const CustomExpression<int>('"tab_item_purchases"."rowid"'),
),
]); ]);
return query; return query;
@@ -224,31 +238,63 @@ class DriftBarTabService implements BarTabService {
)..where((tab) => tab.id.equals(tabId))).getSingleOrNull(); )..where((tab) => tab.id.equals(tabId))).getSingleOrNull();
} }
Future<void> _recordPurchase(String tabItemId) async { /// [id] is the one the server gives the unit: `<event id>:<index>`.
Future<void> _recordPurchase(
String tabItemId,
String orderId, {
required String id,
}) async {
await database await database
.into(database.tabItemPurchases) .into(database.tabItemPurchases)
.insert( .insert(
TabItemPurchasesCompanion.insert( TabItemPurchasesCompanion.insert(
id: _uuid.v4(), id: id,
tabItemId: tabItemId, tabItemId: tabItemId,
purchasedAt: DateTime.now(), purchasedAt: DateTime.now(),
orderId: Value(orderId),
), ),
); );
} }
/// Marks the most recent active purchases as removed, keeping them in the /// The active purchases of [tabItemId] a removal may take off: those of
/// audit trail. /// [order] when given, as the server does; else all of them (events from
/// before orders).
Future<List<TabItemPurchaseRow>> _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<void> _removeLatestPurchases( Future<void> _removeLatestPurchases(
String tabItemId, String tabItemId,
int count, { int count, {
DateTime? removedAt, DateTime? removedAt,
OrderRef? order,
}) async { }) async {
final latest = final latest =
await (database.select(database.tabItemPurchases) await (database.select(database.tabItemPurchases)
..where( ..where(
(purchase) => (purchase) =>
purchase.tabItemId.equals(tabItemId) & 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([ ..orderBy([
(purchase) => OrderingTerm.desc(purchase.purchasedAt), (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) /// Queues a `line_added` ([delta] > 0) or `line_removed` ([delta] < 0)
/// event and moves the stock of [productId], the product the line was /// 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 /// created from, the other way. Every line event goes through here, so a
/// line event and its stock change can never drift apart. /// line event and its stock change can never drift apart.
Future<void> _recordLineChange({ Future<String> _recordLineChange({
required String tabId, required String tabId,
required String customerName, required String customerName,
required String lineId, required String lineId,
@@ -315,22 +363,32 @@ class DriftBarTabService implements BarTabService {
required String productName, required String productName,
required int unitPriceInCents, required int unitPriceInCents,
required int delta, required int delta,
required OrderRef? order,
}) async { }) async {
final lineEvent = delta > 0 final event = delta > 0
? eventFactory.lineAdded ? eventFactory.lineAdded(
: eventFactory.lineRemoved; tabId: tabId,
await _recordEvent( customerName: customerName,
lineEvent( lineId: lineId,
tabId: tabId, productId: productId,
customerName: customerName, productName: productName,
lineId: lineId, unitPriceInCents: unitPriceInCents,
productId: productId, quantity: delta,
productName: productName, orderId: order?.orderId,
unitPriceInCents: unitPriceInCents, )
quantity: delta.abs(), : 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); await database.changeTrackedStock(productId, -delta);
return event.id;
} }
Future<void> _incrementTabVersion(String tabId) async { Future<void> _incrementTabVersion(String tabId) async {
@@ -377,9 +435,16 @@ class DriftBarTabService implements BarTabService {
} }
@override @override
Future<BarTab> createTab({required String customerName}) async { Future<BarTab> createTab({
required String customerName,
String? email,
}) async {
final id = _uuid.v4(); final id = _uuid.v4();
final name = _validateCustomerName(customerName); final name = _validateCustomerName(customerName);
final trimmedEmail = email?.trim();
final cleanEmail = trimmedEmail == null || trimmedEmail.isEmpty
? null
: trimmedEmail;
final openedAt = DateTime.now(); final openedAt = DateTime.now();
await database.transaction(() async { await database.transaction(() async {
@@ -389,6 +454,7 @@ class DriftBarTabService implements BarTabService {
BarTabsCompanion.insert( BarTabsCompanion.insert(
id: id, id: id,
customerName: name, customerName: name,
email: Value(cleanEmail),
openedAt: openedAt, openedAt: openedAt,
version: const Value(1), version: const Value(1),
), ),
@@ -398,6 +464,7 @@ class DriftBarTabService implements BarTabService {
tabId: id, tabId: id,
customerName: name, customerName: name,
openedAt: openedAt, openedAt: openedAt,
email: cleanEmail,
), ),
); );
}); });
@@ -434,6 +501,7 @@ class DriftBarTabService implements BarTabService {
if (tab == null) return; if (tab == null) return;
for (final item in tab.items) { for (final item in tab.items) {
// Without an order: everything on the line, whatever its order
await _recordLineChange( await _recordLineChange(
tabId: tab.id, tabId: tab.id,
customerName: tab.customerName, customerName: tab.customerName,
@@ -442,6 +510,7 @@ class DriftBarTabService implements BarTabService {
productName: item.productName, productName: item.productName,
unitPriceInCents: item.unitPriceInCents, unitPriceInCents: item.unitPriceInCents,
delta: -item.quantity, delta: -item.quantity,
order: null,
); );
} }
await _recordEvent( await _recordEvent(
@@ -456,6 +525,7 @@ class DriftBarTabService implements BarTabService {
Future<void> addProductToTab({ Future<void> addProductToTab({
required String tabId, required String tabId,
required Product product, required Product product,
String? orderId,
}) async { }) async {
await database.transaction(() async { await database.transaction(() async {
final tabRow = await _getTabRow(tabId); final tabRow = await _getTabRow(tabId);
@@ -467,6 +537,7 @@ class DriftBarTabService implements BarTabService {
productName: product.name, productName: product.name,
unitPriceInCents: product.priceInCents, unitPriceInCents: product.priceInCents,
quantity: 1, quantity: 1,
orderId: orderId ?? _uuid.v4(),
); );
await _incrementTabVersion(tabId); await _incrementTabVersion(tabId);
}); });
@@ -485,6 +556,7 @@ class DriftBarTabService implements BarTabService {
required String productName, required String productName,
required int unitPriceInCents, required int unitPriceInCents,
required int quantity, required int quantity,
required String orderId,
}) async { }) async {
final existingItem = final existingItem =
await (database.select(database.tabItems) await (database.select(database.tabItems)
@@ -524,10 +596,7 @@ class DriftBarTabService implements BarTabService {
); );
} }
for (var index = 0; index < quantity; index++) { final eventId = await _recordLineChange(
await _recordPurchase(lineId);
}
await _recordLineChange(
tabId: tabRow.id, tabId: tabRow.id,
customerName: tabRow.customerName, customerName: tabRow.customerName,
lineId: lineId, lineId: lineId,
@@ -535,7 +604,13 @@ class DriftBarTabService implements BarTabService {
productName: productName, productName: productName,
unitPriceInCents: unitPriceInCents, unitPriceInCents: unitPriceInCents,
delta: quantity, 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 /// Taking drinks off changes [tabItemId] itself. Adding is ringing up new
@@ -546,6 +621,7 @@ class DriftBarTabService implements BarTabService {
Future<int> adjustTabItemQuantity({ Future<int> adjustTabItemQuantity({
required String tabItemId, required String tabItemId,
required int delta, required int delta,
OrderRef? order,
}) async { }) async {
if (delta == 0) return 0; if (delta == 0) return 0;
@@ -570,17 +646,23 @@ class DriftBarTabService implements BarTabService {
productName: product?.name ?? item.productName, productName: product?.name ?? item.productName,
unitPriceInCents: product?.priceInCents ?? item.unitPriceInCents, unitPriceInCents: product?.priceInCents ?? item.unitPriceInCents,
quantity: delta, quantity: delta,
orderId: order?.orderId ?? _uuid.v4(),
); );
await _incrementTabVersion(item.tabId); await _incrementTabVersion(item.tabId);
return delta; return delta;
} }
final newQuantity = max(item.quantity + delta, 0); // Only the drinks of the order can be taken off; without an order the
final actualDelta = newQuantity - item.quantity; // 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; if (actualDelta == 0) return 0;
await _removeLatestPurchases(item.id, -actualDelta); await _removeLatestPurchases(item.id, -actualDelta, order: order);
await (database.update(database.tabItems) await (database.update(database.tabItems)
..where((row) => row.id.equals(tabItemId))) ..where((row) => row.id.equals(tabItemId)))
.write(TabItemsCompanion(quantity: Value(newQuantity))); .write(TabItemsCompanion(quantity: Value(newQuantity)));
@@ -594,6 +676,7 @@ class DriftBarTabService implements BarTabService {
productName: item.productName, productName: item.productName,
unitPriceInCents: item.unitPriceInCents, unitPriceInCents: item.unitPriceInCents,
delta: actualDelta, delta: actualDelta,
order: order,
); );
await _incrementTabVersion(item.tabId); await _incrementTabVersion(item.tabId);
@@ -681,6 +764,7 @@ class DriftBarTabService implements BarTabService {
unitPriceInCents: item.unitPriceInCents, unitPriceInCents: item.unitPriceInCents,
purchasedAt: Value(purchase.purchasedAt), purchasedAt: Value(purchase.purchasedAt),
removedAt: Value(purchase.removedAt), removedAt: Value(purchase.removedAt),
orderId: Value(purchase.orderId),
), ),
]); ]);
}); });
@@ -703,7 +787,9 @@ class DriftBarTabService implements BarTabService {
) )
..orderBy([ ..orderBy([
(item) => OrderingTerm.desc(item.purchasedAt), (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(); .get();
@@ -787,12 +873,25 @@ class DriftBarTabService implements BarTabService {
@override @override
Future<void> applyServerState(List<ServerTab> tabs) async { Future<void> applyServerState(List<ServerTab> tabs) async {
await database.transaction(() 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 = <String, String>{};
for (final tab in await database.select(database.barTabs).get()) { for (final tab in await database.select(database.barTabs).get()) {
if (tab.email case final email?) knownEmails[tab.id] = email;
await _deleteTabWithItems(tab.id); await _deleteTabWithItems(tab.id);
} }
for (final tab in tabs) { 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 // Read here, so changes made while the sync was in flight are included
@@ -808,13 +907,18 @@ class DriftBarTabService implements BarTabService {
}); });
} }
Future<void> _insertServerTab(ServerTab tab) async { Future<void> _insertServerTab(
ServerTab tab,
Map<String, String> knownOrders,
String? knownEmail,
) async {
await database await database
.into(database.barTabs) .into(database.barTabs)
.insert( .insert(
BarTabsCompanion.insert( BarTabsCompanion.insert(
id: tab.id, id: tab.id,
customerName: tab.customerName, customerName: tab.customerName,
email: Value(tab.email ?? knownEmail),
openedAt: tab.openedAt, openedAt: tab.openedAt,
version: Value(tab.version), version: Value(tab.version),
), ),
@@ -848,6 +952,7 @@ class DriftBarTabService implements BarTabService {
tabItemId: line.id, tabItemId: line.id,
purchasedAt: unit.addedAt, purchasedAt: unit.addedAt,
removedAt: Value(unit.removedAt), removedAt: Value(unit.removedAt),
orderId: Value(unit.orderId ?? knownOrders[unit.id]),
), ),
]); ]);
}); });
@@ -871,7 +976,12 @@ class DriftBarTabService implements BarTabService {
final openedAt = DateTime.tryParse( final openedAt = DateTime.tryParse(
event['openedAt'] as String? ?? '', event['openedAt'] as String? ?? '',
)?.toLocal(); )?.toLocal();
await _ensureTab(tabId, customerName, openedAt ?? occurredAt); await _ensureTab(
tabId,
customerName,
openedAt ?? occurredAt,
email: event['email'] as String?,
);
case TabEventType.tabRenamed: case TabEventType.tabRenamed:
await (database.update(database.barTabs) await (database.update(database.barTabs)
..where((tab) => tab.id.equals(tabId))) ..where((tab) => tab.id.equals(tabId)))
@@ -885,9 +995,20 @@ class DriftBarTabService implements BarTabService {
final quantity = event['quantity'] as int? ?? 0; final quantity = event['quantity'] as int? ?? 0;
if (line == null || quantity <= 0) return; 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 _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: case TabEventType.settled:
for (final item in event['items'] as List<dynamic>? ?? []) { for (final item in event['items'] as List<dynamic>? ?? []) {
await _replaySettledLine(tabId, item as Map<String, dynamic>); await _replaySettledLine(tabId, item as Map<String, dynamic>);
@@ -947,6 +1068,7 @@ class DriftBarTabService implements BarTabService {
id: '${event['id']}:$index', id: '${event['id']}:$index',
tabItemId: line!.id, tabItemId: line!.id,
purchasedAt: occurredAt, purchasedAt: occurredAt,
orderId: Value(event['orderId'] as String?),
), ),
]); ]);
}); });
@@ -998,14 +1120,16 @@ class DriftBarTabService implements BarTabService {
Future<void> _ensureTab( Future<void> _ensureTab(
String tabId, String tabId,
String customerName, String customerName,
DateTime openedAt, DateTime openedAt, {
) async { String? email,
}) async {
await database await database
.into(database.barTabs) .into(database.barTabs)
.insert( .insert(
BarTabsCompanion.insert( BarTabsCompanion.insert(
id: tabId, id: tabId,
customerName: customerName, customerName: customerName,
email: Value(email),
openedAt: openedAt, openedAt: openedAt,
version: const Value(1), version: const Value(1),
), ),
@@ -1096,6 +1220,7 @@ class DriftBarTabService implements BarTabService {
unitPriceInCents: line.unitPriceInCents, unitPriceInCents: line.unitPriceInCents,
purchasedAt: Value(unit.addedAt), purchasedAt: Value(unit.addedAt),
removedAt: Value(unit.removedAt), removedAt: Value(unit.removedAt),
orderId: Value(unit.orderId),
), ),
]); ]);
}); });
+1 -3
View File
@@ -60,9 +60,7 @@ class KoolTapApiClient {
this.appVersion, this.appVersion,
}) : _client = client ?? http.Client(); }) : _client = client ?? http.Client();
Map<String, String> get _versionHeader => { Map<String, String> get _versionHeader => {'X-App-Version': ?appVersion};
'X-App-Version': ?appVersion,
};
static const _timeout = Duration(seconds: 15); static const _timeout = Duration(seconds: 15);
static const _defaultRetryAfter = Duration(seconds: 60); static const _defaultRetryAfter = Duration(seconds: 60);
-48
View File
@@ -1,48 +0,0 @@
import '../models/closed_tab_item.dart';
class ClosedTabItemDayGroup {
final DateTime day;
final List<ClosedTabItem> items;
const ClosedTabItemDayGroup({required this.day, required this.items});
int get itemCount => items.fold(
0,
(total, item) => total + (item.isRemoved ? 0 : item.quantity),
);
}
List<ClosedTabItemDayGroup> groupClosedTabItemsByDay(
Iterable<ClosedTabItem> items, {
required DateTime fallbackDate,
}) {
final groups = <DateTime, List<ClosedTabItem>>{};
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();
}
+219
View File
@@ -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<L> {
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<L> {
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<L> {
/// 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<TabOrderRow<L>> 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<TabOrder<L>> groupOrders<L>(Iterable<OrderDrink<L>> drinks) {
final sorted = drinks.toList();
mergeSort(sorted, compare: (a, b) => a.addedAt.compareTo(b.addedAt));
final orders = <_OrderBuilder<L>>[];
final byId = <String, _OrderBuilder<L>>{};
_OrderBuilder<L>? unordered;
DateTime? unorderedLastAt;
for (final drink in sorted) {
final _OrderBuilder<L> order;
final orderId = drink.orderId;
if (orderId != null) {
order = byId.putIfAbsent(orderId, () {
final created = _OrderBuilder<L>(orderId, drink.addedAt);
orders.add(created);
return created;
});
} else {
if (unordered == null ||
drink.addedAt.difference(unorderedLastAt!) > orderGap) {
unordered = _OrderBuilder<L>(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<L> {
final String? orderId;
final DateTime startedAt;
final _rows = <Object, ({L line, int quantity, int removed})>{};
_OrderBuilder(this.orderId, this.startedAt);
void add(OrderDrink<L> 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<L> 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<TabOrder<TabItem>> 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<TabOrder<ClosedTabItem>> 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),
};
}
+104 -11
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:uuid/uuid.dart';
import '../models/app_error.dart'; import '../models/app_error.dart';
import '../models/bar_tab.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 /// up without manual reloads. Actions report to Sentry and rethrow; the view
/// decides how to tell the user. /// decides how to tell the user.
class BarScreenViewModel extends ChangeNotifier { 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<List<BarTab>>? _subscription; StreamSubscription<List<BarTab>>? _subscription;
List<BarTab> _tabs = []; List<BarTab> _tabs = [];
@@ -25,7 +37,30 @@ class BarScreenViewModel extends ChangeNotifier {
bool _hasLoaded = false; bool _hasLoaded = false;
AppError? _loadError; AppError? _loadError;
List<BarTab> 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<String> _openedTabIds = {};
/// Every tab the venue knows, open or not.
List<BarTab> get allTabs => _tabs;
/// The tabs on the bar screen: opened here, with drinks on them, or
/// selected.
List<BarTab> get tabs => [
for (final tab in _tabs)
if (_isOpen(tab)) tab,
];
/// The tabs that can be opened from the Open Tab dialog.
List<BarTab> 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; String? get selectedTabId => _selectedTabId;
@@ -55,11 +90,22 @@ class BarScreenViewModel extends ChangeNotifier {
} }
void _onTabs(List<BarTab> tabs) { void _onTabs(List<BarTab> 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; _tabs = tabs;
_hasLoaded = true; _hasLoaded = true;
_loadError = null; _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(); notifyListeners();
} }
@@ -75,13 +121,44 @@ class BarScreenViewModel extends ChangeNotifier {
void selectTab(String tabId) { void selectTab(String tabId) {
_selectedTabId = tabId; _selectedTabId = tabId;
_order = null;
notifyListeners(); notifyListeners();
} }
Future<void> 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<void> createTab(String customerName, {String? email}) {
return _guard('createTab', () async { return _guard('createTab', () async {
final tab = await barTabService.createTab(customerName: customerName); final tab = await barTabService.createTab(
selectTab(tab.id); customerName: customerName,
email: email,
);
openTab(tab.id);
}); });
} }
@@ -102,17 +179,30 @@ class BarScreenViewModel extends ChangeNotifier {
await _guard( await _guard(
'addProductToSelectedTab', 'addProductToSelectedTab',
() => barTabService.addProductToTab(tabId: tab.id, product: product), () => barTabService.addProductToTab(
tabId: tab.id,
product: product,
orderId: _orderFor(tab.id),
),
); );
} }
Future<void> 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<void> changeItemQuantity(
TabItem item,
int delta,
String? orderId,
) async {
if (delta == 0) return; if (delta == 0) return;
await _guard( await _guard(
'changeItemQuantity', 'changeItemQuantity',
() => () => barTabService.adjustTabItemQuantity(
barTabService.adjustTabItemQuantity(tabItemId: item.id, delta: delta), tabItemId: item.id,
delta: delta,
order: (orderId: delta > 0 ? _orderFor(item.tabId) : orderId),
),
); );
} }
@@ -126,6 +216,9 @@ class BarScreenViewModel extends ChangeNotifier {
'closeSelectedTab', 'closeSelectedTab',
() => barTabService.closeTab(tab.id, paymentMethod: paymentMethod), () => barTabService.closeTab(tab.id, paymentMethod: paymentMethod),
); );
// A paid tab goes back to the Open Tab dialog
_openedTabIds.remove(tab.id);
deselectTab();
} }
Future<T> _guard<T>(String action, Future<T> Function() body) async { Future<T> _guard<T>(String action, Future<T> Function() body) async {
-17
View File
@@ -8,7 +8,6 @@ import '../models/remote_command.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/admin_pin_view_model.dart';
import '../viewmodels/remote_sync_view_model.dart'; import '../viewmodels/remote_sync_view_model.dart';
import 'dialogs/new_tab_dialog.dart';
class AdminScreenView extends StatelessWidget { class AdminScreenView extends StatelessWidget {
const AdminScreenView({super.key}); const AdminScreenView({super.key});
@@ -19,16 +18,6 @@ class AdminScreenView extends StatelessWidget {
adminPinViewModel.lock(); adminPinViewModel.lock();
} }
Future<void> _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<void> _confirmUnlink(BuildContext context) async { Future<void> _confirmUnlink(BuildContext context) async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@@ -94,12 +83,6 @@ class AdminScreenView extends StatelessWidget {
_AdminSection( _AdminSection(
title: l10n.adminTools, title: l10n.adminTools,
children: [ children: [
_AdminTile(
icon: Icons.person_add_alt_1_outlined,
title: l10n.openNewTab,
subtitle: l10n.openNewTabDescription,
onTap: () => _openNewTab(context),
),
_AdminTile( _AdminTile(
icon: Icons.inventory_2_outlined, icon: Icons.inventory_2_outlined,
title: l10n.products, title: l10n.products,
+172 -135
View File
@@ -18,6 +18,7 @@ import '../../viewmodels/remote_sync_view_model.dart';
import '../../viewmodels/settings_view_model.dart'; import '../../viewmodels/settings_view_model.dart';
import '../dialogs/close_tab_dialog.dart'; import '../dialogs/close_tab_dialog.dart';
import '../dialogs/edit_tab_dialog.dart'; import '../dialogs/edit_tab_dialog.dart';
import '../dialogs/open_tab_dialog.dart';
import '../widgets/venue_title.dart'; import '../widgets/venue_title.dart';
import 'product_grid.dart'; import 'product_grid.dart';
import 'tab_panel.dart'; import 'tab_panel.dart';
@@ -30,8 +31,11 @@ class BarScreenView extends StatefulWidget {
} }
class _BarScreenViewState extends State<BarScreenView> { class _BarScreenViewState extends State<BarScreenView> {
static const _idleTimeout = Duration(minutes: 5);
bool _showSearch = false; bool _showSearch = false;
late final StreamSubscription<RemoteSyncEvent> _remoteCloseSubscription; late final StreamSubscription<RemoteSyncEvent> _remoteCloseSubscription;
Timer? _idleTimer;
@override @override
void initState() { void initState() {
@@ -41,6 +45,7 @@ class _BarScreenViewState extends State<BarScreenView> {
.read<RemoteSyncViewModel>() .read<RemoteSyncViewModel>()
.tabsClosedRemotely .tabsClosedRemotely
.listen(_showRemoteClose); .listen(_showRemoteClose);
_restartIdleTimer();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
UpdateChecker.check(context); UpdateChecker.check(context);
@@ -50,9 +55,19 @@ class _BarScreenViewState extends State<BarScreenView> {
@override @override
void dispose() { void dispose() {
_remoteCloseSubscription.cancel(); _remoteCloseSubscription.cancel();
_idleTimer?.cancel();
super.dispose(); 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<BarScreenViewModel>().deselectTab();
});
}
void _showRemoteClose(RemoteSyncEvent event) { void _showRemoteClose(RemoteSyncEvent event) {
if (!mounted) return; if (!mounted) return;
@@ -91,6 +106,16 @@ class _BarScreenViewState extends State<BarScreenView> {
} }
} }
Future<void> _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}) { void _showSyncProblem({required String title, required String description}) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
showDialog<void>( showDialog<void>(
@@ -151,150 +176,162 @@ class _BarScreenViewState extends State<BarScreenView> {
(viewModel) => viewModel.venueName, (viewModel) => viewModel.venueName,
); );
return Scaffold( // Any touch counts as activity, wherever it lands
appBar: AppBar( return Listener(
title: VenueTitle( behavior: HitTestBehavior.translucent,
text: venueName == null || venueName.trim().isEmpty onPointerDown: (_) => _restartIdleTimer(),
? l10n.barTabs child: Scaffold(
: venueName, appBar: AppBar(
), title: VenueTitle(
actions: [ text: venueName == null || venueName.trim().isEmpty
IconButton( ? l10n.barTabs
tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs, : venueName,
isSelected: _showSearch,
onPressed: () => setState(() => _showSearch = !_showSearch),
icon: const Icon(Icons.search_rounded),
selectedIcon: const Icon(Icons.search_off_rounded),
), ),
const SizedBox(width: 6), actions: [
IconButton( IconButton(
tooltip: l10n.admin, tooltip: l10n.openTab,
onPressed: () => context.push('/admin'), onPressed: _openTab,
icon: const Icon(Icons.admin_panel_settings_outlined), icon: const Icon(Icons.person_add_alt_1_outlined),
),
if (kDebugMode) ...[
const SizedBox(width: 6),
_DebugShortcut(
tooltip: l10n.manageProducts,
icon: Icons.inventory_2_outlined,
location: '/products',
), ),
const SizedBox(width: 6),
_DebugShortcut(
tooltip: l10n.tabHistory,
icon: Icons.history_rounded,
location: '/history',
),
],
if (context.watch<RemoteSyncViewModel>().wasRevoked) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
IconButton( IconButton(
tooltip: l10n.deviceAccessRevoked, tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs,
color: Theme.of(context).colorScheme.error, 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'), onPressed: () => context.push('/admin'),
icon: const Icon(Icons.cloud_off_rounded), icon: const Icon(Icons.admin_panel_settings_outlined),
), ),
], if (kDebugMode) ...[
if (context.watch<RemoteSyncViewModel>().updateRequired const SizedBox(width: 6),
case final message?) ...[ _DebugShortcut(
const SizedBox(width: 6), tooltip: l10n.manageProducts,
IconButton( icon: Icons.inventory_2_outlined,
tooltip: l10n.appUpdateRequired, location: '/products',
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<RemoteSyncViewModel>().lastError
case final error?) ...[
const SizedBox(width: 6),
// Offline is normal for a bar; anything else needs attention
if (context.watch<RemoteSyncViewModel>().isOffline)
IconButton(
tooltip: l10n.offline,
onPressed: () => _showSyncProblem(
title: l10n.offline,
description: l10n.offlineDescription,
),
icon: const Icon(Icons.wifi_off_rounded),
)
else
IconButton(
tooltip: '${l10n.syncProblem}: $error',
color: Theme.of(context).colorScheme.error,
onPressed: () => _showSyncProblem(
title: l10n.syncProblem,
description: '${l10n.syncProblemDescription}\n\n$error',
),
icon: const Icon(Icons.sync_problem_rounded),
), ),
], const SizedBox(width: 6),
const SizedBox(width: 6), _DebugShortcut(
IconButton( tooltip: l10n.tabHistory,
tooltip: l10n.refresh, icon: Icons.history_rounded,
onPressed: () => context.read<RemoteSyncViewModel>().syncNow(), location: '/history',
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<PinLockViewModel>().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),
),
), ),
], ],
); if (context.watch<RemoteSyncViewModel>().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<RemoteSyncViewModel>().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<RemoteSyncViewModel>().lastError
case final error?) ...[
const SizedBox(width: 6),
// Offline is normal for a bar; anything else needs attention
if (context.watch<RemoteSyncViewModel>().isOffline)
IconButton(
tooltip: l10n.offline,
onPressed: () => _showSyncProblem(
title: l10n.offline,
description: l10n.offlineDescription,
),
icon: const Icon(Icons.wifi_off_rounded),
)
else
IconButton(
tooltip: '${l10n.syncProblem}: $error',
color: Theme.of(context).colorScheme.error,
onPressed: () => _showSyncProblem(
title: l10n.syncProblem,
description: '${l10n.syncProblemDescription}\n\n$error',
),
icon: const Icon(Icons.sync_problem_rounded),
),
],
const SizedBox(width: 6),
IconButton(
tooltip: l10n.refresh,
onPressed: () => context.read<RemoteSyncViewModel>().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<PinLockViewModel>().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),
),
),
],
);
},
),
), ),
); );
} }
+87 -17
View File
@@ -4,10 +4,17 @@ import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart'; import '../../models/bar_tab.dart';
import '../../models/tab_item.dart'; import '../../models/tab_item.dart';
import '../../utils/money.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<void> Function(TabItem item, int delta, String? orderId);
class SelectedTabDetails extends StatelessWidget { class SelectedTabDetails extends StatelessWidget {
final BarTab tab; final BarTab tab;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged; final ItemQuantityChanged onItemQuantityChanged;
final VoidCallback onCloseTabPressed; final VoidCallback onCloseTabPressed;
const SelectedTabDetails({ const SelectedTabDetails({
@@ -44,17 +51,11 @@ class SelectedTabDetails extends StatelessWidget {
], ],
), ),
) )
: ListView.separated( : _OrderList(
itemCount: tab.items.length, // Another tab starts with its own orders folded
separatorBuilder: (_, _) => const Divider(height: 1), key: ValueKey(tab.id),
itemBuilder: (context, index) { orders: ordersOfTab(tab),
final item = tab.items[index]; onItemQuantityChanged: onItemQuantityChanged,
return TabItemRow(
item: item,
onQuantityChanged: onItemQuantityChanged,
);
},
), ),
), ),
const SizedBox(height: 8), 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<TabOrder<TabItem>> 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, bool>{};
String _key(TabOrder<TabItem> order) =>
order.orderId ?? order.startedAt.toIso8601String();
@override
Widget build(BuildContext context) {
final orders = widget.orders;
final children = <Widget>[];
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<int>(
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 { class TabItemRow extends StatelessWidget {
final TabItem item; final TabItem item;
final Future<void> 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({ const TabItemRow({
super.key, super.key,
required this.item, required this.item,
required this.quantity,
required this.orderId,
required this.onQuantityChanged, required this.onQuantityChanged,
}); });
@@ -116,7 +186,7 @@ class TabItemRow extends StatelessWidget {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
try { try {
await onQuantityChanged(item, delta); await onQuantityChanged(item, delta, orderId);
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(
@@ -145,7 +215,7 @@ class TabItemRow extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'${item.quantity} × ${item.unitPriceInCents.asEuro}', '$quantity × ${item.unitPriceInCents.asEuro}',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],
@@ -167,7 +237,7 @@ class TabItemRow extends StatelessWidget {
SizedBox( SizedBox(
width: 22, width: 22,
child: Text( child: Text(
'${item.quantity}', '$quantity',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.w700), style: const TextStyle(fontWeight: FontWeight.w700),
), ),
@@ -183,7 +253,7 @@ class TabItemRow extends StatelessWidget {
SizedBox( SizedBox(
width: 72, width: 72,
child: Text( child: Text(
item.lineTotalInCents.asEuro, (quantity * item.unitPriceInCents).asEuro,
textAlign: TextAlign.end, textAlign: TextAlign.end,
style: const TextStyle(fontWeight: FontWeight.w700), style: const TextStyle(fontWeight: FontWeight.w700),
), ),
+75 -36
View File
@@ -7,7 +7,6 @@ import 'package:flutter_slidable/flutter_slidable.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart'; import '../../models/bar_tab.dart';
import '../../models/tab_item.dart';
import '../../utils/easter_eggs.dart'; import '../../utils/easter_eggs.dart';
import '../../utils/money.dart'; import '../../utils/money.dart';
import '../../utils/name_index.dart'; import '../../utils/name_index.dart';
@@ -42,7 +41,8 @@ class TabPanel extends StatefulWidget {
final String? selectedTabId; final String? selectedTabId;
final bool showSearch; final bool showSearch;
final ValueChanged<String> onTabSelected; final ValueChanged<String> onTabSelected;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged; final VoidCallback onTabDeselected;
final ItemQuantityChanged onItemQuantityChanged;
final VoidCallback onCloseTabPressed; final VoidCallback onCloseTabPressed;
final Future<void> Function(BarTab) onTabEdited; final Future<void> Function(BarTab) onTabEdited;
final Future<void> Function(BarTab) onTabDeleted; final Future<void> Function(BarTab) onTabDeleted;
@@ -54,6 +54,7 @@ class TabPanel extends StatefulWidget {
required this.selectedTabId, required this.selectedTabId,
required this.showSearch, required this.showSearch,
required this.onTabSelected, required this.onTabSelected,
required this.onTabDeselected,
required this.onItemQuantityChanged, required this.onItemQuantityChanged,
required this.onCloseTabPressed, required this.onCloseTabPressed,
required this.onTabEdited, required this.onTabEdited,
@@ -77,13 +78,23 @@ class _TabPanelState extends State<TabPanel> {
/// The names-list offset the selected tile was last lined up at. /// The names-list offset the selected tile was last lined up at.
double? _attachedOffset; double? _attachedOffset;
@override /// The first row of the names list, measured for [_rowExtent].
void initState() { final GlobalKey _firstRowKey = GlobalKey();
super.initState();
// 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((_) { 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<TabPanel> {
if (tab == null || _focus == _PanelFocus.names) return null; if (tab == null || _focus == _PanelFocus.names) return null;
final index = tabs.indexWhere((candidate) => candidate.id == tab.id); final index = tabs.indexWhere((candidate) => candidate.id == tab.id);
final extent = _tabExtent(tabs.length); final extent = _rowExtent;
if (index == -1 || extent == null) return null; if (index == -1 || extent == null) return null;
final offset = (index + 1) * extent - _tabRowGap - viewportHeight; 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) { void _lineUpSelectedTab(double? offset) {
@@ -152,12 +164,12 @@ class _TabPanelState extends State<TabPanel> {
_searchQuery = ''; _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 tab = widget.selectedTab;
final oldTab = oldWidget.selectedTab; final oldTab = oldWidget.selectedTab;
if (tab != null && if (tab != null &&
tab.id == oldTab?.id && (tab.id != oldTab?.id || tab.itemCount != oldTab?.itemCount)) {
tab.itemCount != oldTab?.itemCount) {
_focus = _PanelFocus.items; _focus = _PanelFocus.items;
} }
} }
@@ -169,14 +181,6 @@ class _TabPanelState extends State<TabPanel> {
super.dispose(); 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 /// Scrolls to the first tab at or after [letter], so a letter without names
/// lands on the next one. /// lands on the next one.
void _jumpToLetter(String letter, List<BarTab> tabs) { void _jumpToLetter(String letter, List<BarTab> tabs) {
@@ -186,8 +190,8 @@ class _TabPanelState extends State<TabPanel> {
setState(() => _touchedLetter = letter); setState(() => _touchedLetter = letter);
} }
final extent = _tabExtent(tabs.length); final extent = _rowExtent;
if (extent == null) return; if (extent == null || !_tabsScrollController.hasClients) return;
final position = _tabsScrollController.position; final position = _tabsScrollController.position;
final start = indexLetters.indexOf(letter); final start = indexLetters.indexOf(letter);
@@ -205,8 +209,8 @@ class _TabPanelState extends State<TabPanel> {
String? _visibleLetter(List<BarTab> tabs) { String? _visibleLetter(List<BarTab> tabs) {
if (tabs.isEmpty) return null; if (tabs.isEmpty) return null;
final extent = _tabExtent(tabs.length); final extent = _rowExtent;
final index = extent == null final index = extent == null || !_tabsScrollController.hasClients
? 0 ? 0
: (_tabsScrollController.offset / extent).round().clamp( : (_tabsScrollController.offset / extent).round().clamp(
0, 0,
@@ -226,6 +230,7 @@ class _TabPanelState extends State<TabPanel> {
_searchQuery.toLowerCase(), _searchQuery.toLowerCase(),
); );
}).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName)); }).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName));
_measureRow();
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration(color: scheme.surfaceContainerLow), decoration: BoxDecoration(color: scheme.surfaceContainerLow),
@@ -236,7 +241,7 @@ class _TabPanelState extends State<TabPanel> {
children: [ children: [
ListenableBuilder( ListenableBuilder(
listenable: _tabsScrollController, listenable: _tabsScrollController,
builder: (context, child) => _LetterIndexBar( builder: (context, child) => LetterIndexBar(
availableLetters: { availableLetters: {
for (final tab in filteredTabs) for (final tab in filteredTabs)
indexLetterOf(tab.customerName), indexLetterOf(tab.customerName),
@@ -262,11 +267,30 @@ class _TabPanelState extends State<TabPanel> {
? _PanelFocus.balanced ? _PanelFocus.balanced
: _focus; : _focus;
final searchHeight = widget.showSearch ? _searchHeight : 0.0; final searchHeight = widget.showSearch ? _searchHeight : 0.0;
final namesHeight = switch (focus) { // Without a selected tab there are no items to show, so
_PanelFocus.balanced => min(246.0, available / 2), // the names get the whole panel
_PanelFocus.names => available - collapsedItems, final hasSelection = widget.selectedTab != null;
_PanelFocus.items => collapsedNames + searchHeight, 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( final attachOffset = _attachOffset(
filteredTabs, filteredTabs,
@@ -335,12 +359,19 @@ class _TabPanelState extends State<TabPanel> {
}, },
child: OpenTabsList( child: OpenTabsList(
controller: _tabsScrollController, controller: _tabsScrollController,
firstRowKey: _firstRowKey,
tabs: filteredTabs, tabs: filteredTabs,
selectedTabId: widget.selectedTabId, selectedTabId: widget.selectedTabId,
attachedTabId: attached attachedTabId: attached
? widget.selectedTabId ? widget.selectedTabId
: null, : null,
onTabSelected: (id) { onTabSelected: (id) {
// Tapping the open tab again closes it
if (id == widget.selectedTabId) {
_focusOn(_PanelFocus.names);
widget.onTabDeselected();
return;
}
_focusOn(_PanelFocus.items); _focusOn(_PanelFocus.items);
widget.onTabSelected(id); widget.onTabSelected(id);
}, },
@@ -352,7 +383,8 @@ class _TabPanelState extends State<TabPanel> {
], ],
), ),
), ),
if (!attached) const Divider(height: dividerHeight), if (!attached && hasSelection)
const Divider(height: dividerHeight),
Expanded( Expanded(
child: Listener( child: Listener(
onPointerDown: (_) => _focusOn(_PanelFocus.items), onPointerDown: (_) => _focusOn(_PanelFocus.items),
@@ -434,6 +466,9 @@ class _TabPanelState extends State<TabPanel> {
class OpenTabsList extends StatelessWidget { class OpenTabsList extends StatelessWidget {
final ScrollController? controller; final ScrollController? controller;
/// Put on the first row, so its height can be measured.
final Key? firstRowKey;
final List<BarTab> tabs; final List<BarTab> tabs;
final String? selectedTabId; final String? selectedTabId;
@@ -446,6 +481,7 @@ class OpenTabsList extends StatelessWidget {
const OpenTabsList({ const OpenTabsList({
super.key, super.key,
this.controller, this.controller,
this.firstRowKey,
required this.tabs, required this.tabs,
required this.selectedTabId, required this.selectedTabId,
this.attachedTabId, this.attachedTabId,
@@ -477,6 +513,7 @@ class OpenTabsList extends StatelessWidget {
final attached = tab.id == attachedTabId; final attached = tab.id == attachedTabId;
return _TabRow( return _TabRow(
key: index == 0 ? firstRowKey : null,
child: ClipRRect( child: ClipRRect(
borderRadius: attached borderRadius: attached
? _attachedTileRadius ? _attachedTileRadius
@@ -520,7 +557,7 @@ class OpenTabsList extends StatelessWidget {
class _TabRow extends StatelessWidget { class _TabRow extends StatelessWidget {
final Widget child; final Widget child;
const _TabRow({required this.child}); const _TabRow({super.key, required this.child});
@override @override
Widget build(BuildContext context) { 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<String> availableLetters; final Set<String> availableLetters;
final String? activeLetter; final String? activeLetter;
final bool isTouched; final bool isTouched;
final ValueChanged<String> onLetterSelected; final ValueChanged<String> onLetterSelected;
final VoidCallback onReleased; final VoidCallback onReleased;
const _LetterIndexBar({ const LetterIndexBar({
super.key,
required this.availableLetters, required this.availableLetters,
required this.activeLetter, required this.activeLetter,
required this.isTouched, required this.isTouched,
@@ -619,10 +658,10 @@ class _LetterIndexBar extends StatefulWidget {
}); });
@override @override
State<_LetterIndexBar> createState() => _LetterIndexBarState(); State<LetterIndexBar> createState() => _LetterIndexBarState();
} }
class _LetterIndexBarState extends State<_LetterIndexBar> class _LetterIndexBarState extends State<LetterIndexBar>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
static final _zIndex = indexLetters.indexOf('Z'); static final _zIndex = indexLetters.indexOf('Z');
+17
View File
@@ -1,3 +1,6 @@
import 'dart:math';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -6,6 +9,7 @@ import '../../l10n/app_localizations.dart';
import '../../models/payment_method.dart'; import '../../models/payment_method.dart';
import '../../utils/money.dart'; import '../../utils/money.dart';
import '../../viewmodels/bar_screen_view_model.dart'; import '../../viewmodels/bar_screen_view_model.dart';
import '../widgets/money_rain.dart';
import '../widgets/slide_confirm.dart'; import '../widgets/slide_confirm.dart';
Future<void> confirmCloseTab(BuildContext context) async { Future<void> confirmCloseTab(BuildContext context) async {
@@ -73,6 +77,9 @@ Future<void> confirmCloseTab(BuildContext context) async {
try { try {
await viewModel.closeSelectedTab(paymentMethod: paymentMethod); 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 (_) { } catch (_) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(
@@ -81,6 +88,16 @@ Future<void> 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'; const _payconiqQrAsset = 'assets/payconiq/qr.png';
/// Shows the Payconiq QR code for the customer to scan. Resolves to true /// Shows the Payconiq QR code for the customer to scan. Resolves to true
-63
View File
@@ -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<String?> showNewTabDialog(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final controller = TextEditingController();
final name = await showDialog<String>(
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<BarScreenViewModel>();
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;
}
}
+328
View File
@@ -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<String?> showOpenTabDialog(BuildContext context) async {
final viewModel = context.read<BarScreenViewModel>();
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<BarTab> 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<BarTab> 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<String?> _addCustomer(
BuildContext context, {
required String initialName,
}) async {
final l10n = AppLocalizations.of(context);
final nameController = TextEditingController(text: initialName);
final emailController = TextEditingController();
final formKey = GlobalKey<FormState>();
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<BarScreenViewModel>();
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;
}
}
+8 -225
View File
@@ -6,8 +6,9 @@ import '../../models/closed_tab.dart';
import '../../models/closed_tab_item.dart'; import '../../models/closed_tab_item.dart';
import '../../models/payment_method.dart'; import '../../models/payment_method.dart';
import '../../utils/easter_eggs.dart'; import '../../utils/easter_eggs.dart';
import '../../utils/history_grouping.dart';
import '../../utils/money.dart'; import '../../utils/money.dart';
import '../../utils/tab_orders.dart';
import 'order_history_list.dart';
class ClosedTabCard extends StatefulWidget { class ClosedTabCard extends StatefulWidget {
final ClosedTab closedTab; final ClosedTab closedTab;
@@ -35,10 +36,6 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag(); final locale = Localizations.localeOf(context).toLanguageTag();
final dateFormat = DateFormat.yMMMd(locale).add_jm(); final dateFormat = DateFormat.yMMMd(locale).add_jm();
final dayGroups = groupClosedTabItemsByDay(
closedTab.items,
fallbackDate: closedTab.closedAt,
);
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return Container( return Container(
@@ -159,16 +156,12 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
children: [ children: [
const Divider(height: 1), const Divider(height: 1),
const SizedBox(height: 8), const SizedBox(height: 8),
for (var index = 0; index < dayGroups.length; index++) OrderHistoryList(
_ClosedTabDayDropdown( orders: ordersOfClosedTab(closedTab),
key: ValueKey( productName: (ClosedTabItem item) => item.productName,
'${dayGroups[index].items.first.closedTabId}-' unitPriceInCents: (ClosedTabItem item) =>
'${dayGroups[index].day.toIso8601String()}', item.unitPriceInCents,
), ),
group: dayGroups[index],
fallbackDate: closedTab.closedAt,
initiallyExpanded: index == 0,
),
], ],
), ),
), ),
@@ -179,213 +172,3 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
); );
} }
} }
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,
),
),
),
],
],
),
);
}
}
+135
View File
@@ -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<double> animation;
final List<_Drop> drops;
final List<TextPainter> _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;
}
+9 -118
View File
@@ -3,9 +3,11 @@ import 'package:intl/intl.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart'; import '../../models/bar_tab.dart';
import '../../models/tab_item_purchase.dart'; import '../../models/tab_item.dart';
import '../../utils/easter_eggs.dart'; import '../../utils/easter_eggs.dart';
import '../../utils/money.dart'; import '../../utils/money.dart';
import '../../utils/tab_orders.dart';
import 'order_history_list.dart';
class OpenTabHistoryCard extends StatefulWidget { class OpenTabHistoryCard extends StatefulWidget {
final BarTab tab; final BarTab tab;
@@ -19,42 +21,6 @@ class OpenTabHistoryCard extends StatefulWidget {
class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> { class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
bool _expanded = false; bool _expanded = false;
List<_OpenPurchase> _purchases(BarTab tab) {
final purchases = <_OpenPurchase>[];
for (final item in tab.items) {
final records = item.purchases.isEmpty
? List<TabItemPurchase>.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tab = widget.tab; final tab = widget.tab;
@@ -163,9 +129,12 @@ class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
children: [ children: [
const Divider(height: 1), const Divider(height: 1),
const SizedBox(height: 8), const SizedBox(height: 8),
..._purchases( OrderHistoryList(
tab, orders: ordersOfTab(tab, includeRemoved: true),
).map((purchase) => _OpenPurchaseRow(purchase: purchase)), productName: (TabItem item) => item.productName,
unitPriceInCents: (TabItem item) =>
item.unitPriceInCents,
),
], ],
), ),
), ),
@@ -178,81 +147,3 @@ class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
); );
} }
} }
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,
),
],
),
);
}
}
+77
View File
@@ -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,
);
}
}
+105
View File
@@ -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<L> extends StatelessWidget {
final List<TabOrder<L>> 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),
),
],
),
);
}
}
+64
View File
@@ -41,6 +41,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" 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: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -970,6 +1026,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5
url: "https://pub.dev"
source: hosted
version: "3.5.0"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
+3 -1
View File
@@ -2,7 +2,7 @@ name: kooltap
description: "KoolTap: a tablet point-of-sale app for running bar tabs." description: "KoolTap: a tablet point-of-sale app for running bar tabs."
publish_to: 'none' publish_to: 'none'
version: 1.4.0+1 version: 1.4.4+0
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2
@@ -13,6 +13,7 @@ dependencies:
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
audioplayers: ^6.8.1
collection: ^1.19.1 collection: ^1.19.1
crypto: ^3.0.0 crypto: ^3.0.0
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
@@ -50,6 +51,7 @@ flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/Cha-Ching.mp3
- assets/icons/payconic.svg - assets/icons/payconic.svg
- assets/payconiq/ - assets/payconiq/
- assets/products/ - assets/products/
+86 -20
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:kooltap/models/bar_tab.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/models/tab_item.dart';
import 'package:kooltap/services/bar_tab_service.dart'; import 'package:kooltap/services/bar_tab_service.dart';
import 'package:kooltap/viewmodels/bar_screen_view_model.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 {} class MockBarTabService extends Mock implements BarTabService {}
BarTab _tab(String id) { BarTab _tab(String id, {int drinks = 0}) {
return BarTab( return BarTab(
id: id, id: id,
customerName: id, customerName: id,
openedAt: DateTime(2026), 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(); 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(); viewModel.ensureLoaded();
expect(viewModel.isLoading, true); expect(viewModel.isLoading, true);
@@ -46,8 +57,62 @@ void main() {
await pumpEventQueue(); await pumpEventQueue();
expect(viewModel.isLoading, false); expect(viewModel.isLoading, false);
expect(viewModel.tabs.map((tab) => tab.id), ['a', 'b']); expect(viewModel.tabs, isEmpty);
expect(viewModel.selectedTabId, 'a'); 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 { test('keeps the selection while that tab still exists', () async {
@@ -62,20 +127,17 @@ void main() {
expect(viewModel.selectedTabId, 'b'); expect(viewModel.selectedTabId, 'b');
}); });
test( test('selects nothing when the selected tab disappears', () async {
'falls back to the first tab when the selected one disappears', viewModel.ensureLoaded();
() async { openTabs.add([_tab('a'), _tab('b')]);
viewModel.ensureLoaded(); await pumpEventQueue();
openTabs.add([_tab('a'), _tab('b')]); viewModel.selectTab('b');
await pumpEventQueue();
viewModel.selectTab('b');
openTabs.add([_tab('a')]); openTabs.add([_tab('a')]);
await pumpEventQueue(); await pumpEventQueue();
expect(viewModel.selectedTabId, 'a'); expect(viewModel.selectedTabId, isNull);
}, });
);
test('exposes a load error when the stream fails', () async { test('exposes a load error when the stream fails', () async {
viewModel.ensureLoaded(); viewModel.ensureLoaded();
@@ -97,11 +159,15 @@ void main() {
); );
when( 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')); ).thenAnswer((_) async => throw const TabNotFoundException('tab-1'));
await expectLater( await expectLater(
viewModel.changeItemQuantity(item, 1), viewModel.changeItemQuantity(item, 1, null),
throwsA(isA<TabNotFoundException>()), throwsA(isA<TabNotFoundException>()),
); );
@@ -110,7 +176,7 @@ void main() {
test('selects a newly created tab', () async { test('selects a newly created tab', () async {
when( when(
() => barTabService.createTab(customerName: 'New'), () => barTabService.createTab(customerName: 'New', email: null),
).thenAnswer((_) async => _tab('new')); ).thenAnswer((_) async => _tab('new'));
await viewModel.createTab('New'); await viewModel.createTab('New');
+11 -5
View File
@@ -103,10 +103,16 @@ void main() {
expect((await database.select(database.products).get()), hasLength(1)); expect((await database.select(database.products).get()), hasLength(1));
}); });
test('gives products no server picture yet and an empty upload queue', () async { test(
final product = await database.select(database.products).getSingle(); '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(product.imageId, isNull);
expect(await database.select(database.pendingImageUploads).get(), isEmpty); expect(
}); await database.select(database.pendingImageUploads).get(),
isEmpty,
);
},
);
} }
-35
View File
@@ -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,
);
}
+34
View File
@@ -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);
});
}
+3
View File
@@ -694,6 +694,7 @@ void main() {
productName: 'Cola', productName: 'Cola',
unitPriceInCents: 250, unitPriceInCents: 250,
quantity: 2, quantity: 2,
orderId: 'order-1',
); );
expect(event.payload['occurredAt'], '2026-10-24T21:41:03.120Z'); expect(event.payload['occurredAt'], '2026-10-24T21:41:03.120Z');
@@ -723,6 +724,7 @@ class _FixedIdEventFactory extends TabEventFactory {
required String productName, required String productName,
required int unitPriceInCents, required int unitPriceInCents,
required int quantity, required int quantity,
required String? orderId,
}) { }) {
final event = super.lineAdded( final event = super.lineAdded(
tabId: tabId, tabId: tabId,
@@ -732,6 +734,7 @@ class _FixedIdEventFactory extends TabEventFactory {
productName: productName, productName: productName,
unitPriceInCents: unitPriceInCents, unitPriceInCents: unitPriceInCents,
quantity: quantity, quantity: quantity,
orderId: orderId,
); );
return TabEvent(id: 'fixed', payload: {...event.payload, 'id': 'fixed'}); return TabEvent(id: 'fixed', payload: {...event.payload, 'id': 'fixed'});
} }
+509
View File
@@ -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<String> _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<List<Object?>> _summary(List<TabOrder<String>> 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<List<Map<String, dynamic>>> 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<String, dynamic>,
];
}
/// 2 Duvel and 1 Cola in order o1, then 1 Duvel in o2.
Future<BarTab> 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<void>.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 = <String, List<TabItemPurchase>>{};
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<List<BarTab>> openTabs;
late DateTime now;
late BarScreenViewModel viewModel;
final orderIds = <String?>[];
setUp(() async {
barTabService = MockBarTabService();
openTabs = StreamController<List<BarTab>>.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));
});
});
}
+41 -4
View File
@@ -45,6 +45,7 @@ void main() {
WidgetTester tester, { WidgetTester tester, {
bool showSearch = false, bool showSearch = false,
BarTab? selectedTab, BarTab? selectedTab,
List<BarTab>? panelTabs,
}) async { }) async {
tester.view.physicalSize = const Size(500, 800); tester.view.physicalSize = const Size(500, 800);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@@ -56,12 +57,13 @@ void main() {
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold( home: Scaffold(
body: TabPanel( body: TabPanel(
tabs: tabs, tabs: panelTabs ?? tabs,
selectedTab: selectedTab, selectedTab: selectedTab,
selectedTabId: selectedTab?.id, selectedTabId: selectedTab?.id,
showSearch: showSearch, showSearch: showSearch,
onTabSelected: (_) {}, onTabSelected: (_) {},
onItemQuantityChanged: (_, _) async {}, onTabDeselected: () {},
onItemQuantityChanged: (_, _, _) async {},
onCloseTabPressed: () {}, onCloseTabPressed: () {},
onTabEdited: (_) async {}, onTabEdited: (_) async {},
onTabDeleted: (_) async {}, onTabDeleted: (_) async {},
@@ -92,7 +94,7 @@ void main() {
await tester.tap( await tester.tap(
find.descendant( find.descendant(
of: find.byWidgetPredicate( of: find.byWidgetPredicate(
(widget) => widget.runtimeType.toString() == '_LetterIndexBar', (widget) => widget.runtimeType.toString() == 'LetterIndexBar',
), ),
matching: find.text(letter), 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 { testWidgets('browsing the names shows the divider again', (tester) async {
final marc = tabs.firstWhere((tab) => tab.customerName == 'Marc'); final marc = tabs.firstWhere((tab) => tab.customerName == 'Marc');
await pumpPanel(tester, selectedTab: marc); await pumpPanel(tester, selectedTab: marc);
@@ -221,7 +257,8 @@ void main() {
}); });
testWidgets('a letter without names goes to the next one', (tester) async { 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'); await tapLetter(tester, 'Q');
expect(firstVisibleName(tester), 'Rik'); expect(firstVisibleName(tester), 'Rik');