diff --git a/lib/theme/mornye_theme.dart b/lib/theme/mornye_theme.dart index 977abaa6..eb2da827 100644 --- a/lib/theme/mornye_theme.dart +++ b/lib/theme/mornye_theme.dart @@ -37,6 +37,11 @@ class MornyeTheme extends ThemeExtension { ? 0.42 : 0.70; + static double navigationOpacity(BuildContext context) => + Theme.of(context).extension()?.chromeSurface != null + ? 0.28 + : 0.54; + static const lightAccent = Color.fromRGBO(204, 46, 51, 1); static const darkAccent = Color.fromRGBO(224, 61, 60, 1); diff --git a/lib/widgets/mornye_bottom_bar.dart b/lib/widgets/mornye_bottom_bar.dart index 03a84a53..a9634580 100644 --- a/lib/widgets/mornye_bottom_bar.dart +++ b/lib/widgets/mornye_bottom_bar.dart @@ -89,44 +89,42 @@ 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({double radius = 26}) => RepaintBoundary( + Widget sideSurface() => RepaintBoundary( child: MornyeGlass.navigation( blurEnabled: blurEnabled, strongTint: true, - tintOpacity: MornyeTheme.chromeOpacity(context), - radius: radius, + tintOpacity: MornyeTheme.navigationOpacity(context), + radius: 26, child: const SizedBox.expand(), ), ); final leadingSurface = sideSurface(); - final searchSurface = sideSurface(radius: 32); - final mainDestinations = destinations.sublist(0, destinations.length - 1); + final searchSurface = sideSurface(); final searchSelected = selectedIndex == destinations.length - 1; final leadingIndex = searchSelected ? 0 : selectedIndex; final scheme = Theme.of(context).colorScheme; - final inactiveIconColor = Color.lerp( - scheme.onSurfaceVariant, - scheme.onSurface, - scheme.brightness == Brightness.dark ? 0.5 : 0.4, - ); + final inactiveIconColor = scheme.onSurface; final player = MiniPlayer(compact: collapsed, bottomPadding: 0); - Widget tabs({required bool hideLeadingIcon}) => TickerMode( + Widget tabs({required bool hideMovingIcons}) => TickerMode( enabled: !collapsed, child: RepaintBoundary( child: MornyeTabBar( - destinations: mainDestinations, - selectedIndex: searchSelected ? -1 : selectedIndex, - onSelected: onSelected, + destinations: destinations, + selectedIndex: selectedIndex, + onSelected: (index) => + index == destinations.length - 1 ? onSearch() : onSelected(index), blurEnabled: blurEnabled, - hiddenIconIndex: hideLeadingIcon ? leadingIndex : null, + hiddenIconIndices: hideMovingIcons + ? {leadingIndex, destinations.length - 1} + : const {}, ), ), ); // At rest the glass bar must paint its own active 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(hideLeadingIcon: false); - final foldingTabs = tabs(hideLeadingIcon: true); + final fullTabs = tabs(hideMovingIcons: false); + final foldingTabs = tabs(hideMovingIcons: true); return LayoutBuilder( builder: (context, constraints) { // Match the tab's actual label height, including accessibility scaling. @@ -147,14 +145,11 @@ class MornyeBottomBar extends ConsumerWidget { ? 40 + (labelHeight + 2) / 2 : math.max(64.0, 49 + labelHeight) - 23.5; final tabInset = glassTabs ? 6.0 : 5.0; - final fullIconStart = + double fullIconStart(int index) => tabInset + - (constraints.maxWidth - 76 - tabInset * 2) / - mainDestinations.length * - (leadingIndex + 0.5); - final fullSearchBottom = glassTabs - ? 8.0 - : (math.max(64.0, 49 + labelHeight) - 64) / 2; + (constraints.maxWidth - tabInset * 2) / + destinations.length * + (index + 0.5); return TweenAnimationBuilder( tween: Tween(end: collapsed ? 1 : 0), duration: MediaQuery.disableAnimationsOf(context) @@ -167,28 +162,26 @@ class MornyeBottomBar extends ConsumerWidget { required Widget surface, }) { final index = leading ? leadingIndex : destinations.length - 1; - final offset = (fullIconStart - 26) * (1 - amount); - final size = leading ? 52.0 : 64 - 12 * amount; + final origin = fullIconStart(index); + final offset = + ((leading ? origin : constraints.maxWidth - origin) - 26) * + (1 - amount); + const size = 52.0; return PositionedDirectional( start: leading ? offset : null, - end: leading ? null : 0, - bottom: leading - ? fullIconBottom * (1 - amount) + 34 * amount - 26 - : fullSearchBottom * (1 - amount) + 8 * amount, + end: leading ? null : offset, + bottom: fullIconBottom * (1 - amount) + 34 * amount - 26, width: size, height: size, child: IgnorePointer( - ignoring: leading && amount < 0.5, + ignoring: amount < 0.5, child: ExcludeSemantics( - excluding: leading && amount < 0.5, + excluding: amount < 0.5, child: Stack( alignment: Alignment.center, children: [ Positioned.fill( - child: Opacity( - opacity: leading ? amount : 1, - child: surface, - ), + child: Opacity(opacity: amount, child: surface), ), Material( color: Colors.transparent, @@ -214,7 +207,7 @@ class MornyeBottomBar extends ConsumerWidget { amount, ), icon: Opacity( - opacity: leading && amount == 0 ? 0 : 1, + opacity: amount == 0 ? 0 : 1, child: destinations[index].icon, ), onPressed: leading @@ -274,12 +267,7 @@ class MornyeBottomBar extends ConsumerWidget { excluding: amount > 0.5, child: Opacity( opacity: 1 - amount, - child: Padding( - padding: const EdgeInsetsDirectional.only( - end: 76, - ), - child: amount == 0 ? fullTabs : foldingTabs, - ), + child: amount == 0 ? fullTabs : foldingTabs, ), ), ), diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index a3492079..a3fc8839 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -437,15 +437,15 @@ class MornyeTabBar extends StatelessWidget { required this.selectedIndex, required this.onSelected, required this.blurEnabled, - this.hiddenIconIndex, + this.hiddenIconIndices = const {}, }); final List destinations; final int selectedIndex; final ValueChanged onSelected; final bool blurEnabled; - // The active icon moves independently while the full capsule folds away. - final int? hiddenIconIndex; + // The active and Search icons move independently while the capsule folds. + final Set hiddenIconIndices; @override Widget build(BuildContext context) { @@ -453,11 +453,7 @@ class MornyeTabBar extends StatelessWidget { final selectionFill = scheme.onSurface.withValues( alpha: scheme.brightness == Brightness.dark ? 0.12 : 0.08, ); - final inactiveIconColor = Color.lerp( - scheme.onSurfaceVariant, - scheme.onSurface, - scheme.brightness == Brightness.dark ? 0.5 : 0.4, - ); + final inactiveIconColor = scheme.onSurface; if (blurEnabled && !MediaQuery.disableAnimationsOf(context) && !MediaQuery.highContrastOf(context)) { @@ -480,7 +476,7 @@ class MornyeTabBar extends StatelessWidget { child: _MornyeGlassSurface( blurEnabled: blurEnabled, strongTint: true, - tintOpacity: MornyeTheme.chromeOpacity(context), + tintOpacity: MornyeTheme.navigationOpacity(context), child: const SizedBox.expand(), ), ), @@ -535,7 +531,7 @@ class MornyeTabBar extends StatelessWidget { : inactiveIconColor, ), child: Opacity( - opacity: hiddenIconIndex == index ? 0 : 1, + opacity: hiddenIconIndices.contains(index) ? 0 : 1, child: destination.icon, ), ), @@ -555,8 +551,8 @@ class MornyeTabBar extends StatelessWidget { ), ], ), - // Search lives outside the capsule. With no selected tab the - // package still needs an internal index; handle taps here so + // 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( @@ -590,7 +586,7 @@ class MornyeTabBar extends StatelessWidget { return MornyeGlass.navigation( blurEnabled: blurEnabled, strongTint: true, - tintOpacity: MornyeTheme.chromeOpacity(context), + tintOpacity: MornyeTheme.navigationOpacity(context), child: Padding( padding: const EdgeInsets.all(5), child: Row( @@ -635,7 +631,9 @@ class MornyeTabBar extends StatelessWidget { // Keep tab selection quiet, as in Mornye. Badges // stay live without the Material bounce/spin. child: Opacity( - opacity: hiddenIconIndex == index ? 0 : 1, + opacity: hiddenIconIndices.contains(index) + ? 0 + : 1, child: destinations[index].icon, ), ), diff --git a/test/mornye_bottom_bar_test.dart b/test/mornye_bottom_bar_test.dart index 91e6dffb..a401512a 100644 --- a/test/mornye_bottom_bar_test.dart +++ b/test/mornye_bottom_bar_test.dart @@ -152,20 +152,16 @@ void main() { (3, 'Search'), (0, 'Home'), ]) { - 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.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( @@ -221,8 +217,8 @@ void main() { expect( tester .widget(find.byType(MornyeTabBar)) - .hiddenIconIndex, - index == 3 ? 0 : index, + .hiddenIconIndices, + {index == 3 ? 0 : index, 3}, ); await tester.pumpAndSettle(); expect( @@ -238,7 +234,7 @@ void main() { await tester.pumpAndSettle(); expect( tester.widget(find.byType(MornyeTabBar)).selectedIndex, - index == 3 ? -1 : index, + index, ); } }); @@ -285,37 +281,54 @@ void main() { }, ); - 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.byKey(const ValueKey('mornye-compact-search')); - final repo = find.descendant(of: tabs, matching: find.text('Repo')).first; - 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, - ); - expect( - 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(searches, 1); - expect(tester.takeException(), isNull); - }); + testWidgets( + 'Search joins full tabs and separates when compact (glass: $blur)', + (tester) async { + await pumpShell(tester, blur: blur); + final tabs = find.byType(MornyeTabBar); + 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)); + final searchLabel = find + .descendant(of: tabs, matching: find.text('Search')) + .first; + expect( + find.descendant(of: tabs, matching: find.text('Search')), + findsWidgets, + ); + expect( + find.descendant(of: tabs, matching: find.text('Settings')), + findsNothing, + ); + expect( + tester.getCenter(search).dx, + greaterThan(tester.getCenter(repo).dx), + ); + expect( + tester.getSize(tabs).width, + tester.getSize(find.byType(MornyeBottomBar)).width, + ); + final searchIcon = find + .descendant(of: tabs, matching: find.byIcon(Icons.search)) + .first; + expect( + (tester.getCenter(search) - tester.getCenter(searchIcon)).distance, + lessThan(0.5), + ); + // The glass renderer paints the labels under a gesture overlay. + await tester.tapAt(tester.getCenter(searchLabel)); + await tester.pumpAndSettle(); + expect(searches, 1); + chrome.value = true; + await tester.pumpAndSettle(); + expect(tester.getSize(search), const Size(52, 52)); + await tester.tap(search); + expect(searches, 2); + expect(tester.takeException(), isNull); + }, + ); } for (final withPlayer in [false, true]) {