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:
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user