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
-17
View File
@@ -8,7 +8,6 @@ import '../models/remote_command.dart';
import '../utils/navigation.dart';
import '../viewmodels/admin_pin_view_model.dart';
import '../viewmodels/remote_sync_view_model.dart';
import 'dialogs/new_tab_dialog.dart';
class AdminScreenView extends StatelessWidget {
const AdminScreenView({super.key});
@@ -19,16 +18,6 @@ class AdminScreenView extends StatelessWidget {
adminPinViewModel.lock();
}
Future<void> _openNewTab(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final name = await showNewTabDialog(context);
if (name == null || !context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.tabOpened(name))));
}
Future<void> _confirmUnlink(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
@@ -94,12 +83,6 @@ class AdminScreenView extends StatelessWidget {
_AdminSection(
title: l10n.adminTools,
children: [
_AdminTile(
icon: Icons.person_add_alt_1_outlined,
title: l10n.openNewTab,
subtitle: l10n.openNewTabDescription,
onTap: () => _openNewTab(context),
),
_AdminTile(
icon: Icons.inventory_2_outlined,
title: l10n.products,
+172 -135
View File
@@ -18,6 +18,7 @@ import '../../viewmodels/remote_sync_view_model.dart';
import '../../viewmodels/settings_view_model.dart';
import '../dialogs/close_tab_dialog.dart';
import '../dialogs/edit_tab_dialog.dart';
import '../dialogs/open_tab_dialog.dart';
import '../widgets/venue_title.dart';
import 'product_grid.dart';
import 'tab_panel.dart';
@@ -30,8 +31,11 @@ class BarScreenView extends StatefulWidget {
}
class _BarScreenViewState extends State<BarScreenView> {
static const _idleTimeout = Duration(minutes: 5);
bool _showSearch = false;
late final StreamSubscription<RemoteSyncEvent> _remoteCloseSubscription;
Timer? _idleTimer;
@override
void initState() {
@@ -41,6 +45,7 @@ class _BarScreenViewState extends State<BarScreenView> {
.read<RemoteSyncViewModel>()
.tabsClosedRemotely
.listen(_showRemoteClose);
_restartIdleTimer();
WidgetsBinding.instance.addPostFrameCallback((_) {
UpdateChecker.check(context);
@@ -50,9 +55,19 @@ class _BarScreenViewState extends State<BarScreenView> {
@override
void dispose() {
_remoteCloseSubscription.cancel();
_idleTimer?.cancel();
super.dispose();
}
/// A tab left open after the bar goes quiet is easy to add to by mistake,
/// so it is put back in the list.
void _restartIdleTimer() {
_idleTimer?.cancel();
_idleTimer = Timer(_idleTimeout, () {
if (mounted) context.read<BarScreenViewModel>().deselectTab();
});
}
void _showRemoteClose(RemoteSyncEvent event) {
if (!mounted) return;
@@ -91,6 +106,16 @@ class _BarScreenViewState extends State<BarScreenView> {
}
}
Future<void> _openTab() async {
final l10n = AppLocalizations.of(context);
final name = await showOpenTabDialog(context);
if (name == null || !mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.tabOpened(name))));
}
void _showSyncProblem({required String title, required String description}) {
final l10n = AppLocalizations.of(context);
showDialog<void>(
@@ -151,150 +176,162 @@ class _BarScreenViewState extends State<BarScreenView> {
(viewModel) => viewModel.venueName,
);
return Scaffold(
appBar: AppBar(
title: VenueTitle(
text: venueName == null || venueName.trim().isEmpty
? l10n.barTabs
: venueName,
),
actions: [
IconButton(
tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs,
isSelected: _showSearch,
onPressed: () => setState(() => _showSearch = !_showSearch),
icon: const Icon(Icons.search_rounded),
selectedIcon: const Icon(Icons.search_off_rounded),
// Any touch counts as activity, wherever it lands
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) => _restartIdleTimer(),
child: Scaffold(
appBar: AppBar(
title: VenueTitle(
text: venueName == null || venueName.trim().isEmpty
? l10n.barTabs
: venueName,
),
const SizedBox(width: 6),
IconButton(
tooltip: l10n.admin,
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.admin_panel_settings_outlined),
),
if (kDebugMode) ...[
const SizedBox(width: 6),
_DebugShortcut(
tooltip: l10n.manageProducts,
icon: Icons.inventory_2_outlined,
location: '/products',
actions: [
IconButton(
tooltip: l10n.openTab,
onPressed: _openTab,
icon: const Icon(Icons.person_add_alt_1_outlined),
),
const SizedBox(width: 6),
_DebugShortcut(
tooltip: l10n.tabHistory,
icon: Icons.history_rounded,
location: '/history',
),
],
if (context.watch<RemoteSyncViewModel>().wasRevoked) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.deviceAccessRevoked,
color: Theme.of(context).colorScheme.error,
tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs,
isSelected: _showSearch,
onPressed: () => setState(() => _showSearch = !_showSearch),
icon: const Icon(Icons.search_rounded),
selectedIcon: const Icon(Icons.search_off_rounded),
),
const SizedBox(width: 6),
IconButton(
tooltip: l10n.admin,
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.cloud_off_rounded),
icon: const Icon(Icons.admin_panel_settings_outlined),
),
],
if (context.watch<RemoteSyncViewModel>().updateRequired
case final message?) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.appUpdateRequired,
color: Theme.of(context).colorScheme.error,
onPressed: () => _showUpdateRequired(message),
icon: const Icon(Icons.system_update_rounded),
),
],
// A failing sync is otherwise invisible: the bar keeps working
// offline while nothing from the website arrives
if (context.watch<RemoteSyncViewModel>().lastError
case final error?) ...[
const SizedBox(width: 6),
// Offline is normal for a bar; anything else needs attention
if (context.watch<RemoteSyncViewModel>().isOffline)
IconButton(
tooltip: l10n.offline,
onPressed: () => _showSyncProblem(
title: l10n.offline,
description: l10n.offlineDescription,
),
icon: const Icon(Icons.wifi_off_rounded),
)
else
IconButton(
tooltip: '${l10n.syncProblem}: $error',
color: Theme.of(context).colorScheme.error,
onPressed: () => _showSyncProblem(
title: l10n.syncProblem,
description: '${l10n.syncProblemDescription}\n\n$error',
),
icon: const Icon(Icons.sync_problem_rounded),
if (kDebugMode) ...[
const SizedBox(width: 6),
_DebugShortcut(
tooltip: l10n.manageProducts,
icon: Icons.inventory_2_outlined,
location: '/products',
),
],
const SizedBox(width: 6),
IconButton(
tooltip: l10n.refresh,
onPressed: () => context.read<RemoteSyncViewModel>().syncNow(),
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 6),
IconButton(
tooltip: l10n.settings,
onPressed: () => context.push('/settings'),
icon: const Icon(Icons.settings),
),
if (context.watch<PinLockViewModel>().isPinSet) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.logout,
onPressed: _logout,
icon: const Icon(Icons.logout),
),
],
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
),
resizeToAvoidBottomInset: false,
body: Builder(
builder: (context) {
if (viewModel.isLoading) {
return const Center(
child: CircularProgressIndicator(strokeWidth: 2.5),
);
}
if (viewModel.loadError != null) {
return _LoadError(message: l10n.errorText(viewModel.loadError));
}
return Row(
children: [
Expanded(
flex: 2,
child: ProductGrid(
products: productsViewModel.products,
rows: settings.barGridRows,
hasSelectedTab: viewModel.selectedTab != null,
onProductTap: _addProduct,
),
),
Container(width: 1, color: Theme.of(context).dividerColor),
Expanded(
child: TabPanel(
tabs: viewModel.tabs,
selectedTab: viewModel.selectedTab,
selectedTabId: viewModel.selectedTabId,
showSearch: _showSearch,
onTabSelected: viewModel.selectTab,
onItemQuantityChanged: viewModel.changeItemQuantity,
onCloseTabPressed: () => confirmCloseTab(context),
onTabEdited: (tab) => showEditTabDialog(context, tab),
onTabDeleted: (tab) => confirmDeleteTab(context, tab),
),
const SizedBox(width: 6),
_DebugShortcut(
tooltip: l10n.tabHistory,
icon: Icons.history_rounded,
location: '/history',
),
],
);
},
if (context.watch<RemoteSyncViewModel>().wasRevoked) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.deviceAccessRevoked,
color: Theme.of(context).colorScheme.error,
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.cloud_off_rounded),
),
],
if (context.watch<RemoteSyncViewModel>().updateRequired
case final message?) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.appUpdateRequired,
color: Theme.of(context).colorScheme.error,
onPressed: () => _showUpdateRequired(message),
icon: const Icon(Icons.system_update_rounded),
),
],
// A failing sync is otherwise invisible: the bar keeps working
// offline while nothing from the website arrives
if (context.watch<RemoteSyncViewModel>().lastError
case final error?) ...[
const SizedBox(width: 6),
// Offline is normal for a bar; anything else needs attention
if (context.watch<RemoteSyncViewModel>().isOffline)
IconButton(
tooltip: l10n.offline,
onPressed: () => _showSyncProblem(
title: l10n.offline,
description: l10n.offlineDescription,
),
icon: const Icon(Icons.wifi_off_rounded),
)
else
IconButton(
tooltip: '${l10n.syncProblem}: $error',
color: Theme.of(context).colorScheme.error,
onPressed: () => _showSyncProblem(
title: l10n.syncProblem,
description: '${l10n.syncProblemDescription}\n\n$error',
),
icon: const Icon(Icons.sync_problem_rounded),
),
],
const SizedBox(width: 6),
IconButton(
tooltip: l10n.refresh,
onPressed: () => context.read<RemoteSyncViewModel>().syncNow(),
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 6),
IconButton(
tooltip: l10n.settings,
onPressed: () => context.push('/settings'),
icon: const Icon(Icons.settings),
),
if (context.watch<PinLockViewModel>().isPinSet) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.logout,
onPressed: _logout,
icon: const Icon(Icons.logout),
),
],
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
),
resizeToAvoidBottomInset: false,
body: Builder(
builder: (context) {
if (viewModel.isLoading) {
return const Center(
child: CircularProgressIndicator(strokeWidth: 2.5),
);
}
if (viewModel.loadError != null) {
return _LoadError(message: l10n.errorText(viewModel.loadError));
}
return Row(
children: [
Expanded(
flex: 2,
child: ProductGrid(
products: productsViewModel.products,
rows: settings.barGridRows,
hasSelectedTab: viewModel.selectedTab != null,
onProductTap: _addProduct,
),
),
Container(width: 1, color: Theme.of(context).dividerColor),
Expanded(
child: TabPanel(
tabs: viewModel.tabs,
selectedTab: viewModel.selectedTab,
selectedTabId: viewModel.selectedTabId,
showSearch: _showSearch,
onTabSelected: viewModel.selectTab,
onTabDeselected: viewModel.deselectTab,
onItemQuantityChanged: viewModel.changeItemQuantity,
onCloseTabPressed: () => confirmCloseTab(context),
onTabEdited: (tab) => showEditTabDialog(context, tab),
onTabDeleted: (tab) => confirmDeleteTab(context, tab),
),
),
],
);
},
),
),
);
}
+87 -17
View File
@@ -4,10 +4,17 @@ import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart';
import '../../models/tab_item.dart';
import '../../utils/money.dart';
import '../../utils/tab_orders.dart';
import '../widgets/order_divider.dart';
/// Changes how many of [item] are on the tab: [delta] > 0 rings up more,
/// [delta] < 0 takes drinks of order [orderId] off.
typedef ItemQuantityChanged =
Future<void> Function(TabItem item, int delta, String? orderId);
class SelectedTabDetails extends StatelessWidget {
final BarTab tab;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
final ItemQuantityChanged onItemQuantityChanged;
final VoidCallback onCloseTabPressed;
const SelectedTabDetails({
@@ -44,17 +51,11 @@ class SelectedTabDetails extends StatelessWidget {
],
),
)
: ListView.separated(
itemCount: tab.items.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final item = tab.items[index];
return TabItemRow(
item: item,
onQuantityChanged: onItemQuantityChanged,
);
},
: _OrderList(
// Another tab starts with its own orders folded
key: ValueKey(tab.id),
orders: ordersOfTab(tab),
onItemQuantityChanged: onItemQuantityChanged,
),
),
const SizedBox(height: 8),
@@ -102,13 +103,82 @@ class SelectedTabDetails extends StatelessWidget {
}
}
/// The tab's orders, oldest first, each under the time it started.
class _OrderList extends StatefulWidget {
final List<TabOrder<TabItem>> orders;
final ItemQuantityChanged onItemQuantityChanged;
const _OrderList({
super.key,
required this.orders,
required this.onItemQuantityChanged,
});
@override
State<_OrderList> createState() => _OrderListState();
}
/// The current (latest) order is open, earlier ones are folded away under
/// their time and total; tapping a header flips it.
class _OrderListState extends State<_OrderList> {
/// Orders opened or closed by hand, by [_key].
final _toggled = <String, bool>{};
String _key(TabOrder<TabItem> order) =>
order.orderId ?? order.startedAt.toIso8601String();
@override
Widget build(BuildContext context) {
final orders = widget.orders;
final children = <Widget>[];
for (final (orderIndex, order) in orders.indexed) {
final key = _key(order);
final expanded = _toggled[key] ?? orderIndex == orders.length - 1;
children.add(
OrderDivider(
key: ValueKey('order-$key'),
startedAt: order.startedAt,
totalInCents: order.rows.fold<int>(
0,
(total, row) => total + row.quantity * row.line.unitPriceInCents,
),
expanded: expanded,
onTap: () => setState(() => _toggled[key] = !expanded),
),
);
if (!expanded) continue;
for (final (index, row) in order.rows.indexed) {
if (index > 0) children.add(const Divider(height: 1));
children.add(
TabItemRow(
key: ValueKey('${order.orderId ?? order.startedAt}-${row.line.id}'),
item: row.line,
quantity: row.quantity,
orderId: order.orderId,
onQuantityChanged: widget.onItemQuantityChanged,
),
);
}
}
return ListView(children: children);
}
}
class TabItemRow extends StatelessWidget {
final TabItem item;
final Future<void> Function(TabItem item, int delta) onQuantityChanged;
/// How many of [item] are in this order.
final int quantity;
final String? orderId;
final ItemQuantityChanged onQuantityChanged;
const TabItemRow({
super.key,
required this.item,
required this.quantity,
required this.orderId,
required this.onQuantityChanged,
});
@@ -116,7 +186,7 @@ class TabItemRow extends StatelessWidget {
final l10n = AppLocalizations.of(context);
try {
await onQuantityChanged(item, delta);
await onQuantityChanged(item, delta, orderId);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(
@@ -145,7 +215,7 @@ class TabItemRow extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
'${item.quantity} × ${item.unitPriceInCents.asEuro}',
'$quantity × ${item.unitPriceInCents.asEuro}',
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -167,7 +237,7 @@ class TabItemRow extends StatelessWidget {
SizedBox(
width: 22,
child: Text(
'${item.quantity}',
'$quantity',
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.w700),
),
@@ -183,7 +253,7 @@ class TabItemRow extends StatelessWidget {
SizedBox(
width: 72,
child: Text(
item.lineTotalInCents.asEuro,
(quantity * item.unitPriceInCents).asEuro,
textAlign: TextAlign.end,
style: const TextStyle(fontWeight: FontWeight.w700),
),
+75 -36
View File
@@ -7,7 +7,6 @@ import 'package:flutter_slidable/flutter_slidable.dart';
import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart';
import '../../models/tab_item.dart';
import '../../utils/easter_eggs.dart';
import '../../utils/money.dart';
import '../../utils/name_index.dart';
@@ -42,7 +41,8 @@ class TabPanel extends StatefulWidget {
final String? selectedTabId;
final bool showSearch;
final ValueChanged<String> onTabSelected;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
final VoidCallback onTabDeselected;
final ItemQuantityChanged onItemQuantityChanged;
final VoidCallback onCloseTabPressed;
final Future<void> Function(BarTab) onTabEdited;
final Future<void> Function(BarTab) onTabDeleted;
@@ -54,6 +54,7 @@ class TabPanel extends StatefulWidget {
required this.selectedTabId,
required this.showSearch,
required this.onTabSelected,
required this.onTabDeselected,
required this.onItemQuantityChanged,
required this.onCloseTabPressed,
required this.onTabEdited,
@@ -77,13 +78,23 @@ class _TabPanelState extends State<TabPanel> {
/// The names-list offset the selected tile was last lined up at.
double? _attachedOffset;
@override
void initState() {
super.initState();
/// The first row of the names list, measured for [_rowExtent].
final GlobalKey _firstRowKey = GlobalKey();
// The row height is only known once the list is laid out
/// The height of every row in the names list, gap included. Measured, as
/// a short list can't tell it from its scroll range.
double? _rowExtent;
/// The row height is only known once the list is laid out (and changes
/// with the text size).
void _measureRow() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
if (!mounted) return;
final box = _firstRowKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return;
if (box.size.height != _rowExtent) {
setState(() => _rowExtent = box.size.height);
}
});
}
@@ -99,11 +110,12 @@ class _TabPanelState extends State<TabPanel> {
if (tab == null || _focus == _PanelFocus.names) return null;
final index = tabs.indexWhere((candidate) => candidate.id == tab.id);
final extent = _tabExtent(tabs.length);
final extent = _rowExtent;
if (index == -1 || extent == null) return null;
final offset = (index + 1) * extent - _tabRowGap - viewportHeight;
return offset < 0 ? null : offset;
// Rounding can leave a sliver below zero
return offset < -0.5 ? null : max(offset, 0);
}
void _lineUpSelectedTab(double? offset) {
@@ -152,12 +164,12 @@ class _TabPanelState extends State<TabPanel> {
_searchQuery = '';
}
// Adding products means working on the selected tab
// Adding products, or opening a tab from the Open Tab dialog, means
// working on the selected tab
final tab = widget.selectedTab;
final oldTab = oldWidget.selectedTab;
if (tab != null &&
tab.id == oldTab?.id &&
tab.itemCount != oldTab?.itemCount) {
(tab.id != oldTab?.id || tab.itemCount != oldTab?.itemCount)) {
_focus = _PanelFocus.items;
}
}
@@ -169,14 +181,6 @@ class _TabPanelState extends State<TabPanel> {
super.dispose();
}
/// Every tab row has the same height, so it follows from the scroll range.
double? _tabExtent(int tabCount) {
if (!_tabsScrollController.hasClients || tabCount == 0) return null;
final position = _tabsScrollController.position;
return (position.maxScrollExtent + position.viewportDimension) / tabCount;
}
/// Scrolls to the first tab at or after [letter], so a letter without names
/// lands on the next one.
void _jumpToLetter(String letter, List<BarTab> tabs) {
@@ -186,8 +190,8 @@ class _TabPanelState extends State<TabPanel> {
setState(() => _touchedLetter = letter);
}
final extent = _tabExtent(tabs.length);
if (extent == null) return;
final extent = _rowExtent;
if (extent == null || !_tabsScrollController.hasClients) return;
final position = _tabsScrollController.position;
final start = indexLetters.indexOf(letter);
@@ -205,8 +209,8 @@ class _TabPanelState extends State<TabPanel> {
String? _visibleLetter(List<BarTab> tabs) {
if (tabs.isEmpty) return null;
final extent = _tabExtent(tabs.length);
final index = extent == null
final extent = _rowExtent;
final index = extent == null || !_tabsScrollController.hasClients
? 0
: (_tabsScrollController.offset / extent).round().clamp(
0,
@@ -226,6 +230,7 @@ class _TabPanelState extends State<TabPanel> {
_searchQuery.toLowerCase(),
);
}).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName));
_measureRow();
return DecoratedBox(
decoration: BoxDecoration(color: scheme.surfaceContainerLow),
@@ -236,7 +241,7 @@ class _TabPanelState extends State<TabPanel> {
children: [
ListenableBuilder(
listenable: _tabsScrollController,
builder: (context, child) => _LetterIndexBar(
builder: (context, child) => LetterIndexBar(
availableLetters: {
for (final tab in filteredTabs)
indexLetterOf(tab.customerName),
@@ -262,11 +267,30 @@ class _TabPanelState extends State<TabPanel> {
? _PanelFocus.balanced
: _focus;
final searchHeight = widget.showSearch ? _searchHeight : 0.0;
final namesHeight = switch (focus) {
_PanelFocus.balanced => min(246.0, available / 2),
_PanelFocus.names => available - collapsedItems,
_PanelFocus.items => collapsedNames + searchHeight,
};
// Without a selected tab there are no items to show, so
// the names get the whole panel
final hasSelection = widget.selectedTab != null;
final selectedIndex = filteredTabs.indexWhere(
(tab) => tab.id == widget.selectedTabId,
);
final rowExtent = _rowExtent;
final namesHeight = !hasSelection
? constraints.maxHeight
: switch (focus) {
_PanelFocus.balanced => min(246.0, available / 2),
_PanelFocus.names => available - collapsedItems,
// A tile near the top can't scroll down to its
// items, so the names shrink up to it instead
_PanelFocus.items =>
(selectedIndex == -1 || rowExtent == null
? collapsedNames
: min(
collapsedNames,
(selectedIndex + 1) * rowExtent -
_tabRowGap,
)) +
searchHeight,
};
final attachOffset = _attachOffset(
filteredTabs,
@@ -335,12 +359,19 @@ class _TabPanelState extends State<TabPanel> {
},
child: OpenTabsList(
controller: _tabsScrollController,
firstRowKey: _firstRowKey,
tabs: filteredTabs,
selectedTabId: widget.selectedTabId,
attachedTabId: attached
? widget.selectedTabId
: null,
onTabSelected: (id) {
// Tapping the open tab again closes it
if (id == widget.selectedTabId) {
_focusOn(_PanelFocus.names);
widget.onTabDeselected();
return;
}
_focusOn(_PanelFocus.items);
widget.onTabSelected(id);
},
@@ -352,7 +383,8 @@ class _TabPanelState extends State<TabPanel> {
],
),
),
if (!attached) const Divider(height: dividerHeight),
if (!attached && hasSelection)
const Divider(height: dividerHeight),
Expanded(
child: Listener(
onPointerDown: (_) => _focusOn(_PanelFocus.items),
@@ -434,6 +466,9 @@ class _TabPanelState extends State<TabPanel> {
class OpenTabsList extends StatelessWidget {
final ScrollController? controller;
/// Put on the first row, so its height can be measured.
final Key? firstRowKey;
final List<BarTab> tabs;
final String? selectedTabId;
@@ -446,6 +481,7 @@ class OpenTabsList extends StatelessWidget {
const OpenTabsList({
super.key,
this.controller,
this.firstRowKey,
required this.tabs,
required this.selectedTabId,
this.attachedTabId,
@@ -477,6 +513,7 @@ class OpenTabsList extends StatelessWidget {
final attached = tab.id == attachedTabId;
return _TabRow(
key: index == 0 ? firstRowKey : null,
child: ClipRRect(
borderRadius: attached
? _attachedTileRadius
@@ -520,7 +557,7 @@ class OpenTabsList extends StatelessWidget {
class _TabRow extends StatelessWidget {
final Widget child;
const _TabRow({required this.child});
const _TabRow({super.key, required this.child});
@override
Widget build(BuildContext context) {
@@ -603,14 +640,16 @@ class _TabCard extends StatelessWidget {
}
}
class _LetterIndexBar extends StatefulWidget {
/// The A–Z strip beside a list of names: tap or slide to jump to a letter.
class LetterIndexBar extends StatefulWidget {
final Set<String> availableLetters;
final String? activeLetter;
final bool isTouched;
final ValueChanged<String> onLetterSelected;
final VoidCallback onReleased;
const _LetterIndexBar({
const LetterIndexBar({
super.key,
required this.availableLetters,
required this.activeLetter,
required this.isTouched,
@@ -619,10 +658,10 @@ class _LetterIndexBar extends StatefulWidget {
});
@override
State<_LetterIndexBar> createState() => _LetterIndexBarState();
State<LetterIndexBar> createState() => _LetterIndexBarState();
}
class _LetterIndexBarState extends State<_LetterIndexBar>
class _LetterIndexBarState extends State<LetterIndexBar>
with SingleTickerProviderStateMixin {
static final _zIndex = indexLetters.indexOf('Z');
+17
View File
@@ -1,3 +1,6 @@
import 'dart:math';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:provider/provider.dart';
@@ -6,6 +9,7 @@ import '../../l10n/app_localizations.dart';
import '../../models/payment_method.dart';
import '../../utils/money.dart';
import '../../viewmodels/bar_screen_view_model.dart';
import '../widgets/money_rain.dart';
import '../widgets/slide_confirm.dart';
Future<void> confirmCloseTab(BuildContext context) async {
@@ -73,6 +77,9 @@ Future<void> confirmCloseTab(BuildContext context) async {
try {
await viewModel.closeSelectedTab(paymentMethod: paymentMethod);
_playChaChing();
// Make it rain, but only once in a hundred tabs.
if (context.mounted && Random().nextInt(25) == 0) showMoneyRain(context);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(
@@ -81,6 +88,16 @@ Future<void> confirmCloseTab(BuildContext context) async {
}
}
final _chaChingPlayer = AudioPlayer();
/// Plays the till sound once a tab is paid; a sound that fails to play never
/// gets in the way of closing the tab.
void _playChaChing() {
_chaChingPlayer
.play(AssetSource('Cha-Ching.mp3'))
.catchError((Object e) => debugPrint('Could not play cha-ching: $e'));
}
const _payconiqQrAsset = 'assets/payconiq/qr.png';
/// Shows the Payconiq QR code for the customer to scan. Resolves to true
-63
View File
@@ -1,63 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../viewmodels/bar_screen_view_model.dart';
import 'duplicate_tab_name_dialog.dart';
/// Returns the name of the opened tab, or null when nothing was opened.
Future<String?> showNewTabDialog(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final controller = TextEditingController();
final name = await showDialog<String>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(l10n.openNewTab),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(labelText: l10n.customerGroupName),
onSubmitted: (value) {
Navigator.of(dialogContext).pop(value);
},
),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () {
Navigator.of(dialogContext).pop(controller.text);
},
child: Text(l10n.openTab),
),
],
);
},
);
final trimmed = name?.trim() ?? '';
if (trimmed.isEmpty || !context.mounted) return null;
final viewModel = context.read<BarScreenViewModel>();
if (viewModel.hasOpenTabNamed(trimmed) &&
!await confirmDuplicateTabName(context, trimmed)) {
return null;
}
try {
await viewModel.createTab(trimmed);
return trimmed;
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage)));
}
return null;
}
}
+328
View File
@@ -0,0 +1,328 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart';
import '../../utils/name_index.dart';
import '../../viewmodels/bar_screen_view_model.dart';
import '../bar/tab_panel.dart';
import 'duplicate_tab_name_dialog.dart';
/// What the Open Tab dialog was closed with.
sealed class _Choice {}
class _ExistingTab extends _Choice {
final BarTab tab;
_ExistingTab(this.tab);
}
class _NewCustomer extends _Choice {
/// The search text, as a start for the name.
final String name;
_NewCustomer(this.name);
}
/// Lets the bartender pick a tab that isn't open yet, or add a new customer.
/// Returns the name of the opened tab, or null when nothing was opened.
Future<String?> showOpenTabDialog(BuildContext context) async {
final viewModel = context.read<BarScreenViewModel>();
final choice = await showDialog<_Choice>(
context: context,
builder: (dialogContext) => _OpenTabDialog(viewModel: viewModel),
);
if (!context.mounted) return null;
switch (choice) {
case null:
return null;
case _ExistingTab(:final tab):
viewModel.openTab(tab.id);
return tab.customerName;
case _NewCustomer(:final name):
return _addCustomer(context, initialName: name);
}
}
class _OpenTabDialog extends StatefulWidget {
final BarScreenViewModel viewModel;
const _OpenTabDialog({required this.viewModel});
@override
State<_OpenTabDialog> createState() => _OpenTabDialogState();
}
/// Every row is this tall, so the letter index knows where each name is.
const _rowExtent = 64.0;
class _OpenTabDialogState extends State<_OpenTabDialog> {
final ScrollController _scrollController = ScrollController();
String _query = '';
/// The letter under the finger while the index bar is pressed.
String? _touchedLetter;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
/// Scrolls to the first tab at or after [letter], so a letter without names
/// lands on the next one.
void _jumpToLetter(String letter, List<BarTab> tabs) {
if (letter != _touchedLetter) {
HapticFeedback.selectionClick();
setState(() => _touchedLetter = letter);
}
if (!_scrollController.hasClients) return;
final position = _scrollController.position;
final start = indexLetters.indexOf(letter);
final index = tabs.indexWhere(
(tab) => indexLetters.indexOf(indexLetterOf(tab.customerName)) >= start,
);
_scrollController.jumpTo(
index == -1
? position.maxScrollExtent
: min(index * _rowExtent, position.maxScrollExtent),
);
}
String? _visibleLetter(List<BarTab> tabs) {
if (tabs.isEmpty) return null;
final offset = _scrollController.hasClients ? _scrollController.offset : 0;
final index = (offset / _rowExtent).round().clamp(0, tabs.length - 1);
return indexLetterOf(tabs[index].customerName);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
return AlertDialog(
title: Text(l10n.openTab),
content: SizedBox(
width: 420,
height: 480,
child: Column(
children: [
TextField(
onChanged: (value) => setState(() => _query = value),
decoration: InputDecoration(
hintText: l10n.searchByName,
prefixIcon: const Icon(Icons.search_rounded, size: 20),
isDense: true,
),
),
const SizedBox(height: 12),
Expanded(
// Tabs opened or closed elsewhere while the dialog is up
child: ListenableBuilder(
listenable: widget.viewModel,
builder: (context, _) {
final query = _query.trim().toLowerCase();
final available = widget.viewModel.closedTabs;
final tabs =
available
.where(
(tab) =>
tab.customerName.toLowerCase().contains(query),
)
.toList()
..sort(
(a, b) =>
compareByIndex(a.customerName, b.customerName),
);
if (tabs.isEmpty) {
return Center(
child: Text(
available.isEmpty ? l10n.allTabsOpen : l10n.noTabsFound,
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
final list = ListView.builder(
controller: _scrollController,
itemCount: tabs.length,
itemExtent: _rowExtent,
itemBuilder: (context, index) {
final tab = tabs[index];
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
leading: CircleAvatar(
backgroundColor: scheme.primary.withValues(
alpha: 0.12,
),
foregroundColor: scheme.primary,
child: Text(indexLetterOf(tab.customerName)),
),
title: Text(
tab.customerName,
style: const TextStyle(fontWeight: FontWeight.w700),
),
subtitle: tab.email == null
? null
: Text(
tab.email!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () =>
Navigator.of(context).pop(_ExistingTab(tab)),
);
},
);
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListenableBuilder(
listenable: _scrollController,
builder: (context, _) => LetterIndexBar(
availableLetters: {
for (final tab in tabs)
indexLetterOf(tab.customerName),
},
activeLetter: _touchedLetter ?? _visibleLetter(tabs),
isTouched: _touchedLetter != null,
onLetterSelected: (letter) =>
_jumpToLetter(letter, tabs),
onReleased: () =>
setState(() => _touchedLetter = null),
),
),
const SizedBox(width: 8),
Expanded(child: list),
],
);
},
),
),
],
),
),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
FilledButton.icon(
onPressed: () =>
Navigator.of(context).pop(_NewCustomer(_query.trim())),
icon: const Icon(Icons.person_add_alt_1_outlined),
label: Text(l10n.newCustomer),
),
],
);
}
}
final _emailPattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
/// Asks for a name and an optional email, then opens a tab for them.
Future<String?> _addCustomer(
BuildContext context, {
required String initialName,
}) async {
final l10n = AppLocalizations.of(context);
final nameController = TextEditingController(text: initialName);
final emailController = TextEditingController();
final formKey = GlobalKey<FormState>();
final customer = await showDialog<({String name, String? email})>(
context: context,
builder: (dialogContext) {
void submit() {
if (!formKey.currentState!.validate()) return;
final email = emailController.text.trim();
Navigator.of(dialogContext).pop((
name: nameController.text.trim(),
email: email.isEmpty ? null : email,
));
}
return AlertDialog(
title: Text(l10n.newCustomer),
content: SizedBox(
width: 420,
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nameController,
autofocus: true,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: l10n.customerGroupName,
),
validator: (value) =>
(value ?? '').trim().isEmpty ? l10n.nameRequired : null,
),
const SizedBox(height: 12),
TextFormField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
autocorrect: false,
decoration: InputDecoration(labelText: l10n.emailOptional),
validator: (value) {
final email = (value ?? '').trim();
return email.isEmpty || _emailPattern.hasMatch(email)
? null
: l10n.invalidEmail;
},
onFieldSubmitted: (_) => submit(),
),
],
),
),
),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(l10n.cancel),
),
FilledButton(onPressed: submit, child: Text(l10n.openTab)),
],
);
},
);
if (customer == null || !context.mounted) return null;
final viewModel = context.read<BarScreenViewModel>();
if (viewModel.hasOpenTabNamed(customer.name) &&
!await confirmDuplicateTabName(context, customer.name)) {
return null;
}
try {
await viewModel.createTab(customer.name, email: customer.email);
return customer.name;
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage)));
}
return null;
}
}
+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),
),
],
),
);
}
}