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:
Binary file not shown.
@@ -21,7 +21,7 @@ Common fields (already sent): `id`, `type`, `tabId`, `customerName`, `occurredAt
|
||||
|
||||
| `type` | Extra fields | New? |
|
||||
|---|---|---|
|
||||
| `tab_opened` | `openedAt` | **new** |
|
||||
| `tab_opened` | `openedAt`, `email?` | **new** (`email` newer, see section 6) |
|
||||
| `tab_renamed` | — (`customerName` is the new name) | **new** |
|
||||
| `tab_deleted` | — (always preceded by a `line_removed` for every line still on the tab) | **new** |
|
||||
| `line_added` | `lineId`, `productId`, `productName`, `unitPriceInCents`, `quantity` (≥ 1) | `lineId`, `productId` new |
|
||||
@@ -30,10 +30,15 @@ Common fields (already sent): `id`, `type`, `tabId`, `customerName`, `occurredAt
|
||||
|
||||
Events created by an older app version may lack `lineId` / `productId`; match those by name (see below).
|
||||
|
||||
`line_added` and `line_removed` also carry `orderId`: the order the drinks belong to (drinks rung up on one tab
|
||||
without a pause of 5 minutes, and without leaving the tab in between). Each unit stores it as `orderId`; units from
|
||||
older app versions have none and are grouped by time (a gap of over 5 minutes starts a new order). On `line_removed`
|
||||
the field is optional: present (also `null`), only units of that order count; absent, any unit of the line.
|
||||
|
||||
### 2. The venue's tab state
|
||||
|
||||
```
|
||||
Tab { id, customerName, openedAt, version, lines: Line[] }
|
||||
Tab { id, customerName, email | null, openedAt, version, lines: Line[] }
|
||||
Line { id, productId, productName, unitPriceInCents, quantity, units: Unit[] }
|
||||
Unit { id, addedAt, removedAt | null } // one per piece ever put on the line
|
||||
```
|
||||
@@ -48,11 +53,11 @@ Unit { id, addedAt, removedAt | null } // one per piece ever put on the lin
|
||||
|
||||
| Event | Rule |
|
||||
|---|---|
|
||||
| `tab_opened` | Create the tab with `tabId`, `customerName`, `openedAt` if it doesn't exist. Duplicate names are allowed (two offline tablets opening "Jan" give two tabs). |
|
||||
| `tab_opened` | Create the tab with `tabId`, `customerName`, `openedAt` and `email` (absent → `null`) if it doesn't exist. Duplicate names are allowed (two offline tablets opening "Jan" give two tabs). |
|
||||
| `tab_renamed` | Set `customerName`. Unknown tab → ignore (acknowledge). |
|
||||
| `tab_deleted` | Remove the tab and its lines. Unknown tab → ignore. |
|
||||
| `line_added` | If the tab doesn't exist (deleted elsewhere), **re-create it** from `tabId` + `customerName` (`openedAt = occurredAt`), so no sale is lost. Find the line; none → create it with `id = lineId` (or a new id if that id is taken). Add `quantity` units with `id = "<event id>:<index>"` (index 0…quantity−1) and `addedAt = occurredAt`. |
|
||||
| `line_removed` | Find the line; none → ignore. Mark the `min(quantity, line.quantity)` **latest** active units removed (`removedAt = occurredAt`). |
|
||||
| `line_added` | If the tab doesn't exist (deleted elsewhere), **re-create it** from `tabId` + `customerName` (`openedAt = occurredAt`), so no sale is lost. Find the line; none → create it with `id = lineId` (or a new id if that id is taken). Add `quantity` units with `id = "<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, n)` **latest** active units removed (`removedAt = occurredAt`), where `n` counts the line's active units of the event's `orderId` (all of them when the event has no `orderId`). |
|
||||
| `settled` | For each item: find the line; take `min(item.quantity, line.quantity)` of the **oldest** active units off it, together with the line's removed units; drop the line when nothing is left. Drinks added after the settlement (by another tablet) **stay on the tab**. Record a settlement (section 4) with id = the event's `id`. The tab itself stays open (tabs are never closed, only emptied). |
|
||||
|
||||
The unit ids **must** follow `"<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
|
||||
{
|
||||
"tabs": [
|
||||
{ "id": "…", "customerName": "Alice", "openedAt": "2026-10-24T20:00:00.000Z", "version": 7,
|
||||
{ "id": "…", "customerName": "Alice", "email": "alice@example.com", "openedAt": "2026-10-24T20:00:00.000Z", "version": 7,
|
||||
"lines": [
|
||||
{ "id": "…", "productId": "12", "productName": "Cola", "unitPriceInCents": 250, "quantity": 1,
|
||||
"units": [ { "id": "<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
|
||||
`plannedTabNames` (otherwise every tablet would open them).
|
||||
|
||||
### 6. Customer email
|
||||
|
||||
Since the Open Tab dialog, the bartender can add a customer with a name and an **optional email**. The app sends it
|
||||
on the `tab_opened` event:
|
||||
|
||||
```json
|
||||
{ "id": "…", "type": "tab_opened", "tabId": "…", "customerName": "Alice", "occurredAt": "…",
|
||||
"openedAt": "…", "email": "alice@example.com" }
|
||||
```
|
||||
|
||||
- `email` is **left out** (not `null`) when none was entered, and on every event from older app versions. The app trims
|
||||
it and checks it looks like `name@domain.tld`; validate again if you rely on it, and reject nothing over it (store
|
||||
`null` instead of refusing the event).
|
||||
- Store it on the tab and return it as `email` in `tabs` (`null` or absent when there is none).
|
||||
- There is no event to change or remove an email yet; the dashboard is the place for that if needed.
|
||||
- Until the server returns `email`, the tablet keeps the one entered on it (by tab id) across syncs, but other
|
||||
tablets don't see it. Note that the tablet treats `null`/absent as "unknown" and keeps its own value, so clearing an
|
||||
email from the dashboard won't reach a tablet that has one stored.
|
||||
- The email is personal data: show it only to venue staff, and delete it with the tab.
|
||||
|
||||
### Order within one sync
|
||||
|
||||
1. Apply the received events (once per id), oldest first; acknowledge/reject them.
|
||||
@@ -138,3 +163,5 @@ Response:
|
||||
- Settle on the dashboard with every tablet off: the tab is emptied at once; the tablets show it in History after their
|
||||
next sync, exactly once.
|
||||
- The same event id sent twice (also in two syncs) is applied once.
|
||||
- Add a customer with an email on tablet A: after both synced, tablet B shows the email under the name in the Open Tab
|
||||
dialog. A customer added without email has `email: null` in `tabs`.
|
||||
|
||||
+1
-2
@@ -168,8 +168,7 @@ GoRouter createAppRouter({
|
||||
onSet: adminPinViewModel.setPin,
|
||||
error: () => adminPinViewModel.error,
|
||||
onSuccess: () => context.go(_returnLocation(state) ?? '/admin'),
|
||||
onClose: () =>
|
||||
context.canPop() ? context.pop() : context.go('/bar'),
|
||||
onClose: () => context.canPop() ? context.pop() : context.go('/bar'),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ class BarTabs extends Table {
|
||||
|
||||
TextColumn get customerName => text()();
|
||||
|
||||
/// Optional, entered when the customer was added.
|
||||
TextColumn get email => text().nullable()();
|
||||
|
||||
DateTimeColumn get openedAt => dateTime()();
|
||||
|
||||
IntColumn get version => integer().withDefault(const Constant(0))();
|
||||
@@ -95,6 +98,10 @@ class TabItemPurchases extends Table {
|
||||
|
||||
DateTimeColumn get removedAt => dateTime().nullable()();
|
||||
|
||||
/// The order this drink was rung up in: drinks ordered together are shown
|
||||
/// together. Null for drinks from before orders were tracked.
|
||||
TextColumn get orderId => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -135,6 +142,8 @@ class ClosedTabItems extends Table {
|
||||
|
||||
DateTimeColumn get removedAt => dateTime().nullable()();
|
||||
|
||||
TextColumn get orderId => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -268,7 +277,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 18;
|
||||
int get schemaVersion => 20;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -358,7 +367,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
// Tabs never close (only their items are archived), so the status
|
||||
// and closed_at columns were never anything but 'open' and null.
|
||||
if (await hasTableColumn('bar_tabs', 'status')) {
|
||||
await migrator.alterTable(TableMigration(barTabs));
|
||||
// `email` only arrives in v20
|
||||
await migrator.alterTable(
|
||||
TableMigration(barTabs, newColumns: [barTabs.email]),
|
||||
);
|
||||
}
|
||||
await _backfillPurchaseRows();
|
||||
await _deleteOrphanedPurchaseRows();
|
||||
@@ -391,6 +403,25 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
await migrator.createTable(pendingImageUploads);
|
||||
}
|
||||
|
||||
if (from < 19) {
|
||||
// `id`: the table exists
|
||||
if (await hasTableColumn('tab_item_purchases', 'id') &&
|
||||
!await hasTableColumn('tab_item_purchases', 'order_id')) {
|
||||
await migrator.addColumn(
|
||||
tabItemPurchases,
|
||||
tabItemPurchases.orderId,
|
||||
);
|
||||
}
|
||||
if (await hasTableColumn('closed_tab_items', 'id') &&
|
||||
!await hasTableColumn('closed_tab_items', 'order_id')) {
|
||||
await migrator.addColumn(closedTabItems, closedTabItems.orderId);
|
||||
}
|
||||
}
|
||||
|
||||
if (from < 20 && !await hasTableColumn('bar_tabs', 'email')) {
|
||||
await migrator.addColumn(barTabs, barTabs.email);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -663,6 +663,15 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _emailMeta = const VerificationMeta('email');
|
||||
@override
|
||||
late final GeneratedColumn<String> email = GeneratedColumn<String>(
|
||||
'email',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _openedAtMeta = const VerificationMeta(
|
||||
'openedAt',
|
||||
);
|
||||
@@ -687,7 +696,13 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
|
||||
defaultValue: const Constant(0),
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [id, customerName, openedAt, version];
|
||||
List<GeneratedColumn> get $columns => [
|
||||
id,
|
||||
customerName,
|
||||
email,
|
||||
openedAt,
|
||||
version,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
@@ -716,6 +731,12 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
|
||||
} else if (isInserting) {
|
||||
context.missing(_customerNameMeta);
|
||||
}
|
||||
if (data.containsKey('email')) {
|
||||
context.handle(
|
||||
_emailMeta,
|
||||
email.isAcceptableOrUnknown(data['email']!, _emailMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('opened_at')) {
|
||||
context.handle(
|
||||
_openedAtMeta,
|
||||
@@ -747,6 +768,10 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}customer_name'],
|
||||
)!,
|
||||
email: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}email'],
|
||||
),
|
||||
openedAt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}opened_at'],
|
||||
@@ -767,11 +792,15 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
|
||||
class BarTabRow extends DataClass implements Insertable<BarTabRow> {
|
||||
final String id;
|
||||
final String customerName;
|
||||
|
||||
/// Optional, entered when the customer was added.
|
||||
final String? email;
|
||||
final DateTime openedAt;
|
||||
final int version;
|
||||
const BarTabRow({
|
||||
required this.id,
|
||||
required this.customerName,
|
||||
this.email,
|
||||
required this.openedAt,
|
||||
required this.version,
|
||||
});
|
||||
@@ -780,6 +809,9 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
|
||||
final map = <String, Expression>{};
|
||||
map['id'] = Variable<String>(id);
|
||||
map['customer_name'] = Variable<String>(customerName);
|
||||
if (!nullToAbsent || email != null) {
|
||||
map['email'] = Variable<String>(email);
|
||||
}
|
||||
map['opened_at'] = Variable<DateTime>(openedAt);
|
||||
map['version'] = Variable<int>(version);
|
||||
return map;
|
||||
@@ -789,6 +821,9 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
|
||||
return BarTabsCompanion(
|
||||
id: Value(id),
|
||||
customerName: Value(customerName),
|
||||
email: email == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(email),
|
||||
openedAt: Value(openedAt),
|
||||
version: Value(version),
|
||||
);
|
||||
@@ -802,6 +837,7 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
|
||||
return BarTabRow(
|
||||
id: serializer.fromJson<String>(json['id']),
|
||||
customerName: serializer.fromJson<String>(json['customerName']),
|
||||
email: serializer.fromJson<String?>(json['email']),
|
||||
openedAt: serializer.fromJson<DateTime>(json['openedAt']),
|
||||
version: serializer.fromJson<int>(json['version']),
|
||||
);
|
||||
@@ -812,6 +848,7 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
|
||||
return <String, dynamic>{
|
||||
'id': serializer.toJson<String>(id),
|
||||
'customerName': serializer.toJson<String>(customerName),
|
||||
'email': serializer.toJson<String?>(email),
|
||||
'openedAt': serializer.toJson<DateTime>(openedAt),
|
||||
'version': serializer.toJson<int>(version),
|
||||
};
|
||||
@@ -820,11 +857,13 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
|
||||
BarTabRow copyWith({
|
||||
String? id,
|
||||
String? customerName,
|
||||
Value<String?> email = const Value.absent(),
|
||||
DateTime? openedAt,
|
||||
int? version,
|
||||
}) => BarTabRow(
|
||||
id: id ?? this.id,
|
||||
customerName: customerName ?? this.customerName,
|
||||
email: email.present ? email.value : this.email,
|
||||
openedAt: openedAt ?? this.openedAt,
|
||||
version: version ?? this.version,
|
||||
);
|
||||
@@ -834,6 +873,7 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
|
||||
customerName: data.customerName.present
|
||||
? data.customerName.value
|
||||
: this.customerName,
|
||||
email: data.email.present ? data.email.value : this.email,
|
||||
openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt,
|
||||
version: data.version.present ? data.version.value : this.version,
|
||||
);
|
||||
@@ -844,6 +884,7 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
|
||||
return (StringBuffer('BarTabRow(')
|
||||
..write('id: $id, ')
|
||||
..write('customerName: $customerName, ')
|
||||
..write('email: $email, ')
|
||||
..write('openedAt: $openedAt, ')
|
||||
..write('version: $version')
|
||||
..write(')'))
|
||||
@@ -851,13 +892,14 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, customerName, openedAt, version);
|
||||
int get hashCode => Object.hash(id, customerName, email, openedAt, version);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is BarTabRow &&
|
||||
other.id == this.id &&
|
||||
other.customerName == this.customerName &&
|
||||
other.email == this.email &&
|
||||
other.openedAt == this.openedAt &&
|
||||
other.version == this.version);
|
||||
}
|
||||
@@ -865,12 +907,14 @@ class BarTabRow extends DataClass implements Insertable<BarTabRow> {
|
||||
class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
|
||||
final Value<String> id;
|
||||
final Value<String> customerName;
|
||||
final Value<String?> email;
|
||||
final Value<DateTime> openedAt;
|
||||
final Value<int> version;
|
||||
final Value<int> rowid;
|
||||
const BarTabsCompanion({
|
||||
this.id = const Value.absent(),
|
||||
this.customerName = const Value.absent(),
|
||||
this.email = const Value.absent(),
|
||||
this.openedAt = const Value.absent(),
|
||||
this.version = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
@@ -878,6 +922,7 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
|
||||
BarTabsCompanion.insert({
|
||||
required String id,
|
||||
required String customerName,
|
||||
this.email = const Value.absent(),
|
||||
required DateTime openedAt,
|
||||
this.version = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
@@ -887,6 +932,7 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
|
||||
static Insertable<BarTabRow> custom({
|
||||
Expression<String>? id,
|
||||
Expression<String>? customerName,
|
||||
Expression<String>? email,
|
||||
Expression<DateTime>? openedAt,
|
||||
Expression<int>? version,
|
||||
Expression<int>? rowid,
|
||||
@@ -894,6 +940,7 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
|
||||
return RawValuesInsertable({
|
||||
if (id != null) 'id': id,
|
||||
if (customerName != null) 'customer_name': customerName,
|
||||
if (email != null) 'email': email,
|
||||
if (openedAt != null) 'opened_at': openedAt,
|
||||
if (version != null) 'version': version,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
@@ -903,6 +950,7 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
|
||||
BarTabsCompanion copyWith({
|
||||
Value<String>? id,
|
||||
Value<String>? customerName,
|
||||
Value<String?>? email,
|
||||
Value<DateTime>? openedAt,
|
||||
Value<int>? version,
|
||||
Value<int>? rowid,
|
||||
@@ -910,6 +958,7 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
|
||||
return BarTabsCompanion(
|
||||
id: id ?? this.id,
|
||||
customerName: customerName ?? this.customerName,
|
||||
email: email ?? this.email,
|
||||
openedAt: openedAt ?? this.openedAt,
|
||||
version: version ?? this.version,
|
||||
rowid: rowid ?? this.rowid,
|
||||
@@ -925,6 +974,9 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
|
||||
if (customerName.present) {
|
||||
map['customer_name'] = Variable<String>(customerName.value);
|
||||
}
|
||||
if (email.present) {
|
||||
map['email'] = Variable<String>(email.value);
|
||||
}
|
||||
if (openedAt.present) {
|
||||
map['opened_at'] = Variable<DateTime>(openedAt.value);
|
||||
}
|
||||
@@ -942,6 +994,7 @@ class BarTabsCompanion extends UpdateCompanion<BarTabRow> {
|
||||
return (StringBuffer('BarTabsCompanion(')
|
||||
..write('id: $id, ')
|
||||
..write('customerName: $customerName, ')
|
||||
..write('email: $email, ')
|
||||
..write('openedAt: $openedAt, ')
|
||||
..write('version: $version, ')
|
||||
..write('rowid: $rowid')
|
||||
@@ -1479,8 +1532,25 @@ class $TabItemPurchasesTable extends TabItemPurchases
|
||||
type: DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _orderIdMeta = const VerificationMeta(
|
||||
'orderId',
|
||||
);
|
||||
@override
|
||||
List<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
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
@@ -1523,6 +1593,12 @@ class $TabItemPurchasesTable extends TabItemPurchases
|
||||
removedAt.isAcceptableOrUnknown(data['removed_at']!, _removedAtMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('order_id')) {
|
||||
context.handle(
|
||||
_orderIdMeta,
|
||||
orderId.isAcceptableOrUnknown(data['order_id']!, _orderIdMeta),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1548,6 +1624,10 @@ class $TabItemPurchasesTable extends TabItemPurchases
|
||||
DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}removed_at'],
|
||||
),
|
||||
orderId: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}order_id'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1563,11 +1643,16 @@ class TabItemPurchaseRow extends DataClass
|
||||
final String tabItemId;
|
||||
final DateTime purchasedAt;
|
||||
final DateTime? removedAt;
|
||||
|
||||
/// The order this drink was rung up in: drinks ordered together are shown
|
||||
/// together. Null for drinks from before orders were tracked.
|
||||
final String? orderId;
|
||||
const TabItemPurchaseRow({
|
||||
required this.id,
|
||||
required this.tabItemId,
|
||||
required this.purchasedAt,
|
||||
this.removedAt,
|
||||
this.orderId,
|
||||
});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -1578,6 +1663,9 @@ class TabItemPurchaseRow extends DataClass
|
||||
if (!nullToAbsent || removedAt != null) {
|
||||
map['removed_at'] = Variable<DateTime>(removedAt);
|
||||
}
|
||||
if (!nullToAbsent || orderId != null) {
|
||||
map['order_id'] = Variable<String>(orderId);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1589,6 +1677,9 @@ class TabItemPurchaseRow extends DataClass
|
||||
removedAt: removedAt == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(removedAt),
|
||||
orderId: orderId == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(orderId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1602,6 +1693,7 @@ class TabItemPurchaseRow extends DataClass
|
||||
tabItemId: serializer.fromJson<String>(json['tabItemId']),
|
||||
purchasedAt: serializer.fromJson<DateTime>(json['purchasedAt']),
|
||||
removedAt: serializer.fromJson<DateTime?>(json['removedAt']),
|
||||
orderId: serializer.fromJson<String?>(json['orderId']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -1612,6 +1704,7 @@ class TabItemPurchaseRow extends DataClass
|
||||
'tabItemId': serializer.toJson<String>(tabItemId),
|
||||
'purchasedAt': serializer.toJson<DateTime>(purchasedAt),
|
||||
'removedAt': serializer.toJson<DateTime?>(removedAt),
|
||||
'orderId': serializer.toJson<String?>(orderId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1620,11 +1713,13 @@ class TabItemPurchaseRow extends DataClass
|
||||
String? tabItemId,
|
||||
DateTime? purchasedAt,
|
||||
Value<DateTime?> removedAt = const Value.absent(),
|
||||
Value<String?> orderId = const Value.absent(),
|
||||
}) => TabItemPurchaseRow(
|
||||
id: id ?? this.id,
|
||||
tabItemId: tabItemId ?? this.tabItemId,
|
||||
purchasedAt: purchasedAt ?? this.purchasedAt,
|
||||
removedAt: removedAt.present ? removedAt.value : this.removedAt,
|
||||
orderId: orderId.present ? orderId.value : this.orderId,
|
||||
);
|
||||
TabItemPurchaseRow copyWithCompanion(TabItemPurchasesCompanion data) {
|
||||
return TabItemPurchaseRow(
|
||||
@@ -1634,6 +1729,7 @@ class TabItemPurchaseRow extends DataClass
|
||||
? data.purchasedAt.value
|
||||
: this.purchasedAt,
|
||||
removedAt: data.removedAt.present ? data.removedAt.value : this.removedAt,
|
||||
orderId: data.orderId.present ? data.orderId.value : this.orderId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1643,13 +1739,15 @@ class TabItemPurchaseRow extends DataClass
|
||||
..write('id: $id, ')
|
||||
..write('tabItemId: $tabItemId, ')
|
||||
..write('purchasedAt: $purchasedAt, ')
|
||||
..write('removedAt: $removedAt')
|
||||
..write('removedAt: $removedAt, ')
|
||||
..write('orderId: $orderId')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, tabItemId, purchasedAt, removedAt);
|
||||
int get hashCode =>
|
||||
Object.hash(id, tabItemId, purchasedAt, removedAt, orderId);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
@@ -1657,7 +1755,8 @@ class TabItemPurchaseRow extends DataClass
|
||||
other.id == this.id &&
|
||||
other.tabItemId == this.tabItemId &&
|
||||
other.purchasedAt == this.purchasedAt &&
|
||||
other.removedAt == this.removedAt);
|
||||
other.removedAt == this.removedAt &&
|
||||
other.orderId == this.orderId);
|
||||
}
|
||||
|
||||
class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
|
||||
@@ -1665,12 +1764,14 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
|
||||
final Value<String> tabItemId;
|
||||
final Value<DateTime> purchasedAt;
|
||||
final Value<DateTime?> removedAt;
|
||||
final Value<String?> orderId;
|
||||
final Value<int> rowid;
|
||||
const TabItemPurchasesCompanion({
|
||||
this.id = const Value.absent(),
|
||||
this.tabItemId = const Value.absent(),
|
||||
this.purchasedAt = const Value.absent(),
|
||||
this.removedAt = const Value.absent(),
|
||||
this.orderId = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
TabItemPurchasesCompanion.insert({
|
||||
@@ -1678,6 +1779,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
|
||||
required String tabItemId,
|
||||
required DateTime purchasedAt,
|
||||
this.removedAt = const Value.absent(),
|
||||
this.orderId = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
}) : id = Value(id),
|
||||
tabItemId = Value(tabItemId),
|
||||
@@ -1687,6 +1789,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
|
||||
Expression<String>? tabItemId,
|
||||
Expression<DateTime>? purchasedAt,
|
||||
Expression<DateTime>? removedAt,
|
||||
Expression<String>? orderId,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
@@ -1694,6 +1797,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
|
||||
if (tabItemId != null) 'tab_item_id': tabItemId,
|
||||
if (purchasedAt != null) 'purchased_at': purchasedAt,
|
||||
if (removedAt != null) 'removed_at': removedAt,
|
||||
if (orderId != null) 'order_id': orderId,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
@@ -1703,6 +1807,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
|
||||
Value<String>? tabItemId,
|
||||
Value<DateTime>? purchasedAt,
|
||||
Value<DateTime?>? removedAt,
|
||||
Value<String?>? orderId,
|
||||
Value<int>? rowid,
|
||||
}) {
|
||||
return TabItemPurchasesCompanion(
|
||||
@@ -1710,6 +1815,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
|
||||
tabItemId: tabItemId ?? this.tabItemId,
|
||||
purchasedAt: purchasedAt ?? this.purchasedAt,
|
||||
removedAt: removedAt ?? this.removedAt,
|
||||
orderId: orderId ?? this.orderId,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
@@ -1729,6 +1835,9 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
|
||||
if (removedAt.present) {
|
||||
map['removed_at'] = Variable<DateTime>(removedAt.value);
|
||||
}
|
||||
if (orderId.present) {
|
||||
map['order_id'] = Variable<String>(orderId.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(rowid.value);
|
||||
}
|
||||
@@ -1742,6 +1851,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion<TabItemPurchaseRow> {
|
||||
..write('tabItemId: $tabItemId, ')
|
||||
..write('purchasedAt: $purchasedAt, ')
|
||||
..write('removedAt: $removedAt, ')
|
||||
..write('orderId: $orderId, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
.toString();
|
||||
@@ -2216,6 +2326,17 @@ class $ClosedTabItemsTable extends ClosedTabItems
|
||||
type: DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _orderIdMeta = const VerificationMeta(
|
||||
'orderId',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> orderId = GeneratedColumn<String>(
|
||||
'order_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [
|
||||
id,
|
||||
@@ -2226,6 +2347,7 @@ class $ClosedTabItemsTable extends ClosedTabItems
|
||||
unitPriceInCents,
|
||||
purchasedAt,
|
||||
removedAt,
|
||||
orderId,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -2308,6 +2430,12 @@ class $ClosedTabItemsTable extends ClosedTabItems
|
||||
removedAt.isAcceptableOrUnknown(data['removed_at']!, _removedAtMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('order_id')) {
|
||||
context.handle(
|
||||
_orderIdMeta,
|
||||
orderId.isAcceptableOrUnknown(data['order_id']!, _orderIdMeta),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -2349,6 +2477,10 @@ class $ClosedTabItemsTable extends ClosedTabItems
|
||||
DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}removed_at'],
|
||||
),
|
||||
orderId: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}order_id'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2368,6 +2500,7 @@ class ClosedTabItemRow extends DataClass
|
||||
final int unitPriceInCents;
|
||||
final DateTime? purchasedAt;
|
||||
final DateTime? removedAt;
|
||||
final String? orderId;
|
||||
const ClosedTabItemRow({
|
||||
required this.id,
|
||||
required this.closedTabId,
|
||||
@@ -2377,6 +2510,7 @@ class ClosedTabItemRow extends DataClass
|
||||
required this.unitPriceInCents,
|
||||
this.purchasedAt,
|
||||
this.removedAt,
|
||||
this.orderId,
|
||||
});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -2393,6 +2527,9 @@ class ClosedTabItemRow extends DataClass
|
||||
if (!nullToAbsent || removedAt != null) {
|
||||
map['removed_at'] = Variable<DateTime>(removedAt);
|
||||
}
|
||||
if (!nullToAbsent || orderId != null) {
|
||||
map['order_id'] = Variable<String>(orderId);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -2410,6 +2547,9 @@ class ClosedTabItemRow extends DataClass
|
||||
removedAt: removedAt == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(removedAt),
|
||||
orderId: orderId == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(orderId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2427,6 +2567,7 @@ class ClosedTabItemRow extends DataClass
|
||||
unitPriceInCents: serializer.fromJson<int>(json['unitPriceInCents']),
|
||||
purchasedAt: serializer.fromJson<DateTime?>(json['purchasedAt']),
|
||||
removedAt: serializer.fromJson<DateTime?>(json['removedAt']),
|
||||
orderId: serializer.fromJson<String?>(json['orderId']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -2441,6 +2582,7 @@ class ClosedTabItemRow extends DataClass
|
||||
'unitPriceInCents': serializer.toJson<int>(unitPriceInCents),
|
||||
'purchasedAt': serializer.toJson<DateTime?>(purchasedAt),
|
||||
'removedAt': serializer.toJson<DateTime?>(removedAt),
|
||||
'orderId': serializer.toJson<String?>(orderId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2453,6 +2595,7 @@ class ClosedTabItemRow extends DataClass
|
||||
int? unitPriceInCents,
|
||||
Value<DateTime?> purchasedAt = const Value.absent(),
|
||||
Value<DateTime?> removedAt = const Value.absent(),
|
||||
Value<String?> orderId = const Value.absent(),
|
||||
}) => ClosedTabItemRow(
|
||||
id: id ?? this.id,
|
||||
closedTabId: closedTabId ?? this.closedTabId,
|
||||
@@ -2462,6 +2605,7 @@ class ClosedTabItemRow extends DataClass
|
||||
unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents,
|
||||
purchasedAt: purchasedAt.present ? purchasedAt.value : this.purchasedAt,
|
||||
removedAt: removedAt.present ? removedAt.value : this.removedAt,
|
||||
orderId: orderId.present ? orderId.value : this.orderId,
|
||||
);
|
||||
ClosedTabItemRow copyWithCompanion(ClosedTabItemsCompanion data) {
|
||||
return ClosedTabItemRow(
|
||||
@@ -2481,6 +2625,7 @@ class ClosedTabItemRow extends DataClass
|
||||
? data.purchasedAt.value
|
||||
: this.purchasedAt,
|
||||
removedAt: data.removedAt.present ? data.removedAt.value : this.removedAt,
|
||||
orderId: data.orderId.present ? data.orderId.value : this.orderId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2494,7 +2639,8 @@ class ClosedTabItemRow extends DataClass
|
||||
..write('quantity: $quantity, ')
|
||||
..write('unitPriceInCents: $unitPriceInCents, ')
|
||||
..write('purchasedAt: $purchasedAt, ')
|
||||
..write('removedAt: $removedAt')
|
||||
..write('removedAt: $removedAt, ')
|
||||
..write('orderId: $orderId')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -2509,6 +2655,7 @@ class ClosedTabItemRow extends DataClass
|
||||
unitPriceInCents,
|
||||
purchasedAt,
|
||||
removedAt,
|
||||
orderId,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -2521,7 +2668,8 @@ class ClosedTabItemRow extends DataClass
|
||||
other.quantity == this.quantity &&
|
||||
other.unitPriceInCents == this.unitPriceInCents &&
|
||||
other.purchasedAt == this.purchasedAt &&
|
||||
other.removedAt == this.removedAt);
|
||||
other.removedAt == this.removedAt &&
|
||||
other.orderId == this.orderId);
|
||||
}
|
||||
|
||||
class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
|
||||
@@ -2533,6 +2681,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
|
||||
final Value<int> unitPriceInCents;
|
||||
final Value<DateTime?> purchasedAt;
|
||||
final Value<DateTime?> removedAt;
|
||||
final Value<String?> orderId;
|
||||
final Value<int> rowid;
|
||||
const ClosedTabItemsCompanion({
|
||||
this.id = const Value.absent(),
|
||||
@@ -2543,6 +2692,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
|
||||
this.unitPriceInCents = const Value.absent(),
|
||||
this.purchasedAt = const Value.absent(),
|
||||
this.removedAt = const Value.absent(),
|
||||
this.orderId = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
ClosedTabItemsCompanion.insert({
|
||||
@@ -2554,6 +2704,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
|
||||
required int unitPriceInCents,
|
||||
this.purchasedAt = const Value.absent(),
|
||||
this.removedAt = const Value.absent(),
|
||||
this.orderId = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
}) : id = Value(id),
|
||||
closedTabId = Value(closedTabId),
|
||||
@@ -2570,6 +2721,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
|
||||
Expression<int>? unitPriceInCents,
|
||||
Expression<DateTime>? purchasedAt,
|
||||
Expression<DateTime>? removedAt,
|
||||
Expression<String>? orderId,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
@@ -2581,6 +2733,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
|
||||
if (unitPriceInCents != null) 'unit_price_in_cents': unitPriceInCents,
|
||||
if (purchasedAt != null) 'purchased_at': purchasedAt,
|
||||
if (removedAt != null) 'removed_at': removedAt,
|
||||
if (orderId != null) 'order_id': orderId,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
@@ -2594,6 +2747,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
|
||||
Value<int>? unitPriceInCents,
|
||||
Value<DateTime?>? purchasedAt,
|
||||
Value<DateTime?>? removedAt,
|
||||
Value<String?>? orderId,
|
||||
Value<int>? rowid,
|
||||
}) {
|
||||
return ClosedTabItemsCompanion(
|
||||
@@ -2605,6 +2759,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
|
||||
unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents,
|
||||
purchasedAt: purchasedAt ?? this.purchasedAt,
|
||||
removedAt: removedAt ?? this.removedAt,
|
||||
orderId: orderId ?? this.orderId,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
@@ -2636,6 +2791,9 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
|
||||
if (removedAt.present) {
|
||||
map['removed_at'] = Variable<DateTime>(removedAt.value);
|
||||
}
|
||||
if (orderId.present) {
|
||||
map['order_id'] = Variable<String>(orderId.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(rowid.value);
|
||||
}
|
||||
@@ -2653,6 +2811,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion<ClosedTabItemRow> {
|
||||
..write('unitPriceInCents: $unitPriceInCents, ')
|
||||
..write('purchasedAt: $purchasedAt, ')
|
||||
..write('removedAt: $removedAt, ')
|
||||
..write('orderId: $orderId, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
.toString();
|
||||
@@ -5238,6 +5397,7 @@ typedef $$BarTabsTableCreateCompanionBuilder =
|
||||
BarTabsCompanion Function({
|
||||
required String id,
|
||||
required String customerName,
|
||||
Value<String?> email,
|
||||
required DateTime openedAt,
|
||||
Value<int> version,
|
||||
Value<int> rowid,
|
||||
@@ -5246,6 +5406,7 @@ typedef $$BarTabsTableUpdateCompanionBuilder =
|
||||
BarTabsCompanion Function({
|
||||
Value<String> id,
|
||||
Value<String> customerName,
|
||||
Value<String?> email,
|
||||
Value<DateTime> openedAt,
|
||||
Value<int> version,
|
||||
Value<int> rowid,
|
||||
@@ -5293,6 +5454,11 @@ class $$BarTabsTableFilterComposer
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get email => $composableBuilder(
|
||||
column: $table.email,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<DateTime> get openedAt => $composableBuilder(
|
||||
column: $table.openedAt,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
@@ -5348,6 +5514,11 @@ class $$BarTabsTableOrderingComposer
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get email => $composableBuilder(
|
||||
column: $table.email,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<DateTime> get openedAt => $composableBuilder(
|
||||
column: $table.openedAt,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
@@ -5376,6 +5547,9 @@ class $$BarTabsTableAnnotationComposer
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<String> get email =>
|
||||
$composableBuilder(column: $table.email, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<DateTime> get openedAt =>
|
||||
$composableBuilder(column: $table.openedAt, builder: (column) => column);
|
||||
|
||||
@@ -5438,12 +5612,14 @@ class $$BarTabsTableTableManager
|
||||
({
|
||||
Value<String> id = const Value.absent(),
|
||||
Value<String> customerName = const Value.absent(),
|
||||
Value<String?> email = const Value.absent(),
|
||||
Value<DateTime> openedAt = const Value.absent(),
|
||||
Value<int> version = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => BarTabsCompanion(
|
||||
id: id,
|
||||
customerName: customerName,
|
||||
email: email,
|
||||
openedAt: openedAt,
|
||||
version: version,
|
||||
rowid: rowid,
|
||||
@@ -5452,12 +5628,14 @@ class $$BarTabsTableTableManager
|
||||
({
|
||||
required String id,
|
||||
required String customerName,
|
||||
Value<String?> email = const Value.absent(),
|
||||
required DateTime openedAt,
|
||||
Value<int> version = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => BarTabsCompanion.insert(
|
||||
id: id,
|
||||
customerName: customerName,
|
||||
email: email,
|
||||
openedAt: openedAt,
|
||||
version: version,
|
||||
rowid: rowid,
|
||||
@@ -6072,6 +6250,7 @@ typedef $$TabItemPurchasesTableCreateCompanionBuilder =
|
||||
required String tabItemId,
|
||||
required DateTime purchasedAt,
|
||||
Value<DateTime?> removedAt,
|
||||
Value<String?> orderId,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$TabItemPurchasesTableUpdateCompanionBuilder =
|
||||
@@ -6080,6 +6259,7 @@ typedef $$TabItemPurchasesTableUpdateCompanionBuilder =
|
||||
Value<String> tabItemId,
|
||||
Value<DateTime> purchasedAt,
|
||||
Value<DateTime?> removedAt,
|
||||
Value<String?> orderId,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
@@ -6138,6 +6318,11 @@ class $$TabItemPurchasesTableFilterComposer
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get orderId => $composableBuilder(
|
||||
column: $table.orderId,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
$$TabItemsTableFilterComposer get tabItemId {
|
||||
final $$TabItemsTableFilterComposer composer = $composerBuilder(
|
||||
composer: this,
|
||||
@@ -6186,6 +6371,11 @@ class $$TabItemPurchasesTableOrderingComposer
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get orderId => $composableBuilder(
|
||||
column: $table.orderId,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
$$TabItemsTableOrderingComposer get tabItemId {
|
||||
final $$TabItemsTableOrderingComposer composer = $composerBuilder(
|
||||
composer: this,
|
||||
@@ -6230,6 +6420,9 @@ class $$TabItemPurchasesTableAnnotationComposer
|
||||
GeneratedColumn<DateTime> get removedAt =>
|
||||
$composableBuilder(column: $table.removedAt, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get orderId =>
|
||||
$composableBuilder(column: $table.orderId, builder: (column) => column);
|
||||
|
||||
$$TabItemsTableAnnotationComposer get tabItemId {
|
||||
final $$TabItemsTableAnnotationComposer composer = $composerBuilder(
|
||||
composer: this,
|
||||
@@ -6288,12 +6481,14 @@ class $$TabItemPurchasesTableTableManager
|
||||
Value<String> tabItemId = const Value.absent(),
|
||||
Value<DateTime> purchasedAt = const Value.absent(),
|
||||
Value<DateTime?> removedAt = const Value.absent(),
|
||||
Value<String?> orderId = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => TabItemPurchasesCompanion(
|
||||
id: id,
|
||||
tabItemId: tabItemId,
|
||||
purchasedAt: purchasedAt,
|
||||
removedAt: removedAt,
|
||||
orderId: orderId,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback:
|
||||
@@ -6302,12 +6497,14 @@ class $$TabItemPurchasesTableTableManager
|
||||
required String tabItemId,
|
||||
required DateTime purchasedAt,
|
||||
Value<DateTime?> removedAt = const Value.absent(),
|
||||
Value<String?> orderId = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => TabItemPurchasesCompanion.insert(
|
||||
id: id,
|
||||
tabItemId: tabItemId,
|
||||
purchasedAt: purchasedAt,
|
||||
removedAt: removedAt,
|
||||
orderId: orderId,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
@@ -6597,6 +6794,7 @@ typedef $$ClosedTabItemsTableCreateCompanionBuilder =
|
||||
required int unitPriceInCents,
|
||||
Value<DateTime?> purchasedAt,
|
||||
Value<DateTime?> removedAt,
|
||||
Value<String?> orderId,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$ClosedTabItemsTableUpdateCompanionBuilder =
|
||||
@@ -6609,6 +6807,7 @@ typedef $$ClosedTabItemsTableUpdateCompanionBuilder =
|
||||
Value<int> unitPriceInCents,
|
||||
Value<DateTime?> purchasedAt,
|
||||
Value<DateTime?> removedAt,
|
||||
Value<String?> orderId,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
@@ -6660,6 +6859,11 @@ class $$ClosedTabItemsTableFilterComposer
|
||||
column: $table.removedAt,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get orderId => $composableBuilder(
|
||||
column: $table.orderId,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$ClosedTabItemsTableOrderingComposer
|
||||
@@ -6710,6 +6914,11 @@ class $$ClosedTabItemsTableOrderingComposer
|
||||
column: $table.removedAt,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get orderId => $composableBuilder(
|
||||
column: $table.orderId,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$ClosedTabItemsTableAnnotationComposer
|
||||
@@ -6752,6 +6961,9 @@ class $$ClosedTabItemsTableAnnotationComposer
|
||||
|
||||
GeneratedColumn<DateTime> get removedAt =>
|
||||
$composableBuilder(column: $table.removedAt, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get orderId =>
|
||||
$composableBuilder(column: $table.orderId, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$ClosedTabItemsTableTableManager
|
||||
@@ -6799,6 +7011,7 @@ class $$ClosedTabItemsTableTableManager
|
||||
Value<int> unitPriceInCents = const Value.absent(),
|
||||
Value<DateTime?> purchasedAt = const Value.absent(),
|
||||
Value<DateTime?> removedAt = const Value.absent(),
|
||||
Value<String?> orderId = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => ClosedTabItemsCompanion(
|
||||
id: id,
|
||||
@@ -6809,6 +7022,7 @@ class $$ClosedTabItemsTableTableManager
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
purchasedAt: purchasedAt,
|
||||
removedAt: removedAt,
|
||||
orderId: orderId,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback:
|
||||
@@ -6821,6 +7035,7 @@ class $$ClosedTabItemsTableTableManager
|
||||
required int unitPriceInCents,
|
||||
Value<DateTime?> purchasedAt = const Value.absent(),
|
||||
Value<DateTime?> removedAt = const Value.absent(),
|
||||
Value<String?> orderId = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => ClosedTabItemsCompanion.insert(
|
||||
id: id,
|
||||
@@ -6831,6 +7046,7 @@ class $$ClosedTabItemsTableTableManager
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
purchasedAt: purchasedAt,
|
||||
removedAt: removedAt,
|
||||
orderId: orderId,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
|
||||
+15
-1
@@ -125,6 +125,14 @@
|
||||
"incorrectPin": "Incorrect PIN.",
|
||||
"tooManyPinAttempts": "Too many attempts. Try again later.",
|
||||
"removed": "Removed",
|
||||
"removedCount": "{count} removed",
|
||||
"@removedCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"orderStartedToday": "{time} today",
|
||||
"@orderStartedToday": {"placeholders": {"time": {"type": "String"}}},
|
||||
"orderStartedYesterday": "{time} yesterday",
|
||||
"@orderStartedYesterday": {"placeholders": {"time": {"type": "String"}}},
|
||||
"orderStartedOn": "{date} {time}",
|
||||
"@orderStartedOn": {"placeholders": {"date": {"type": "String"}, "time": {"type": "String"}}},
|
||||
"enterPrice": "Enter a price.",
|
||||
"enterValidPrice": "Enter a valid price.",
|
||||
"enterValidStock": "Enter a valid stock amount.",
|
||||
@@ -365,5 +373,11 @@
|
||||
"creditsEveryoneAtTheBar": "Everyone at the bar",
|
||||
"creditsDrinkResponsibly": "Drink responsibly. Mostly.",
|
||||
"lazyPinRemark": "🤨 Really?",
|
||||
"rainbowRoad": "Rainbow road 🌈"
|
||||
"rainbowRoad": "Rainbow road 🌈",
|
||||
"newCustomer": "New customer",
|
||||
"emailOptional": "Email (optional)",
|
||||
"invalidEmail": "Enter a valid email address",
|
||||
"allTabsOpen": "Every tab is already open",
|
||||
"noTabsFound": "No tabs found",
|
||||
"nameRequired": "Enter a name"
|
||||
}
|
||||
|
||||
@@ -800,6 +800,30 @@ abstract class AppLocalizations {
|
||||
/// **'Removed'**
|
||||
String get removed;
|
||||
|
||||
/// No description provided for @removedCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} removed'**
|
||||
String removedCount(int count);
|
||||
|
||||
/// No description provided for @orderStartedToday.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{time} today'**
|
||||
String orderStartedToday(String time);
|
||||
|
||||
/// No description provided for @orderStartedYesterday.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{time} yesterday'**
|
||||
String orderStartedYesterday(String time);
|
||||
|
||||
/// No description provided for @orderStartedOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{date} {time}'**
|
||||
String orderStartedOn(String date, String time);
|
||||
|
||||
/// No description provided for @enterPrice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2089,6 +2113,42 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Rainbow road 🌈'**
|
||||
String get rainbowRoad;
|
||||
|
||||
/// No description provided for @newCustomer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New customer'**
|
||||
String get newCustomer;
|
||||
|
||||
/// No description provided for @emailOptional.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Email (optional)'**
|
||||
String get emailOptional;
|
||||
|
||||
/// No description provided for @invalidEmail.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter a valid email address'**
|
||||
String get invalidEmail;
|
||||
|
||||
/// No description provided for @allTabsOpen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Every tab is already open'**
|
||||
String get allTabsOpen;
|
||||
|
||||
/// No description provided for @noTabsFound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No tabs found'**
|
||||
String get noTabsFound;
|
||||
|
||||
/// No description provided for @nameRequired.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter a name'**
|
||||
String get nameRequired;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -393,6 +393,26 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get removed => 'Removed';
|
||||
|
||||
@override
|
||||
String removedCount(int count) {
|
||||
return '$count removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String orderStartedToday(String time) {
|
||||
return '$time today';
|
||||
}
|
||||
|
||||
@override
|
||||
String orderStartedYesterday(String time) {
|
||||
return '$time yesterday';
|
||||
}
|
||||
|
||||
@override
|
||||
String orderStartedOn(String date, String time) {
|
||||
return '$date $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get enterPrice => 'Enter a price.';
|
||||
|
||||
@@ -1110,4 +1130,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get rainbowRoad => 'Rainbow road 🌈';
|
||||
|
||||
@override
|
||||
String get newCustomer => 'New customer';
|
||||
|
||||
@override
|
||||
String get emailOptional => 'Email (optional)';
|
||||
|
||||
@override
|
||||
String get invalidEmail => 'Enter a valid email address';
|
||||
|
||||
@override
|
||||
String get allTabsOpen => 'Every tab is already open';
|
||||
|
||||
@override
|
||||
String get noTabsFound => 'No tabs found';
|
||||
|
||||
@override
|
||||
String get nameRequired => 'Enter a name';
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get total => 'Totaal';
|
||||
|
||||
@override
|
||||
String get closeTab => 'Poef sluiten';
|
||||
String get closeTab => 'Poef afsluiten';
|
||||
|
||||
@override
|
||||
String productStockSummary(String category, String price, int stock) {
|
||||
@@ -397,6 +397,26 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get removed => 'Verwijderd';
|
||||
|
||||
@override
|
||||
String removedCount(int count) {
|
||||
return '$count verwijderd';
|
||||
}
|
||||
|
||||
@override
|
||||
String orderStartedToday(String time) {
|
||||
return '$time vandaag';
|
||||
}
|
||||
|
||||
@override
|
||||
String orderStartedYesterday(String time) {
|
||||
return '$time gisteren';
|
||||
}
|
||||
|
||||
@override
|
||||
String orderStartedOn(String date, String time) {
|
||||
return '$date $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get enterPrice => 'Voer een prijs in.';
|
||||
|
||||
@@ -441,7 +461,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get couldNotCloseTab => 'Poef kon niet worden gesloten.';
|
||||
|
||||
@override
|
||||
String get closingTab => 'Poef sluiten…';
|
||||
String get closingTab => 'Poef afsluiten…';
|
||||
|
||||
@override
|
||||
String get slideToConfirmClosing => 'Schuif om het sluiten te bevestigen';
|
||||
@@ -977,7 +997,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get remoteCommandCloseTab => 'Poef sluiten via dashboard';
|
||||
String get remoteCommandCloseTab => 'Poef afsluiten via dashboard';
|
||||
|
||||
@override
|
||||
String remoteCommandUnknown(String type) {
|
||||
@@ -1124,4 +1144,22 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get rainbowRoad => 'Regenboogweg 🌈';
|
||||
|
||||
@override
|
||||
String get newCustomer => 'Nieuwe klant';
|
||||
|
||||
@override
|
||||
String get emailOptional => 'E-mail (optioneel)';
|
||||
|
||||
@override
|
||||
String get invalidEmail => 'Geef een geldig e-mailadres in';
|
||||
|
||||
@override
|
||||
String get allTabsOpen => 'Alle poefs zijn al open';
|
||||
|
||||
@override
|
||||
String get noTabsFound => 'Geen poefs gevonden';
|
||||
|
||||
@override
|
||||
String get nameRequired => 'Geef een naam in';
|
||||
}
|
||||
|
||||
+14
-4
@@ -104,7 +104,7 @@
|
||||
"@tabItemCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"tapProductsToAdd": "Tik op producten om ze toe te voegen",
|
||||
"total": "Totaal",
|
||||
"closeTab": "Poef sluiten",
|
||||
"closeTab": "Poef afsluiten",
|
||||
"productStockSummary": "{category} • {price} • Voorraad: {stock}",
|
||||
"@productStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}, "stock": {"type": "int"}}},
|
||||
"infiniteStock": "Oneindige voorraad",
|
||||
@@ -125,6 +125,10 @@
|
||||
"incorrectPin": "Onjuiste PIN.",
|
||||
"tooManyPinAttempts": "Te veel pogingen. Probeer het later opnieuw.",
|
||||
"removed": "Verwijderd",
|
||||
"removedCount": "{count} verwijderd",
|
||||
"orderStartedToday": "{time} vandaag",
|
||||
"orderStartedYesterday": "{time} gisteren",
|
||||
"orderStartedOn": "{date} {time}",
|
||||
"enterPrice": "Voer een prijs in.",
|
||||
"enterValidPrice": "Voer een geldige prijs in.",
|
||||
"enterValidStock": "Voer een geldige voorraad in.",
|
||||
@@ -140,7 +144,7 @@
|
||||
"currentTotal": "Huidig totaal: {total}",
|
||||
"@currentTotal": {"placeholders": {"total": {"type": "String"}}},
|
||||
"couldNotCloseTab": "Poef kon niet worden gesloten.",
|
||||
"closingTab": "Poef sluiten…",
|
||||
"closingTab": "Poef afsluiten…",
|
||||
"slideToConfirmClosing": "Schuif om het sluiten te bevestigen",
|
||||
"errorScreen": "Foutscherm",
|
||||
"unexpectedError": "Er is een onverwachte fout opgetreden.\nProbeer de app opnieuw te starten.",
|
||||
@@ -307,7 +311,7 @@
|
||||
"justNow": "zonet",
|
||||
"minutesAgo": "{count, plural, =1{1 minuut geleden} other{{count} minuten geleden}}",
|
||||
"hoursAgo": "{count, plural, =1{1 uur geleden} other{{count} uur geleden}}",
|
||||
"remoteCommandCloseTab": "Poef sluiten via dashboard",
|
||||
"remoteCommandCloseTab": "Poef afsluiten via dashboard",
|
||||
"remoteCommandUnknown": "Onbekende opdracht ({type})",
|
||||
"commandCompleted": "Uitgevoerd",
|
||||
"commandConflict": "Conflict",
|
||||
@@ -353,5 +357,11 @@
|
||||
"creditsEveryoneAtTheBar": "Iedereen aan de toog",
|
||||
"creditsDrinkResponsibly": "Drink met mate. Meestal.",
|
||||
"lazyPinRemark": "🤨 Echt?",
|
||||
"rainbowRoad": "Regenboogweg 🌈"
|
||||
"rainbowRoad": "Regenboogweg 🌈",
|
||||
"newCustomer": "Nieuwe klant",
|
||||
"emailOptional": "E-mail (optioneel)",
|
||||
"invalidEmail": "Geef een geldig e-mailadres in",
|
||||
"allTabsOpen": "Alle poefs zijn al open",
|
||||
"noTabsFound": "Geen poefs gevonden",
|
||||
"nameRequired": "Geef een naam in"
|
||||
}
|
||||
|
||||
+11
-1
@@ -5,6 +5,7 @@ import 'tab_item.dart';
|
||||
class BarTab {
|
||||
final String id;
|
||||
final String customerName;
|
||||
final String? email;
|
||||
final DateTime openedAt;
|
||||
final int version;
|
||||
final List<TabItem> items;
|
||||
@@ -12,6 +13,7 @@ class BarTab {
|
||||
const BarTab({
|
||||
required this.id,
|
||||
required this.customerName,
|
||||
this.email,
|
||||
required this.openedAt,
|
||||
this.version = 0,
|
||||
required this.items,
|
||||
@@ -31,11 +33,19 @@ class BarTab {
|
||||
other is BarTab &&
|
||||
id == other.id &&
|
||||
customerName == other.customerName &&
|
||||
email == other.email &&
|
||||
openedAt == other.openedAt &&
|
||||
version == other.version &&
|
||||
listEquals(items, other.items);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(id, customerName, openedAt, version, Object.hashAll(items));
|
||||
Object.hash(
|
||||
id,
|
||||
customerName,
|
||||
email,
|
||||
openedAt,
|
||||
version,
|
||||
Object.hashAll(items),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class ClosedTabItem {
|
||||
final int unitPriceInCents;
|
||||
final DateTime? purchasedAt;
|
||||
final DateTime? removedAt;
|
||||
final String? orderId;
|
||||
|
||||
ClosedTabItem({
|
||||
required this.id,
|
||||
@@ -17,6 +18,7 @@ class ClosedTabItem {
|
||||
required this.unitPriceInCents,
|
||||
this.purchasedAt,
|
||||
this.removedAt,
|
||||
this.orderId,
|
||||
});
|
||||
|
||||
bool get isRemoved => removedAt != null;
|
||||
@@ -34,7 +36,8 @@ class ClosedTabItem {
|
||||
quantity == other.quantity &&
|
||||
unitPriceInCents == other.unitPriceInCents &&
|
||||
purchasedAt == other.purchasedAt &&
|
||||
removedAt == other.removedAt;
|
||||
removedAt == other.removedAt &&
|
||||
orderId == other.orderId;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
@@ -46,5 +49,6 @@ class ClosedTabItem {
|
||||
unitPriceInCents,
|
||||
purchasedAt,
|
||||
removedAt,
|
||||
orderId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,15 @@ class ServerUnit {
|
||||
final DateTime addedAt;
|
||||
final DateTime? removedAt;
|
||||
|
||||
const ServerUnit({required this.id, required this.addedAt, this.removedAt});
|
||||
/// The order it was rung up in; null for drinks from older app versions.
|
||||
final String? orderId;
|
||||
|
||||
const ServerUnit({
|
||||
required this.id,
|
||||
required this.addedAt,
|
||||
this.removedAt,
|
||||
this.orderId,
|
||||
});
|
||||
|
||||
factory ServerUnit.fromJson(Map<String, dynamic> json) {
|
||||
return ServerUnit(
|
||||
@@ -18,6 +26,7 @@ class ServerUnit {
|
||||
final String value => DateTime.parse(value).toLocal(),
|
||||
_ => null,
|
||||
},
|
||||
orderId: json['orderId'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +67,7 @@ class ServerTabLine {
|
||||
class ServerTab {
|
||||
final String id;
|
||||
final String customerName;
|
||||
final String? email;
|
||||
final DateTime openedAt;
|
||||
final int version;
|
||||
final List<ServerTabLine> lines;
|
||||
@@ -65,6 +75,7 @@ class ServerTab {
|
||||
const ServerTab({
|
||||
required this.id,
|
||||
required this.customerName,
|
||||
this.email,
|
||||
required this.openedAt,
|
||||
required this.version,
|
||||
this.lines = const [],
|
||||
@@ -74,6 +85,7 @@ class ServerTab {
|
||||
return ServerTab(
|
||||
id: json['id'] as String,
|
||||
customerName: json['customerName'] as String,
|
||||
email: json['email'] as String?,
|
||||
openedAt: DateTime.parse(json['openedAt'] as String).toLocal(),
|
||||
version: json['version'] as int? ?? 0,
|
||||
lines: [
|
||||
|
||||
@@ -47,6 +47,10 @@ class TabEvent {
|
||||
const TabEvent({required this.id, required this.payload});
|
||||
}
|
||||
|
||||
/// The order a removal takes drinks off: `orderId`, or (when null) the
|
||||
/// drinks rung up before orders were tracked.
|
||||
typedef OrderRef = ({String? orderId});
|
||||
|
||||
/// One settled line in a `settled` event.
|
||||
typedef SettledLine = ({
|
||||
String productId,
|
||||
@@ -66,12 +70,16 @@ class TabEventFactory {
|
||||
required String tabId,
|
||||
required String customerName,
|
||||
required DateTime openedAt,
|
||||
String? email,
|
||||
}) {
|
||||
return _event(
|
||||
TabEventType.tabOpened,
|
||||
tabId: tabId,
|
||||
customerName: customerName,
|
||||
fields: {'openedAt': openedAt.toUtc().toIso8601String()},
|
||||
fields: {
|
||||
'openedAt': openedAt.toUtc().toIso8601String(),
|
||||
'email': ?email,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,7 +104,8 @@ class TabEventFactory {
|
||||
}
|
||||
|
||||
/// [quantity] is how many pieces were added; always positive. [lineId] is
|
||||
/// the local line, so the server can keep its id.
|
||||
/// the local line, so the server can keep its id. [orderId] is the order
|
||||
/// they are part of.
|
||||
TabEvent lineAdded({
|
||||
required String tabId,
|
||||
required String customerName,
|
||||
@@ -105,6 +114,7 @@ class TabEventFactory {
|
||||
required String productName,
|
||||
required int unitPriceInCents,
|
||||
required int quantity,
|
||||
required String? orderId,
|
||||
}) {
|
||||
return _lineEvent(
|
||||
TabEventType.lineAdded,
|
||||
@@ -115,10 +125,13 @@ class TabEventFactory {
|
||||
productName: productName,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
quantity: quantity,
|
||||
order: (orderId: orderId),
|
||||
);
|
||||
}
|
||||
|
||||
/// [quantity] is how many pieces were removed; always positive.
|
||||
/// [quantity] is how many pieces were removed; always positive. With
|
||||
/// [order] they are taken off that order only; without, the latest drinks
|
||||
/// of the line (as before orders).
|
||||
TabEvent lineRemoved({
|
||||
required String tabId,
|
||||
required String customerName,
|
||||
@@ -127,6 +140,7 @@ class TabEventFactory {
|
||||
required String productName,
|
||||
required int unitPriceInCents,
|
||||
required int quantity,
|
||||
OrderRef? order,
|
||||
}) {
|
||||
return _lineEvent(
|
||||
TabEventType.lineRemoved,
|
||||
@@ -137,6 +151,7 @@ class TabEventFactory {
|
||||
productName: productName,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
quantity: quantity,
|
||||
order: order,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -246,6 +261,7 @@ class TabEventFactory {
|
||||
required String productName,
|
||||
required int unitPriceInCents,
|
||||
required int quantity,
|
||||
required OrderRef? order,
|
||||
}) {
|
||||
if (quantity < 1) {
|
||||
throw ArgumentError.value(quantity, 'quantity', 'Must be at least 1');
|
||||
@@ -263,6 +279,9 @@ class TabEventFactory {
|
||||
'productName': productName,
|
||||
'unitPriceInCents': unitPriceInCents,
|
||||
'quantity': quantity,
|
||||
// Also when null: a removal then only takes drinks without an order,
|
||||
// where a removal without `orderId` takes the latest of the line
|
||||
if (order != null) 'orderId': order.orderId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ class TabItemPurchase {
|
||||
final DateTime purchasedAt;
|
||||
final DateTime? removedAt;
|
||||
|
||||
/// The order it was rung up in; null for drinks from before orders.
|
||||
final String? orderId;
|
||||
|
||||
const TabItemPurchase({
|
||||
required this.id,
|
||||
required this.tabItemId,
|
||||
required this.purchasedAt,
|
||||
this.removedAt,
|
||||
this.orderId,
|
||||
});
|
||||
|
||||
bool get isRemoved => removedAt != null;
|
||||
@@ -20,8 +24,10 @@ class TabItemPurchase {
|
||||
id == other.id &&
|
||||
tabItemId == other.tabItemId &&
|
||||
purchasedAt == other.purchasedAt &&
|
||||
removedAt == other.removedAt;
|
||||
removedAt == other.removedAt &&
|
||||
orderId == other.orderId;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, tabItemId, purchasedAt, removedAt);
|
||||
int get hashCode =>
|
||||
Object.hash(id, tabItemId, purchasedAt, removedAt, orderId);
|
||||
}
|
||||
|
||||
@@ -50,23 +50,29 @@ abstract class BarTabService {
|
||||
|
||||
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});
|
||||
|
||||
/// Deletes an open tab and returns its items to inventory.
|
||||
Future<void> deleteTab(String tabId);
|
||||
|
||||
/// Rings up one [product] as part of order [orderId]; a new order when
|
||||
/// null.
|
||||
Future<void> addProductToTab({
|
||||
required String tabId,
|
||||
required Product product,
|
||||
String? orderId,
|
||||
});
|
||||
|
||||
/// Returns the delta that was actually applied, which is smaller than
|
||||
/// [delta] when it would push the quantity below zero.
|
||||
/// Adds drinks to [order] (a new order when null), or takes drinks of
|
||||
/// [order] off (when null: the latest of the line). Returns the delta that
|
||||
/// was actually applied, which is smaller than [delta] when there are fewer
|
||||
/// drinks to take off.
|
||||
Future<int> adjustTabItemQuantity({
|
||||
required String tabItemId,
|
||||
required int delta,
|
||||
OrderRef? order,
|
||||
});
|
||||
|
||||
/// Archives the tab's current items into history and clears them.
|
||||
@@ -119,6 +125,7 @@ class DriftBarTabService implements BarTabService {
|
||||
tabItemId: row.tabItemId,
|
||||
purchasedAt: row.purchasedAt,
|
||||
removedAt: row.removedAt,
|
||||
orderId: row.orderId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,6 +145,7 @@ class DriftBarTabService implements BarTabService {
|
||||
return BarTab(
|
||||
id: row.id,
|
||||
customerName: row.customerName,
|
||||
email: row.email,
|
||||
openedAt: row.openedAt,
|
||||
version: row.version,
|
||||
items: items,
|
||||
@@ -154,6 +162,7 @@ class DriftBarTabService implements BarTabService {
|
||||
unitPriceInCents: row.unitPriceInCents,
|
||||
purchasedAt: row.purchasedAt,
|
||||
removedAt: row.removedAt,
|
||||
orderId: row.orderId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,11 +182,16 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
if (tabId != null) query.where(database.barTabs.id.equals(tabId));
|
||||
|
||||
// Times are stored to the second: drinks rung up within one second keep
|
||||
// the order they were added in (rowid), so a tab's rows never swap
|
||||
query.orderBy([
|
||||
OrderingTerm.desc(database.barTabs.openedAt),
|
||||
OrderingTerm.asc(database.tabItems.createdAt),
|
||||
OrderingTerm.asc(const CustomExpression<int>('"tab_items"."rowid"')),
|
||||
OrderingTerm.asc(database.tabItemPurchases.purchasedAt),
|
||||
OrderingTerm.asc(database.tabItemPurchases.id),
|
||||
OrderingTerm.asc(
|
||||
const CustomExpression<int>('"tab_item_purchases"."rowid"'),
|
||||
),
|
||||
]);
|
||||
|
||||
return query;
|
||||
@@ -224,31 +238,63 @@ class DriftBarTabService implements BarTabService {
|
||||
)..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
|
||||
.into(database.tabItemPurchases)
|
||||
.insert(
|
||||
TabItemPurchasesCompanion.insert(
|
||||
id: _uuid.v4(),
|
||||
id: id,
|
||||
tabItemId: tabItemId,
|
||||
purchasedAt: DateTime.now(),
|
||||
orderId: Value(orderId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Marks the most recent active purchases as removed, keeping them in the
|
||||
/// audit trail.
|
||||
/// The active purchases of [tabItemId] a removal may take off: those of
|
||||
/// [order] when given, as the server does; else all of them (events from
|
||||
/// before orders).
|
||||
Future<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(
|
||||
String tabItemId,
|
||||
int count, {
|
||||
DateTime? removedAt,
|
||||
OrderRef? order,
|
||||
}) async {
|
||||
final latest =
|
||||
await (database.select(database.tabItemPurchases)
|
||||
..where(
|
||||
(purchase) =>
|
||||
purchase.tabItemId.equals(tabItemId) &
|
||||
purchase.removedAt.isNull(),
|
||||
purchase.removedAt.isNull() &
|
||||
switch (order) {
|
||||
null => const Constant(true),
|
||||
(orderId: null) => purchase.orderId.isNull(),
|
||||
(orderId: final String id) => purchase.orderId.equals(id),
|
||||
},
|
||||
)
|
||||
..orderBy([
|
||||
(purchase) => OrderingTerm.desc(purchase.purchasedAt),
|
||||
@@ -303,11 +349,13 @@ class DriftBarTabService implements BarTabService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the id of the event.
|
||||
///
|
||||
/// Queues a `line_added` ([delta] > 0) or `line_removed` ([delta] < 0)
|
||||
/// event and moves the stock of [productId], the product the line was
|
||||
/// created from, the other way. Every line event goes through here, so a
|
||||
/// line event and its stock change can never drift apart.
|
||||
Future<void> _recordLineChange({
|
||||
Future<String> _recordLineChange({
|
||||
required String tabId,
|
||||
required String customerName,
|
||||
required String lineId,
|
||||
@@ -315,22 +363,32 @@ class DriftBarTabService implements BarTabService {
|
||||
required String productName,
|
||||
required int unitPriceInCents,
|
||||
required int delta,
|
||||
required OrderRef? order,
|
||||
}) async {
|
||||
final lineEvent = delta > 0
|
||||
? eventFactory.lineAdded
|
||||
: eventFactory.lineRemoved;
|
||||
await _recordEvent(
|
||||
lineEvent(
|
||||
tabId: tabId,
|
||||
customerName: customerName,
|
||||
lineId: lineId,
|
||||
productId: productId,
|
||||
productName: productName,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
quantity: delta.abs(),
|
||||
),
|
||||
);
|
||||
final event = delta > 0
|
||||
? eventFactory.lineAdded(
|
||||
tabId: tabId,
|
||||
customerName: customerName,
|
||||
lineId: lineId,
|
||||
productId: productId,
|
||||
productName: productName,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
quantity: delta,
|
||||
orderId: order?.orderId,
|
||||
)
|
||||
: eventFactory.lineRemoved(
|
||||
tabId: tabId,
|
||||
customerName: customerName,
|
||||
lineId: lineId,
|
||||
productId: productId,
|
||||
productName: productName,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
quantity: -delta,
|
||||
order: order,
|
||||
);
|
||||
await _recordEvent(event);
|
||||
await database.changeTrackedStock(productId, -delta);
|
||||
return event.id;
|
||||
}
|
||||
|
||||
Future<void> _incrementTabVersion(String tabId) async {
|
||||
@@ -377,9 +435,16 @@ class DriftBarTabService implements BarTabService {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BarTab> createTab({required String customerName}) async {
|
||||
Future<BarTab> createTab({
|
||||
required String customerName,
|
||||
String? email,
|
||||
}) async {
|
||||
final id = _uuid.v4();
|
||||
final name = _validateCustomerName(customerName);
|
||||
final trimmedEmail = email?.trim();
|
||||
final cleanEmail = trimmedEmail == null || trimmedEmail.isEmpty
|
||||
? null
|
||||
: trimmedEmail;
|
||||
final openedAt = DateTime.now();
|
||||
|
||||
await database.transaction(() async {
|
||||
@@ -389,6 +454,7 @@ class DriftBarTabService implements BarTabService {
|
||||
BarTabsCompanion.insert(
|
||||
id: id,
|
||||
customerName: name,
|
||||
email: Value(cleanEmail),
|
||||
openedAt: openedAt,
|
||||
version: const Value(1),
|
||||
),
|
||||
@@ -398,6 +464,7 @@ class DriftBarTabService implements BarTabService {
|
||||
tabId: id,
|
||||
customerName: name,
|
||||
openedAt: openedAt,
|
||||
email: cleanEmail,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -434,6 +501,7 @@ class DriftBarTabService implements BarTabService {
|
||||
if (tab == null) return;
|
||||
|
||||
for (final item in tab.items) {
|
||||
// Without an order: everything on the line, whatever its order
|
||||
await _recordLineChange(
|
||||
tabId: tab.id,
|
||||
customerName: tab.customerName,
|
||||
@@ -442,6 +510,7 @@ class DriftBarTabService implements BarTabService {
|
||||
productName: item.productName,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
delta: -item.quantity,
|
||||
order: null,
|
||||
);
|
||||
}
|
||||
await _recordEvent(
|
||||
@@ -456,6 +525,7 @@ class DriftBarTabService implements BarTabService {
|
||||
Future<void> addProductToTab({
|
||||
required String tabId,
|
||||
required Product product,
|
||||
String? orderId,
|
||||
}) async {
|
||||
await database.transaction(() async {
|
||||
final tabRow = await _getTabRow(tabId);
|
||||
@@ -467,6 +537,7 @@ class DriftBarTabService implements BarTabService {
|
||||
productName: product.name,
|
||||
unitPriceInCents: product.priceInCents,
|
||||
quantity: 1,
|
||||
orderId: orderId ?? _uuid.v4(),
|
||||
);
|
||||
await _incrementTabVersion(tabId);
|
||||
});
|
||||
@@ -485,6 +556,7 @@ class DriftBarTabService implements BarTabService {
|
||||
required String productName,
|
||||
required int unitPriceInCents,
|
||||
required int quantity,
|
||||
required String orderId,
|
||||
}) async {
|
||||
final existingItem =
|
||||
await (database.select(database.tabItems)
|
||||
@@ -524,10 +596,7 @@ class DriftBarTabService implements BarTabService {
|
||||
);
|
||||
}
|
||||
|
||||
for (var index = 0; index < quantity; index++) {
|
||||
await _recordPurchase(lineId);
|
||||
}
|
||||
await _recordLineChange(
|
||||
final eventId = await _recordLineChange(
|
||||
tabId: tabRow.id,
|
||||
customerName: tabRow.customerName,
|
||||
lineId: lineId,
|
||||
@@ -535,7 +604,13 @@ class DriftBarTabService implements BarTabService {
|
||||
productName: productName,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
delta: quantity,
|
||||
order: (orderId: orderId),
|
||||
);
|
||||
// The ids the server gives these units, so they are recognised after a
|
||||
// sync
|
||||
for (var index = 0; index < quantity; index++) {
|
||||
await _recordPurchase(lineId, orderId, id: '$eventId:$index');
|
||||
}
|
||||
}
|
||||
|
||||
/// Taking drinks off changes [tabItemId] itself. Adding is ringing up new
|
||||
@@ -546,6 +621,7 @@ class DriftBarTabService implements BarTabService {
|
||||
Future<int> adjustTabItemQuantity({
|
||||
required String tabItemId,
|
||||
required int delta,
|
||||
OrderRef? order,
|
||||
}) async {
|
||||
if (delta == 0) return 0;
|
||||
|
||||
@@ -570,17 +646,23 @@ class DriftBarTabService implements BarTabService {
|
||||
productName: product?.name ?? item.productName,
|
||||
unitPriceInCents: product?.priceInCents ?? item.unitPriceInCents,
|
||||
quantity: delta,
|
||||
orderId: order?.orderId ?? _uuid.v4(),
|
||||
);
|
||||
await _incrementTabVersion(item.tabId);
|
||||
return delta;
|
||||
}
|
||||
|
||||
final newQuantity = max(item.quantity + delta, 0);
|
||||
final actualDelta = newQuantity - item.quantity;
|
||||
// Only the drinks of the order can be taken off; without an order the
|
||||
// line's quantity counts, as before orders
|
||||
final available = order == null
|
||||
? item.quantity
|
||||
: (await _removablePurchases(item.id, order)).length;
|
||||
final actualDelta = -min(-delta, available);
|
||||
final newQuantity = item.quantity + actualDelta;
|
||||
|
||||
if (actualDelta == 0) return 0;
|
||||
|
||||
await _removeLatestPurchases(item.id, -actualDelta);
|
||||
await _removeLatestPurchases(item.id, -actualDelta, order: order);
|
||||
await (database.update(database.tabItems)
|
||||
..where((row) => row.id.equals(tabItemId)))
|
||||
.write(TabItemsCompanion(quantity: Value(newQuantity)));
|
||||
@@ -594,6 +676,7 @@ class DriftBarTabService implements BarTabService {
|
||||
productName: item.productName,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
delta: actualDelta,
|
||||
order: order,
|
||||
);
|
||||
|
||||
await _incrementTabVersion(item.tabId);
|
||||
@@ -681,6 +764,7 @@ class DriftBarTabService implements BarTabService {
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
purchasedAt: Value(purchase.purchasedAt),
|
||||
removedAt: Value(purchase.removedAt),
|
||||
orderId: Value(purchase.orderId),
|
||||
),
|
||||
]);
|
||||
});
|
||||
@@ -703,7 +787,9 @@ class DriftBarTabService implements BarTabService {
|
||||
)
|
||||
..orderBy([
|
||||
(item) => OrderingTerm.desc(item.purchasedAt),
|
||||
(item) => OrderingTerm.desc(item.id),
|
||||
// Newest first also within one second: the order they were
|
||||
// stored in, reversed
|
||||
(item) => OrderingTerm.desc(const CustomExpression('rowid')),
|
||||
]))
|
||||
.get();
|
||||
|
||||
@@ -787,12 +873,25 @@ class DriftBarTabService implements BarTabService {
|
||||
@override
|
||||
Future<void> applyServerState(List<ServerTab> tabs) async {
|
||||
await database.transaction(() async {
|
||||
// A server without orders sends units back without one: they keep the
|
||||
// order they were rung up in here, so the tab doesn't regroup
|
||||
final knownOrders = {
|
||||
for (final purchase in await (database.select(
|
||||
database.tabItemPurchases,
|
||||
)..where((row) => row.orderId.isNotNull())).get())
|
||||
purchase.id: purchase.orderId!,
|
||||
};
|
||||
|
||||
// A server that doesn't know emails yet would wipe the ones entered
|
||||
// here
|
||||
final knownEmails = <String, String>{};
|
||||
for (final tab in await database.select(database.barTabs).get()) {
|
||||
if (tab.email case final email?) knownEmails[tab.id] = email;
|
||||
await _deleteTabWithItems(tab.id);
|
||||
}
|
||||
|
||||
for (final tab in tabs) {
|
||||
await _insertServerTab(tab);
|
||||
await _insertServerTab(tab, knownOrders, knownEmails[tab.id]);
|
||||
}
|
||||
|
||||
// Read here, so changes made while the sync was in flight are included
|
||||
@@ -808,13 +907,18 @@ class DriftBarTabService implements BarTabService {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _insertServerTab(ServerTab tab) async {
|
||||
Future<void> _insertServerTab(
|
||||
ServerTab tab,
|
||||
Map<String, String> knownOrders,
|
||||
String? knownEmail,
|
||||
) async {
|
||||
await database
|
||||
.into(database.barTabs)
|
||||
.insert(
|
||||
BarTabsCompanion.insert(
|
||||
id: tab.id,
|
||||
customerName: tab.customerName,
|
||||
email: Value(tab.email ?? knownEmail),
|
||||
openedAt: tab.openedAt,
|
||||
version: Value(tab.version),
|
||||
),
|
||||
@@ -848,6 +952,7 @@ class DriftBarTabService implements BarTabService {
|
||||
tabItemId: line.id,
|
||||
purchasedAt: unit.addedAt,
|
||||
removedAt: Value(unit.removedAt),
|
||||
orderId: Value(unit.orderId ?? knownOrders[unit.id]),
|
||||
),
|
||||
]);
|
||||
});
|
||||
@@ -871,7 +976,12 @@ class DriftBarTabService implements BarTabService {
|
||||
final openedAt = DateTime.tryParse(
|
||||
event['openedAt'] as String? ?? '',
|
||||
)?.toLocal();
|
||||
await _ensureTab(tabId, customerName, openedAt ?? occurredAt);
|
||||
await _ensureTab(
|
||||
tabId,
|
||||
customerName,
|
||||
openedAt ?? occurredAt,
|
||||
email: event['email'] as String?,
|
||||
);
|
||||
case TabEventType.tabRenamed:
|
||||
await (database.update(database.barTabs)
|
||||
..where((tab) => tab.id.equals(tabId)))
|
||||
@@ -885,9 +995,20 @@ class DriftBarTabService implements BarTabService {
|
||||
final quantity = event['quantity'] as int? ?? 0;
|
||||
if (line == null || quantity <= 0) return;
|
||||
|
||||
final removed = min(quantity, line.quantity);
|
||||
// Events from before orders have no `orderId`: the latest of the line
|
||||
final OrderRef? order = event.containsKey('orderId')
|
||||
? (orderId: event['orderId'] as String?)
|
||||
: null;
|
||||
final removable = await _removablePurchases(line.id, order);
|
||||
final removed = min(quantity, removable.length);
|
||||
if (removed == 0) return;
|
||||
await _setLineQuantity(line.id, line.quantity - removed);
|
||||
await _removeLatestPurchases(line.id, removed, removedAt: occurredAt);
|
||||
await _removeLatestPurchases(
|
||||
line.id,
|
||||
removed,
|
||||
removedAt: occurredAt,
|
||||
order: order,
|
||||
);
|
||||
case TabEventType.settled:
|
||||
for (final item in event['items'] as List<dynamic>? ?? []) {
|
||||
await _replaySettledLine(tabId, item as Map<String, dynamic>);
|
||||
@@ -947,6 +1068,7 @@ class DriftBarTabService implements BarTabService {
|
||||
id: '${event['id']}:$index',
|
||||
tabItemId: line!.id,
|
||||
purchasedAt: occurredAt,
|
||||
orderId: Value(event['orderId'] as String?),
|
||||
),
|
||||
]);
|
||||
});
|
||||
@@ -998,14 +1120,16 @@ class DriftBarTabService implements BarTabService {
|
||||
Future<void> _ensureTab(
|
||||
String tabId,
|
||||
String customerName,
|
||||
DateTime openedAt,
|
||||
) async {
|
||||
DateTime openedAt, {
|
||||
String? email,
|
||||
}) async {
|
||||
await database
|
||||
.into(database.barTabs)
|
||||
.insert(
|
||||
BarTabsCompanion.insert(
|
||||
id: tabId,
|
||||
customerName: customerName,
|
||||
email: Value(email),
|
||||
openedAt: openedAt,
|
||||
version: const Value(1),
|
||||
),
|
||||
@@ -1096,6 +1220,7 @@ class DriftBarTabService implements BarTabService {
|
||||
unitPriceInCents: line.unitPriceInCents,
|
||||
purchasedAt: Value(unit.addedAt),
|
||||
removedAt: Value(unit.removedAt),
|
||||
orderId: Value(unit.orderId),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -60,9 +60,7 @@ class KoolTapApiClient {
|
||||
this.appVersion,
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
Map<String, String> get _versionHeader => {
|
||||
'X-App-Version': ?appVersion,
|
||||
};
|
||||
Map<String, String> get _versionHeader => {'X-App-Version': ?appVersion};
|
||||
|
||||
static const _timeout = Duration(seconds: 15);
|
||||
static const _defaultRetryAfter = Duration(seconds: 60);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/app_error.dart';
|
||||
import '../models/bar_tab.dart';
|
||||
@@ -15,9 +16,20 @@ import '../services/bar_tab_service.dart';
|
||||
/// up without manual reloads. Actions report to Sentry and rethrow; the view
|
||||
/// decides how to tell the user.
|
||||
class BarScreenViewModel extends ChangeNotifier {
|
||||
final BarTabService barTabService;
|
||||
/// Drinks rung up on the same tab within this time of the previous one are
|
||||
/// one order; a later drink starts a new order.
|
||||
static const orderTimeout = Duration(minutes: 5);
|
||||
|
||||
BarScreenViewModel({required this.barTabService});
|
||||
final BarTabService barTabService;
|
||||
final DateTime Function() _now;
|
||||
final _uuid = const Uuid();
|
||||
|
||||
BarScreenViewModel({required this.barTabService, DateTime Function()? now})
|
||||
: _now = now ?? DateTime.now;
|
||||
|
||||
/// The order being rung up: selecting a tab (again) or a pause of
|
||||
/// [orderTimeout] starts a new one.
|
||||
({String tabId, String orderId, DateTime lastAddedAt})? _order;
|
||||
|
||||
StreamSubscription<List<BarTab>>? _subscription;
|
||||
List<BarTab> _tabs = [];
|
||||
@@ -25,7 +37,30 @@ class BarScreenViewModel extends ChangeNotifier {
|
||||
bool _hasLoaded = false;
|
||||
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;
|
||||
|
||||
@@ -55,11 +90,22 @@ class BarScreenViewModel extends ChangeNotifier {
|
||||
}
|
||||
|
||||
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;
|
||||
_hasLoaded = true;
|
||||
_loadError = null;
|
||||
|
||||
if (selectedTab == null) _selectedTabId = tabs.firstOrNull?.id;
|
||||
final ids = {for (final tab in tabs) tab.id};
|
||||
_openedTabIds.retainAll(ids);
|
||||
if (!ids.contains(_selectedTabId)) _selectedTabId = null;
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -75,13 +121,44 @@ class BarScreenViewModel extends ChangeNotifier {
|
||||
|
||||
void selectTab(String tabId) {
|
||||
_selectedTabId = tabId;
|
||||
_order = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
final tab = await barTabService.createTab(customerName: customerName);
|
||||
selectTab(tab.id);
|
||||
final tab = await barTabService.createTab(
|
||||
customerName: customerName,
|
||||
email: email,
|
||||
);
|
||||
openTab(tab.id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,17 +179,30 @@ class BarScreenViewModel extends ChangeNotifier {
|
||||
|
||||
await _guard(
|
||||
'addProductToSelectedTab',
|
||||
() => barTabService.addProductToTab(tabId: tab.id, product: product),
|
||||
() => barTabService.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: product,
|
||||
orderId: _orderFor(tab.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<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;
|
||||
|
||||
await _guard(
|
||||
'changeItemQuantity',
|
||||
() =>
|
||||
barTabService.adjustTabItemQuantity(tabItemId: item.id, delta: delta),
|
||||
() => barTabService.adjustTabItemQuantity(
|
||||
tabItemId: item.id,
|
||||
delta: delta,
|
||||
order: (orderId: delta > 0 ? _orderFor(item.tabId) : orderId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,6 +216,9 @@ class BarScreenViewModel extends ChangeNotifier {
|
||||
'closeSelectedTab',
|
||||
() => barTabService.closeTab(tab.id, paymentMethod: paymentMethod),
|
||||
);
|
||||
// A paid tab goes back to the Open Tab dialog
|
||||
_openedTabIds.remove(tab.id);
|
||||
deselectTab();
|
||||
}
|
||||
|
||||
Future<T> _guard<T>(String action, Future<T> Function() body) async {
|
||||
|
||||
@@ -8,7 +8,6 @@ import '../models/remote_command.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/admin_pin_view_model.dart';
|
||||
import '../viewmodels/remote_sync_view_model.dart';
|
||||
import 'dialogs/new_tab_dialog.dart';
|
||||
|
||||
class AdminScreenView extends StatelessWidget {
|
||||
const AdminScreenView({super.key});
|
||||
@@ -19,16 +18,6 @@ class AdminScreenView extends StatelessWidget {
|
||||
adminPinViewModel.lock();
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -94,12 +83,6 @@ class AdminScreenView extends StatelessWidget {
|
||||
_AdminSection(
|
||||
title: l10n.adminTools,
|
||||
children: [
|
||||
_AdminTile(
|
||||
icon: Icons.person_add_alt_1_outlined,
|
||||
title: l10n.openNewTab,
|
||||
subtitle: l10n.openNewTabDescription,
|
||||
onTap: () => _openNewTab(context),
|
||||
),
|
||||
_AdminTile(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
title: l10n.products,
|
||||
|
||||
+172
-135
@@ -18,6 +18,7 @@ import '../../viewmodels/remote_sync_view_model.dart';
|
||||
import '../../viewmodels/settings_view_model.dart';
|
||||
import '../dialogs/close_tab_dialog.dart';
|
||||
import '../dialogs/edit_tab_dialog.dart';
|
||||
import '../dialogs/open_tab_dialog.dart';
|
||||
import '../widgets/venue_title.dart';
|
||||
import 'product_grid.dart';
|
||||
import 'tab_panel.dart';
|
||||
@@ -30,8 +31,11 @@ class BarScreenView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _BarScreenViewState extends State<BarScreenView> {
|
||||
static const _idleTimeout = Duration(minutes: 5);
|
||||
|
||||
bool _showSearch = false;
|
||||
late final StreamSubscription<RemoteSyncEvent> _remoteCloseSubscription;
|
||||
Timer? _idleTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -41,6 +45,7 @@ class _BarScreenViewState extends State<BarScreenView> {
|
||||
.read<RemoteSyncViewModel>()
|
||||
.tabsClosedRemotely
|
||||
.listen(_showRemoteClose);
|
||||
_restartIdleTimer();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
UpdateChecker.check(context);
|
||||
@@ -50,9 +55,19 @@ class _BarScreenViewState extends State<BarScreenView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_remoteCloseSubscription.cancel();
|
||||
_idleTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// A tab left open after the bar goes quiet is easy to add to by mistake,
|
||||
/// so it is put back in the list.
|
||||
void _restartIdleTimer() {
|
||||
_idleTimer?.cancel();
|
||||
_idleTimer = Timer(_idleTimeout, () {
|
||||
if (mounted) context.read<BarScreenViewModel>().deselectTab();
|
||||
});
|
||||
}
|
||||
|
||||
void _showRemoteClose(RemoteSyncEvent event) {
|
||||
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}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
showDialog<void>(
|
||||
@@ -151,150 +176,162 @@ class _BarScreenViewState extends State<BarScreenView> {
|
||||
(viewModel) => viewModel.venueName,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: VenueTitle(
|
||||
text: venueName == null || venueName.trim().isEmpty
|
||||
? l10n.barTabs
|
||||
: venueName,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs,
|
||||
isSelected: _showSearch,
|
||||
onPressed: () => setState(() => _showSearch = !_showSearch),
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
selectedIcon: const Icon(Icons.search_off_rounded),
|
||||
// Any touch counts as activity, wherever it lands
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (_) => _restartIdleTimer(),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: VenueTitle(
|
||||
text: venueName == null || venueName.trim().isEmpty
|
||||
? l10n.barTabs
|
||||
: venueName,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.admin,
|
||||
onPressed: () => context.push('/admin'),
|
||||
icon: const Icon(Icons.admin_panel_settings_outlined),
|
||||
),
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(width: 6),
|
||||
_DebugShortcut(
|
||||
tooltip: l10n.manageProducts,
|
||||
icon: Icons.inventory_2_outlined,
|
||||
location: '/products',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: l10n.openTab,
|
||||
onPressed: _openTab,
|
||||
icon: const Icon(Icons.person_add_alt_1_outlined),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_DebugShortcut(
|
||||
tooltip: l10n.tabHistory,
|
||||
icon: Icons.history_rounded,
|
||||
location: '/history',
|
||||
),
|
||||
],
|
||||
if (context.watch<RemoteSyncViewModel>().wasRevoked) ...[
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.deviceAccessRevoked,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs,
|
||||
isSelected: _showSearch,
|
||||
onPressed: () => setState(() => _showSearch = !_showSearch),
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
selectedIcon: const Icon(Icons.search_off_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.admin,
|
||||
onPressed: () => context.push('/admin'),
|
||||
icon: const Icon(Icons.cloud_off_rounded),
|
||||
icon: const Icon(Icons.admin_panel_settings_outlined),
|
||||
),
|
||||
],
|
||||
if (context.watch<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),
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(width: 6),
|
||||
_DebugShortcut(
|
||||
tooltip: l10n.manageProducts,
|
||||
icon: Icons.inventory_2_outlined,
|
||||
location: '/products',
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.refresh,
|
||||
onPressed: () => context.read<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,
|
||||
onItemQuantityChanged: viewModel.changeItemQuantity,
|
||||
onCloseTabPressed: () => confirmCloseTab(context),
|
||||
onTabEdited: (tab) => showEditTabDialog(context, tab),
|
||||
onTabDeleted: (tab) => confirmDeleteTab(context, tab),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_DebugShortcut(
|
||||
tooltip: l10n.tabHistory,
|
||||
icon: Icons.history_rounded,
|
||||
location: '/history',
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
if (context.watch<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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,17 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bar_tab.dart';
|
||||
import '../../models/tab_item.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../utils/tab_orders.dart';
|
||||
import '../widgets/order_divider.dart';
|
||||
|
||||
/// Changes how many of [item] are on the tab: [delta] > 0 rings up more,
|
||||
/// [delta] < 0 takes drinks of order [orderId] off.
|
||||
typedef ItemQuantityChanged =
|
||||
Future<void> Function(TabItem item, int delta, String? orderId);
|
||||
|
||||
class SelectedTabDetails extends StatelessWidget {
|
||||
final BarTab tab;
|
||||
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
||||
final ItemQuantityChanged onItemQuantityChanged;
|
||||
final VoidCallback onCloseTabPressed;
|
||||
|
||||
const SelectedTabDetails({
|
||||
@@ -44,17 +51,11 @@ class SelectedTabDetails extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: tab.items.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final item = tab.items[index];
|
||||
|
||||
return TabItemRow(
|
||||
item: item,
|
||||
onQuantityChanged: onItemQuantityChanged,
|
||||
);
|
||||
},
|
||||
: _OrderList(
|
||||
// Another tab starts with its own orders folded
|
||||
key: ValueKey(tab.id),
|
||||
orders: ordersOfTab(tab),
|
||||
onItemQuantityChanged: onItemQuantityChanged,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -102,13 +103,82 @@ class SelectedTabDetails extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab's orders, oldest first, each under the time it started.
|
||||
class _OrderList extends StatefulWidget {
|
||||
final List<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 {
|
||||
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({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.quantity,
|
||||
required this.orderId,
|
||||
required this.onQuantityChanged,
|
||||
});
|
||||
|
||||
@@ -116,7 +186,7 @@ class TabItemRow extends StatelessWidget {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
try {
|
||||
await onQuantityChanged(item, delta);
|
||||
await onQuantityChanged(item, delta, orderId);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
@@ -145,7 +215,7 @@ class TabItemRow extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${item.quantity} × ${item.unitPriceInCents.asEuro}',
|
||||
'$quantity × ${item.unitPriceInCents.asEuro}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -167,7 +237,7 @@ class TabItemRow extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 22,
|
||||
child: Text(
|
||||
'${item.quantity}',
|
||||
'$quantity',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
@@ -183,7 +253,7 @@ class TabItemRow extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
item.lineTotalInCents.asEuro,
|
||||
(quantity * item.unitPriceInCents).asEuro,
|
||||
textAlign: TextAlign.end,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bar_tab.dart';
|
||||
import '../../models/tab_item.dart';
|
||||
import '../../utils/easter_eggs.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../utils/name_index.dart';
|
||||
@@ -42,7 +41,8 @@ class TabPanel extends StatefulWidget {
|
||||
final String? selectedTabId;
|
||||
final bool showSearch;
|
||||
final ValueChanged<String> onTabSelected;
|
||||
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
||||
final VoidCallback onTabDeselected;
|
||||
final ItemQuantityChanged onItemQuantityChanged;
|
||||
final VoidCallback onCloseTabPressed;
|
||||
final Future<void> Function(BarTab) onTabEdited;
|
||||
final Future<void> Function(BarTab) onTabDeleted;
|
||||
@@ -54,6 +54,7 @@ class TabPanel extends StatefulWidget {
|
||||
required this.selectedTabId,
|
||||
required this.showSearch,
|
||||
required this.onTabSelected,
|
||||
required this.onTabDeselected,
|
||||
required this.onItemQuantityChanged,
|
||||
required this.onCloseTabPressed,
|
||||
required this.onTabEdited,
|
||||
@@ -77,13 +78,23 @@ class _TabPanelState extends State<TabPanel> {
|
||||
/// The names-list offset the selected tile was last lined up at.
|
||||
double? _attachedOffset;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
/// The first row of the names list, measured for [_rowExtent].
|
||||
final GlobalKey _firstRowKey = GlobalKey();
|
||||
|
||||
// The row height is only known once the list is laid out
|
||||
/// The height of every row in the names list, gap included. Measured, as
|
||||
/// a short list can't tell it from its scroll range.
|
||||
double? _rowExtent;
|
||||
|
||||
/// The row height is only known once the list is laid out (and changes
|
||||
/// with the text size).
|
||||
void _measureRow() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() {});
|
||||
if (!mounted) return;
|
||||
final box = _firstRowKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return;
|
||||
if (box.size.height != _rowExtent) {
|
||||
setState(() => _rowExtent = box.size.height);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,11 +110,12 @@ class _TabPanelState extends State<TabPanel> {
|
||||
if (tab == null || _focus == _PanelFocus.names) return null;
|
||||
|
||||
final index = tabs.indexWhere((candidate) => candidate.id == tab.id);
|
||||
final extent = _tabExtent(tabs.length);
|
||||
final extent = _rowExtent;
|
||||
if (index == -1 || extent == null) return null;
|
||||
|
||||
final offset = (index + 1) * extent - _tabRowGap - viewportHeight;
|
||||
return offset < 0 ? null : offset;
|
||||
// Rounding can leave a sliver below zero
|
||||
return offset < -0.5 ? null : max(offset, 0);
|
||||
}
|
||||
|
||||
void _lineUpSelectedTab(double? offset) {
|
||||
@@ -152,12 +164,12 @@ class _TabPanelState extends State<TabPanel> {
|
||||
_searchQuery = '';
|
||||
}
|
||||
|
||||
// Adding products means working on the selected tab
|
||||
// Adding products, or opening a tab from the Open Tab dialog, means
|
||||
// working on the selected tab
|
||||
final tab = widget.selectedTab;
|
||||
final oldTab = oldWidget.selectedTab;
|
||||
if (tab != null &&
|
||||
tab.id == oldTab?.id &&
|
||||
tab.itemCount != oldTab?.itemCount) {
|
||||
(tab.id != oldTab?.id || tab.itemCount != oldTab?.itemCount)) {
|
||||
_focus = _PanelFocus.items;
|
||||
}
|
||||
}
|
||||
@@ -169,14 +181,6 @@ class _TabPanelState extends State<TabPanel> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Every tab row has the same height, so it follows from the scroll range.
|
||||
double? _tabExtent(int tabCount) {
|
||||
if (!_tabsScrollController.hasClients || tabCount == 0) return null;
|
||||
|
||||
final position = _tabsScrollController.position;
|
||||
return (position.maxScrollExtent + position.viewportDimension) / tabCount;
|
||||
}
|
||||
|
||||
/// Scrolls to the first tab at or after [letter], so a letter without names
|
||||
/// lands on the next one.
|
||||
void _jumpToLetter(String letter, List<BarTab> tabs) {
|
||||
@@ -186,8 +190,8 @@ class _TabPanelState extends State<TabPanel> {
|
||||
setState(() => _touchedLetter = letter);
|
||||
}
|
||||
|
||||
final extent = _tabExtent(tabs.length);
|
||||
if (extent == null) return;
|
||||
final extent = _rowExtent;
|
||||
if (extent == null || !_tabsScrollController.hasClients) return;
|
||||
|
||||
final position = _tabsScrollController.position;
|
||||
final start = indexLetters.indexOf(letter);
|
||||
@@ -205,8 +209,8 @@ class _TabPanelState extends State<TabPanel> {
|
||||
String? _visibleLetter(List<BarTab> tabs) {
|
||||
if (tabs.isEmpty) return null;
|
||||
|
||||
final extent = _tabExtent(tabs.length);
|
||||
final index = extent == null
|
||||
final extent = _rowExtent;
|
||||
final index = extent == null || !_tabsScrollController.hasClients
|
||||
? 0
|
||||
: (_tabsScrollController.offset / extent).round().clamp(
|
||||
0,
|
||||
@@ -226,6 +230,7 @@ class _TabPanelState extends State<TabPanel> {
|
||||
_searchQuery.toLowerCase(),
|
||||
);
|
||||
}).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName));
|
||||
_measureRow();
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(color: scheme.surfaceContainerLow),
|
||||
@@ -236,7 +241,7 @@ class _TabPanelState extends State<TabPanel> {
|
||||
children: [
|
||||
ListenableBuilder(
|
||||
listenable: _tabsScrollController,
|
||||
builder: (context, child) => _LetterIndexBar(
|
||||
builder: (context, child) => LetterIndexBar(
|
||||
availableLetters: {
|
||||
for (final tab in filteredTabs)
|
||||
indexLetterOf(tab.customerName),
|
||||
@@ -262,11 +267,30 @@ class _TabPanelState extends State<TabPanel> {
|
||||
? _PanelFocus.balanced
|
||||
: _focus;
|
||||
final searchHeight = widget.showSearch ? _searchHeight : 0.0;
|
||||
final namesHeight = switch (focus) {
|
||||
_PanelFocus.balanced => min(246.0, available / 2),
|
||||
_PanelFocus.names => available - collapsedItems,
|
||||
_PanelFocus.items => collapsedNames + searchHeight,
|
||||
};
|
||||
// Without a selected tab there are no items to show, so
|
||||
// the names get the whole panel
|
||||
final hasSelection = widget.selectedTab != null;
|
||||
final selectedIndex = filteredTabs.indexWhere(
|
||||
(tab) => tab.id == widget.selectedTabId,
|
||||
);
|
||||
final rowExtent = _rowExtent;
|
||||
final namesHeight = !hasSelection
|
||||
? constraints.maxHeight
|
||||
: switch (focus) {
|
||||
_PanelFocus.balanced => min(246.0, available / 2),
|
||||
_PanelFocus.names => available - collapsedItems,
|
||||
// A tile near the top can't scroll down to its
|
||||
// items, so the names shrink up to it instead
|
||||
_PanelFocus.items =>
|
||||
(selectedIndex == -1 || rowExtent == null
|
||||
? collapsedNames
|
||||
: min(
|
||||
collapsedNames,
|
||||
(selectedIndex + 1) * rowExtent -
|
||||
_tabRowGap,
|
||||
)) +
|
||||
searchHeight,
|
||||
};
|
||||
|
||||
final attachOffset = _attachOffset(
|
||||
filteredTabs,
|
||||
@@ -335,12 +359,19 @@ class _TabPanelState extends State<TabPanel> {
|
||||
},
|
||||
child: OpenTabsList(
|
||||
controller: _tabsScrollController,
|
||||
firstRowKey: _firstRowKey,
|
||||
tabs: filteredTabs,
|
||||
selectedTabId: widget.selectedTabId,
|
||||
attachedTabId: attached
|
||||
? widget.selectedTabId
|
||||
: null,
|
||||
onTabSelected: (id) {
|
||||
// Tapping the open tab again closes it
|
||||
if (id == widget.selectedTabId) {
|
||||
_focusOn(_PanelFocus.names);
|
||||
widget.onTabDeselected();
|
||||
return;
|
||||
}
|
||||
_focusOn(_PanelFocus.items);
|
||||
widget.onTabSelected(id);
|
||||
},
|
||||
@@ -352,7 +383,8 @@ class _TabPanelState extends State<TabPanel> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!attached) const Divider(height: dividerHeight),
|
||||
if (!attached && hasSelection)
|
||||
const Divider(height: dividerHeight),
|
||||
Expanded(
|
||||
child: Listener(
|
||||
onPointerDown: (_) => _focusOn(_PanelFocus.items),
|
||||
@@ -434,6 +466,9 @@ class _TabPanelState extends State<TabPanel> {
|
||||
|
||||
class OpenTabsList extends StatelessWidget {
|
||||
final ScrollController? controller;
|
||||
|
||||
/// Put on the first row, so its height can be measured.
|
||||
final Key? firstRowKey;
|
||||
final List<BarTab> tabs;
|
||||
final String? selectedTabId;
|
||||
|
||||
@@ -446,6 +481,7 @@ class OpenTabsList extends StatelessWidget {
|
||||
const OpenTabsList({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.firstRowKey,
|
||||
required this.tabs,
|
||||
required this.selectedTabId,
|
||||
this.attachedTabId,
|
||||
@@ -477,6 +513,7 @@ class OpenTabsList extends StatelessWidget {
|
||||
final attached = tab.id == attachedTabId;
|
||||
|
||||
return _TabRow(
|
||||
key: index == 0 ? firstRowKey : null,
|
||||
child: ClipRRect(
|
||||
borderRadius: attached
|
||||
? _attachedTileRadius
|
||||
@@ -520,7 +557,7 @@ class OpenTabsList extends StatelessWidget {
|
||||
class _TabRow extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _TabRow({required this.child});
|
||||
const _TabRow({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -603,14 +640,16 @@ class _TabCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _LetterIndexBar extends StatefulWidget {
|
||||
/// The A–Z strip beside a list of names: tap or slide to jump to a letter.
|
||||
class LetterIndexBar extends StatefulWidget {
|
||||
final Set<String> availableLetters;
|
||||
final String? activeLetter;
|
||||
final bool isTouched;
|
||||
final ValueChanged<String> onLetterSelected;
|
||||
final VoidCallback onReleased;
|
||||
|
||||
const _LetterIndexBar({
|
||||
const LetterIndexBar({
|
||||
super.key,
|
||||
required this.availableLetters,
|
||||
required this.activeLetter,
|
||||
required this.isTouched,
|
||||
@@ -619,10 +658,10 @@ class _LetterIndexBar extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<_LetterIndexBar> createState() => _LetterIndexBarState();
|
||||
State<LetterIndexBar> createState() => _LetterIndexBarState();
|
||||
}
|
||||
|
||||
class _LetterIndexBarState extends State<_LetterIndexBar>
|
||||
class _LetterIndexBarState extends State<LetterIndexBar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static final _zIndex = indexLetters.indexOf('Z');
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -6,6 +9,7 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../../models/payment_method.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../viewmodels/bar_screen_view_model.dart';
|
||||
import '../widgets/money_rain.dart';
|
||||
import '../widgets/slide_confirm.dart';
|
||||
|
||||
Future<void> confirmCloseTab(BuildContext context) async {
|
||||
@@ -73,6 +77,9 @@ Future<void> confirmCloseTab(BuildContext context) async {
|
||||
|
||||
try {
|
||||
await viewModel.closeSelectedTab(paymentMethod: paymentMethod);
|
||||
_playChaChing();
|
||||
// Make it rain, but only once in a hundred tabs.
|
||||
if (context.mounted && Random().nextInt(25) == 0) showMoneyRain(context);
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
@@ -81,6 +88,16 @@ Future<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';
|
||||
|
||||
/// Shows the Payconiq QR code for the customer to scan. Resolves to true
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ import '../../models/closed_tab.dart';
|
||||
import '../../models/closed_tab_item.dart';
|
||||
import '../../models/payment_method.dart';
|
||||
import '../../utils/easter_eggs.dart';
|
||||
import '../../utils/history_grouping.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../utils/tab_orders.dart';
|
||||
import 'order_history_list.dart';
|
||||
|
||||
class ClosedTabCard extends StatefulWidget {
|
||||
final ClosedTab closedTab;
|
||||
@@ -35,10 +36,6 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toLanguageTag();
|
||||
final dateFormat = DateFormat.yMMMd(locale).add_jm();
|
||||
final dayGroups = groupClosedTabItemsByDay(
|
||||
closedTab.items,
|
||||
fallbackDate: closedTab.closedAt,
|
||||
);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
@@ -159,16 +156,12 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 8),
|
||||
for (var index = 0; index < dayGroups.length; index++)
|
||||
_ClosedTabDayDropdown(
|
||||
key: ValueKey(
|
||||
'${dayGroups[index].items.first.closedTabId}-'
|
||||
'${dayGroups[index].day.toIso8601String()}',
|
||||
),
|
||||
group: dayGroups[index],
|
||||
fallbackDate: closedTab.closedAt,
|
||||
initiallyExpanded: index == 0,
|
||||
),
|
||||
OrderHistoryList(
|
||||
orders: ordersOfClosedTab(closedTab),
|
||||
productName: (ClosedTabItem item) => item.productName,
|
||||
unitPriceInCents: (ClosedTabItem item) =>
|
||||
item.unitPriceInCents,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -179,213 +172,3 @@ class _ClosedTabCardState extends State<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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import 'package:intl/intl.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bar_tab.dart';
|
||||
import '../../models/tab_item_purchase.dart';
|
||||
import '../../models/tab_item.dart';
|
||||
import '../../utils/easter_eggs.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../utils/tab_orders.dart';
|
||||
import 'order_history_list.dart';
|
||||
|
||||
class OpenTabHistoryCard extends StatefulWidget {
|
||||
final BarTab tab;
|
||||
@@ -19,42 +21,6 @@ class OpenTabHistoryCard extends StatefulWidget {
|
||||
class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final tab = widget.tab;
|
||||
@@ -163,9 +129,12 @@ class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 8),
|
||||
..._purchases(
|
||||
tab,
|
||||
).map((purchase) => _OpenPurchaseRow(purchase: purchase)),
|
||||
OrderHistoryList(
|
||||
orders: ordersOfTab(tab, includeRemoved: true),
|
||||
productName: (TabItem item) => item.productName,
|
||||
unitPriceInCents: (TabItem item) =>
|
||||
item.unitPriceInCents,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -178,81 +147,3 @@ class _OpenTabHistoryCardState extends State<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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
audioplayers:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: audioplayers
|
||||
sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.8.1"
|
||||
audioplayers_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_android
|
||||
sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.3.0"
|
||||
audioplayers_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_darwin
|
||||
sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
audioplayers_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_linux
|
||||
sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
audioplayers_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_platform_interface
|
||||
sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
audioplayers_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_web
|
||||
sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.3.0"
|
||||
audioplayers_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_windows
|
||||
sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.4.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -970,6 +1026,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.5.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@ name: kooltap
|
||||
description: "KoolTap: a tablet point-of-sale app for running bar tabs."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.4.0+1
|
||||
version: 1.4.4+0
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@@ -13,6 +13,7 @@ dependencies:
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
|
||||
audioplayers: ^6.8.1
|
||||
collection: ^1.19.1
|
||||
crypto: ^3.0.0
|
||||
cupertino_icons: ^1.0.8
|
||||
@@ -50,6 +51,7 @@ flutter:
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/Cha-Ching.mp3
|
||||
- assets/icons/payconic.svg
|
||||
- assets/payconiq/
|
||||
- assets/products/
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kooltap/models/bar_tab.dart';
|
||||
import 'package:kooltap/models/payment_method.dart';
|
||||
import 'package:kooltap/models/tab_item.dart';
|
||||
import 'package:kooltap/services/bar_tab_service.dart';
|
||||
import 'package:kooltap/viewmodels/bar_screen_view_model.dart';
|
||||
@@ -9,12 +10,22 @@ import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockBarTabService extends Mock implements BarTabService {}
|
||||
|
||||
BarTab _tab(String id) {
|
||||
BarTab _tab(String id, {int drinks = 0}) {
|
||||
return BarTab(
|
||||
id: id,
|
||||
customerName: id,
|
||||
openedAt: DateTime(2026),
|
||||
items: const [],
|
||||
items: [
|
||||
if (drinks > 0)
|
||||
TabItem(
|
||||
id: '$id-item',
|
||||
tabId: id,
|
||||
productId: 'prod-1',
|
||||
productName: 'Beer',
|
||||
quantity: drinks,
|
||||
unitPriceInCents: 400,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +49,7 @@ void main() {
|
||||
await openTabs.close();
|
||||
});
|
||||
|
||||
test('shows tabs from the stream and selects the first one', () async {
|
||||
test('starts with no open tabs and nothing selected', () async {
|
||||
viewModel.ensureLoaded();
|
||||
expect(viewModel.isLoading, true);
|
||||
|
||||
@@ -46,8 +57,62 @@ void main() {
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(viewModel.isLoading, false);
|
||||
expect(viewModel.tabs.map((tab) => tab.id), ['a', 'b']);
|
||||
expect(viewModel.selectedTabId, 'a');
|
||||
expect(viewModel.tabs, isEmpty);
|
||||
expect(viewModel.closedTabs.map((tab) => tab.id), ['a', 'b']);
|
||||
expect(viewModel.selectedTabId, isNull);
|
||||
});
|
||||
|
||||
test('tabs with drinks on them are open', () async {
|
||||
viewModel.ensureLoaded();
|
||||
openTabs.add([_tab('a', drinks: 2), _tab('b')]);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(viewModel.tabs.map((tab) => tab.id), ['a']);
|
||||
expect(viewModel.closedTabs.map((tab) => tab.id), ['b']);
|
||||
});
|
||||
|
||||
test('opening a tab shows and selects it', () async {
|
||||
viewModel.ensureLoaded();
|
||||
openTabs.add([_tab('a'), _tab('b')]);
|
||||
await pumpEventQueue();
|
||||
|
||||
viewModel.openTab('b');
|
||||
viewModel.deselectTab();
|
||||
|
||||
expect(viewModel.tabs.map((tab) => tab.id), ['b']);
|
||||
expect(viewModel.closedTabs.map((tab) => tab.id), ['a']);
|
||||
});
|
||||
|
||||
test('a tab settled elsewhere goes back to the closed tabs', () async {
|
||||
viewModel.ensureLoaded();
|
||||
openTabs.add([_tab('a', drinks: 2)]);
|
||||
await pumpEventQueue();
|
||||
viewModel.openTab('a');
|
||||
viewModel.deselectTab();
|
||||
|
||||
openTabs.add([_tab('a')]);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(viewModel.tabs, isEmpty);
|
||||
expect(viewModel.closedTabs.map((tab) => tab.id), ['a']);
|
||||
});
|
||||
|
||||
test('closing the selected tab moves it back and deselects', () async {
|
||||
when(
|
||||
() => barTabService.closeTab(
|
||||
'a',
|
||||
paymentMethod: PaymentMethod.cash,
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
viewModel.ensureLoaded();
|
||||
openTabs.add([_tab('a'), _tab('b')]);
|
||||
await pumpEventQueue();
|
||||
viewModel.openTab('a');
|
||||
|
||||
await viewModel.closeSelectedTab();
|
||||
|
||||
expect(viewModel.selectedTabId, isNull);
|
||||
expect(viewModel.tabs, isEmpty);
|
||||
});
|
||||
|
||||
test('keeps the selection while that tab still exists', () async {
|
||||
@@ -62,20 +127,17 @@ void main() {
|
||||
expect(viewModel.selectedTabId, 'b');
|
||||
});
|
||||
|
||||
test(
|
||||
'falls back to the first tab when the selected one disappears',
|
||||
() async {
|
||||
viewModel.ensureLoaded();
|
||||
openTabs.add([_tab('a'), _tab('b')]);
|
||||
await pumpEventQueue();
|
||||
viewModel.selectTab('b');
|
||||
test('selects nothing when the selected tab disappears', () async {
|
||||
viewModel.ensureLoaded();
|
||||
openTabs.add([_tab('a'), _tab('b')]);
|
||||
await pumpEventQueue();
|
||||
viewModel.selectTab('b');
|
||||
|
||||
openTabs.add([_tab('a')]);
|
||||
await pumpEventQueue();
|
||||
openTabs.add([_tab('a')]);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(viewModel.selectedTabId, 'a');
|
||||
},
|
||||
);
|
||||
expect(viewModel.selectedTabId, isNull);
|
||||
});
|
||||
|
||||
test('exposes a load error when the stream fails', () async {
|
||||
viewModel.ensureLoaded();
|
||||
@@ -97,11 +159,15 @@ void main() {
|
||||
);
|
||||
|
||||
when(
|
||||
() => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1),
|
||||
() => barTabService.adjustTabItemQuantity(
|
||||
tabItemId: 'item-1',
|
||||
delta: 1,
|
||||
order: any(named: 'order'),
|
||||
),
|
||||
).thenAnswer((_) async => throw const TabNotFoundException('tab-1'));
|
||||
|
||||
await expectLater(
|
||||
viewModel.changeItemQuantity(item, 1),
|
||||
viewModel.changeItemQuantity(item, 1, null),
|
||||
throwsA(isA<TabNotFoundException>()),
|
||||
);
|
||||
|
||||
@@ -110,7 +176,7 @@ void main() {
|
||||
|
||||
test('selects a newly created tab', () async {
|
||||
when(
|
||||
() => barTabService.createTab(customerName: 'New'),
|
||||
() => barTabService.createTab(customerName: 'New', email: null),
|
||||
).thenAnswer((_) async => _tab('new'));
|
||||
|
||||
await viewModel.createTab('New');
|
||||
|
||||
@@ -103,10 +103,16 @@ void main() {
|
||||
expect((await database.select(database.products).get()), hasLength(1));
|
||||
});
|
||||
|
||||
test('gives products no server picture yet and an empty upload queue', () async {
|
||||
final product = await database.select(database.products).getSingle();
|
||||
test(
|
||||
'gives products no server picture yet and an empty upload queue',
|
||||
() async {
|
||||
final product = await database.select(database.products).getSingle();
|
||||
|
||||
expect(product.imageId, isNull);
|
||||
expect(await database.select(database.pendingImageUploads).get(), isEmpty);
|
||||
});
|
||||
expect(product.imageId, isNull);
|
||||
expect(
|
||||
await database.select(database.pendingImageUploads).get(),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -694,6 +694,7 @@ void main() {
|
||||
productName: 'Cola',
|
||||
unitPriceInCents: 250,
|
||||
quantity: 2,
|
||||
orderId: 'order-1',
|
||||
);
|
||||
|
||||
expect(event.payload['occurredAt'], '2026-10-24T21:41:03.120Z');
|
||||
@@ -723,6 +724,7 @@ class _FixedIdEventFactory extends TabEventFactory {
|
||||
required String productName,
|
||||
required int unitPriceInCents,
|
||||
required int quantity,
|
||||
required String? orderId,
|
||||
}) {
|
||||
final event = super.lineAdded(
|
||||
tabId: tabId,
|
||||
@@ -732,6 +734,7 @@ class _FixedIdEventFactory extends TabEventFactory {
|
||||
productName: productName,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
quantity: quantity,
|
||||
orderId: orderId,
|
||||
);
|
||||
return TabEvent(id: 'fixed', payload: {...event.payload, 'id': 'fixed'});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -45,6 +45,7 @@ void main() {
|
||||
WidgetTester tester, {
|
||||
bool showSearch = false,
|
||||
BarTab? selectedTab,
|
||||
List<BarTab>? panelTabs,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(500, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -56,12 +57,13 @@ void main() {
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: TabPanel(
|
||||
tabs: tabs,
|
||||
tabs: panelTabs ?? tabs,
|
||||
selectedTab: selectedTab,
|
||||
selectedTabId: selectedTab?.id,
|
||||
showSearch: showSearch,
|
||||
onTabSelected: (_) {},
|
||||
onItemQuantityChanged: (_, _) async {},
|
||||
onTabDeselected: () {},
|
||||
onItemQuantityChanged: (_, _, _) async {},
|
||||
onCloseTabPressed: () {},
|
||||
onTabEdited: (_) async {},
|
||||
onTabDeleted: (_) async {},
|
||||
@@ -92,7 +94,7 @@ void main() {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byWidgetPredicate(
|
||||
(widget) => widget.runtimeType.toString() == '_LetterIndexBar',
|
||||
(widget) => widget.runtimeType.toString() == 'LetterIndexBar',
|
||||
),
|
||||
matching: find.text(letter),
|
||||
),
|
||||
@@ -190,6 +192,40 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
for (final (description, name, count) in [
|
||||
('the first name', 'anna', null),
|
||||
('a name in a short list', 'Bram', 3),
|
||||
]) {
|
||||
testWidgets('lines up $description on top of its items', (
|
||||
tester,
|
||||
) async {
|
||||
final panelTabs = count == null
|
||||
? tabs
|
||||
: (tabs.toList()..sort(
|
||||
(a, b) => compareByIndex(a.customerName, b.customerName),
|
||||
))
|
||||
.take(count)
|
||||
.toList();
|
||||
final tab = panelTabs.firstWhere((tab) => tab.customerName == name);
|
||||
|
||||
// Selected from outside, as the Open Tab dialog does
|
||||
await pumpPanel(tester, panelTabs: panelTabs);
|
||||
await pumpPanel(tester, panelTabs: panelTabs, selectedTab: tab);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final list = tester.getRect(find.byType(OpenTabsList));
|
||||
final details = tester.getRect(find.byType(SelectedTabDetails));
|
||||
final tile = tester.getRect(
|
||||
find
|
||||
.ancestor(of: find.text(name), matching: find.byType(ClipRRect))
|
||||
.first,
|
||||
);
|
||||
|
||||
expect(tile.bottom, closeTo(list.bottom, 1));
|
||||
expect(details.top - tile.bottom, inInclusiveRange(0, 20));
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('browsing the names shows the divider again', (tester) async {
|
||||
final marc = tabs.firstWhere((tab) => tab.customerName == 'Marc');
|
||||
await pumpPanel(tester, selectedTab: marc);
|
||||
@@ -221,7 +257,8 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('a letter without names goes to the next one', (tester) async {
|
||||
await pumpPanel(tester);
|
||||
// A selected tab keeps the list short enough to scroll Rik to the top
|
||||
await pumpPanel(tester, selectedTab: tabs.first);
|
||||
|
||||
await tapLetter(tester, 'Q');
|
||||
expect(firstVisibleName(tester), 'Rik');
|
||||
|
||||
Reference in New Issue
Block a user