diff --git a/lib/widgets/mornye_bottom_bar.dart b/lib/widgets/mornye_bottom_bar.dart index 196f18be..b19bcd57 100644 --- a/lib/widgets/mornye_bottom_bar.dart +++ b/lib/widgets/mornye_bottom_bar.dart @@ -89,17 +89,19 @@ class MornyeBottomBar extends ConsumerWidget { final tabGap = glassTabs ? 0.0 : 8.0; // These contents do not depend on animation progress. Retain their widget // instances so folding only updates size/opacity wrappers each frame. - Widget sideSurface() => RepaintBoundary( + Widget sideSurface({double radius = 26}) => RepaintBoundary( child: MornyeGlass.navigation( blurEnabled: blurEnabled, strongTint: true, tintOpacity: MornyeTheme.chromeOpacity(context), - radius: 26, - child: const SizedBox.square(dimension: 52), + radius: radius, + child: const SizedBox.expand(), ), ); final homeSurface = sideSurface(); - final searchSurface = sideSurface(); + final searchSurface = sideSurface(radius: 32); + final mainDestinations = destinations.sublist(0, destinations.length - 1); + final searchSelected = selectedIndex == destinations.length - 1; final scheme = Theme.of(context).colorScheme; final inactiveIconColor = Color.lerp( scheme.onSurfaceVariant, @@ -107,23 +109,23 @@ class MornyeBottomBar extends ConsumerWidget { scheme.brightness == Brightness.dark ? 0.5 : 0.4, ); final player = MiniPlayer(compact: collapsed, bottomPadding: 0); - Widget tabs({required bool hideEdgeIcons}) => TickerMode( + Widget tabs({required bool hideHomeIcon}) => TickerMode( enabled: !collapsed, child: RepaintBoundary( child: MornyeTabBar( - destinations: destinations, - selectedIndex: selectedIndex, + destinations: mainDestinations, + selectedIndex: searchSelected ? -1 : selectedIndex, onSelected: onSelected, blurEnabled: blurEnabled, - hideEdgeIcons: hideEdgeIcons, + hideHomeIcon: hideHomeIcon, ), ), ); - // At rest the glass bar must paint its own edge icons: its selected layer + // At rest the glass bar must paint its own Home icon: its selected layer // follows a dragged pill before the destination is committed. Hand them // to the moving overlays only while folding, at the same coordinates. - final fullTabs = tabs(hideEdgeIcons: false); - final foldingTabs = tabs(hideEdgeIcons: true); + final fullTabs = tabs(hideHomeIcon: false); + final foldingTabs = tabs(hideHomeIcon: true); return LayoutBuilder( builder: (context, constraints) { // Match the tab's actual label height, including accessibility scaling. @@ -146,7 +148,12 @@ class MornyeBottomBar extends ConsumerWidget { final tabInset = glassTabs ? 6.0 : 5.0; final fullIconStart = tabInset + - (constraints.maxWidth - tabInset * 2) / destinations.length / 2; + (constraints.maxWidth - 76 - tabInset * 2) / + mainDestinations.length / + 2; + final fullSearchBottom = glassTabs + ? 8.0 + : (math.max(64.0, 49 + labelHeight) - 64) / 2; return TweenAnimationBuilder( tween: Tween(end: collapsed ? 1 : 0), duration: MediaQuery.disableAnimationsOf(context) @@ -157,20 +164,28 @@ class MornyeBottomBar extends ConsumerWidget { Widget movingIcon({required bool home, required Widget surface}) { final index = home ? 0 : destinations.length - 1; final offset = (fullIconStart - 26) * (1 - amount); + final size = home ? 52.0 : 64 - 12 * amount; return PositionedDirectional( start: home ? offset : null, - end: home ? null : offset, - bottom: fullIconBottom * (1 - amount) + 34 * amount - 26, - width: 52, - height: 52, + end: home ? null : 0, + bottom: home + ? fullIconBottom * (1 - amount) + 34 * amount - 26 + : fullSearchBottom * (1 - amount) + 8 * amount, + width: size, + height: size, child: IgnorePointer( - ignoring: amount < 0.5, + ignoring: home && amount < 0.5, child: ExcludeSemantics( - excluding: amount < 0.5, + excluding: home && amount < 0.5, child: Stack( alignment: Alignment.center, children: [ - Opacity(opacity: amount, child: surface), + Positioned.fill( + child: Opacity( + opacity: home ? amount : 1, + child: surface, + ), + ), Material( color: Colors.transparent, child: IconButton( @@ -183,6 +198,10 @@ class MornyeBottomBar extends ConsumerWidget { ? context.l10n.navHome : context.l10n.mornyeSearch, iconSize: 25, + constraints: BoxConstraints.tightFor( + width: size, + height: size, + ), color: Color.lerp( index == selectedIndex ? scheme.primary @@ -191,7 +210,7 @@ class MornyeBottomBar extends ConsumerWidget { amount, ), icon: Opacity( - opacity: amount == 0 ? 0 : 1, + opacity: home && amount == 0 ? 0 : 1, child: destinations[index].icon, ), onPressed: home ? onHome : onSearch, @@ -247,7 +266,12 @@ class MornyeBottomBar extends ConsumerWidget { excluding: amount > 0.5, child: Opacity( opacity: 1 - amount, - child: amount == 0 ? fullTabs : foldingTabs, + child: Padding( + padding: const EdgeInsetsDirectional.only( + end: 76, + ), + child: amount == 0 ? fullTabs : foldingTabs, + ), ), ), ), diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 599edd1a..6585dabb 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -437,16 +437,15 @@ class MornyeTabBar extends StatelessWidget { required this.selectedIndex, required this.onSelected, required this.blurEnabled, - this.hideEdgeIcons = false, + this.hideHomeIcon = false, }); final List destinations; final int selectedIndex; final ValueChanged onSelected; final bool blurEnabled; - // The folding bottom bar paints these two icons above both layouts so they - // can travel without fading along with the full tab capsule. - final bool hideEdgeIcons; + // Home moves independently while the full tab capsule folds away. + final bool hideHomeIcon; @override Widget build(BuildContext context) { @@ -489,7 +488,7 @@ class MornyeTabBar extends StatelessWidget { width: constraints.maxWidth, height: 64, margin: const EdgeInsets.only(bottom: 8), - selectedIndex: selectedIndex, + selectedIndex: selectedIndex < 0 ? 0 : selectedIndex, onChanged: onSelected, style: LiquidGlassTabBar.defaultStyle.copyWith( // The frosted base owns the subtle outline. Disable the @@ -515,6 +514,7 @@ class MornyeTabBar extends StatelessWidget { ), pillStyle: LiquidGlassTabPillStyle( mode: LiquidGlassPillMode.impellerOnly, + show: selectedIndex >= 0, color: selectionFill, animated: true, // Keep the moving refractive pill, without stacking the @@ -530,17 +530,12 @@ class MornyeTabBar extends StatelessWidget { iconBuilder: (context, icon) => IconTheme( data: IconThemeData( size: icon.size, - color: icon.selected + color: icon.selected && selectedIndex >= 0 ? scheme.primary : inactiveIconColor, ), child: Opacity( - opacity: - hideEdgeIcons && - (index == 0 || - index == destinations.length - 1) - ? 0 - : 1, + opacity: hideHomeIcon && index == 0 ? 0 : 1, child: destination.icon, ), ), @@ -552,12 +547,40 @@ class MornyeTabBar extends StatelessWidget { ?.copyWith( fontSize: label.textStyle.fontSize, fontWeight: FontWeight.w600, - color: label.textStyle.color, + color: selectedIndex < 0 + ? scheme.onSurface + : label.textStyle.color, ), ), ), ], ), + // Search lives outside the capsule. With no selected tab the + // package still needs an internal index; handle taps here so + // returning to that index (Home) is not swallowed as a re-tap. + if (selectedIndex < 0) + Positioned( + left: 6, + right: 6, + bottom: 8, + height: 64, + child: Row( + children: [ + for ( + var index = 0; + index < destinations.length; + index++ + ) + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onSelected(index), + child: const SizedBox.expand(), + ), + ), + ], + ), + ), ], ), ), @@ -612,12 +635,7 @@ class MornyeTabBar extends StatelessWidget { // Keep tab selection quiet, as in Mornye. Badges // stay live without the Material bounce/spin. child: Opacity( - opacity: - hideEdgeIcons && - (index == 0 || - index == destinations.length - 1) - ? 0 - : 1, + opacity: hideHomeIcon && index == 0 ? 0 : 1, child: destinations[index].icon, ), ), diff --git a/test/mornye_bottom_bar_test.dart b/test/mornye_bottom_bar_test.dart index 210663cd..a0955c59 100644 --- a/test/mornye_bottom_bar_test.dart +++ b/test/mornye_bottom_bar_test.dart @@ -23,14 +23,12 @@ void main() { late MornyeChromeController chrome; late ScrollController scroll; late int searches; - late int selected; final capture = GlobalKey(); setUp(() { chrome = MornyeChromeController(); scroll = ScrollController(); searches = 0; - selected = 0; }); tearDown(() { chrome.dispose(); @@ -109,11 +107,13 @@ void main() { ], selectedIndex: activeTab?.value ?? 0, onSelected: (index) { - selected = index; activeTab?.value = index; }, onHome: chrome.expand, - onSearch: () => searches++, + onSearch: () { + searches++; + activeTab?.value = 3; + }, blurEnabled: blur, ), ), @@ -147,21 +147,25 @@ void main() { (3, 'Search'), (0, 'Home'), ]) { - await tester.tapAt( - tester.getCenter( - find - .descendant( - of: find.byType(MornyeTabBar), - matching: find.text(label), - ) - .first, - ), - ); + if (index == 3) { + await tester.tap(find.byTooltip('Search')); + } else { + await tester.tapAt( + tester.getCenter( + find + .descendant( + of: find.byType(MornyeTabBar), + matching: find.text(label), + ) + .first, + ), + ); + } await tester.pumpAndSettle(); expect(activeTab.value, index); final tabIcons = find.descendant( of: find.byType(MornyeTabBar), - matching: find.byIcon(index == 3 ? Icons.search : Icons.home), + matching: find.byIcon(Icons.home), ); // Edge icons stay in the glass bar's selected/unselected layers at // rest, so the pill can reveal their red state during a held drag. @@ -189,7 +193,7 @@ void main() { await tester.pumpAndSettle(); expect( tester.widget(find.byType(MornyeTabBar)).selectedIndex, - index, + index == 3 ? -1 : index, ); } }); @@ -236,16 +240,18 @@ void main() { }, ); - testWidgets('four tabs keep Search at the right edge (glass: $blur)', ( + testWidgets('Search is a separate circle at the right (glass: $blur)', ( tester, ) async { await pumpShell(tester, blur: blur); final tabs = find.byType(MornyeTabBar); - final search = find - .descendant(of: tabs, matching: find.text('Search')) - .first; + final search = find.byKey(const ValueKey('mornye-compact-search')); final repo = find.descendant(of: tabs, matching: find.text('Repo')).first; - expect(tester.widget(tabs).destinations, hasLength(4)); + expect(tester.widget(tabs).destinations, hasLength(3)); + expect( + find.descendant(of: tabs, matching: find.text('Search')), + findsNothing, + ); expect( find.descendant(of: tabs, matching: find.text('Settings')), findsNothing, @@ -254,10 +260,15 @@ void main() { tester.getCenter(search).dx, greaterThan(tester.getCenter(repo).dx), ); + expect( + tester.getRect(search).left - tester.getRect(tabs).right, + closeTo(12, 0.1), + ); + expect(tester.getSize(search), const Size(64, 64)); // The glass renderer paints the labels under a gesture overlay. await tester.tapAt(tester.getCenter(search)); await tester.pumpAndSettle(); - expect(selected, 3); + expect(searches, 1); expect(tester.takeException(), isNull); }); }