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.
This commit is contained in:
zarzet committed 2026-09-21 18:15:52 +07:00
1 parent 1c535b493c
commit ed80d3e8bb
3 files changed
+115 -62

No files matched your search

+45 -21
View File
@@ -89,17 +89,19 @@ class MornyeBottomBar extends ConsumerWidget {
final tabGap = glassTabs ? 0.0 : 8.0; final tabGap = glassTabs ? 0.0 : 8.0;
// These contents do not depend on animation progress. Retain their widget // These contents do not depend on animation progress. Retain their widget
// instances so folding only updates size/opacity wrappers each frame. // instances so folding only updates size/opacity wrappers each frame.
Widget sideSurface() => RepaintBoundary( Widget sideSurface({double radius = 26}) => RepaintBoundary(
child: MornyeGlass.navigation( child: MornyeGlass.navigation(
blurEnabled: blurEnabled, blurEnabled: blurEnabled,
strongTint: true, strongTint: true,
tintOpacity: MornyeTheme.chromeOpacity(context), tintOpacity: MornyeTheme.chromeOpacity(context),
radius: 26, radius: radius,
child: const SizedBox.square(dimension: 52), child: const SizedBox.expand(),
), ),
); );
final homeSurface = sideSurface(); 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 scheme = Theme.of(context).colorScheme;
final inactiveIconColor = Color.lerp( final inactiveIconColor = Color.lerp(
scheme.onSurfaceVariant, scheme.onSurfaceVariant,
@@ -107,23 +109,23 @@ class MornyeBottomBar extends ConsumerWidget {
scheme.brightness == Brightness.dark ? 0.5 : 0.4, scheme.brightness == Brightness.dark ? 0.5 : 0.4,
); );
final player = MiniPlayer(compact: collapsed, bottomPadding: 0); final player = MiniPlayer(compact: collapsed, bottomPadding: 0);
Widget tabs({required bool hideEdgeIcons}) => TickerMode( Widget tabs({required bool hideHomeIcon}) => TickerMode(
enabled: !collapsed, enabled: !collapsed,
child: RepaintBoundary( child: RepaintBoundary(
child: MornyeTabBar( child: MornyeTabBar(
destinations: destinations, destinations: mainDestinations,
selectedIndex: selectedIndex, selectedIndex: searchSelected ? -1 : selectedIndex,
onSelected: onSelected, onSelected: onSelected,
blurEnabled: blurEnabled, 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 // follows a dragged pill before the destination is committed. Hand them
// to the moving overlays only while folding, at the same coordinates. // to the moving overlays only while folding, at the same coordinates.
final fullTabs = tabs(hideEdgeIcons: false); final fullTabs = tabs(hideHomeIcon: false);
final foldingTabs = tabs(hideEdgeIcons: true); final foldingTabs = tabs(hideHomeIcon: true);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Match the tab's actual label height, including accessibility scaling. // 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 tabInset = glassTabs ? 6.0 : 5.0;
final fullIconStart = final fullIconStart =
tabInset + 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<double>( return TweenAnimationBuilder<double>(
tween: Tween(end: collapsed ? 1 : 0), tween: Tween(end: collapsed ? 1 : 0),
duration: MediaQuery.disableAnimationsOf(context) duration: MediaQuery.disableAnimationsOf(context)
@@ -157,20 +164,28 @@ class MornyeBottomBar extends ConsumerWidget {
Widget movingIcon({required bool home, required Widget surface}) { Widget movingIcon({required bool home, required Widget surface}) {
final index = home ? 0 : destinations.length - 1; final index = home ? 0 : destinations.length - 1;
final offset = (fullIconStart - 26) * (1 - amount); final offset = (fullIconStart - 26) * (1 - amount);
final size = home ? 52.0 : 64 - 12 * amount;
return PositionedDirectional( return PositionedDirectional(
start: home ? offset : null, start: home ? offset : null,
end: home ? null : offset, end: home ? null : 0,
bottom: fullIconBottom * (1 - amount) + 34 * amount - 26, bottom: home
width: 52, ? fullIconBottom * (1 - amount) + 34 * amount - 26
height: 52, : fullSearchBottom * (1 - amount) + 8 * amount,
width: size,
height: size,
child: IgnorePointer( child: IgnorePointer(
ignoring: amount < 0.5, ignoring: home && amount < 0.5,
child: ExcludeSemantics( child: ExcludeSemantics(
excluding: amount < 0.5, excluding: home && amount < 0.5,
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
Opacity(opacity: amount, child: surface), Positioned.fill(
child: Opacity(
opacity: home ? amount : 1,
child: surface,
),
),
Material( Material(
color: Colors.transparent, color: Colors.transparent,
child: IconButton( child: IconButton(
@@ -183,6 +198,10 @@ class MornyeBottomBar extends ConsumerWidget {
? context.l10n.navHome ? context.l10n.navHome
: context.l10n.mornyeSearch, : context.l10n.mornyeSearch,
iconSize: 25, iconSize: 25,
constraints: BoxConstraints.tightFor(
width: size,
height: size,
),
color: Color.lerp( color: Color.lerp(
index == selectedIndex index == selectedIndex
? scheme.primary ? scheme.primary
@@ -191,7 +210,7 @@ class MornyeBottomBar extends ConsumerWidget {
amount, amount,
), ),
icon: Opacity( icon: Opacity(
opacity: amount == 0 ? 0 : 1, opacity: home && amount == 0 ? 0 : 1,
child: destinations[index].icon, child: destinations[index].icon,
), ),
onPressed: home ? onHome : onSearch, onPressed: home ? onHome : onSearch,
@@ -247,7 +266,12 @@ class MornyeBottomBar extends ConsumerWidget {
excluding: amount > 0.5, excluding: amount > 0.5,
child: Opacity( child: Opacity(
opacity: 1 - amount, opacity: 1 - amount,
child: amount == 0 ? fullTabs : foldingTabs, child: Padding(
padding: const EdgeInsetsDirectional.only(
end: 76,
),
child: amount == 0 ? fullTabs : foldingTabs,
),
), ),
), ),
), ),
+37 -19
View File
@@ -437,16 +437,15 @@ class MornyeTabBar extends StatelessWidget {
required this.selectedIndex, required this.selectedIndex,
required this.onSelected, required this.onSelected,
required this.blurEnabled, required this.blurEnabled,
this.hideEdgeIcons = false, this.hideHomeIcon = false,
}); });
final List<NavigationDestination> destinations; final List<NavigationDestination> destinations;
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onSelected; final ValueChanged<int> onSelected;
final bool blurEnabled; final bool blurEnabled;
// The folding bottom bar paints these two icons above both layouts so they // Home moves independently while the full tab capsule folds away.
// can travel without fading along with the full tab capsule. final bool hideHomeIcon;
final bool hideEdgeIcons;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -489,7 +488,7 @@ class MornyeTabBar extends StatelessWidget {
width: constraints.maxWidth, width: constraints.maxWidth,
height: 64, height: 64,
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
selectedIndex: selectedIndex, selectedIndex: selectedIndex < 0 ? 0 : selectedIndex,
onChanged: onSelected, onChanged: onSelected,
style: LiquidGlassTabBar.defaultStyle.copyWith( style: LiquidGlassTabBar.defaultStyle.copyWith(
// The frosted base owns the subtle outline. Disable the // The frosted base owns the subtle outline. Disable the
@@ -515,6 +514,7 @@ class MornyeTabBar extends StatelessWidget {
), ),
pillStyle: LiquidGlassTabPillStyle( pillStyle: LiquidGlassTabPillStyle(
mode: LiquidGlassPillMode.impellerOnly, mode: LiquidGlassPillMode.impellerOnly,
show: selectedIndex >= 0,
color: selectionFill, color: selectionFill,
animated: true, animated: true,
// Keep the moving refractive pill, without stacking the // Keep the moving refractive pill, without stacking the
@@ -530,17 +530,12 @@ class MornyeTabBar extends StatelessWidget {
iconBuilder: (context, icon) => IconTheme( iconBuilder: (context, icon) => IconTheme(
data: IconThemeData( data: IconThemeData(
size: icon.size, size: icon.size,
color: icon.selected color: icon.selected && selectedIndex >= 0
? scheme.primary ? scheme.primary
: inactiveIconColor, : inactiveIconColor,
), ),
child: Opacity( child: Opacity(
opacity: opacity: hideHomeIcon && index == 0 ? 0 : 1,
hideEdgeIcons &&
(index == 0 ||
index == destinations.length - 1)
? 0
: 1,
child: destination.icon, child: destination.icon,
), ),
), ),
@@ -552,12 +547,40 @@ class MornyeTabBar extends StatelessWidget {
?.copyWith( ?.copyWith(
fontSize: label.textStyle.fontSize, fontSize: label.textStyle.fontSize,
fontWeight: FontWeight.w600, 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 // Keep tab selection quiet, as in Mornye. Badges
// stay live without the Material bounce/spin. // stay live without the Material bounce/spin.
child: Opacity( child: Opacity(
opacity: opacity: hideHomeIcon && index == 0 ? 0 : 1,
hideEdgeIcons &&
(index == 0 ||
index == destinations.length - 1)
? 0
: 1,
child: destinations[index].icon, child: destinations[index].icon,
), ),
), ),
+33 -22
View File
@@ -23,14 +23,12 @@ void main() {
late MornyeChromeController chrome; late MornyeChromeController chrome;
late ScrollController scroll; late ScrollController scroll;
late int searches; late int searches;
late int selected;
final capture = GlobalKey(); final capture = GlobalKey();
setUp(() { setUp(() {
chrome = MornyeChromeController(); chrome = MornyeChromeController();
scroll = ScrollController(); scroll = ScrollController();
searches = 0; searches = 0;
selected = 0;
}); });
tearDown(() { tearDown(() {
chrome.dispose(); chrome.dispose();
@@ -109,11 +107,13 @@ void main() {
], ],
selectedIndex: activeTab?.value ?? 0, selectedIndex: activeTab?.value ?? 0,
onSelected: (index) { onSelected: (index) {
selected = index;
activeTab?.value = index; activeTab?.value = index;
}, },
onHome: chrome.expand, onHome: chrome.expand,
onSearch: () => searches++, onSearch: () {
searches++;
activeTab?.value = 3;
},
blurEnabled: blur, blurEnabled: blur,
), ),
), ),
@@ -147,21 +147,25 @@ void main() {
(3, 'Search'), (3, 'Search'),
(0, 'Home'), (0, 'Home'),
]) { ]) {
await tester.tapAt( if (index == 3) {
tester.getCenter( await tester.tap(find.byTooltip('Search'));
find } else {
.descendant( await tester.tapAt(
of: find.byType(MornyeTabBar), tester.getCenter(
matching: find.text(label), find
) .descendant(
.first, of: find.byType(MornyeTabBar),
), matching: find.text(label),
); )
.first,
),
);
}
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(activeTab.value, index); expect(activeTab.value, index);
final tabIcons = find.descendant( final tabIcons = find.descendant(
of: find.byType(MornyeTabBar), 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 // 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. // rest, so the pill can reveal their red state during a held drag.
@@ -189,7 +193,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
tester.widget<MornyeTabBar>(find.byType(MornyeTabBar)).selectedIndex, tester.widget<MornyeTabBar>(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, tester,
) async { ) async {
await pumpShell(tester, blur: blur); await pumpShell(tester, blur: blur);
final tabs = find.byType(MornyeTabBar); final tabs = find.byType(MornyeTabBar);
final search = find final search = find.byKey(const ValueKey('mornye-compact-search'));
.descendant(of: tabs, matching: find.text('Search'))
.first;
final repo = find.descendant(of: tabs, matching: find.text('Repo')).first; final repo = find.descendant(of: tabs, matching: find.text('Repo')).first;
expect(tester.widget<MornyeTabBar>(tabs).destinations, hasLength(4)); expect(tester.widget<MornyeTabBar>(tabs).destinations, hasLength(3));
expect(
find.descendant(of: tabs, matching: find.text('Search')),
findsNothing,
);
expect( expect(
find.descendant(of: tabs, matching: find.text('Settings')), find.descendant(of: tabs, matching: find.text('Settings')),
findsNothing, findsNothing,
@@ -254,10 +260,15 @@ void main() {
tester.getCenter(search).dx, tester.getCenter(search).dx,
greaterThan(tester.getCenter(repo).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. // The glass renderer paints the labels under a gesture overlay.
await tester.tapAt(tester.getCenter(search)); await tester.tapAt(tester.getCenter(search));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(selected, 3); expect(searches, 1);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
} }