Files
kooltab/lib/views/bar/tab_panel.dart
T

725 lines
25 KiB
Dart

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';
/// 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;
final String? selectedTabId;
final Map<String, int> stockByProductId;
final bool showSearch;
final ValueChanged<String> onTabSelected;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
final VoidCallback onCloseTabPressed;
final Future<void> Function(BarTab) onTabEdited;
final Future<void> Function(BarTab) onTabDeleted;
const TabPanel({
super.key,
required this.tabs,
required this.selectedTab,
required this.selectedTabId,
required this.stockByProductId,
required this.showSearch,
required this.onTabSelected,
required this.onItemQuantityChanged,
required this.onCloseTabPressed,
required this.onTabEdited,
required this.onTabDeleted,
});
@override
State<TabPanel> createState() => _TabPanelState();
}
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;
_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);
// Hiding the search bar shouldn't leave the list silently filtered
if (oldWidget.showSearch && !widget.showSearch) {
_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
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) {
_focusOn(_PanelFocus.names);
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;
final l10n = AppLocalizations.of(context);
final filteredTabs = widget.tabs.where((tab) {
return tab.customerName.toLowerCase().contains(
_searchQuery.toLowerCase(),
);
}).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName));
return DecoratedBox(
decoration: BoxDecoration(color: scheme.surfaceContainerLow),
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 16, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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 SizedBox(width: 8),
Expanded(
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,
),
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),
],
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,
),
),
);
},
),
),
),
),
],
);
},
),
),
],
),
),
);
}
}
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;
const OpenTabsList({
super.key,
this.controller,
required this.tabs,
required this.selectedTabId,
this.attachedTabId,
required this.onTabSelected,
required this.onTabEdited,
required this.onTabDeleted,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
if (tabs.isEmpty) {
return Center(
child: Text(
l10n.noOpenTabs,
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
// A fixed row height lets the letter index work out where each tab is
return ListView.builder(
controller: controller,
itemCount: tabs.length,
// 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 attached = tab.id == attachedTabId;
return _TabRow(
child: ClipRRect(
borderRadius: attached
? _attachedTileRadius
: 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,
attached: attached,
onTap: () => onTabSelected(tab.id),
),
),
),
);
},
);
}
}
class _TabRow extends StatelessWidget {
final Widget child;
const _TabRow({required this.child});
@override
Widget build(BuildContext context) {
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.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),
decoration: BoxDecoration(
color: selected
? primary.withValues(alpha: 0.14)
: scheme.onSurface.withValues(alpha: 0.04),
borderRadius: radius,
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: radius,
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,
),
),
);
}
}