feat: alphabet list, change Layout etc

This commit is contained in:
2026-10-11 20:37:59 +02:00
parent 489896305d
commit 072f560989
31 changed files with 1847 additions and 832 deletions
+395 -161
View File
@@ -1,10 +1,15 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart';
import '../../models/tab_item.dart';
import '../../utils/money.dart';
import '../../utils/name_index.dart';
import 'selected_tab_details.dart';
class TabPanel extends StatefulWidget {
@@ -12,7 +17,7 @@ class TabPanel extends StatefulWidget {
final BarTab? selectedTab;
final String? selectedTabId;
final Map<String, int> stockByProductId;
final VoidCallback onNewTabPressed;
final bool showSearch;
final ValueChanged<String> onTabSelected;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
final VoidCallback onCloseTabPressed;
@@ -25,7 +30,7 @@ class TabPanel extends StatefulWidget {
required this.selectedTab,
required this.selectedTabId,
required this.stockByProductId,
required this.onNewTabPressed,
required this.showSearch,
required this.onTabSelected,
required this.onItemQuantityChanged,
required this.onCloseTabPressed,
@@ -39,14 +44,76 @@ class TabPanel extends StatefulWidget {
class _TabPanelState extends State<TabPanel> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _tabsScrollController = ScrollController();
String _searchQuery = '';
/// The letter under the finger while the index bar is pressed.
String? _touchedLetter;
@override
void didUpdateWidget(TabPanel oldWidget) {
super.didUpdateWidget(oldWidget);
// Hiding the search bar shouldn't leave the list silently filtered
if (oldWidget.showSearch && !widget.showSearch) {
_searchController.clear();
_searchQuery = '';
}
}
@override
void dispose() {
_searchController.dispose();
_tabsScrollController.dispose();
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) {
if (letter != _touchedLetter) {
unawaited(HapticFeedback.selectionClick());
setState(() => _touchedLetter = letter);
}
final extent = _tabExtent(tabs.length);
if (extent == null) return;
final position = _tabsScrollController.position;
final start = indexLetters.indexOf(letter);
final index = tabs.indexWhere(
(tab) => indexLetters.indexOf(indexLetterOf(tab.customerName)) >= start,
);
_tabsScrollController.jumpTo(
index == -1
? position.maxScrollExtent
: min(index * extent, position.maxScrollExtent),
);
}
String? _visibleLetter(List<BarTab> tabs) {
if (tabs.isEmpty) return null;
final extent = _tabExtent(tabs.length);
final index = extent == null
? 0
: (_tabsScrollController.offset / extent).round().clamp(
0,
tabs.length - 1,
);
return indexLetterOf(tabs[index].customerName);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
@@ -56,95 +123,115 @@ class _TabPanelState extends State<TabPanel> {
return tab.customerName.toLowerCase().contains(
_searchQuery.toLowerCase(),
);
}).toList();
}).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName));
return DecoratedBox(
decoration: BoxDecoration(color: scheme.surfaceContainerLow),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.fromLTRB(4, 16, 16, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
decoration: InputDecoration(
hintText: l10n.searchByName,
prefixIcon: Icon(Icons.search_rounded, size: 20),
suffixIcon: _searchQuery.isEmpty
? null
: IconButton(
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
icon: const Icon(Icons.clear_rounded),
tooltip: l10n.clearSearch,
),
isDense: true,
),
),
),
const SizedBox(width: 10),
IconButton.filled(
onPressed: widget.onNewTabPressed,
icon: const Icon(Icons.add_rounded),
tooltip: l10n.openNewTab,
),
],
),
const SizedBox(height: 8),
SizedBox(
height: 190,
child: OpenTabsList(
tabs: filteredTabs,
selectedTabId: widget.selectedTabId,
onTabSelected: widget.onTabSelected,
onTabEdited: widget.onTabEdited,
onTabDeleted: widget.onTabDeleted,
ListenableBuilder(
listenable: _tabsScrollController,
builder: (context, child) => _LetterIndexBar(
availableLetters: {
for (final tab in filteredTabs)
indexLetterOf(tab.customerName),
},
activeLetter: _touchedLetter ?? _visibleLetter(filteredTabs),
isTouched: _touchedLetter != null,
onLetterSelected: (letter) =>
_jumpToLetter(letter, filteredTabs),
onReleased: () => setState(() => _touchedLetter = null),
),
),
const Divider(height: 28),
const SizedBox(width: 8),
Expanded(
child: widget.selectedTab == null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.receipt_long_outlined,
size: 36,
color: scheme.onSurface.withValues(alpha: 0.25),
),
const SizedBox(height: 10),
Text(
l10n.selectOrOpenTab,
style: Theme.of(context).textTheme.bodyMedium,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Fixed height, so hiding the search bar gives its space
// to the names
SizedBox(
height: 246,
child: Column(
children: [
if (widget.showSearch) ...[
TextField(
autofocus: true,
controller: _searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
decoration: InputDecoration(
hintText: l10n.searchByName,
prefixIcon: Icon(Icons.search_rounded, size: 20),
suffixIcon: _searchQuery.isEmpty
? null
: IconButton(
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
icon: const Icon(Icons.clear_rounded),
tooltip: l10n.clearSearch,
),
isDense: true,
),
),
const SizedBox(height: 8),
],
),
)
: SelectedTabDetails(
tab: widget.selectedTab!,
stockByProductId: widget.stockByProductId,
onItemQuantityChanged: widget.onItemQuantityChanged,
onCloseTabPressed: widget.onCloseTabPressed,
Expanded(
child: OpenTabsList(
controller: _tabsScrollController,
tabs: filteredTabs,
selectedTabId: widget.selectedTabId,
onTabSelected: widget.onTabSelected,
onTabEdited: widget.onTabEdited,
onTabDeleted: widget.onTabDeleted,
),
),
],
),
),
const Divider(height: 28),
Expanded(
child: widget.selectedTab == null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.receipt_long_outlined,
size: 36,
color: scheme.onSurface.withValues(
alpha: 0.25,
),
),
const SizedBox(height: 10),
Text(
l10n.selectOrOpenTab,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
)
: SelectedTabDetails(
tab: widget.selectedTab!,
stockByProductId: widget.stockByProductId,
onItemQuantityChanged: widget.onItemQuantityChanged,
onCloseTabPressed: widget.onCloseTabPressed,
),
),
],
),
),
],
),
@@ -154,6 +241,7 @@ class _TabPanelState extends State<TabPanel> {
}
class OpenTabsList extends StatelessWidget {
final ScrollController? controller;
final List<BarTab> tabs;
final String? selectedTabId;
final ValueChanged<String> onTabSelected;
@@ -162,6 +250,7 @@ class OpenTabsList extends StatelessWidget {
const OpenTabsList({
super.key,
this.controller,
required this.tabs,
required this.selectedTabId,
required this.onTabSelected,
@@ -181,92 +270,44 @@ class OpenTabsList extends StatelessWidget {
);
}
return ListView.separated(
// A fixed row height lets the letter index work out where each tab is
return ListView.builder(
controller: controller,
itemCount: tabs.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
// The selected card's border is thickest, so it sets the height
prototypeItem: _TabRow(child: _TabCard(tab: tabs.first, selected: true)),
itemBuilder: (context, index) {
final tab = tabs[index];
final selected = tab.id == selectedTabId;
final scheme = Theme.of(context).colorScheme;
final primary = scheme.primary;
return ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Slidable(
key: ValueKey(tab.id),
endActionPane: ActionPane(
motion: const DrawerMotion(),
extentRatio: 0.6,
children: [
SlidableAction(
onPressed: (_) => onTabEdited(tab),
icon: Icons.edit_outlined,
label: l10n.edit,
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
),
SlidableAction(
onPressed: (_) => onTabDeleted(tab),
icon: Icons.delete_outline_rounded,
label: l10n.delete,
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
],
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: selected
? primary.withValues(alpha: 0.14)
: scheme.onSurface.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? primary.withValues(alpha: 0.6)
: scheme.onSurface.withValues(alpha: 0.06),
width: selected ? 1.4 : 1,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => onTabSelected(tab.id),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
tab.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: scheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
l10n.tabItemSummary(
tab.itemCount,
tab.totalInCents.asEuro,
),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
return _TabRow(
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Slidable(
key: ValueKey(tab.id),
endActionPane: ActionPane(
motion: const DrawerMotion(),
extentRatio: 0.6,
children: [
SlidableAction(
onPressed: (_) => onTabEdited(tab),
icon: Icons.edit_outlined,
label: l10n.edit,
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
),
),
SlidableAction(
onPressed: (_) => onTabDeleted(tab),
icon: Icons.delete_outline_rounded,
label: l10n.delete,
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
],
),
child: _TabCard(
tab: tab,
selected: tab.id == selectedTabId,
onTap: () => onTabSelected(tab.id),
),
),
),
@@ -275,3 +316,196 @@ class OpenTabsList extends StatelessWidget {
);
}
}
class _TabRow extends StatelessWidget {
final Widget child;
const _TabRow({required this.child});
@override
Widget build(BuildContext context) {
return Padding(padding: const EdgeInsets.only(bottom: 8), child: child);
}
}
class _TabCard extends StatelessWidget {
final BarTab tab;
final bool selected;
final VoidCallback? onTap;
const _TabCard({required this.tab, this.selected = false, this.onTap});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
final primary = scheme.primary;
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: selected
? primary.withValues(alpha: 0.14)
: scheme.onSurface.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? primary.withValues(alpha: 0.6)
: scheme.onSurface.withValues(alpha: 0.06),
width: selected ? 1.4 : 1,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
tab.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: scheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
l10n.tabItemSummary(tab.itemCount, tab.totalInCents.asEuro),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
),
),
);
}
}
class _LetterIndexBar extends StatelessWidget {
final Set<String> availableLetters;
final String? activeLetter;
final bool isTouched;
final ValueChanged<String> onLetterSelected;
final VoidCallback onReleased;
const _LetterIndexBar({
required this.availableLetters,
required this.activeLetter,
required this.isTouched,
required this.onLetterSelected,
required this.onReleased,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return LayoutBuilder(
builder: (context, constraints) {
final letterHeight = constraints.maxHeight / indexLetters.length;
final fontSize = min(13.0, letterHeight * 0.65);
String letterAt(Offset position) {
final index = (position.dy / letterHeight).floor();
return indexLetters[index.clamp(0, indexLetters.length - 1)];
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) =>
onLetterSelected(letterAt(details.localPosition)),
onTapUp: (_) => onReleased(),
onTapCancel: onReleased,
onVerticalDragStart: (details) =>
onLetterSelected(letterAt(details.localPosition)),
onVerticalDragUpdate: (details) =>
onLetterSelected(letterAt(details.localPosition)),
onVerticalDragEnd: (_) => onReleased(),
onVerticalDragCancel: onReleased,
child: SizedBox(
width: 28,
child: Column(
children: [
for (final letter in indexLetters)
SizedBox(
height: letterHeight,
child: Center(
child: _IndexLetter(
letter: letter,
fontSize: fontSize,
isActive: letter == activeLetter,
isTouched: isTouched,
hasNames: availableLetters.contains(letter),
scheme: scheme,
),
),
),
],
),
),
);
},
);
}
}
class _IndexLetter extends StatelessWidget {
final String letter;
final double fontSize;
final bool isActive;
final bool isTouched;
final bool hasNames;
final ColorScheme scheme;
const _IndexLetter({
required this.letter,
required this.fontSize,
required this.isActive,
required this.isTouched,
required this.hasNames,
required this.scheme,
});
@override
Widget build(BuildContext context) {
final color = switch ((isActive, isTouched, hasNames)) {
(true, true, _) => scheme.onPrimary,
(true, false, _) => scheme.primary,
(false, _, true) => scheme.onSurface,
(false, _, false) => scheme.onSurface.withValues(alpha: 0.25),
};
return AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: fontSize * 1.8,
height: fontSize * 1.8,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive && isTouched ? scheme.primary : Colors.transparent,
),
child: Text(
letter,
style: TextStyle(
fontSize: fontSize,
height: 1,
fontWeight: isActive || hasNames ? FontWeight.w800 : FontWeight.w500,
color: color,
),
),
);
}
}