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