Files
SpotiFLAC-Mobile/test/mornye_bottom_bar_test.dart

713 lines
25 KiB
Dart

import 'dart:ui' as ui;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:liquid_glass_easy/liquid_glass_easy.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/widgets/mini_player.dart';
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
import 'package:spotiflac_android/widgets/mornye_bottom_bar.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
void main() {
late MornyeChromeController chrome;
late ScrollController scroll;
late int searches;
final capture = GlobalKey();
setUp(() {
chrome = MornyeChromeController();
scroll = ScrollController();
searches = 0;
});
tearDown(() {
chrome.dispose();
scroll.dispose();
});
Future<void> pumpShell(
WidgetTester tester, {
bool withPlayer = true,
bool blur = false,
Brightness brightness = Brightness.light,
Color? backdrop,
Color? chromeSurface,
Widget? body,
ValueNotifier<int>? activeTab,
}) async {
tester.view.physicalSize = const Size(393, 760);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentMediaItemProvider.overrideWith(
(ref) => Stream.value(
withPlayer
? const MediaItem(
id: 'track',
title: 'A song',
artist: 'Artist',
)
: null,
),
),
playbackStateProvider.overrideWith((ref) => const Stream.empty()),
lowEndDeviceProvider.overrideWithValue(!blur),
backdropBlurEnabledProvider.overrideWithValue(blur),
],
child: MaterialApp(
theme: MornyeTheme.build(brightness, chromeSurface: chromeSurface),
builder: (_, child) => RepaintBoundary(key: capture, child: child),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
backgroundColor: backdrop,
extendBody: true,
body: NotificationListener<ScrollNotification>(
onNotification: chrome.handleScroll,
child:
body ??
ListView.builder(
controller: scroll,
itemExtent: 60,
itemCount: 50,
itemBuilder: (_, index) => Text('Row $index'),
),
),
bottomNavigationBar: ListenableBuilder(
listenable: Listenable.merge([chrome, ?activeTab]),
builder: (_, _) => MornyeBottomBar(
collapsed: chrome.value,
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(
icon: Icon(Icons.music_note),
label: 'Library',
),
NavigationDestination(
icon: Icon(Icons.grid_view),
label: 'Repo',
),
NavigationDestination(
icon: Icon(Icons.search),
label: 'Search',
),
],
selectedIndex: activeTab?.value ?? 0,
onSelected: (index) {
activeTab?.value = index;
chrome.expand();
},
onHome: () {
activeTab?.value = 0;
chrome.expand();
},
onSearch: () {
searches++;
activeTab?.value = 3;
},
blurEnabled: blur,
),
),
),
),
),
);
await tester.pumpAndSettle();
}
const albumBlue = Color(0xff464566);
for (final blur in [false, true]) {
testWidgets('active edge icons and tabs stay synchronized (glass: $blur)', (
tester,
) async {
final activeTab = ValueNotifier(0);
addTearDown(activeTab.dispose);
await pumpShell(
tester,
blur: blur,
activeTab: activeTab,
brightness: Brightness.dark,
);
final scheme = MornyeTheme.build(Brightness.dark).colorScheme;
final primary = scheme.primary;
Color? iconColor(String key, IconData data) {
final icon = find.descendant(
of: find.byKey(ValueKey(key)),
matching: find.byIcon(data),
);
return IconTheme.of(tester.element(icon)).color;
}
for (final (index, label) in [
(1, 'Library'),
(2, 'Repo'),
(3, 'Search'),
(0, 'Home'),
]) {
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(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.
for (final icon in tabIcons.evaluate()) {
for (final opacity in tester.widgetList<Opacity>(
find.ancestor(
of: find.byElementPredicate((e) => e == icon),
matching: find.byType(Opacity),
),
)) {
expect(opacity.opacity, 1);
}
}
expect(
iconColor('mornye-compact-leading', switch (index) {
1 => Icons.music_note,
2 => Icons.grid_view,
_ => Icons.home,
}),
index == 3 ? scheme.onSurface : primary,
);
expect(
iconColor('mornye-compact-search', Icons.search),
index == 3 ? primary : scheme.onSurface,
);
final movingIcon = switch (index) {
1 => Icons.music_note,
2 => Icons.grid_view,
_ => Icons.home,
};
final leading = find.byKey(const ValueKey('mornye-compact-leading'));
final origin = tester.getCenter(
find.descendant(of: leading, matching: find.byIcon(movingIcon)),
);
final originalTabIcon = find
.descendant(
of: find.byType(MornyeTabBar),
matching: find.byIcon(movingIcon),
)
.first;
expect(
(origin - tester.getCenter(originalTabIcon)).distance,
lessThan(0.5),
);
chrome.value = true;
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(
tester
.widget<MornyeTabBar>(find.byType(MornyeTabBar))
.hiddenIconIndices,
{index == 3 ? 0 : index, 3},
);
await tester.pumpAndSettle();
expect(
iconColor('mornye-compact-leading', movingIcon),
index == 3 ? scheme.onSurface : primary,
);
expect(
iconColor('mornye-compact-search', Icons.search),
index == 3 ? primary : scheme.onSurface,
);
expect(
tester.widget<IconButton>(leading).tooltip,
index == 3 ? 'Home' : label,
);
await tester.tap(leading);
await tester.pumpAndSettle();
expect(activeTab.value, index == 3 ? 0 : index);
expect(chrome.value, isFalse);
activeTab.value = index;
chrome.expand();
await tester.pumpAndSettle();
expect(
tester.widget<MornyeTabBar>(find.byType(MornyeTabBar)).selectedIndex,
index,
);
}
});
testWidgets(
'edge icons travel continuously without fading (glass: $blur)',
(tester) async {
await pumpShell(tester, blur: blur);
final homeButton = find.byKey(const ValueKey('mornye-compact-leading'));
final icon = find.descendant(
of: homeButton,
matching: find.byIcon(Icons.home),
);
final element = tester.element(icon);
final tabIcon = find
.descendant(
of: find.byType(MornyeTabBar),
matching: find.byIcon(Icons.home),
)
.first;
final start = tester.getCenter(icon);
expect((start - tester.getCenter(tabIcon)).distance, lessThan(0.5));
chrome.value = true;
await tester.pump();
var previous = start;
for (var frame = 0; frame < 12; frame++) {
await tester.pump(const Duration(milliseconds: 16));
expect(tester.element(icon), same(element));
for (final opacity in tester.widgetList<Opacity>(
find.ancestor(of: icon, matching: find.byType(Opacity)),
)) {
expect(opacity.opacity, 1);
}
final center = tester.getCenter(icon);
expect((center - previous).distance, lessThan(8));
previous = center;
}
expect(previous.dx, lessThan(start.dx));
chrome.expand();
await tester.pumpAndSettle();
expect(tester.element(icon), same(element));
expect((tester.getCenter(icon) - start).distance, lessThan(0.5));
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]) {
testWidgets('compact transition has no height jump (player: $withPlayer)', (
tester,
) async {
await pumpShell(tester, withPlayer: withPlayer);
final bar = find.byType(MornyeBottomBar);
var previousHeight = tester.getSize(bar).height;
final playerState = withPlayer
? tester.state(find.byType(MiniPlayer))
: null;
chrome.value = true;
await tester.pump();
Offset? firstHomeCenter;
for (var frame = 0; frame < 25; frame++) {
await tester.pump(const Duration(milliseconds: 16));
final height = tester.getSize(bar).height;
expect(height, lessThanOrEqualTo(previousHeight + 0.01));
previousHeight = height;
firstHomeCenter ??= tester.getCenter(
find.byKey(const ValueKey('mornye-compact-leading')),
);
}
final homeCenter = tester.getCenter(
find.byKey(const ValueKey('mornye-compact-leading')),
);
expect((homeCenter.dy - firstHomeCenter!.dy).abs(), lessThan(20));
expect(find.byTooltip('Home').hitTestable(), findsOneWidget);
expect(find.byTooltip('Search').hitTestable(), findsOneWidget);
if (withPlayer) {
expect(tester.state(find.byType(MiniPlayer)), same(playerState));
}
chrome.expand();
await tester.pump();
for (var frame = 0; frame < 25; frame++) {
await tester.pump(const Duration(milliseconds: 16));
final height = tester.getSize(bar).height;
expect(height, greaterThanOrEqualTo(previousHeight - 0.01));
previousHeight = height;
}
expect(tester.takeException(), isNull);
});
}
for (final brightness in Brightness.values) {
for (final backdrop in [
Colors.white,
Colors.black,
if (brightness == Brightness.dark) albumBlue,
]) {
testWidgets(
'floating glass stays legible in $brightness over $backdrop',
(tester) async {
await pumpShell(
tester,
blur: true,
brightness: brightness,
backdrop: backdrop,
chromeSurface: backdrop == albumBlue ? albumBlue : null,
);
final artist = find.text('Artist');
// The glass renderer also builds a copy for the refractive pill.
final libraryIcon = find
.descendant(
of: find.byType(MornyeTabBar),
matching: find.byIcon(Icons.music_note),
)
.first;
final artistRect = tester.getRect(artist);
final iconRect = tester.getRect(libraryIcon);
final foregrounds = [
tester.widget<Text>(artist).style!.color!,
IconTheme.of(tester.element(libraryIcon)).color!,
];
final samples = [
Offset(artistRect.left + 8, artistRect.bottom + 2),
Offset(iconRect.right + 8, iconRect.center.dy),
];
final backgrounds = await tester.runAsync(() async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(capture),
);
final image = await boundary.toImage();
final bytes = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
final colors = <Color>[];
for (final point in samples) {
final offset =
(point.dy.floor() * image.width + point.dx.floor()) * 4;
colors.add(
Color.fromARGB(
bytes.getUint8(offset + 3),
bytes.getUint8(offset),
bytes.getUint8(offset + 1),
bytes.getUint8(offset + 2),
),
);
}
image.dispose();
return colors;
});
for (var index = 0; index < foregrounds.length; index++) {
if (backdrop == albumBlue) {
final glass = HSLColor.fromColor(backgrounds![index]);
final page = HSLColor.fromColor(albumBlue);
expect(glass.hue, closeTo(page.hue, 8));
expect(glass.saturation, greaterThan(page.saturation * 0.7));
}
final luminances = [
foregrounds[index].computeLuminance(),
backgrounds![index].computeLuminance(),
]..sort();
final contrast =
(luminances.last + 0.05) / (luminances.first + 0.05);
expect(
contrast,
greaterThanOrEqualTo(index == 0 ? 4.5 : 3),
reason: index == 0 ? 'Mini-player artist' : 'Inactive tab icon',
);
}
expect(tester.takeException(), isNull);
},
);
}
}
testWidgets('downward scroll folds the tabs without replacing the player', (
tester,
) async {
await pumpShell(tester);
final playerState = tester.state(find.byType(MiniPlayer));
final expandedHeight = tester.getSize(find.byType(MornyeBottomBar)).height;
await tester.drag(find.byType(ListView), const Offset(0, -240));
await tester.pumpAndSettle();
expect(chrome.value, isTrue);
expect(tester.state(find.byType(MiniPlayer)), same(playerState));
expect(tester.widget<MiniPlayer>(find.byType(MiniPlayer)).compact, isTrue);
expect(
tester.getSize(find.byType(MornyeBottomBar)).height,
lessThan(expandedHeight - 60),
);
expect(find.byTooltip('Next track'), findsNothing);
await tester.tap(find.byTooltip('Home'));
await tester.pumpAndSettle();
expect(chrome.value, isFalse);
expect(tester.getSize(find.byType(MornyeBottomBar)).height, expandedHeight);
expect(tester.takeException(), isNull);
});
testWidgets(
'collapse keeps the player and tab contents out of frame rebuilds',
(tester) async {
await pumpShell(tester);
var tabBuilds = 0;
var playerBuilds = 0;
final previous = debugOnRebuildDirtyWidget;
debugOnRebuildDirtyWidget = (element, builtOnce) {
previous?.call(element, builtOnce);
if (element.widget is MornyeTabBar) tabBuilds++;
if (element.widget is MiniPlayer) playerBuilds++;
};
addTearDown(() => debugOnRebuildDirtyWidget = previous);
chrome.value = true;
await tester.pump();
for (var frame = 0; frame < 25; frame++) {
await tester.pump(const Duration(milliseconds: 16));
}
expect(
tabBuilds,
lessThanOrEqualTo(2),
reason: 'Only the wrapper geometry animates each frame',
);
expect(
playerBuilds,
lessThanOrEqualTo(2),
reason:
'The player rebuilds for compact mode, not for every animation tick',
);
expect(tester.takeException(), isNull);
},
);
testWidgets('folding chrome updates the spacer without rebuilding Settings', (
tester,
) async {
await pumpShell(tester, body: const SettingsTab(), blur: true);
var settingsBuilds = 0;
var spacerBuilds = 0;
final previous = debugOnRebuildDirtyWidget;
debugOnRebuildDirtyWidget = (element, builtOnce) {
previous?.call(element, builtOnce);
if (element.widget is SettingsTab) settingsBuilds++;
if (element.widget is NavBarSliverSpacer) spacerBuilds++;
};
addTearDown(() => debugOnRebuildDirtyWidget = previous);
for (final collapsed in [true, false]) {
chrome.value = collapsed;
await tester.pump();
for (var frame = 0; frame < 25; frame++) {
await tester.pump(const Duration(milliseconds: 16));
}
}
expect(settingsBuilds, 0);
expect(spacerBuilds, greaterThan(20));
expect(tester.takeException(), isNull);
});
for (final artist in [true, false]) {
testWidgets('folding chrome isolates header insets (artist: $artist)', (
tester,
) async {
var pageBuilds = 0;
await pumpShell(
tester,
body: Builder(
builder: (context) {
pageBuilds++;
final tracks = SliverFixedExtentList.builder(
itemExtent: 60,
itemCount: 50,
itemBuilder: (_, index) => Text('Track $index'),
);
if (artist) {
return CustomScrollView(
controller: scroll,
slivers: [
...const MornyeArtistHeader(
name: 'Artist',
showTitle: false,
artwork: ColoredBox(color: Colors.blue),
actions: [],
).buildSlivers(context),
tracks,
const NavBarSliverSpacer(),
],
);
}
return CollectionScaffold(
scrollController: scroll,
isSelectionMode: false,
onExitSelectionMode: () {},
appBar: const AlbumDetailHeader(
title: 'Album',
expandedHeight: 400,
immersive: true,
showTitleInAppBar: false,
background: ColoredBox(color: Colors.blue),
),
slivers: [tracks],
);
},
),
);
pageBuilds = 0;
var collectionBuilds = 0;
final previous = debugOnRebuildDirtyWidget;
debugOnRebuildDirtyWidget = (element, builtOnce) {
previous?.call(element, builtOnce);
if (element.widget is CollectionScaffold) collectionBuilds++;
};
addTearDown(() => debugOnRebuildDirtyWidget = previous);
for (final collapsed in [true, false]) {
chrome.value = collapsed;
await tester.pump();
for (var frame = 0; frame < 25; frame++) {
await tester.pump(const Duration(milliseconds: 16));
}
}
expect(pageBuilds, 0);
expect(collectionBuilds, 0);
expect(tester.takeException(), isNull);
});
}
testWidgets(
'compact tabs survive upward scrolling until the page returns to the top',
(tester) async {
await pumpShell(tester);
scroll.jumpTo(500);
await tester.pumpAndSettle();
expect(chrome.value, isFalse);
await tester.drag(find.byType(ListView), const Offset(-120, 0));
await tester.pumpAndSettle();
expect(chrome.value, isFalse);
await tester.drag(find.byType(ListView), const Offset(0, -160));
await tester.pumpAndSettle();
expect(chrome.value, isTrue);
await tester.drag(find.byType(ListView), const Offset(0, 90));
await tester.pumpAndSettle();
expect(chrome.value, isTrue);
await tester.drag(find.byType(ListView), const Offset(0, 900));
await tester.pumpAndSettle();
expect(chrome.value, isFalse);
},
);
testWidgets('Library keeps compact tabs until the outer header returns', (
tester,
) async {
await pumpShell(
tester,
body: NestedScrollView(
headerSliverBuilder: (_, _) => const [
SliverAppBar(expandedHeight: 180, title: Text('Library')),
],
body: PageView(
children: [
ListView.builder(
itemExtent: 60,
itemCount: 60,
itemBuilder: (_, index) => Text('Track $index'),
),
],
),
),
);
await tester.drag(find.byType(ListView), const Offset(0, -600));
await tester.pumpAndSettle();
expect(chrome.value, isTrue);
await tester.drag(find.byType(ListView), const Offset(0, 100));
await tester.pumpAndSettle();
expect(chrome.value, isTrue);
await tester.drag(find.byType(ListView), const Offset(0, 900));
await tester.pumpAndSettle();
expect(chrome.value, isFalse);
expect(tester.takeException(), isNull);
});
testWidgets(
'glass bar stays mounted and pauses its tickers while collapsed',
(tester) async {
await pumpShell(tester, blur: true);
final glass = tester.element(find.byType(LiquidGlassTabBar));
final player = tester.state(find.byType(MiniPlayer));
expect(TickerMode.valuesOf(glass).enabled, isTrue);
chrome.value = true;
await tester.pumpAndSettle();
expect(tester.element(find.byType(LiquidGlassTabBar)), same(glass));
expect(TickerMode.valuesOf(glass).enabled, isFalse);
chrome.expand();
await tester.pumpAndSettle();
expect(tester.element(find.byType(LiquidGlassTabBar)), same(glass));
expect(tester.state(find.byType(MiniPlayer)), same(player));
expect(TickerMode.valuesOf(glass).enabled, isTrue);
expect(tester.takeException(), isNull);
},
);
testWidgets('compact navigation works without a playing track', (
tester,
) async {
await pumpShell(tester, withPlayer: false);
await tester.drag(find.byType(ListView), const Offset(0, -240));
await tester.pumpAndSettle();
expect(chrome.value, isTrue);
await tester.tap(find.byTooltip('Search'));
expect(searches, 1);
await tester.tap(find.byTooltip('Home'));
await tester.pumpAndSettle();
expect(chrome.value, isFalse);
expect(tester.takeException(), isNull);
});
}