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