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,
),
),
),
],
],
),
);
}
}