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
+509
View File
@@ -0,0 +1,509 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter/material.dart' hide Table;
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:kooltap/database/app_database.dart';
import 'package:kooltap/l10n/app_localizations.dart';
import 'package:kooltap/models/bar_tab.dart';
import 'package:kooltap/models/product.dart';
import 'package:kooltap/models/server_tab.dart';
import 'package:kooltap/models/tab_item.dart';
import 'package:kooltap/models/tab_item_purchase.dart';
import 'package:kooltap/services/bar_tab_service.dart';
import 'package:kooltap/utils/money.dart';
import 'package:kooltap/utils/tab_orders.dart';
import 'package:kooltap/viewmodels/bar_screen_view_model.dart';
import 'package:kooltap/views/bar/selected_tab_details.dart';
import 'package:mocktail/mocktail.dart';
const _duvel = Product(
id: 'duvel',
name: 'Duvel',
category: 'Drinks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 400,
);
const _cola = Product(
id: 'cola',
name: 'Cola',
category: 'Drinks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 250,
);
class MockBarTabService extends Mock implements BarTabService {}
OrderDrink<String> _drink(
String name,
DateTime at, {
String? orderId,
bool removed = false,
}) {
return OrderDrink(
line: name,
lineKey: name,
id: '$name-${at.toIso8601String()}-$removed',
addedAt: at,
orderId: orderId,
isRemoved: removed,
);
}
List<List<Object?>> _summary(List<TabOrder<String>> orders) => [
for (final order in orders)
[
order.orderId,
order.startedAt,
[for (final row in order.rows) '${row.quantity}x ${row.line}'],
],
];
void main() {
final t1232 = DateTime(2026, 10, 28, 12, 32);
final t1310 = DateTime(2026, 10, 28, 13, 10);
group('groupOrders', () {
test('shows each order under the time it started, oldest first', () {
final orders = groupOrders([
_drink('Duvel', t1310, orderId: 'o2'),
_drink('Duvel', t1232, orderId: 'o1'),
_drink('Duvel', t1232, orderId: 'o1'),
_drink('Cola', t1232.add(const Duration(minutes: 1)), orderId: 'o1'),
_drink('Beer', t1310.add(const Duration(minutes: 1)), orderId: 'o2'),
]);
expect(_summary(orders), [
[
'o1',
t1232,
['2x Duvel', '1x Cola'],
],
[
'o2',
t1310,
['1x Duvel', '1x Beer'],
],
]);
});
test('groups drinks without an order by time: over 5 minutes apart is '
'a new order', () {
final orders = groupOrders([
_drink('Duvel', t1232),
_drink('Cola', t1232.add(const Duration(minutes: 4))),
_drink('Beer', t1310),
]);
expect(_summary(orders), [
[
null,
t1232,
['1x Duvel', '1x Cola'],
],
[
null,
t1310,
['1x Beer'],
],
]);
});
test('counts drinks taken off again apart', () {
final orders = groupOrders([
_drink('Duvel', t1232, orderId: 'o1'),
_drink('Duvel', t1232, orderId: 'o1', removed: true),
]);
expect(orders.single.rows.single.quantity, 1);
expect(orders.single.rows.single.removed, 1);
});
});
test('orderTimeLabel says today, yesterday or the date', () async {
await initializeDateFormatting('en');
final l10n = lookupAppLocalizations(const Locale('en'));
final now = DateTime(2026, 10, 28, 14);
expect(orderTimeLabel(l10n, 'en', t1232, now: now), '12:32 today');
expect(
orderTimeLabel(
l10n,
'en',
t1232.subtract(const Duration(days: 1)),
now: now,
),
'12:32 yesterday',
);
expect(
orderTimeLabel(l10n, 'en', DateTime(2026, 10, 20, 9, 5), now: now),
'Oct 20, 2026 09:05',
);
});
group('DriftBarTabService orders', () {
late AppDatabase database;
late DriftBarTabService service;
setUp(() async {
database = AppDatabase(NativeDatabase.memory());
service = DriftBarTabService(database: database);
for (final product in [_duvel, _cola]) {
await database
.into(database.products)
.insert(
ProductsCompanion.insert(
id: product.id,
name: product.name,
category: product.category,
stockQuantity: product.stockQuantity,
lowStockThreshold: product.lowStockThreshold,
priceInCents: product.priceInCents,
),
);
}
});
tearDown(() => database.close());
Future<List<Map<String, dynamic>>> outbox() async {
final rows =
await (database.select(database.tabEvents)..orderBy([
(row) => OrderingTerm.asc(const CustomExpression('rowid')),
]))
.get();
return [
for (final row in rows) jsonDecode(row.payload) as Map<String, dynamic>,
];
}
/// 2 Duvel and 1 Cola in order o1, then 1 Duvel in o2.
Future<BarTab> ringUp() async {
final tab = await service.createTab(customerName: 'Bram');
for (final (product, orderId) in [
(_duvel, 'o1'),
(_duvel, 'o1'),
(_cola, 'o1'),
(_duvel, 'o2'),
]) {
// Taps are never at the same moment
await Future<void>.delayed(const Duration(milliseconds: 5));
await service.addProductToTab(
tabId: tab.id,
product: product,
orderId: orderId,
);
}
return (await service.getTabById(tab.id))!;
}
test(
'keeps the order of every drink and sends it with the event',
() async {
final tab = await ringUp();
expect(
[
for (final order in ordersOfTab(tab))
[
order.orderId,
[
for (final row in order.rows)
'${row.quantity}x ${row.line.productName}',
],
],
],
[
[
'o1',
['2x Duvel', '1x Cola'],
],
[
'o2',
['1x Duvel'],
],
],
);
expect((await outbox()).skip(1).map((e) => e['orderId']), [
'o1',
'o1',
'o1',
'o2',
]);
},
);
test('takes a drink off the order it is shown in', () async {
final tab = await ringUp();
final duvel = tab.items.firstWhere((item) => item.productId == 'duvel');
final applied = await service.adjustTabItemQuantity(
tabItemId: duvel.id,
delta: -5,
order: (orderId: 'o2'),
);
expect(applied, -1);
final orders = ordersOfTab((await service.getTabById(tab.id))!);
expect(orders.map((order) => order.orderId), ['o1']);
expect((await outbox()).last, containsPair('orderId', 'o2'));
});
test('deleting a tab takes every line off whatever its orders', () async {
final tab = await ringUp();
await service.deleteTab(tab.id);
final removals = (await outbox()).where(
(e) => e['type'] == 'line_removed',
);
// Without an order: the server takes everything on the line off
expect([
for (final e in removals)
'${e['productName']} ${e.containsKey('orderId')} ${e['quantity']}',
], unorderedEquals(['Duvel false 3', 'Cola false 1']));
});
test('a paid tab keeps its orders in history', () async {
final tab = await ringUp();
await service.closeTab(tab.id);
final closed = (await service.getClosedTabs()).single;
expect(ordersOfClosedTab(closed).map((order) => order.orderId), [
'o1',
'o2',
]);
});
test(
'replays a removal on the order it was made on, after a sync',
() async {
final openedAt = DateTime(2026, 10, 28, 12);
ServerUnit unit(String id, String orderId, int minutes) => ServerUnit(
id: id,
addedAt: openedAt.add(Duration(minutes: minutes)),
orderId: orderId,
);
final serverTab = ServerTab(
id: 'tab-1',
customerName: 'Bram',
openedAt: openedAt,
version: 3,
lines: [
ServerTabLine(
id: 'line-1',
productId: 'duvel',
productName: 'Duvel',
unitPriceInCents: 400,
quantity: 2,
units: [unit('u1', 'o1', 0), unit('u2', 'o2', 40)],
),
],
);
await service.applyServerState([serverTab]);
final tab = (await service.getTabById('tab-1'))!;
await service.adjustTabItemQuantity(
tabItemId: tab.items.single.id,
delta: -1,
order: (orderId: 'o1'),
);
// The server has not seen the removal yet: it is replayed on top
await service.applyServerState([serverTab]);
final orders = ordersOfTab((await service.getTabById('tab-1'))!);
expect(orders.map((order) => order.orderId), ['o2']);
},
);
test('a drink keeps its order when a server without orders sends it '
'back', () async {
final tab = await ringUp();
final events = await outbox();
// The server applied every event and knows nothing of orders
await database.delete(database.tabEvents).go();
final serverTab = ServerTab(
id: tab.id,
customerName: tab.customerName,
openedAt: tab.openedAt,
version: tab.version,
lines: [
for (final item in tab.items)
ServerTabLine(
id: item.id,
productId: item.productId,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
quantity: item.quantity,
units: [
for (final event in events)
if (event['lineId'] == item.id)
ServerUnit(
id: '${event['id']}:0',
addedAt: DateTime.parse(
event['occurredAt'] as String,
).toLocal(),
),
],
),
],
);
await service.applyServerState([serverTab]);
final orders = ordersOfTab((await service.getTabById(tab.id))!);
expect(orders.map((order) => order.orderId), ['o1', 'o2']);
});
});
group('SelectedTabDetails', () {
BarTab tabWith(List<(String product, String orderId, DateTime at)> drinks) {
final purchases = <String, List<TabItemPurchase>>{};
for (final (index, (product, orderId, at)) in drinks.indexed) {
purchases
.putIfAbsent(product, () => [])
.add(
TabItemPurchase(
id: 'p$index',
tabItemId: product,
purchasedAt: at,
orderId: orderId,
),
);
}
return BarTab(
id: 'tab-1',
customerName: 'Bram',
openedAt: t1232,
items: [
for (final MapEntry(key: product, value: units) in purchases.entries)
TabItem(
id: product,
tabId: 'tab-1',
productId: product,
productName: product,
quantity: units.length,
unitPriceInCents: 400,
purchases: units,
),
],
);
}
testWidgets('folds earlier orders under their time and total', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SelectedTabDetails(
tab: tabWith([
(
'Duvel',
'o1',
DateTime.now().subtract(const Duration(hours: 1)),
),
(
'Duvel',
'o1',
DateTime.now().subtract(const Duration(hours: 1)),
),
('Beer', 'o2', DateTime.now()),
]),
onItemQuantityChanged: (_, _, _) async {},
onCloseTabPressed: () {},
),
),
),
);
// The earlier order shows its total only; the current one its drinks
expect(find.textContaining('– ${800.asEuro}'), findsOneWidget);
expect(find.text('Duvel'), findsNothing);
expect(find.text('Beer'), findsOneWidget);
await tester.tap(find.textContaining('– ${800.asEuro}'));
await tester.pump();
expect(find.text('Duvel'), findsOneWidget);
});
});
group('BarScreenViewModel orders', () {
late MockBarTabService barTabService;
late StreamController<List<BarTab>> openTabs;
late DateTime now;
late BarScreenViewModel viewModel;
final orderIds = <String?>[];
setUp(() async {
barTabService = MockBarTabService();
openTabs = StreamController<List<BarTab>>.broadcast();
when(
() => barTabService.watchOpenTabs(),
).thenAnswer((_) => openTabs.stream);
orderIds.clear();
when(
() => barTabService.addProductToTab(
tabId: any(named: 'tabId'),
product: any(named: 'product'),
orderId: any(named: 'orderId'),
),
).thenAnswer((invocation) async {
orderIds.add(invocation.namedArguments[#orderId] as String?);
});
now = DateTime(2026, 10, 28, 12, 32);
viewModel = BarScreenViewModel(
barTabService: barTabService,
now: () => now,
);
viewModel.ensureLoaded();
openTabs.add([
BarTab(id: 'a', customerName: 'Bram', openedAt: now, items: const []),
BarTab(id: 'b', customerName: 'Jan', openedAt: now, items: const []),
]);
await pumpEventQueue();
viewModel.openTab('a');
});
setUpAll(() => registerFallbackValue(_duvel));
tearDown(() async {
viewModel.dispose();
await openTabs.close();
});
test('drinks within 5 minutes of each other are one order', () async {
await viewModel.addProductToSelectedTab(_duvel);
now = now.add(const Duration(minutes: 4));
await viewModel.addProductToSelectedTab(_cola);
now = now.add(const Duration(minutes: 5));
await viewModel.addProductToSelectedTab(_duvel);
expect(orderIds[0], orderIds[1]);
expect(orderIds[2], isNot(orderIds[1]));
});
test('closing the tab and coming back starts a new order', () async {
await viewModel.addProductToSelectedTab(_duvel);
viewModel.deselectTab();
viewModel.selectTab('a');
await viewModel.addProductToSelectedTab(_duvel);
viewModel.selectTab('b');
viewModel.selectTab('a');
await viewModel.addProductToSelectedTab(_duvel);
expect(orderIds.toSet(), hasLength(3));
});
});
}