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 tabs; final BarTab? selectedTab; final String? selectedTabId; final bool showSearch; final ValueChanged onTabSelected; final VoidCallback onTabDeselected; final ItemQuantityChanged onItemQuantityChanged; final VoidCallback onCloseTabPressed; final Future Function(BarTab) onTabEdited; final Future 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 createState() => _TabPanelState(); } class _TabPanelState extends State { 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 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 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 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( 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 tabs; final String? selectedTabId; /// The tab whose tile is lined up on top of its items. final String? attachedTabId; final ValueChanged onTabSelected; final Future Function(BarTab) onTabEdited; final Future 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 availableLetters; final String? activeLetter; final bool isTouched; final ValueChanged 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 createState() => _LetterIndexBarState(); } class _LetterIndexBarState extends State 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, ), ), ); } }