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

This commit is contained in:
2026-10-28 02:11:32 +01:00
parent 1aee0541c4
commit 29ceb51177
41 changed files with 2771 additions and 812 deletions
-48
View File
@@ -1,48 +0,0 @@
import '../models/closed_tab_item.dart';
class ClosedTabItemDayGroup {
final DateTime day;
final List<ClosedTabItem> items;
const ClosedTabItemDayGroup({required this.day, required this.items});
int get itemCount => items.fold(
0,
(total, item) => total + (item.isRemoved ? 0 : item.quantity),
);
}
List<ClosedTabItemDayGroup> groupClosedTabItemsByDay(
Iterable<ClosedTabItem> items, {
required DateTime fallbackDate,
}) {
final groups = <DateTime, List<ClosedTabItem>>{};
for (final item in items) {
final localPurchasedAt = (item.purchasedAt ?? fallbackDate).toLocal();
final day = DateTime(
localPurchasedAt.year,
localPurchasedAt.month,
localPurchasedAt.day,
);
groups.putIfAbsent(day, () => []).add(item);
}
for (final group in groups.values) {
group.sort((a, b) {
final aTime = (a.purchasedAt ?? fallbackDate).toLocal();
final bTime = (b.purchasedAt ?? fallbackDate).toLocal();
final byTime = bTime.compareTo(aTime);
if (byTime != 0) return byTime;
return b.id.compareTo(a.id);
});
}
final sortedGroups = groups.entries.toList()
..sort((a, b) => b.key.compareTo(a.key));
return sortedGroups
.map((entry) => ClosedTabItemDayGroup(day: entry.key, items: entry.value))
.toList();
}
+219
View File
@@ -0,0 +1,219 @@
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
import '../l10n/app_localizations.dart';
import '../models/bar_tab.dart';
import '../models/closed_tab.dart';
import '../models/closed_tab_item.dart';
import '../models/tab_item.dart';
/// Drinks rung up without an order (before orders were tracked) that follow
/// each other this closely form one order, as on the server.
const orderGap = Duration(minutes: 5);
/// One drink, for grouping: [line] is what it is shown as, [lineKey] which
/// drinks are counted on one row.
class OrderDrink<L> {
final L line;
final Object lineKey;
final String id;
final DateTime addedAt;
final String? orderId;
final bool isRemoved;
const OrderDrink({
required this.line,
required this.lineKey,
required this.id,
required this.addedAt,
required this.orderId,
required this.isRemoved,
});
}
/// The drinks of one line within an order.
class TabOrderRow<L> {
final L line;
/// Still on the tab (or paid for, in history).
final int quantity;
/// Taken off again.
final int removed;
const TabOrderRow({
required this.line,
required this.quantity,
required this.removed,
});
}
/// Drinks ordered together, shown under one "12:32 today" divider.
class TabOrder<L> {
/// Null for drinks from before orders, grouped by time instead.
final String? orderId;
/// When its first drink was rung up.
final DateTime startedAt;
final List<TabOrderRow<L>> rows;
const TabOrder({
required this.orderId,
required this.startedAt,
required this.rows,
});
int get itemCount => rows.fold(0, (total, row) => total + row.quantity);
}
/// Splits [drinks] into their orders, oldest first. Drinks rung up at the
/// same moment keep the order they are given in.
List<TabOrder<L>> groupOrders<L>(Iterable<OrderDrink<L>> drinks) {
final sorted = drinks.toList();
mergeSort(sorted, compare: (a, b) => a.addedAt.compareTo(b.addedAt));
final orders = <_OrderBuilder<L>>[];
final byId = <String, _OrderBuilder<L>>{};
_OrderBuilder<L>? unordered;
DateTime? unorderedLastAt;
for (final drink in sorted) {
final _OrderBuilder<L> order;
final orderId = drink.orderId;
if (orderId != null) {
order = byId.putIfAbsent(orderId, () {
final created = _OrderBuilder<L>(orderId, drink.addedAt);
orders.add(created);
return created;
});
} else {
if (unordered == null ||
drink.addedAt.difference(unorderedLastAt!) > orderGap) {
unordered = _OrderBuilder<L>(null, drink.addedAt);
orders.add(unordered);
}
unorderedLastAt = drink.addedAt;
order = unordered;
}
order.add(drink);
}
// Started in the order of their first drink: already oldest first
return [for (final order in orders) order.build()];
}
class _OrderBuilder<L> {
final String? orderId;
final DateTime startedAt;
final _rows = <Object, ({L line, int quantity, int removed})>{};
_OrderBuilder(this.orderId, this.startedAt);
void add(OrderDrink<L> drink) {
final row = _rows[drink.lineKey];
_rows[drink.lineKey] = (
line: row?.line ?? drink.line,
quantity: (row?.quantity ?? 0) + (drink.isRemoved ? 0 : 1),
removed: (row?.removed ?? 0) + (drink.isRemoved ? 1 : 0),
);
}
TabOrder<L> build() => TabOrder(
orderId: orderId,
startedAt: startedAt,
rows: [
for (final row in _rows.values)
TabOrderRow(
line: row.line,
quantity: row.quantity,
removed: row.removed,
),
],
);
}
/// The orders of an open tab. Without [includeRemoved] only what is still on
/// it: rows and orders whose drinks were all taken off again are left out.
/// Drinks of an item without purchase records count as ordered when the tab
/// was opened.
List<TabOrder<TabItem>> ordersOfTab(BarTab tab, {bool includeRemoved = false}) {
final orders = groupOrders([
for (final item in tab.items)
if (item.purchases.isEmpty)
for (var index = 0; index < item.quantity; index++)
OrderDrink(
line: item,
lineKey: item.id,
id: '${item.id}-$index',
addedAt: tab.openedAt,
orderId: null,
isRemoved: false,
)
else
for (final purchase in item.purchases)
OrderDrink(
line: item,
lineKey: item.id,
id: purchase.id,
addedAt: purchase.purchasedAt,
orderId: purchase.orderId,
isRemoved: purchase.isRemoved,
),
]);
if (includeRemoved) return orders;
return [
for (final order in orders)
if (order.itemCount > 0)
TabOrder(
orderId: order.orderId,
startedAt: order.startedAt,
rows: [
for (final row in order.rows)
if (row.quantity > 0) row,
],
),
];
}
/// The orders of a paid tab, with the drinks taken off before paying.
List<TabOrder<ClosedTabItem>> ordersOfClosedTab(ClosedTab tab) {
return groupOrders([
// The items come newest first; drinks of the same moment keep their
// order, so start from the oldest
for (final item in tab.items.reversed)
for (var index = 0; index < item.quantity; index++)
OrderDrink(
line: item,
lineKey: (item.productId, item.productName, item.unitPriceInCents),
id: '${item.id}-$index',
addedAt: item.purchasedAt ?? tab.closedAt,
orderId: item.orderId,
isRemoved: item.isRemoved,
),
]);
}
/// "12:32 today", "12:32 yesterday", or the date with the time.
String orderTimeLabel(
AppLocalizations l10n,
String locale,
DateTime startedAt, {
DateTime? now,
}) {
final local = startedAt.toLocal();
final today = now ?? DateTime.now();
final day = DateTime(local.year, local.month, local.day);
final days = DateTime(
today.year,
today.month,
today.day,
).difference(day).inDays;
final time = DateFormat.Hm(locale).format(local);
return switch (days) {
0 => l10n.orderStartedToday(time),
1 => l10n.orderStartedYesterday(time),
_ => l10n.orderStartedOn(DateFormat.yMMMd(locale).format(local), time),
};
}