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