diff --git a/lib/views/bar/selected_tab_details.dart b/lib/views/bar/selected_tab_details.dart index cc751cb..e6bc2b6 100644 --- a/lib/views/bar/selected_tab_details.dart +++ b/lib/views/bar/selected_tab_details.dart @@ -28,20 +28,6 @@ class SelectedTabDetails extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Expanded( - child: Text( - tab.customerName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - Divider(), - ], - ), - const SizedBox(height: 16), Expanded( child: tab.items.isEmpty ? Center( diff --git a/lib/views/bar/tab_panel.dart b/lib/views/bar/tab_panel.dart index 0a0df3c..ae6aacd 100644 --- a/lib/views/bar/tab_panel.dart +++ b/lib/views/bar/tab_panel.dart @@ -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 tabs; final BarTab? selectedTab; @@ -50,6 +73,76 @@ class _TabPanelState extends State { /// 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 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 { _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 { /// 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); @@ -148,89 +251,181 @@ class _TabPanelState extends State { ), 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( + 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 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; @@ -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), diff --git a/pubspec.yaml b/pubspec.yaml index 6121778..be4d247 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: kooltap description: "KoolTap: a tablet point-of-sale app for running bar tabs." publish_to: 'none' -version: 1.2.7+0 +version: 1.2.10+0 environment: sdk: ^3.12.2 diff --git a/test/tab_panel_index_test.dart b/test/tab_panel_index_test.dart index 5033351..2202291 100644 --- a/test/tab_panel_index_test.dart +++ b/test/tab_panel_index_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:kooltap/l10n/app_localizations.dart'; import 'package:kooltap/models/bar_tab.dart'; import 'package:kooltap/utils/name_index.dart'; +import 'package:kooltap/views/bar/selected_tab_details.dart'; import 'package:kooltap/views/bar/tab_panel.dart'; const _names = [ @@ -43,6 +44,7 @@ void main() { Future pumpPanel( WidgetTester tester, { bool showSearch = false, + BarTab? selectedTab, }) async { tester.view.physicalSize = const Size(500, 800); tester.view.devicePixelRatio = 1; @@ -55,8 +57,8 @@ void main() { home: Scaffold( body: TabPanel( tabs: tabs, - selectedTab: null, - selectedTabId: null, + selectedTab: selectedTab, + selectedTabId: selectedTab?.id, stockByProductId: const {}, showSearch: showSearch, onTabSelected: (_) {}, @@ -133,6 +135,92 @@ void main() { expect(find.text('anna'), findsOneWidget); }); + group('collapsing', () { + double namesHeight(WidgetTester tester) => + tester.getSize(find.byType(OpenTabsList)).height; + + testWidgets('starts with the names at their usual height', ( + tester, + ) async { + await pumpPanel(tester, selectedTab: tabs.first); + + expect(namesHeight(tester), 246); + }); + + testWidgets('tapping a name collapses the names', (tester) async { + await pumpPanel(tester, selectedTab: tabs.first); + + await tester.tap(find.text('Bram')); + await tester.pumpAndSettle(); + + expect(namesHeight(tester), lessThan(200)); + }); + + testWidgets('touching the items collapses the names', (tester) async { + await pumpPanel(tester, selectedTab: tabs.first); + + await tester.tap(find.byType(SelectedTabDetails), warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(namesHeight(tester), lessThan(200)); + }); + + testWidgets('lines the selected name up on top of its items', ( + tester, + ) async { + final marc = tabs.firstWhere((tab) => tab.customerName == 'Marc'); + await pumpPanel(tester, selectedTab: marc); + await tester.tap(find.byType(SelectedTabDetails), warnIfMissed: false); + await tester.pumpAndSettle(); + + final list = tester.getRect(find.byType(OpenTabsList)); + final details = tester.getRect(find.byType(SelectedTabDetails)); + final tile = tester.getRect( + find + .ancestor(of: find.text('Marc'), matching: find.byType(ClipRRect)) + .first, + ); + + expect(tile.bottom, closeTo(list.bottom, 1)); + expect(details.top, greaterThan(tile.bottom)); + expect( + find.byWidgetPredicate( + (widget) => widget is Divider && widget.height == 28, + ), + findsNothing, + ); + }); + + testWidgets('browsing the names shows the divider again', (tester) async { + final marc = tabs.firstWhere((tab) => tab.customerName == 'Marc'); + await pumpPanel(tester, selectedTab: marc); + await tester.tap(find.byType(SelectedTabDetails), warnIfMissed: false); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(OpenTabsList), const Offset(0, -40)); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (widget) => widget is Divider && widget.height == 28, + ), + findsOneWidget, + ); + }); + + testWidgets('scrolling the names collapses the items', (tester) async { + await pumpPanel(tester, selectedTab: tabs.first); + await tester.tap(find.byType(SelectedTabDetails), warnIfMissed: false); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(OpenTabsList), const Offset(0, -40)); + await tester.pumpAndSettle(); + + final panel = tester.getSize(find.byType(TabPanel)).height; + expect(namesHeight(tester), greaterThan(panel * 0.75)); + }); + }); + testWidgets('a letter without names goes to the next one', (tester) async { await pumpPanel(tester);