From ed80d3e8bb3eff82f368a5f5df8902ef655f729d Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:15:52 +0700 Subject: [PATCH] feat(mornye): separate Search into a circular navigation button Keep Home, Library, and Repo in the glass capsule while Search remains accessible on the right. Preserve compact transitions and allow returning to Home when Search is active. --- lib/widgets/mornye_bottom_bar.dart | 66 ++++++++++++++++++++---------- lib/widgets/mornye_chrome.dart | 56 ++++++++++++++++--------- test/mornye_bottom_bar_test.dart | 55 +++++++++++++++---------- 3 files changed, 115 insertions(+), 62 deletions(-) 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); }); }