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
+8 -225
View File
@@ -6,8 +6,9 @@ import '../../models/closed_tab.dart';
import '../../models/closed_tab_item.dart';
import '../../models/payment_method.dart';
import '../../utils/easter_eggs.dart';
import '../../utils/history_grouping.dart';
import '../../utils/money.dart';
import '../../utils/tab_orders.dart';
import 'order_history_list.dart';
class ClosedTabCard extends StatefulWidget {
final ClosedTab closedTab;
@@ -35,10 +36,6 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final dateFormat = DateFormat.yMMMd(locale).add_jm();
final dayGroups = groupClosedTabItemsByDay(
closedTab.items,
fallbackDate: closedTab.closedAt,
);
final scheme = Theme.of(context).colorScheme;
return Container(
@@ -159,16 +156,12 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
children: [
const Divider(height: 1),
const SizedBox(height: 8),
for (var index = 0; index < dayGroups.length; index++)
_ClosedTabDayDropdown(
key: ValueKey(
'${dayGroups[index].items.first.closedTabId}-'
'${dayGroups[index].day.toIso8601String()}',
),
group: dayGroups[index],
fallbackDate: closedTab.closedAt,
initiallyExpanded: index == 0,
),
OrderHistoryList(
orders: ordersOfClosedTab(closedTab),
productName: (ClosedTabItem item) => item.productName,
unitPriceInCents: (ClosedTabItem item) =>
item.unitPriceInCents,
),
],
),
),
@@ -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,
),
),
),
],
],
),
);
}
}
+135
View File
@@ -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;
}
+9 -118
View File
@@ -3,9 +3,11 @@ import 'package:intl/intl.dart';
import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart';
import '../../models/tab_item_purchase.dart';
import '../../models/tab_item.dart';
import '../../utils/easter_eggs.dart';
import '../../utils/money.dart';
import '../../utils/tab_orders.dart';
import 'order_history_list.dart';
class OpenTabHistoryCard extends StatefulWidget {
final BarTab tab;
@@ -19,42 +21,6 @@ class OpenTabHistoryCard extends StatefulWidget {
class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
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
Widget build(BuildContext context) {
final tab = widget.tab;
@@ -163,9 +129,12 @@ class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
children: [
const Divider(height: 1),
const SizedBox(height: 8),
..._purchases(
tab,
).map((purchase) => _OpenPurchaseRow(purchase: purchase)),
OrderHistoryList(
orders: ordersOfTab(tab, includeRemoved: true),
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,
),
],
),
);
}
}
+77
View File
@@ -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,
);
}
}
+105
View File
@@ -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),
),
],
),
);
}
}