mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
feat(navigation): refine translucent bottom bar and search folding
Keep Search inside the expanded tab bar and animate it into a separate control when collapsed. Reduce navigation surface tint while retaining clear icons and accessibility fallbacks.
This commit is contained in:
@@ -37,6 +37,11 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
|
||||
? 0.42
|
||||
: 0.70;
|
||||
|
||||
static double navigationOpacity(BuildContext context) =>
|
||||
Theme.of(context).extension<MornyeTheme>()?.chromeSurface != null
|
||||
? 0.28
|
||||
: 0.54;
|
||||
|
||||
static const lightAccent = Color.fromRGBO(204, 46, 51, 1);
|
||||
static const darkAccent = Color.fromRGBO(224, 61, 60, 1);
|
||||
|
||||
|
||||
@@ -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<double>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -437,15 +437,15 @@ class MornyeTabBar extends StatelessWidget {
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.blurEnabled,
|
||||
this.hiddenIconIndex,
|
||||
this.hiddenIconIndices = const {},
|
||||
});
|
||||
|
||||
final List<NavigationDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> 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<int> 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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<MornyeTabBar>(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<MornyeTabBar>(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<MornyeTabBar>(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<MornyeTabBar>(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]) {
|
||||
|
||||
Reference in New Issue
Block a user