feat: make selecting a user fancy

This commit is contained in:
2026-10-11 21:34:59 +02:00
parent 072f560989
commit 30dd74af90
4 changed files with 387 additions and 100 deletions
-14
View File
@@ -28,20 +28,6 @@ class SelectedTabDetails extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(
children: [
Expanded(
child: Text(
tab.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge,
),
),
Divider(),
],
),
const SizedBox(height: 16),
Expanded( Expanded(
child: tab.items.isEmpty child: tab.items.isEmpty
? Center( ? Center(
+296 -83
View File
@@ -12,6 +12,29 @@ import '../../utils/money.dart';
import '../../utils/name_index.dart'; import '../../utils/name_index.dart';
import 'selected_tab_details.dart'; import 'selected_tab_details.dart';
/// Which half of the panel gets most of the height.
enum _PanelFocus { balanced, names, items }
/// Space below every tab row in the names list.
const _tabRowGap = 8.0;
/// The search field's height plus the gap below it.
const _searchHeight = 56.0;
const _panelAnimation = Duration(milliseconds: 250);
const _panelCurve = Curves.easeOutCubic;
/// The selected tile sits on its items, so the corners where they meet are
/// squared off.
const _attachedTileRadius = BorderRadius.vertical(
top: Radius.circular(14),
bottom: Radius.circular(4),
);
const _attachedDetailsRadius = BorderRadius.vertical(
top: Radius.circular(4),
bottom: Radius.circular(16),
);
class TabPanel extends StatefulWidget { class TabPanel extends StatefulWidget {
final List<BarTab> tabs; final List<BarTab> tabs;
final BarTab? selectedTab; final BarTab? selectedTab;
@@ -50,6 +73,76 @@ class _TabPanelState extends State<TabPanel> {
/// The letter under the finger while the index bar is pressed. /// The letter under the finger while the index bar is pressed.
String? _touchedLetter; String? _touchedLetter;
_PanelFocus _focus = _PanelFocus.balanced;
/// The names-list offset the selected tile was last lined up at.
double? _attachedOffset;
@override
void initState() {
super.initState();
// The row height is only known once the list is laid out
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
}
void _focusOn(_PanelFocus focus) {
if (focus != _focus) setState(() => _focus = focus);
}
/// Where the names list scrolls so the selected tab's tile sits right on
/// top of its items. Null while browsing the names, when the tab is
/// filtered out, or when its tile is too close to the top to get there.
double? _attachOffset(List<BarTab> tabs, double viewportHeight) {
final tab = widget.selectedTab;
if (tab == null || _focus == _PanelFocus.names) return null;
final index = tabs.indexWhere((candidate) => candidate.id == tab.id);
final extent = _tabExtent(tabs.length);
if (index == -1 || extent == null) return null;
final offset = (index + 1) * extent - _tabRowGap - viewportHeight;
return offset < 0 ? null : offset;
}
void _lineUpSelectedTab(double? offset) {
if (offset == _attachedOffset) return;
_attachedOffset = offset;
if (offset == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_tabsScrollController.hasClients) return;
// A list that is still shrinking can't scroll that far yet; the end of
// the collapse animation finishes the job
final position = _tabsScrollController.position;
unawaited(
_tabsScrollController.animateTo(
min(offset, position.maxScrollExtent),
duration: _panelAnimation,
curve: _panelCurve,
),
);
});
}
void _finishLineUp() {
final offset = _attachedOffset;
if (offset == null || !_tabsScrollController.hasClients) return;
if ((_tabsScrollController.offset - offset).abs() < 0.5) return;
unawaited(
_tabsScrollController.animateTo(
offset,
duration: const Duration(milliseconds: 150),
curve: _panelCurve,
),
);
}
@override @override
void didUpdateWidget(TabPanel oldWidget) { void didUpdateWidget(TabPanel oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -59,6 +152,15 @@ class _TabPanelState extends State<TabPanel> {
_searchController.clear(); _searchController.clear();
_searchQuery = ''; _searchQuery = '';
} }
// Adding products 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) {
_focus = _PanelFocus.items;
}
} }
@override @override
@@ -79,6 +181,7 @@ class _TabPanelState extends State<TabPanel> {
/// Scrolls to the first tab at or after [letter], so a letter without names /// Scrolls to the first tab at or after [letter], so a letter without names
/// lands on the next one. /// lands on the next one.
void _jumpToLetter(String letter, List<BarTab> tabs) { void _jumpToLetter(String letter, List<BarTab> tabs) {
_focusOn(_PanelFocus.names);
if (letter != _touchedLetter) { if (letter != _touchedLetter) {
unawaited(HapticFeedback.selectionClick()); unawaited(HapticFeedback.selectionClick());
setState(() => _touchedLetter = letter); setState(() => _touchedLetter = letter);
@@ -148,89 +251,181 @@ class _TabPanelState extends State<TabPanel> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.start, builder: (context, constraints) {
children: [ const dividerHeight = 28.0;
// Fixed height, so hiding the search bar gives its space const minDetailsHeight = 240.0;
// to the names final available = constraints.maxHeight - dividerHeight;
SizedBox( final collapsedNames = max(available * 0.2, 64.0);
height: 246, final collapsedItems = max(available * 0.1, 64.0);
child: Column( final focus =
children: [ _focus == _PanelFocus.items && widget.selectedTab == null
if (widget.showSearch) ...[ ? _PanelFocus.balanced
TextField( : _focus;
autofocus: true, final searchHeight = widget.showSearch ? _searchHeight : 0.0;
controller: _searchController, final namesHeight = switch (focus) {
onChanged: (value) { _PanelFocus.balanced => min(246.0, available / 2),
setState(() { _PanelFocus.names => available - collapsedItems,
_searchQuery = value; _PanelFocus.items => collapsedNames + searchHeight,
}); };
},
decoration: InputDecoration( final attachOffset = _attachOffset(
hintText: l10n.searchByName, filteredTabs,
prefixIcon: Icon(Icons.search_rounded, size: 20), namesHeight - searchHeight,
suffixIcon: _searchQuery.isEmpty );
? null final attached = attachOffset != null;
: IconButton( _lineUpSelectedTab(attachOffset);
onPressed: () {
_searchController.clear(); return Column(
setState(() { crossAxisAlignment: CrossAxisAlignment.start,
_searchQuery = ''; children: [
}); AnimatedContainer(
}, duration: _panelAnimation,
icon: const Icon(Icons.clear_rounded), curve: _panelCurve,
tooltip: l10n.clearSearch, height: namesHeight,
onEnd: _finishLineUp,
child: Column(
children: [
if (widget.showSearch) ...[
SizedBox(
height: _searchHeight - 8,
child: TextField(
autofocus: true,
controller: _searchController,
onTap: () => _focusOn(_PanelFocus.names),
onChanged: (value) {
setState(() {
_searchQuery = value;
_focus = _PanelFocus.names;
});
},
decoration: InputDecoration(
hintText: l10n.searchByName,
prefixIcon: Icon(
Icons.search_rounded,
size: 20,
), ),
isDense: true, suffixIcon: _searchQuery.isEmpty
), ? null
), : IconButton(
const SizedBox(height: 8), onPressed: () {
], _searchController.clear();
Expanded( setState(() {
child: OpenTabsList( _searchQuery = '';
controller: _tabsScrollController, });
tabs: filteredTabs, },
selectedTabId: widget.selectedTabId, icon: const Icon(
onTabSelected: widget.onTabSelected, Icons.clear_rounded,
onTabEdited: widget.onTabEdited, ),
onTabDeleted: widget.onTabDeleted, tooltip: l10n.clearSearch,
), ),
), isDense: true,
],
),
),
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( const SizedBox(height: 8),
l10n.selectOrOpenTab, ],
style: Theme.of(context).textTheme.bodyMedium, Expanded(
), child:
], NotificationListener<ScrollStartNotification>(
onNotification: (notification) {
if (notification.dragDetails != null) {
_focusOn(_PanelFocus.names);
}
return false;
},
child: OpenTabsList(
controller: _tabsScrollController,
tabs: filteredTabs,
selectedTabId: widget.selectedTabId,
attachedTabId: attached
? widget.selectedTabId
: null,
onTabSelected: (id) {
_focusOn(_PanelFocus.items);
widget.onTabSelected(id);
},
onTabEdited: widget.onTabEdited,
onTabDeleted: widget.onTabDeleted,
),
),
),
],
),
),
if (!attached) const Divider(height: dividerHeight),
Expanded(
child: Listener(
onPointerDown: (_) => _focusOn(_PanelFocus.items),
child: AnimatedContainer(
duration: _panelAnimation,
curve: _panelCurve,
padding: EdgeInsets.all(attached ? 12 : 0),
decoration: BoxDecoration(
color: attached
? scheme.primary.withValues(alpha: 0.06)
: Colors.transparent,
borderRadius: _attachedDetailsRadius,
border: Border.all(
color: attached
? scheme.primary.withValues(alpha: 0.3)
: Colors.transparent,
),
),
// Collapsed, the details keep their layout and are
// clipped to a peek of the first items
child: LayoutBuilder(
builder: (context, constraints) {
final height = max(
constraints.maxHeight,
minDetailsHeight,
);
return ClipRect(
child: OverflowBox(
alignment: Alignment.topCenter,
minHeight: height,
maxHeight: height,
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,
),
),
);
},
), ),
)
: SelectedTabDetails(
tab: widget.selectedTab!,
stockByProductId: widget.stockByProductId,
onItemQuantityChanged: widget.onItemQuantityChanged,
onCloseTabPressed: widget.onCloseTabPressed,
), ),
), ),
], ),
],
);
},
), ),
), ),
], ],
@@ -244,6 +439,9 @@ class OpenTabsList extends StatelessWidget {
final ScrollController? controller; final ScrollController? controller;
final List<BarTab> tabs; final List<BarTab> tabs;
final String? selectedTabId; final String? selectedTabId;
/// The tab whose tile is lined up on top of its items.
final String? attachedTabId;
final ValueChanged<String> onTabSelected; final ValueChanged<String> onTabSelected;
final Future<void> Function(BarTab) onTabEdited; final Future<void> Function(BarTab) onTabEdited;
final Future<void> Function(BarTab) onTabDeleted; final Future<void> Function(BarTab) onTabDeleted;
@@ -253,6 +451,7 @@ class OpenTabsList extends StatelessWidget {
this.controller, this.controller,
required this.tabs, required this.tabs,
required this.selectedTabId, required this.selectedTabId,
this.attachedTabId,
required this.onTabSelected, required this.onTabSelected,
required this.onTabEdited, required this.onTabEdited,
required this.onTabDeleted, required this.onTabDeleted,
@@ -278,10 +477,13 @@ class OpenTabsList extends StatelessWidget {
prototypeItem: _TabRow(child: _TabCard(tab: tabs.first, selected: true)), prototypeItem: _TabRow(child: _TabCard(tab: tabs.first, selected: true)),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final tab = tabs[index]; final tab = tabs[index];
final attached = tab.id == attachedTabId;
return _TabRow( return _TabRow(
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(14), borderRadius: attached
? _attachedTileRadius
: BorderRadius.circular(14),
child: Slidable( child: Slidable(
key: ValueKey(tab.id), key: ValueKey(tab.id),
endActionPane: ActionPane( endActionPane: ActionPane(
@@ -307,6 +509,7 @@ class OpenTabsList extends StatelessWidget {
child: _TabCard( child: _TabCard(
tab: tab, tab: tab,
selected: tab.id == selectedTabId, selected: tab.id == selectedTabId,
attached: attached,
onTap: () => onTabSelected(tab.id), onTap: () => onTabSelected(tab.id),
), ),
), ),
@@ -324,22 +527,32 @@ class _TabRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding(padding: const EdgeInsets.only(bottom: 8), child: child); return Padding(
padding: const EdgeInsets.only(bottom: _tabRowGap),
child: child,
);
} }
} }
class _TabCard extends StatelessWidget { class _TabCard extends StatelessWidget {
final BarTab tab; final BarTab tab;
final bool selected; final bool selected;
final bool attached;
final VoidCallback? onTap; final VoidCallback? onTap;
const _TabCard({required this.tab, this.selected = false, this.onTap}); const _TabCard({
required this.tab,
this.selected = false,
this.attached = false,
this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final primary = scheme.primary; final primary = scheme.primary;
final radius = attached ? _attachedTileRadius : BorderRadius.circular(14);
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
@@ -347,7 +560,7 @@ class _TabCard extends StatelessWidget {
color: selected color: selected
? primary.withValues(alpha: 0.14) ? primary.withValues(alpha: 0.14)
: scheme.onSurface.withValues(alpha: 0.04), : scheme.onSurface.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14), borderRadius: radius,
border: Border.all( border: Border.all(
color: selected color: selected
? primary.withValues(alpha: 0.6) ? primary.withValues(alpha: 0.6)
@@ -358,7 +571,7 @@ class _TabCard extends StatelessWidget {
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(14), borderRadius: radius,
onTap: onTap, onTap: onTap,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+1 -1
View File
@@ -2,7 +2,7 @@ name: kooltap
description: "KoolTap: a tablet point-of-sale app for running bar tabs." description: "KoolTap: a tablet point-of-sale app for running bar tabs."
publish_to: 'none' publish_to: 'none'
version: 1.2.7+0 version: 1.2.10+0
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2
+90 -2
View File
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:kooltap/l10n/app_localizations.dart'; import 'package:kooltap/l10n/app_localizations.dart';
import 'package:kooltap/models/bar_tab.dart'; import 'package:kooltap/models/bar_tab.dart';
import 'package:kooltap/utils/name_index.dart'; import 'package:kooltap/utils/name_index.dart';
import 'package:kooltap/views/bar/selected_tab_details.dart';
import 'package:kooltap/views/bar/tab_panel.dart'; import 'package:kooltap/views/bar/tab_panel.dart';
const _names = [ const _names = [
@@ -43,6 +44,7 @@ void main() {
Future<void> pumpPanel( Future<void> pumpPanel(
WidgetTester tester, { WidgetTester tester, {
bool showSearch = false, bool showSearch = false,
BarTab? selectedTab,
}) async { }) async {
tester.view.physicalSize = const Size(500, 800); tester.view.physicalSize = const Size(500, 800);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@@ -55,8 +57,8 @@ void main() {
home: Scaffold( home: Scaffold(
body: TabPanel( body: TabPanel(
tabs: tabs, tabs: tabs,
selectedTab: null, selectedTab: selectedTab,
selectedTabId: null, selectedTabId: selectedTab?.id,
stockByProductId: const {}, stockByProductId: const {},
showSearch: showSearch, showSearch: showSearch,
onTabSelected: (_) {}, onTabSelected: (_) {},
@@ -133,6 +135,92 @@ void main() {
expect(find.text('anna'), findsOneWidget); expect(find.text('anna'), findsOneWidget);
}); });
group('collapsing', () {
double namesHeight(WidgetTester tester) =>
tester.getSize(find.byType(OpenTabsList)).height;
testWidgets('starts with the names at their usual height', (
tester,
) async {
await pumpPanel(tester, selectedTab: tabs.first);
expect(namesHeight(tester), 246);
});
testWidgets('tapping a name collapses the names', (tester) async {
await pumpPanel(tester, selectedTab: tabs.first);
await tester.tap(find.text('Bram'));
await tester.pumpAndSettle();
expect(namesHeight(tester), lessThan(200));
});
testWidgets('touching the items collapses the names', (tester) async {
await pumpPanel(tester, selectedTab: tabs.first);
await tester.tap(find.byType(SelectedTabDetails), warnIfMissed: false);
await tester.pumpAndSettle();
expect(namesHeight(tester), lessThan(200));
});
testWidgets('lines the selected name up on top of its items', (
tester,
) async {
final marc = tabs.firstWhere((tab) => tab.customerName == 'Marc');
await pumpPanel(tester, selectedTab: marc);
await tester.tap(find.byType(SelectedTabDetails), warnIfMissed: false);
await tester.pumpAndSettle();
final list = tester.getRect(find.byType(OpenTabsList));
final details = tester.getRect(find.byType(SelectedTabDetails));
final tile = tester.getRect(
find
.ancestor(of: find.text('Marc'), matching: find.byType(ClipRRect))
.first,
);
expect(tile.bottom, closeTo(list.bottom, 1));
expect(details.top, greaterThan(tile.bottom));
expect(
find.byWidgetPredicate(
(widget) => widget is Divider && widget.height == 28,
),
findsNothing,
);
});
testWidgets('browsing the names shows the divider again', (tester) async {
final marc = tabs.firstWhere((tab) => tab.customerName == 'Marc');
await pumpPanel(tester, selectedTab: marc);
await tester.tap(find.byType(SelectedTabDetails), warnIfMissed: false);
await tester.pumpAndSettle();
await tester.drag(find.byType(OpenTabsList), const Offset(0, -40));
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(widget) => widget is Divider && widget.height == 28,
),
findsOneWidget,
);
});
testWidgets('scrolling the names collapses the items', (tester) async {
await pumpPanel(tester, selectedTab: tabs.first);
await tester.tap(find.byType(SelectedTabDetails), warnIfMissed: false);
await tester.pumpAndSettle();
await tester.drag(find.byType(OpenTabsList), const Offset(0, -40));
await tester.pumpAndSettle();
final panel = tester.getSize(find.byType(TabPanel)).height;
expect(namesHeight(tester), greaterThan(panel * 0.75));
});
});
testWidgets('a letter without names goes to the next one', (tester) async { testWidgets('a letter without names goes to the next one', (tester) async {
await pumpPanel(tester); await pumpPanel(tester);