mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
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:
1 parent
1c535b493c
commit
ed80d3e8bb
3 files changed
+115
-62
No files matched your search
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user