Files

860 lines
31 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '../../utils/easter_eggs.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 bool showSearch;
final ValueChanged<String> onTabSelected;
final VoidCallback onTabDeselected;
final ItemQuantityChanged 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.showSearch,
required this.onTabSelected,
required this.onTabDeselected,
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;
/// The first row of the names list, measured for [_rowExtent].
final GlobalKey _firstRowKey = GlobalKey();
/// 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) return;
final box = _firstRowKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return;
if (box.size.height != _rowExtent) {
setState(() => _rowExtent = box.size.height);
}
});
}
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 = _rowExtent;
if (index == -1 || extent == null) return null;
final offset = (index + 1) * extent - _tabRowGap - viewportHeight;
// Rounding can leave a sliver below zero
return offset < -0.5 ? null : max(offset, 0);
}
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, 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)) {
_focus = _PanelFocus.items;
}
}
@override
void dispose() {
_searchController.dispose();
_tabsScrollController.dispose();
super.dispose();
}
/// 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 = _rowExtent;
if (extent == null || !_tabsScrollController.hasClients) 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 = _rowExtent;
final index = extent == null || !_tabsScrollController.hasClients
? 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));
_measureRow();
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;
// 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,
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,
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);
},
onTabEdited: widget.onTabEdited,
onTabDeleted: widget.onTabDeleted,
),
),
),
],
),
),
if (!attached && hasSelection)
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!,
onItemQuantityChanged:
widget.onItemQuantityChanged,
onCloseTabPressed:
widget.onCloseTabPressed,
),
),
);
},
),
),
),
),
],
);
},
),
),
],
),
),
);
}
}
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;
/// 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,
this.firstRowKey,
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(
key: index == 0 ? firstRowKey : null,
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({super.key, 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(
customerDisplayName(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,
),
],
),
),
),
),
),
);
}
}
/// 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({
super.key,
required this.availableLetters,
required this.activeLetter,
required this.isTouched,
required this.onLetterSelected,
required this.onReleased,
});
@override
State<LetterIndexBar> createState() => _LetterIndexBarState();
}
class _LetterIndexBarState extends State<LetterIndexBar>
with SingleTickerProviderStateMixin {
static final _zIndex = indexLetters.indexOf('Z');
/// Sliding (rather than tapping) leaves a fading trail of rainbow dots.
bool _isSliding = false;
/// Progress of an A → Z → A slide: null once broken, else whether Z was hit.
bool? _reachedZ;
/// Sweeps a rainbow down the bar after a full A → Z → A slide.
late final AnimationController _wave = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
);
@override
void dispose() {
_wave.dispose();
super.dispose();
}
void _setSliding(bool value) {
if (_isSliding != value) setState(() => _isSliding = value);
}
void _trackRainbowRoad(int index, {bool isStart = false}) {
if (isStart) {
_reachedZ = index == 0 ? false : null;
} else if (_reachedZ == false && index >= _zIndex) {
_reachedZ = true;
} else if (_reachedZ == true && index == 0) {
_reachedZ = null;
_wave.forward(from: 0);
ScaffoldMessenger.maybeOf(context)
?..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).rainbowRoad)),
);
}
}
static Color _rainbowAt(int index) => HSVColor.fromAHSV(
1,
360 * index / indexLetters.length,
0.75,
0.95,
).toColor();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final onLetterSelected = widget.onLetterSelected;
void onReleased() {
_setSliding(false);
widget.onReleased();
}
return LayoutBuilder(
builder: (context, constraints) {
final letterHeight = constraints.maxHeight / indexLetters.length;
final fontSize = min(13.0, letterHeight * 0.65);
int indexAt(Offset position) => (position.dy / letterHeight)
.floor()
.clamp(0, indexLetters.length - 1);
String letterAt(Offset position) => indexLetters[indexAt(position)];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) =>
onLetterSelected(letterAt(details.localPosition)),
onTapUp: (_) => onReleased(),
onTapCancel: onReleased,
onVerticalDragStart: (details) {
_setSliding(true);
_trackRainbowRoad(indexAt(details.localPosition), isStart: true);
onLetterSelected(letterAt(details.localPosition));
},
onVerticalDragUpdate: (details) {
_trackRainbowRoad(indexAt(details.localPosition));
onLetterSelected(letterAt(details.localPosition));
},
onVerticalDragEnd: (_) => onReleased(),
onVerticalDragCancel: onReleased,
child: SizedBox(
width: 28,
child: AnimatedBuilder(
animation: _wave,
builder: (context, _) {
final isWaving = _wave.isAnimating;
// Runs past both ends so the first and last dots fade too.
final crest = _wave.value * (indexLetters.length + 8) - 2;
return Column(
children: [
for (final (index, letter) in indexLetters.indexed)
SizedBox(
height: letterHeight,
child: Center(
child: _IndexLetter(
letter: letter,
fontSize: fontSize,
isActive: letter == widget.activeLetter,
isTouched: widget.isTouched,
isLit: isWaving && (crest - index).abs() < 1.5,
hasNames: widget.availableLetters.contains(letter),
rainbowColor: _isSliding || isWaving
? _rainbowAt(index)
: null,
scheme: scheme,
),
),
),
],
);
},
),
),
);
},
);
}
}
class _IndexLetter extends StatelessWidget {
final String letter;
final double fontSize;
final bool isActive;
final bool isTouched;
/// Lit by the rainbow road wave, regardless of touch.
final bool isLit;
final bool hasNames;
/// Set while sliding: the dot takes this color and fades out slowly.
final Color? rainbowColor;
final ColorScheme scheme;
const _IndexLetter({
required this.letter,
required this.fontSize,
required this.isActive,
required this.isTouched,
this.isLit = false,
required this.hasNames,
this.rainbowColor,
required this.scheme,
});
@override
Widget build(BuildContext context) {
final rainbowColor = this.rainbowColor;
final showDot = (isActive && isTouched) || isLit;
final color = switch ((isActive, isTouched, hasNames)) {
_ when isLit && rainbowColor != null => Colors.white,
(true, true, _) when rainbowColor != null => Colors.white,
(true, true, _) => scheme.onPrimary,
(true, false, _) => scheme.primary,
(false, _, true) => scheme.onSurface,
(false, _, false) => scheme.onSurface.withValues(alpha: 0.25),
};
return AnimatedContainer(
// Applies to the transition being started, so a sliding dot appears
// instantly but lingers on its way out.
duration: Duration(
milliseconds: rainbowColor != null && !showDot ? 600 : 120,
),
width: fontSize * 1.8,
height: fontSize * 1.8,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: switch ((showDot, rainbowColor)) {
(true, final Color rainbow?) => rainbow,
(true, null) => scheme.primary,
(false, final Color rainbow?) => rainbow.withValues(alpha: 0),
(false, null) => Colors.transparent,
},
),
child: Text(
letter,
style: TextStyle(
fontSize: fontSize,
height: 1,
fontWeight: isActive || hasNames ? FontWeight.w800 : FontWeight.w500,
color: color,
),
),
);
}
}