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
+296 -83
View File
@@ -12,6 +12,29 @@ import '../../utils/money.dart';
import '../../utils/name_index.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 {
final List<BarTab> tabs;
final BarTab? selectedTab;
@@ -50,6 +73,76 @@ class _TabPanelState extends State<TabPanel> {
/// The letter under the finger while the index bar is pressed.
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
void didUpdateWidget(TabPanel oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -59,6 +152,15 @@ class _TabPanelState extends State<TabPanel> {
_searchController.clear();
_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
@@ -79,6 +181,7 @@ class _TabPanelState extends State<TabPanel> {
/// 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) {
_focusOn(_PanelFocus.names);
if (letter != _touchedLetter) {
unawaited(HapticFeedback.selectionClick());
setState(() => _touchedLetter = letter);
@@ -148,89 +251,181 @@ class _TabPanelState extends State<TabPanel> {
),
const SizedBox(width: 8),
Expanded(
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,
child: LayoutBuilder(
builder: (context, constraints) {
const dividerHeight = 28.0;
const minDetailsHeight = 240.0;
final available = constraints.maxHeight - dividerHeight;
final collapsedNames = max(available * 0.2, 64.0);
final collapsedItems = max(available * 0.1, 64.0);
final focus =
_focus == _PanelFocus.items && widget.selectedTab == null
? _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,
};
final attachOffset = _attachOffset(
filteredTabs,
namesHeight - searchHeight,
);
final attached = attachOffset != null;
_lineUpSelectedTab(attachOffset);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedContainer(
duration: _panelAnimation,
curve: _panelCurve,
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,
),
),
const SizedBox(height: 8),
],
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,
suffixIcon: _searchQuery.isEmpty
? null
: IconButton(
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
icon: const Icon(
Icons.clear_rounded,
),
tooltip: l10n.clearSearch,
),
isDense: true,
),
),
const SizedBox(height: 10),
Text(
l10n.selectOrOpenTab,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
const SizedBox(height: 8),
],
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 List<BarTab> tabs;
final String? selectedTabId;
/// The tab whose tile is lined up on top of its items.
final String? attachedTabId;
final ValueChanged<String> onTabSelected;
final Future<void> Function(BarTab) onTabEdited;
final Future<void> Function(BarTab) onTabDeleted;
@@ -253,6 +451,7 @@ class OpenTabsList extends StatelessWidget {
this.controller,
required this.tabs,
required this.selectedTabId,
this.attachedTabId,
required this.onTabSelected,
required this.onTabEdited,
required this.onTabDeleted,
@@ -278,10 +477,13 @@ class OpenTabsList extends StatelessWidget {
prototypeItem: _TabRow(child: _TabCard(tab: tabs.first, selected: true)),
itemBuilder: (context, index) {
final tab = tabs[index];
final attached = tab.id == attachedTabId;
return _TabRow(
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
borderRadius: attached
? _attachedTileRadius
: BorderRadius.circular(14),
child: Slidable(
key: ValueKey(tab.id),
endActionPane: ActionPane(
@@ -307,6 +509,7 @@ class OpenTabsList extends StatelessWidget {
child: _TabCard(
tab: tab,
selected: tab.id == selectedTabId,
attached: attached,
onTap: () => onTabSelected(tab.id),
),
),
@@ -324,22 +527,32 @@ class _TabRow extends StatelessWidget {
@override
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 {
final BarTab tab;
final bool selected;
final bool attached;
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
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
final primary = scheme.primary;
final radius = attached ? _attachedTileRadius : BorderRadius.circular(14);
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
@@ -347,7 +560,7 @@ class _TabCard extends StatelessWidget {
color: selected
? primary.withValues(alpha: 0.14)
: scheme.onSurface.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14),
borderRadius: radius,
border: Border.all(
color: selected
? primary.withValues(alpha: 0.6)
@@ -358,7 +571,7 @@ class _TabCard extends StatelessWidget {
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
borderRadius: radius,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),