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
+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');