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'; class TabPanel extends StatefulWidget { final List tabs; final BarTab? selectedTab; final String? selectedTabId; final Map stockByProductId; final bool showSearch; final ValueChanged onTabSelected; final Future Function(TabItem item, int delta) 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.stockByProductId, required this.showSearch, required this.onTabSelected, 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; @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 = ''; } } @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 tabs) { 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 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: 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, ), 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, ), ), 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 tabs; final String? selectedTabId; final ValueChanged onTabSelected; final Future Function(BarTab) onTabEdited; final Future Function(BarTab) onTabDeleted; const OpenTabsList({ super.key, this.controller, required this.tabs, required this.selectedTabId, 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]; return _TabRow( child: ClipRRect( borderRadius: 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, 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: 8), child: child); } } class _TabCard extends StatelessWidget { final BarTab tab; final bool selected; final VoidCallback? onTap; const _TabCard({required this.tab, this.selected = false, this.onTap}); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final scheme = Theme.of(context).colorScheme; final primary = scheme.primary; return AnimatedContainer( duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: selected ? primary.withValues(alpha: 0.14) : scheme.onSurface.withValues(alpha: 0.04), borderRadius: BorderRadius.circular(14), 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: BorderRadius.circular(14), 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 availableLetters; final String? activeLetter; final bool isTouched; final ValueChanged 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, ), ), ); } }