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
+86 -20
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltap/models/bar_tab.dart';
import 'package:kooltap/models/payment_method.dart';
import 'package:kooltap/models/tab_item.dart';
import 'package:kooltap/services/bar_tab_service.dart';
import 'package:kooltap/viewmodels/bar_screen_view_model.dart';
@@ -9,12 +10,22 @@ import 'package:mocktail/mocktail.dart';
class MockBarTabService extends Mock implements BarTabService {}
BarTab _tab(String id) {
BarTab _tab(String id, {int drinks = 0}) {
return BarTab(
id: id,
customerName: id,
openedAt: DateTime(2026),
items: const [],
items: [
if (drinks > 0)
TabItem(
id: '$id-item',
tabId: id,
productId: 'prod-1',
productName: 'Beer',
quantity: drinks,
unitPriceInCents: 400,
),
],
);
}
@@ -38,7 +49,7 @@ void main() {
await openTabs.close();
});
test('shows tabs from the stream and selects the first one', () async {
test('starts with no open tabs and nothing selected', () async {
viewModel.ensureLoaded();
expect(viewModel.isLoading, true);
@@ -46,8 +57,62 @@ void main() {
await pumpEventQueue();
expect(viewModel.isLoading, false);
expect(viewModel.tabs.map((tab) => tab.id), ['a', 'b']);
expect(viewModel.selectedTabId, 'a');
expect(viewModel.tabs, isEmpty);
expect(viewModel.closedTabs.map((tab) => tab.id), ['a', 'b']);
expect(viewModel.selectedTabId, isNull);
});
test('tabs with drinks on them are open', () async {
viewModel.ensureLoaded();
openTabs.add([_tab('a', drinks: 2), _tab('b')]);
await pumpEventQueue();
expect(viewModel.tabs.map((tab) => tab.id), ['a']);
expect(viewModel.closedTabs.map((tab) => tab.id), ['b']);
});
test('opening a tab shows and selects it', () async {
viewModel.ensureLoaded();
openTabs.add([_tab('a'), _tab('b')]);
await pumpEventQueue();
viewModel.openTab('b');
viewModel.deselectTab();
expect(viewModel.tabs.map((tab) => tab.id), ['b']);
expect(viewModel.closedTabs.map((tab) => tab.id), ['a']);
});
test('a tab settled elsewhere goes back to the closed tabs', () async {
viewModel.ensureLoaded();
openTabs.add([_tab('a', drinks: 2)]);
await pumpEventQueue();
viewModel.openTab('a');
viewModel.deselectTab();
openTabs.add([_tab('a')]);
await pumpEventQueue();
expect(viewModel.tabs, isEmpty);
expect(viewModel.closedTabs.map((tab) => tab.id), ['a']);
});
test('closing the selected tab moves it back and deselects', () async {
when(
() => barTabService.closeTab(
'a',
paymentMethod: PaymentMethod.cash,
),
).thenAnswer((_) async {});
viewModel.ensureLoaded();
openTabs.add([_tab('a'), _tab('b')]);
await pumpEventQueue();
viewModel.openTab('a');
await viewModel.closeSelectedTab();
expect(viewModel.selectedTabId, isNull);
expect(viewModel.tabs, isEmpty);
});
test('keeps the selection while that tab still exists', () async {
@@ -62,20 +127,17 @@ void main() {
expect(viewModel.selectedTabId, 'b');
});
test(
'falls back to the first tab when the selected one disappears',
() async {
viewModel.ensureLoaded();
openTabs.add([_tab('a'), _tab('b')]);
await pumpEventQueue();
viewModel.selectTab('b');
test('selects nothing when the selected tab disappears', () async {
viewModel.ensureLoaded();
openTabs.add([_tab('a'), _tab('b')]);
await pumpEventQueue();
viewModel.selectTab('b');
openTabs.add([_tab('a')]);
await pumpEventQueue();
openTabs.add([_tab('a')]);
await pumpEventQueue();
expect(viewModel.selectedTabId, 'a');
},
);
expect(viewModel.selectedTabId, isNull);
});
test('exposes a load error when the stream fails', () async {
viewModel.ensureLoaded();
@@ -97,11 +159,15 @@ void main() {
);
when(
() => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1),
() => barTabService.adjustTabItemQuantity(
tabItemId: 'item-1',
delta: 1,
order: any(named: 'order'),
),
).thenAnswer((_) async => throw const TabNotFoundException('tab-1'));
await expectLater(
viewModel.changeItemQuantity(item, 1),
viewModel.changeItemQuantity(item, 1, null),
throwsA(isA<TabNotFoundException>()),
);
@@ -110,7 +176,7 @@ void main() {
test('selects a newly created tab', () async {
when(
() => barTabService.createTab(customerName: 'New'),
() => barTabService.createTab(customerName: 'New', email: null),
).thenAnswer((_) async => _tab('new'));
await viewModel.createTab('New');
+11 -5
View File
@@ -103,10 +103,16 @@ void main() {
expect((await database.select(database.products).get()), hasLength(1));
});
test('gives products no server picture yet and an empty upload queue', () async {
final product = await database.select(database.products).getSingle();
test(
'gives products no server picture yet and an empty upload queue',
() async {
final product = await database.select(database.products).getSingle();
expect(product.imageId, isNull);
expect(await database.select(database.pendingImageUploads).get(), isEmpty);
});
expect(product.imageId, isNull);
expect(
await database.select(database.pendingImageUploads).get(),
isEmpty,
);
},
);
}
-35
View File
@@ -1,35 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltap/models/closed_tab_item.dart';
import 'package:kooltap/utils/history_grouping.dart';
void main() {
test('groups items by purchase day with newest day first', () {
final items = [
_closedItem('older', DateTime(2026, 8, 1, 22)),
_closedItem('newer-b', DateTime(2026, 8, 3, 18)),
_closedItem('newer-a', DateTime(2026, 8, 3, 10)),
];
final groups = groupClosedTabItemsByDay(
items,
fallbackDate: DateTime(2026, 8, 4),
);
expect(groups, hasLength(2));
expect(groups.first.day, DateTime(2026, 8, 3));
expect(groups.first.items.map((item) => item.id), ['newer-b', 'newer-a']);
expect(groups.last.day, DateTime(2026, 8, 1));
});
}
ClosedTabItem _closedItem(String id, DateTime purchasedAt) {
return ClosedTabItem(
id: id,
closedTabId: 'closed-tab',
productId: 'product-$id',
productName: id,
quantity: 1,
unitPriceInCents: 100,
purchasedAt: purchasedAt,
);
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltap/views/widgets/money_rain.dart';
void main() {
testWidgets('money rain covers the screen for three seconds', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => TextButton(
onPressed: () => showMoneyRain(context),
child: const Text('Pay'),
),
),
),
);
await tester.tap(find.text('Pay'));
await tester.pump();
expect(find.byType(IgnorePointer), findsWidgets);
await tester.pump(const Duration(milliseconds: 1500));
final painted = find.byWidgetPredicate(
(widget) => widget is CustomPaint && widget.size == Size.infinite,
);
expect(painted, findsOneWidget);
await tester.pump(const Duration(milliseconds: 1600));
await tester.pump();
expect(painted, findsNothing);
});
}
+3
View File
@@ -694,6 +694,7 @@ void main() {
productName: 'Cola',
unitPriceInCents: 250,
quantity: 2,
orderId: 'order-1',
);
expect(event.payload['occurredAt'], '2026-10-24T21:41:03.120Z');
@@ -723,6 +724,7 @@ class _FixedIdEventFactory extends TabEventFactory {
required String productName,
required int unitPriceInCents,
required int quantity,
required String? orderId,
}) {
final event = super.lineAdded(
tabId: tabId,
@@ -732,6 +734,7 @@ class _FixedIdEventFactory extends TabEventFactory {
productName: productName,
unitPriceInCents: unitPriceInCents,
quantity: quantity,
orderId: orderId,
);
return TabEvent(id: 'fixed', payload: {...event.payload, 'id': 'fixed'});
}
+509
View File
@@ -0,0 +1,509 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter/material.dart' hide Table;
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:kooltap/database/app_database.dart';
import 'package:kooltap/l10n/app_localizations.dart';
import 'package:kooltap/models/bar_tab.dart';
import 'package:kooltap/models/product.dart';
import 'package:kooltap/models/server_tab.dart';
import 'package:kooltap/models/tab_item.dart';
import 'package:kooltap/models/tab_item_purchase.dart';
import 'package:kooltap/services/bar_tab_service.dart';
import 'package:kooltap/utils/money.dart';
import 'package:kooltap/utils/tab_orders.dart';
import 'package:kooltap/viewmodels/bar_screen_view_model.dart';
import 'package:kooltap/views/bar/selected_tab_details.dart';
import 'package:mocktail/mocktail.dart';
const _duvel = Product(
id: 'duvel',
name: 'Duvel',
category: 'Drinks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 400,
);
const _cola = Product(
id: 'cola',
name: 'Cola',
category: 'Drinks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 250,
);
class MockBarTabService extends Mock implements BarTabService {}
OrderDrink<String> _drink(
String name,
DateTime at, {
String? orderId,
bool removed = false,
}) {
return OrderDrink(
line: name,
lineKey: name,
id: '$name-${at.toIso8601String()}-$removed',
addedAt: at,
orderId: orderId,
isRemoved: removed,
);
}
List<List<Object?>> _summary(List<TabOrder<String>> orders) => [
for (final order in orders)
[
order.orderId,
order.startedAt,
[for (final row in order.rows) '${row.quantity}x ${row.line}'],
],
];
void main() {
final t1232 = DateTime(2026, 10, 28, 12, 32);
final t1310 = DateTime(2026, 10, 28, 13, 10);
group('groupOrders', () {
test('shows each order under the time it started, oldest first', () {
final orders = groupOrders([
_drink('Duvel', t1310, orderId: 'o2'),
_drink('Duvel', t1232, orderId: 'o1'),
_drink('Duvel', t1232, orderId: 'o1'),
_drink('Cola', t1232.add(const Duration(minutes: 1)), orderId: 'o1'),
_drink('Beer', t1310.add(const Duration(minutes: 1)), orderId: 'o2'),
]);
expect(_summary(orders), [
[
'o1',
t1232,
['2x Duvel', '1x Cola'],
],
[
'o2',
t1310,
['1x Duvel', '1x Beer'],
],
]);
});
test('groups drinks without an order by time: over 5 minutes apart is '
'a new order', () {
final orders = groupOrders([
_drink('Duvel', t1232),
_drink('Cola', t1232.add(const Duration(minutes: 4))),
_drink('Beer', t1310),
]);
expect(_summary(orders), [
[
null,
t1232,
['1x Duvel', '1x Cola'],
],
[
null,
t1310,
['1x Beer'],
],
]);
});
test('counts drinks taken off again apart', () {
final orders = groupOrders([
_drink('Duvel', t1232, orderId: 'o1'),
_drink('Duvel', t1232, orderId: 'o1', removed: true),
]);
expect(orders.single.rows.single.quantity, 1);
expect(orders.single.rows.single.removed, 1);
});
});
test('orderTimeLabel says today, yesterday or the date', () async {
await initializeDateFormatting('en');
final l10n = lookupAppLocalizations(const Locale('en'));
final now = DateTime(2026, 10, 28, 14);
expect(orderTimeLabel(l10n, 'en', t1232, now: now), '12:32 today');
expect(
orderTimeLabel(
l10n,
'en',
t1232.subtract(const Duration(days: 1)),
now: now,
),
'12:32 yesterday',
);
expect(
orderTimeLabel(l10n, 'en', DateTime(2026, 10, 20, 9, 5), now: now),
'Oct 20, 2026 09:05',
);
});
group('DriftBarTabService orders', () {
late AppDatabase database;
late DriftBarTabService service;
setUp(() async {
database = AppDatabase(NativeDatabase.memory());
service = DriftBarTabService(database: database);
for (final product in [_duvel, _cola]) {
await database
.into(database.products)
.insert(
ProductsCompanion.insert(
id: product.id,
name: product.name,
category: product.category,
stockQuantity: product.stockQuantity,
lowStockThreshold: product.lowStockThreshold,
priceInCents: product.priceInCents,
),
);
}
});
tearDown(() => database.close());
Future<List<Map<String, dynamic>>> outbox() async {
final rows =
await (database.select(database.tabEvents)..orderBy([
(row) => OrderingTerm.asc(const CustomExpression('rowid')),
]))
.get();
return [
for (final row in rows) jsonDecode(row.payload) as Map<String, dynamic>,
];
}
/// 2 Duvel and 1 Cola in order o1, then 1 Duvel in o2.
Future<BarTab> ringUp() async {
final tab = await service.createTab(customerName: 'Bram');
for (final (product, orderId) in [
(_duvel, 'o1'),
(_duvel, 'o1'),
(_cola, 'o1'),
(_duvel, 'o2'),
]) {
// Taps are never at the same moment
await Future<void>.delayed(const Duration(milliseconds: 5));
await service.addProductToTab(
tabId: tab.id,
product: product,
orderId: orderId,
);
}
return (await service.getTabById(tab.id))!;
}
test(
'keeps the order of every drink and sends it with the event',
() async {
final tab = await ringUp();
expect(
[
for (final order in ordersOfTab(tab))
[
order.orderId,
[
for (final row in order.rows)
'${row.quantity}x ${row.line.productName}',
],
],
],
[
[
'o1',
['2x Duvel', '1x Cola'],
],
[
'o2',
['1x Duvel'],
],
],
);
expect((await outbox()).skip(1).map((e) => e['orderId']), [
'o1',
'o1',
'o1',
'o2',
]);
},
);
test('takes a drink off the order it is shown in', () async {
final tab = await ringUp();
final duvel = tab.items.firstWhere((item) => item.productId == 'duvel');
final applied = await service.adjustTabItemQuantity(
tabItemId: duvel.id,
delta: -5,
order: (orderId: 'o2'),
);
expect(applied, -1);
final orders = ordersOfTab((await service.getTabById(tab.id))!);
expect(orders.map((order) => order.orderId), ['o1']);
expect((await outbox()).last, containsPair('orderId', 'o2'));
});
test('deleting a tab takes every line off whatever its orders', () async {
final tab = await ringUp();
await service.deleteTab(tab.id);
final removals = (await outbox()).where(
(e) => e['type'] == 'line_removed',
);
// Without an order: the server takes everything on the line off
expect([
for (final e in removals)
'${e['productName']} ${e.containsKey('orderId')} ${e['quantity']}',
], unorderedEquals(['Duvel false 3', 'Cola false 1']));
});
test('a paid tab keeps its orders in history', () async {
final tab = await ringUp();
await service.closeTab(tab.id);
final closed = (await service.getClosedTabs()).single;
expect(ordersOfClosedTab(closed).map((order) => order.orderId), [
'o1',
'o2',
]);
});
test(
'replays a removal on the order it was made on, after a sync',
() async {
final openedAt = DateTime(2026, 10, 28, 12);
ServerUnit unit(String id, String orderId, int minutes) => ServerUnit(
id: id,
addedAt: openedAt.add(Duration(minutes: minutes)),
orderId: orderId,
);
final serverTab = ServerTab(
id: 'tab-1',
customerName: 'Bram',
openedAt: openedAt,
version: 3,
lines: [
ServerTabLine(
id: 'line-1',
productId: 'duvel',
productName: 'Duvel',
unitPriceInCents: 400,
quantity: 2,
units: [unit('u1', 'o1', 0), unit('u2', 'o2', 40)],
),
],
);
await service.applyServerState([serverTab]);
final tab = (await service.getTabById('tab-1'))!;
await service.adjustTabItemQuantity(
tabItemId: tab.items.single.id,
delta: -1,
order: (orderId: 'o1'),
);
// The server has not seen the removal yet: it is replayed on top
await service.applyServerState([serverTab]);
final orders = ordersOfTab((await service.getTabById('tab-1'))!);
expect(orders.map((order) => order.orderId), ['o2']);
},
);
test('a drink keeps its order when a server without orders sends it '
'back', () async {
final tab = await ringUp();
final events = await outbox();
// The server applied every event and knows nothing of orders
await database.delete(database.tabEvents).go();
final serverTab = ServerTab(
id: tab.id,
customerName: tab.customerName,
openedAt: tab.openedAt,
version: tab.version,
lines: [
for (final item in tab.items)
ServerTabLine(
id: item.id,
productId: item.productId,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
quantity: item.quantity,
units: [
for (final event in events)
if (event['lineId'] == item.id)
ServerUnit(
id: '${event['id']}:0',
addedAt: DateTime.parse(
event['occurredAt'] as String,
).toLocal(),
),
],
),
],
);
await service.applyServerState([serverTab]);
final orders = ordersOfTab((await service.getTabById(tab.id))!);
expect(orders.map((order) => order.orderId), ['o1', 'o2']);
});
});
group('SelectedTabDetails', () {
BarTab tabWith(List<(String product, String orderId, DateTime at)> drinks) {
final purchases = <String, List<TabItemPurchase>>{};
for (final (index, (product, orderId, at)) in drinks.indexed) {
purchases
.putIfAbsent(product, () => [])
.add(
TabItemPurchase(
id: 'p$index',
tabItemId: product,
purchasedAt: at,
orderId: orderId,
),
);
}
return BarTab(
id: 'tab-1',
customerName: 'Bram',
openedAt: t1232,
items: [
for (final MapEntry(key: product, value: units) in purchases.entries)
TabItem(
id: product,
tabId: 'tab-1',
productId: product,
productName: product,
quantity: units.length,
unitPriceInCents: 400,
purchases: units,
),
],
);
}
testWidgets('folds earlier orders under their time and total', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SelectedTabDetails(
tab: tabWith([
(
'Duvel',
'o1',
DateTime.now().subtract(const Duration(hours: 1)),
),
(
'Duvel',
'o1',
DateTime.now().subtract(const Duration(hours: 1)),
),
('Beer', 'o2', DateTime.now()),
]),
onItemQuantityChanged: (_, _, _) async {},
onCloseTabPressed: () {},
),
),
),
);
// The earlier order shows its total only; the current one its drinks
expect(find.textContaining('– ${800.asEuro}'), findsOneWidget);
expect(find.text('Duvel'), findsNothing);
expect(find.text('Beer'), findsOneWidget);
await tester.tap(find.textContaining('– ${800.asEuro}'));
await tester.pump();
expect(find.text('Duvel'), findsOneWidget);
});
});
group('BarScreenViewModel orders', () {
late MockBarTabService barTabService;
late StreamController<List<BarTab>> openTabs;
late DateTime now;
late BarScreenViewModel viewModel;
final orderIds = <String?>[];
setUp(() async {
barTabService = MockBarTabService();
openTabs = StreamController<List<BarTab>>.broadcast();
when(
() => barTabService.watchOpenTabs(),
).thenAnswer((_) => openTabs.stream);
orderIds.clear();
when(
() => barTabService.addProductToTab(
tabId: any(named: 'tabId'),
product: any(named: 'product'),
orderId: any(named: 'orderId'),
),
).thenAnswer((invocation) async {
orderIds.add(invocation.namedArguments[#orderId] as String?);
});
now = DateTime(2026, 10, 28, 12, 32);
viewModel = BarScreenViewModel(
barTabService: barTabService,
now: () => now,
);
viewModel.ensureLoaded();
openTabs.add([
BarTab(id: 'a', customerName: 'Bram', openedAt: now, items: const []),
BarTab(id: 'b', customerName: 'Jan', openedAt: now, items: const []),
]);
await pumpEventQueue();
viewModel.openTab('a');
});
setUpAll(() => registerFallbackValue(_duvel));
tearDown(() async {
viewModel.dispose();
await openTabs.close();
});
test('drinks within 5 minutes of each other are one order', () async {
await viewModel.addProductToSelectedTab(_duvel);
now = now.add(const Duration(minutes: 4));
await viewModel.addProductToSelectedTab(_cola);
now = now.add(const Duration(minutes: 5));
await viewModel.addProductToSelectedTab(_duvel);
expect(orderIds[0], orderIds[1]);
expect(orderIds[2], isNot(orderIds[1]));
});
test('closing the tab and coming back starts a new order', () async {
await viewModel.addProductToSelectedTab(_duvel);
viewModel.deselectTab();
viewModel.selectTab('a');
await viewModel.addProductToSelectedTab(_duvel);
viewModel.selectTab('b');
viewModel.selectTab('a');
await viewModel.addProductToSelectedTab(_duvel);
expect(orderIds.toSet(), hasLength(3));
});
});
}
+41 -4
View File
@@ -45,6 +45,7 @@ void main() {
WidgetTester tester, {
bool showSearch = false,
BarTab? selectedTab,
List<BarTab>? panelTabs,
}) async {
tester.view.physicalSize = const Size(500, 800);
tester.view.devicePixelRatio = 1;
@@ -56,12 +57,13 @@ void main() {
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: TabPanel(
tabs: tabs,
tabs: panelTabs ?? tabs,
selectedTab: selectedTab,
selectedTabId: selectedTab?.id,
showSearch: showSearch,
onTabSelected: (_) {},
onItemQuantityChanged: (_, _) async {},
onTabDeselected: () {},
onItemQuantityChanged: (_, _, _) async {},
onCloseTabPressed: () {},
onTabEdited: (_) async {},
onTabDeleted: (_) async {},
@@ -92,7 +94,7 @@ void main() {
await tester.tap(
find.descendant(
of: find.byWidgetPredicate(
(widget) => widget.runtimeType.toString() == '_LetterIndexBar',
(widget) => widget.runtimeType.toString() == 'LetterIndexBar',
),
matching: find.text(letter),
),
@@ -190,6 +192,40 @@ void main() {
);
});
for (final (description, name, count) in [
('the first name', 'anna', null),
('a name in a short list', 'Bram', 3),
]) {
testWidgets('lines up $description on top of its items', (
tester,
) async {
final panelTabs = count == null
? tabs
: (tabs.toList()..sort(
(a, b) => compareByIndex(a.customerName, b.customerName),
))
.take(count)
.toList();
final tab = panelTabs.firstWhere((tab) => tab.customerName == name);
// Selected from outside, as the Open Tab dialog does
await pumpPanel(tester, panelTabs: panelTabs);
await pumpPanel(tester, panelTabs: panelTabs, selectedTab: tab);
await tester.pumpAndSettle();
final list = tester.getRect(find.byType(OpenTabsList));
final details = tester.getRect(find.byType(SelectedTabDetails));
final tile = tester.getRect(
find
.ancestor(of: find.text(name), matching: find.byType(ClipRRect))
.first,
);
expect(tile.bottom, closeTo(list.bottom, 1));
expect(details.top - tile.bottom, inInclusiveRange(0, 20));
});
}
testWidgets('browsing the names shows the divider again', (tester) async {
final marc = tabs.firstWhere((tab) => tab.customerName == 'Marc');
await pumpPanel(tester, selectedTab: marc);
@@ -221,7 +257,8 @@ void main() {
});
testWidgets('a letter without names goes to the next one', (tester) async {
await pumpPanel(tester);
// A selected tab keeps the list short enough to scroll Rik to the top
await pumpPanel(tester, selectedTab: tabs.first);
await tapLetter(tester, 'Q');
expect(firstVisibleName(tester), 'Rik');