feat(ui): add Mornye glass theme and adaptive music player

Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components.

Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures.

Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
This commit is contained in:
zarzet committed 2026-09-19 12:10:10 +07:00
1 parent 1ebdac8645
commit 63e1a9b9c8
155 files changed
+15707 -2318

No files matched your search

+249
View File
@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_announcement_dialog.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
Widget _app(ThemeData theme, Widget home) => ProviderScope(
child: MaterialApp(
theme: theme,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: home,
),
);
void main() {
for (final mornye in [false, true]) {
for (final brightness in Brightness.values) {
final theme = mornye
? MornyeTheme.build(brightness)
: ThemeData(brightness: brightness);
final variant = 'Mornye: $mornye, $brightness';
testWidgets('sheet selects, disables and returns values ($variant)', (
tester,
) async {
tester.view.physicalSize = const Size(360, 740);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
var selected = false;
var disabledTaps = 0;
bool? result;
await tester.pumpWidget(
_app(
theme,
Builder(
builder: (context) => Scaffold(
body: TextButton(
child: const Text('Open'),
onPressed: () async {
result = await showAppBottomSheet<bool>(
context: context,
title: 'Library filter',
builder: (context) => StatefulBuilder(
builder: (context, setSheetState) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
children: [
AppChoiceChip(
label: const Text('Downloaded tracks'),
selected: selected,
onSelected: (value) => setSheetState(() {
selected = value;
}),
),
],
),
AppSheetOption(
enabled: false,
title: const Text('Unavailable'),
onTap: () => disabledTaps++,
),
AppSheetOption(
title: const Text('Apply'),
onTap: () => Navigator.pop(context, selected),
),
],
),
),
);
},
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(
find.byType(MornyeGlassPanel),
mornye ? findsOneWidget : findsNothing,
);
expect(find.byType(FilterChip), mornye ? findsNothing : findsOneWidget);
await tester.tap(find.text('Unavailable'));
expect(disabledTaps, 0);
await tester.tap(find.text('Downloaded tracks'));
await tester.pumpAndSettle();
expect(selected, isTrue);
await tester.tap(find.text('Apply'));
await tester.pumpAndSettle();
expect(result, isTrue);
expect(find.text('Library filter'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('nested dialog retains theme and navigation ($variant)', (
tester,
) async {
final nestedNavigator = GlobalKey<NavigatorState>();
bool? result;
await tester.pumpWidget(
_app(
ThemeData(),
Navigator(
key: nestedNavigator,
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => Theme(
data: theme,
child: Builder(
builder: (context) => Scaffold(
body: TextButton(
child: const Text('Open import'),
onPressed: () async {
result = await showAppDialog<bool>(
context: context,
useRootNavigator: false,
builder: (context) => AppAlertDialog(
title: const Text('Import playlist'),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(context, true),
child: const Text('Confirm'),
),
],
),
);
},
),
),
),
),
),
),
),
);
await tester.tap(find.text('Open import'));
await tester.pumpAndSettle();
expect(nestedNavigator.currentState!.canPop(), isTrue);
expect(
find.byType(MornyeGlassPanel),
mornye ? findsOneWidget : findsNothing,
);
expect(
Theme.of(tester.element(find.text('Import playlist'))).brightness,
brightness,
);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(result, isTrue);
expect(nestedNavigator.currentState!.canPop(), isFalse);
expect(find.text('Open import'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('required update resists back and barrier ($variant)', (
tester,
) async {
tester.view.physicalSize = const Size(393, 740);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
_app(
theme,
Builder(
builder: (context) => Scaffold(
body: TextButton(
child: const Text('Update'),
onPressed: () => showUpdateDialog(
context,
updateInfo: UpdateInfo(
version: '9.0.0',
changelog: 'Playback and library improvements.\n' * 8,
downloadUrl: 'https://example.com/release',
publishedAt: DateTime(2026),
releasesBehind: 3,
),
onDisableUpdates: () =>
fail('Cannot disable required update'),
forced: true,
),
),
),
),
),
);
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await tester.tapAt(const Offset(5, 5));
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(UpdateDialog), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
testWidgets('announcement keeps explicit dismissal (Mornye: $mornye)', (
tester,
) async {
var dismissals = 0;
await tester.pumpWidget(
_app(
mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(),
Builder(
builder: (context) => Scaffold(
body: TextButton(
child: const Text('Open notice'),
onPressed: () => showAppAnnouncementDialog(
context,
announcement: const RemoteAnnouncement(
id: 'notice',
enabled: true,
title: 'Service notice',
message: 'An announcement without an external action.',
dismissible: false,
),
onDismiss: () => dismissals++,
),
),
),
),
),
);
await tester.tap(find.text('Open notice'));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(5, 5));
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Service notice'), findsOneWidget);
expect(dismissals, 0);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(find.text('Service notice'), findsNothing);
expect(dismissals, 1);
expect(tester.takeException(), isNull);
});
}
}
+13
View File
@@ -8,6 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/track_list_tile.dart';
void main() {
@@ -31,6 +33,9 @@ void main() {
'copyright': 'Example Copyright',
'comment': 'Example Comment',
'upc': '0123456789012',
'audio_quality': '16-bit',
'audio_modes': 'DOLBY_ATMOS',
'explicit': true,
};
var albumRequested = false;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
@@ -48,6 +53,7 @@ void main() {
'name': 'Example Album',
'total_tracks': 1,
'album_type': 'album',
'audio_traits': ['lossless', 'dolby_atmos'],
},
});
}
@@ -66,6 +72,7 @@ void main() {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.light),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AlbumScreen(
@@ -91,6 +98,12 @@ void main() {
expect(albumTrack.albumId, 'metadata-album');
expect(albumTrack.albumType, 'album');
expect(albumTrack.totalTracks, 1);
expect(albumTrack.audioQuality, '16-bit');
expect(find.text('Lossless'), findsOneWidget);
expect(find.text('Dolby Atmos'), findsOneWidget);
expect(find.byType(AudioQualityBadge), findsNothing);
expect(find.byType(DolbyAtmosBadge), findsNothing);
expect(find.byType(ExplicitBadge), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
});
+221
View File
@@ -0,0 +1,221 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
void main() {
for (final brightness in Brightness.values) {
for (final reduceMotion in [false, true]) {
testWidgets(
'confirmation keeps blur during entry and cancels promptly ($brightness, reduce motion: $reduceMotion)',
(tester) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(brightness),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: reduceMotion),
child: child!,
),
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
child: const Text('Open'),
onPressed: () => showAppDialog<void>(
context: context,
builder: (context) => AppAlertDialog(
title: const Text('Delete Selected'),
content: const Text(
'This will also delete the files.',
),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pump();
for (final elapsed in [16, 64, 180]) {
await tester.pump(Duration(milliseconds: elapsed));
final blur = find.descendant(
of: find.byType(AppAlertDialog),
matching: find.byType(BackdropFilter),
);
expect(blur, findsOneWidget);
// An opacity layer isolates the filter from the underlying page
// until the route finishes entering.
final fades = tester.widgetList<FadeTransition>(
find.ancestor(of: blur, matching: find.byType(FadeTransition)),
);
expect(fades.every((fade) => fade.opacity.value == 1), isTrue);
final bodyContext = tester.element(
find.text('This will also delete the files.'),
);
expect(
DefaultTextStyle.of(bodyContext).style.color,
Theme.of(bodyContext).colorScheme.onSurface,
);
}
final panel = find.byType(MornyeGlassPanel);
final openWidth = tester.getRect(panel).width;
await tester.tap(find.text('Cancel'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
if (!reduceMotion) {
// Closing must move on the first frame, rather than holding the
// fully open scale while a reversed entrance curve settles.
expect(tester.getRect(panel).width, lessThan(openWidth - 1));
await tester.pump(const Duration(milliseconds: 164));
await tester.pump();
}
expect(find.byType(AppAlertDialog), findsNothing);
expect(tester.takeException(), isNull);
},
);
}
}
for (final mornye in [true, false]) {
testWidgets(
'confirmation preserves cancel and confirm results (Mornye: $mornye)',
(tester) async {
bool? result;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(),
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
child: const Text('Open'),
onPressed: () async {
result = await showAppDialog<bool>(
context: context,
builder: (context) => AppAlertDialog(
title: const Text('Remove extension'),
content: const Text('Remove this example extension?'),
actions: [
AppDialogAction(
isDefault: true,
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
AppDialogAction(
filled: true,
isDestructive: true,
onPressed: () => Navigator.pop(context, true),
child: const Text('Remove'),
),
],
),
);
},
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(
find.byType(MornyeGlassPanel),
mornye ? findsOneWidget : findsNothing,
);
expect(
find.byType(AlertDialog),
mornye ? findsNothing : findsOneWidget,
);
if (mornye) {
final remove = find.widgetWithText(CupertinoButton, 'Remove');
final cancel = find.widgetWithText(CupertinoButton, 'Cancel');
expect(
tester.getRect(remove).bottom,
lessThan(tester.getRect(cancel).top),
);
expect(tester.getRect(remove).width, tester.getRect(cancel).width);
expect(
DefaultTextStyle.of(
tester.element(find.text('Remove')),
).style.color,
MornyeTheme.build(Brightness.dark).colorScheme.primary,
);
expect(find.byType(CupertinoAlertDialog), findsNothing);
expect(find.byType(FilledButton), findsNothing);
}
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(result, isFalse);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remove'));
await tester.pumpAndSettle();
expect(result, isTrue);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(result, isNull);
expect(tester.takeException(), isNull);
},
);
}
testWidgets('glass confirmation keeps actions reachable with long text', (
tester,
) async {
tester.view.physicalSize = const Size(393, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.light),
home: MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(1.5)),
child: AppAlertDialog(
title: const Text('Delete these tracks from your library?'),
content: Text(
List.filled(12, 'This removes the selected files.').join(' '),
),
actions: [
AppDialogAction(onPressed: () {}, child: const Text('Cancel')),
AppDialogAction(
isDestructive: true,
onPressed: () {},
child: const Text('Delete from Library'),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Cancel').hitTestable(), findsOneWidget);
expect(find.text('Delete from Library').hitTestable(), findsOneWidget);
final scroll = find.byType(SingleChildScrollView).first;
await tester.drag(scroll, const Offset(0, -150));
await tester.pumpAndSettle();
expect(find.text('Cancel').hitTestable(), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
+145
View File
@@ -0,0 +1,145 @@
import 'dart:ui' as ui;
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:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
void main() {
for (final brightness in Brightness.values) {
testWidgets('snackbar retains backdrop color during entry in $brightness', (
tester,
) async {
tester.view.physicalSize = const Size(393, 780);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final capture = GlobalKey();
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(brightness),
builder: (_, child) => RepaintBoundary(key: capture, child: child),
home: Builder(
builder: (context) => Scaffold(
body: Stack(
fit: StackFit.expand,
children: [
const Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: ColoredBox(color: Colors.blue)),
Expanded(child: ColoredBox(color: Colors.red)),
],
),
Center(
child: TextButton(
onPressed: () => showAppSnackBar(
context,
content: const Text('Extension installed'),
),
child: const Text('Show'),
),
),
],
),
),
),
),
),
);
await tester.tap(find.text('Show'));
await tester.pump();
for (final elapsed in [16, 80, 250]) {
await tester.pump(Duration(milliseconds: elapsed));
final rect = tester.getRect(find.byType(MornyeGlassPanel));
final colors = 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 fraction in [0.25, 0.75]) {
final x = (rect.left + rect.width * fraction).floor();
final y = (rect.bottom - 8).floor();
final offset = (y * image.width + x) * 4;
colors.add(
Color.fromARGB(
bytes.getUint8(offset + 3),
bytes.getUint8(offset),
bytes.getUint8(offset + 1),
bytes.getUint8(offset + 2),
),
);
}
image.dispose();
return colors;
});
// Retain a visible backdrop hue even beneath the glass shadow.
expect(colors![0].b - colors[0].r, greaterThan(0.20));
expect(colors[1].r - colors[1].b, greaterThan(0.20));
final foreground = MornyeTheme.build(brightness).colorScheme.onSurface;
for (final background in colors) {
final luminances = [
foreground.computeLuminance(),
background.computeLuminance(),
]..sort();
expect(
(luminances.last + 0.05) / (luminances.first + 0.05),
greaterThanOrEqualTo(4.5),
);
}
}
expect(tester.takeException(), isNull);
});
}
for (final mornye in [true, false]) {
testWidgets(
'messages still queue, time out and dismiss (Mornye: $mornye)',
(tester) async {
late BuildContext messageContext;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(),
home: Builder(
builder: (context) {
messageContext = context;
return const Scaffold(body: SizedBox.expand());
},
),
),
),
);
final first = showAppSnackBar(
messageContext,
content: const Text('First'),
duration: const Duration(seconds: 1),
);
final second = showAppSnackBar(
messageContext,
content: const Text('Second'),
);
await tester.pumpAndSettle();
expect(find.text('First'), findsOneWidget);
expect(find.text('Second'), findsNothing);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(await first.closed, SnackBarClosedReason.timeout);
expect(find.text('Second'), findsOneWidget);
await tester.drag(find.text('Second'), const Offset(0, 300));
await tester.pumpAndSettle();
expect(await second.closed, SnackBarClosedReason.swipe);
expect(find.text('Second'), findsNothing);
expect(tester.takeException(), isNull);
},
);
}
}
+128
View File
@@ -0,0 +1,128 @@
import 'dart:ui' show SemanticsAction;
import 'package:flutter/material.dart';
import 'package:flutter/services.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/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_switch.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
void main() {
Future<void> pumpSwitch(
WidgetTester tester, {
required Widget Function(StateSetter setState) builder,
bool reduceMotion = false,
}) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.light),
home: MediaQuery(
data: MediaQueryData(disableAnimations: reduceMotion),
child: Scaffold(
body: Center(
child: StatefulBuilder(
builder: (context, setState) => builder(setState),
),
),
),
),
),
),
);
await tester.pumpAndSettle(const Duration(milliseconds: 16));
}
testWidgets('glass switch supports tap, keyboard and accessible activation', (
tester,
) async {
var value = false;
final changes = <bool>[];
await pumpSwitch(
tester,
builder: (setState) => AppSwitch(
value: value,
semanticLabel: 'Example setting',
onChanged: (next) {
changes.add(next);
setState(() => value = next);
},
),
);
await tester.tap(find.byType(LiquidGlassSwitch));
await tester.pumpAndSettle(const Duration(milliseconds: 16));
expect(changes, [true]);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle(const Duration(milliseconds: 16));
expect(changes, [true, false]);
final node = tester.getSemantics(find.bySemanticsLabel('Example setting'));
node.owner!.performAction(node.id, SemanticsAction.tap);
await tester.pumpAndSettle(const Duration(milliseconds: 16));
expect(changes, [true, false, true]);
expect(tester.takeException(), isNull);
});
testWidgets('disabled glass switch has no toggle action', (tester) async {
await pumpSwitch(
tester,
builder: (_) => const AppSwitch(
value: true,
onChanged: null,
semanticLabel: 'Disabled setting',
),
);
final node = tester.getSemantics(find.bySemanticsLabel('Disabled setting'));
expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isFalse);
expect(find.byType(LiquidGlassSwitch), findsNothing);
expect(tester.takeException(), isNull);
});
for (final grouped in [false, true]) {
testWidgets('row and thumb each toggle once (grouped=$grouped)', (
tester,
) async {
var value = false;
final changes = <bool>[];
await pumpSwitch(
tester,
reduceMotion: true,
builder: (setState) {
void update(bool next) {
changes.add(next);
setState(() => value = next);
}
return grouped
? SettingsGroup(
children: [
SettingsSwitchItem(
title: 'Example setting',
value: value,
onChanged: update,
),
],
)
: AppSwitchListTile(
title: const Text('Example setting'),
value: value,
onChanged: update,
);
},
);
expect(find.byType(LiquidGlassSwitch), findsNothing);
await tester.tap(find.text('Example setting'));
await tester.pumpAndSettle();
expect(changes, [true]);
await tester.tap(find.byType(AppSwitch));
await tester.pumpAndSettle();
expect(changes, [true, false]);
expect(tester.takeException(), isNull);
});
}
}
+119
View File
@@ -6,14 +6,17 @@ import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/app_search_field.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
@@ -77,8 +80,43 @@ void main() {
AppTheme.dark(isAmoled: true).extension<AppTokens>(),
AppTokens.standard,
);
expect(
MornyeTheme.build(Brightness.light).extension<AppTokens>(),
MornyeTheme.tokens,
);
expect(
MornyeTheme.build(Brightness.dark).colorScheme.primary,
MornyeTheme.darkAccent,
);
});
testWidgets(
'Mornye keeps a font family and Cupertino tracking in text roles',
(tester) async {
final theme = MornyeTheme.build(Brightness.light);
final apple = theme.platform == TargetPlatform.iOS;
for (final role in [
theme.textTheme.bodyLarge,
theme.textTheme.bodyMedium,
theme.textTheme.titleMedium,
theme.textTheme.titleSmall,
theme.textTheme.labelLarge,
theme.textTheme.labelSmall,
]) {
expect(role?.fontFamily, apple ? 'CupertinoSystemText' : 'Inter');
expect(role?.letterSpacing, lessThan(0));
}
expect(
theme.textTheme.headlineLarge?.fontFamily,
apple ? 'CupertinoSystemDisplay' : 'Inter',
);
},
variant: const TargetPlatformVariant({
TargetPlatform.iOS,
TargetPlatform.android,
}),
);
testWidgets('context.tokens falls back to the standard scale', (
tester,
) async {
@@ -185,6 +223,87 @@ void main() {
});
group('AppSearchField', () {
for (final direction in TextDirection.values) {
testWidgets(
'glass theme selector preserves logical selection in $direction',
(tester) async {
var selected = 0;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.light),
home: Scaffold(
body: Directionality(
textDirection: direction,
child: StatefulBuilder(
builder: (context, setState) => MornyeSegmentedControl(
labels: const ['System', 'Light', 'Dark'],
selectedIndex: selected,
onChanged: (index) => setState(() => selected = index),
),
),
),
),
),
),
);
await tester.pumpAndSettle(const Duration(milliseconds: 16));
final systemX = tester.getCenter(find.text('System').first).dx;
final darkX = tester.getCenter(find.text('Dark').first).dx;
expect(systemX < darkX, direction == TextDirection.ltr);
// The package puts a transparent tap layer above the drawn labels.
await tester.tapAt(tester.getCenter(find.text('Dark').first));
await tester.pumpAndSettle(const Duration(milliseconds: 16));
expect(selected, 2);
await tester.tapAt(tester.getCenter(find.text('System').first));
await tester.pumpAndSettle(const Duration(milliseconds: 16));
expect(selected, 0);
expect(tester.takeException(), isNull);
},
);
}
testWidgets('glass search keeps text editing, submit and clear usable', (
tester,
) async {
final controller = TextEditingController();
final changes = <String>[];
String? submitted;
var cleared = false;
addTearDown(controller.dispose);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.dark),
home: Scaffold(
body: AppSearchField(
controller: controller,
hintText: 'Search library',
clearTooltip: 'Clear search',
onChanged: changes.add,
onSubmitted: (value) => submitted = value,
onClear: () => cleared = true,
),
),
),
),
);
expect(find.byType(MornyeGlass), findsOneWidget);
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'Album');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pump();
expect(changes, ['Album']);
expect(submitted, 'Album');
await tester.tap(find.byTooltip('Clear search'));
await tester.pump();
expect(controller.text, isEmpty);
expect(cleared, isTrue);
expect(tester.takeException(), isNull);
});
testWidgets('uses the shared filled search style and clears input', (
tester,
) async {
+78 -54
View File
@@ -5,12 +5,13 @@ import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
class _Extensions extends ExtensionNotifier {
_Extensions(this.qualityCount);
_Extensions(this._qualityCount);
final int qualityCount;
final int _qualityCount;
@override
ExtensionState build() => ExtensionState(
@@ -25,7 +26,7 @@ class _Extensions extends ExtensionNotifier {
status: 'loaded',
hasDownloadProvider: true,
qualityOptions: List.generate(
qualityCount,
_qualityCount,
(index) => QualityOption(id: '$index', label: 'Quality $index'),
),
),
@@ -42,64 +43,87 @@ class _Settings extends SettingsNotifier {
}
void main() {
for (final qualityCount in [3, 16]) {
testWidgets('iOS picker fits $qualityCount options and handle dismisses', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
extensionProvider.overrideWith(() => _Extensions(qualityCount)),
settingsProvider.overrideWith(_Settings.new),
],
child: MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => DownloadServicePicker.show(
context,
trackName: 'Example track',
onSelect: (_, _) {},
for (final (qualityCount, mornye) in [
(3, false),
(16, false),
(3, true),
(16, true),
]) {
testWidgets(
'picker fits $qualityCount options and dismisses (Mornye=$mornye)',
(tester) async {
tester.view.physicalSize = const Size(320, 780);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
(String, String)? selection;
await tester.pumpWidget(
ProviderScope(
overrides: [
extensionProvider.overrideWith(() => _Extensions(qualityCount)),
settingsProvider.overrideWith(_Settings.new),
],
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.dark)
: ThemeData(platform: TargetPlatform.iOS),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => DownloadServicePicker.show(
context,
trackName: 'Example track',
onSelect: (quality, service) =>
selection = (quality, service),
),
child: const Text('Open'),
),
child: const Text('Open'),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
if (qualityCount == 3) {
final sheetBottom = tester.getBottomLeft(find.byType(BottomSheet)).dy;
final lastOptionBottom = tester
.getBottomLeft(find.text('Quality 2'))
.dy;
expect(sheetBottom - lastOptionBottom, lessThan(100));
}
if (qualityCount == 16) {
final scroll = find.descendant(
of: find.byType(DownloadServicePicker),
matching: find.byType(SingleChildScrollView),
);
await tester.drag(scroll, const Offset(0, -300));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byType(DownloadServicePicker), findsOneWidget);
await tester.drag(scroll, const Offset(0, 1500));
if (qualityCount == 3) {
final sheetBottom = tester.getBottomLeft(find.byType(BottomSheet)).dy;
final lastOptionBottom = tester
.getBottomLeft(find.text('Quality 2'))
.dy;
expect(sheetBottom - lastOptionBottom, lessThan(100));
}
if (qualityCount == 16) {
final scroll = find.descendant(
of: find.byType(DownloadServicePicker),
matching: find.byType(SingleChildScrollView),
);
await tester.drag(scroll, const Offset(0, -300));
await tester.pumpAndSettle();
expect(find.byType(DownloadServicePicker), findsOneWidget);
await tester.drag(scroll, const Offset(0, 1500));
await tester.pumpAndSettle();
}
final sheetTop = tester.getTopLeft(find.byType(BottomSheet));
final sheetWidth = tester.getSize(find.byType(BottomSheet)).width;
await tester.dragFrom(
sheetTop + Offset(sheetWidth / 2, 16),
const Offset(0, 600),
);
await tester.pumpAndSettle();
}
final sheetTop = tester.getTopLeft(find.byType(BottomSheet));
final sheetWidth = tester.getSize(find.byType(BottomSheet)).width;
await tester.dragFrom(
sheetTop + Offset(sheetWidth / 2, 16),
const Offset(0, 600),
);
await tester.pumpAndSettle();
expect(find.byType(DownloadServicePicker), findsNothing);
expect(tester.takeException(), isNull);
});
expect(find.byType(DownloadServicePicker), findsNothing);
expect(selection, isNull);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
final lastQuality = find.text('Quality ${qualityCount - 1}');
await tester.ensureVisible(lastQuality);
await tester.pumpAndSettle();
await tester.tap(lastQuality);
await tester.pumpAndSettle();
expect(selection, ('${qualityCount - 1}', 'example'));
expect(find.byType(DownloadServicePicker), findsNothing);
expect(tester.takeException(), isNull);
},
);
}
}
+52
View File
@@ -314,6 +314,58 @@ void main() {
expect(container.read(repoProvider).error, isNull);
});
for (final hasWorkingExtension in [false, true]) {
test('native package load errors stay visible and recover on retry '
'(partial=$hasWorkingExtension)', () async {
SharedPreferences.setMockInitialValues({});
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(extensionProvider.notifier);
var fail = true;
const loadError =
'example-unreadable: cipher: message authentication failed';
messenger.setMockMethodCallHandler(backend, (call) async {
switch (call.method) {
case 'loadExtensionsFromDir':
return {
'loaded': hasWorkingExtension ? ['example-working'] : null,
'errors': fail ? [loadError] : <String>[],
};
case 'getInstalledExtensions':
return <Map<String, dynamic>>[
if (hasWorkingExtension)
{
'id': 'example-working',
'name': 'example-working',
'display_name': 'Example Working',
'version': '1.0.0',
'enabled': false,
},
];
case 'getProviderPriority':
case 'getMetadataProviderPriority':
return <String>[];
default:
return null;
}
});
await container.read(settingsProvider.notifier).ensureLoaded();
final loaded = await notifier.loadExtensions('${root.path}/sources');
expect(notifier.state.error, contains(loadError));
expect(loaded, hasWorkingExtension);
expect(notifier.state.isLoading, isFalse);
expect(
notifier.state.extensions.map((extension) => extension.id),
hasWorkingExtension ? ['example-working'] : isEmpty,
);
fail = false;
expect(await notifier.loadExtensions('${root.path}/sources'), isTrue);
expect(notifier.state.error, isNull);
});
}
for (final failureMethod in const [
'loadExtensionsFromDir',
'getInstalledExtensions',
+6
View File
@@ -1107,6 +1107,7 @@ void main() {
useDynamicColor: false,
seedColorValue: 0xff123456,
useAmoled: true,
style: AppThemeStyle.mornye,
);
final decoded = ThemeSettings.fromJson(settings.toJson());
@@ -1117,10 +1118,15 @@ void main() {
expect(decoded.seedColor, const Color(0xff123456));
expect(copied.themeMode, ThemeMode.light);
expect(copied.useAmoled, isTrue);
expect(decoded.style, AppThemeStyle.mornye);
expect(
ThemeSettings.fromJson({'theme_mode': 'invalid'}).themeMode,
ThemeMode.system,
);
expect(
ThemeSettings.fromJson({'theme_style': 'invalid'}).style,
AppThemeStyle.material,
);
});
});
+404
View File
@@ -0,0 +1,404 @@
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,
}) 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: ValueListenableBuilder<bool>(
valueListenable: chrome,
builder: (_, collapsed, _) => MornyeBottomBar(
collapsed: collapsed,
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(
icon: Icon(Icons.music_note),
label: 'Library',
),
NavigationDestination(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
selectedIndex: 0,
onSelected: (_) {},
onExpand: chrome.expand,
onSearch: () => searches++,
blurEnabled: blur,
),
),
),
),
),
);
await tester.pumpAndSettle();
}
const albumBlue = Color(0xff464566);
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('Show tabs'));
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(
'scrolling up expands; programmatic and horizontal movement do not collapse',
(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, isFalse);
},
);
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('Show tabs'));
await tester.pumpAndSettle();
expect(chrome.value, isFalse);
expect(tester.takeException(), isNull);
});
}
+194
View File
@@ -0,0 +1,194 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
const _track = Track(
id: 'example-track',
name: 'Example track',
artistName: 'Example artist',
albumName: 'Example album',
duration: 180,
);
class _Settings extends SettingsNotifier {
@override
AppSettings build() => const AppSettings();
}
class _Collections extends LibraryCollectionsNotifier {
List<PlaylistPickerSummary> _summaries = [];
final additions = <String>[];
final createdNames = <String>[];
@override
LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true);
@override
Future<String> createPlaylist(String name) async {
createdNames.add(name);
_summaries = [..._summaries, _playlist(name)];
ref.invalidate(libraryPlaylistPickerSummariesProvider);
return name;
}
@override
Future<PlaylistAddBatchResult> addTracksToPlaylist(
String playlistId,
Iterable<Track> tracks,
) async {
additions.add(playlistId);
return PlaylistAddBatchResult(
addedCount: tracks.length,
alreadyInPlaylistCount: 0,
);
}
}
PlaylistPickerSummary _playlist(String name, {bool contains = false}) =>
PlaylistPickerSummary(
id: name,
name: name,
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
trackCount: 2,
containsAllRequestedTracks: contains,
);
Widget _app(
Widget child,
_Collections collections, {
required Brightness brightness,
bool mornye = true,
double textScale = 1,
}) => ProviderScope(
overrides: [
settingsProvider.overrideWith(_Settings.new),
libraryCollectionsProvider.overrideWith(() => collections),
libraryPlaylistPickerSummariesProvider.overrideWith(
(ref, request) async => collections._summaries,
),
],
child: MaterialApp(
theme: mornye ? MornyeTheme.build(brightness) : ThemeData(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(textScale)),
child: child!,
),
home: Scaffold(body: child),
),
);
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
setUpAll(() async {
await (FontLoader(
'Inter',
)..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load();
await (FontLoader('packages/cupertino_icons/CupertinoIcons')..addFont(
rootBundle.load('packages/cupertino_icons/assets/CupertinoIcons.ttf'),
))
.load();
});
for (final brightness in Brightness.values) {
testWidgets('track menu reaches playlist creation in $brightness', (
tester,
) async {
tester.view.physicalSize = const Size(320, 680);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final collections = _Collections();
await tester.pumpWidget(
_app(
const TrackCollectionQuickActions(track: _track),
collections,
brightness: brightness,
textScale: 1.5,
),
);
await tester.tap(find.byIcon(CupertinoIcons.ellipsis));
await tester.pumpAndSettle();
expect(find.byType(MornyeGlassPanel), findsOneWidget);
expect(find.byType(ListTile), findsNothing);
expect(find.byIcon(CupertinoIcons.doc_on_doc), findsNWidgets(2));
await tester.ensureVisible(find.text('Add to playlist'));
await tester.tap(find.text('Add to playlist'));
await tester.pumpAndSettle();
expect(find.text('No playlists yet'), findsOneWidget);
expect(find.byType(MornyeGlassPanel), findsOneWidget);
expect(find.text('Done').hitTestable(), findsOneWidget);
await tester.tap(find.text('Create playlist'));
await tester.pumpAndSettle();
expect(find.byType(AppAlertDialog), findsOneWidget);
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
expect(collections.createdNames, isEmpty);
await tester.enterText(find.byType(TextFormField), 'Evening music');
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
expect(collections.createdNames, ['Evening music']);
expect(collections.additions, ['Evening music']);
expect(find.byIcon(CupertinoIcons.checkmark_circle_fill), findsOneWidget);
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(collections.additions, ['Evening music']);
expect(find.byType(MornyeGlassPanel), findsNothing);
expect(tester.takeException(), isNull);
});
}
for (final mornye in [true, false]) {
testWidgets('picker adds only new selections (Mornye: $mornye)', (
tester,
) async {
final collections = _Collections()
.._summaries = [
_playlist('Already included', contains: true),
_playlist('Road trip'),
_playlist('Unselected playlist'),
];
await tester.pumpWidget(
_app(
Consumer(
builder: (context, ref, _) => TextButton(
onPressed: () =>
showAddTracksToPlaylistSheet(context, ref, [_track]),
child: const Text('Open'),
),
),
collections,
brightness: Brightness.light,
mornye: mornye,
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Already included'));
await tester.tap(find.text('Road trip'));
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(collections.additions, ['Road trip']);
expect(tester.takeException(), isNull);
});
}
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
Future<void> openMenu(
WidgetTester tester, {
required Rect anchor,
required ValueChanged<String?> onResult,
Size size = const Size(390, 844),
Brightness brightness = Brightness.light,
double textScale = 1,
bool reduceMotion = false,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(brightness),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.fromLTRB(0, 24, 0, 24),
textScaler: TextScaler.linear(textScale),
disableAnimations: reduceMotion,
),
child: child!,
),
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: TextButton(
onPressed: () async {
final result = await showMornyeContextMenu<String>(
context: context,
anchor: anchor,
builder: (menuContext) {
MornyeMenuAction action(String label, IconData icon) =>
MornyeMenuAction(
icon: icon,
label: label,
onPressed: () =>
Navigator.of(menuContext).pop(label),
);
return MornyeContextMenu(
quickActions: [
action(
'Download',
CupertinoIcons.arrow_down_circle,
),
action('Favorite', CupertinoIcons.star),
action('Share', CupertinoIcons.share),
],
groups: [
[
action('Play', CupertinoIcons.play),
action('Shuffle', CupertinoIcons.shuffle),
],
[
action(
'Add to playlist',
CupertinoIcons.text_badge_plus,
),
action('Play next', CupertinoIcons.text_insert),
],
[action('Remove', CupertinoIcons.trash)],
],
);
},
);
onResult(result);
},
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
}
for (final brightness in Brightness.values) {
testWidgets('menu stays on screen near a bottom edge ($brightness)', (
tester,
) async {
String? result;
const anchor = Rect.fromLTWH(338, 728, 36, 44);
await openMenu(
tester,
anchor: anchor,
brightness: brightness,
onResult: (value) => result = value,
);
final rect = tester.getRect(find.byType(MornyeContextMenu));
expect(rect.left, greaterThanOrEqualTo(16));
expect(rect.right, lessThanOrEqualTo(374));
expect(rect.top, greaterThanOrEqualTo(40));
expect(rect.bottom, lessThan(anchor.top));
expect(find.text('Share').hitTestable(), findsOneWidget);
await tester.tap(find.text('Share'));
await tester.pumpAndSettle();
expect(result, 'Share');
expect(find.byType(MornyeContextMenu), findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets(
'large text in landscape keeps shortcuts and last action usable',
(tester) async {
String? result;
await openMenu(
tester,
anchor: const Rect.fromLTWH(600, 200, 24, 44),
size: const Size(640, 320),
textScale: 2.5,
onResult: (value) => result = value,
);
final rect = tester.getRect(find.byType(MornyeContextMenu));
expect(rect.top, greaterThanOrEqualTo(40));
expect(rect.bottom, lessThanOrEqualTo(280));
expect(find.text('Download').hitTestable(), findsOneWidget);
await tester.ensureVisible(find.text('Remove'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remove'));
await tester.pumpAndSettle();
expect(result, 'Remove');
expect(tester.takeException(), isNull);
},
);
testWidgets('outside tap dismisses without selecting in reduced motion', (
tester,
) async {
final results = <String?>[];
await openMenu(
tester,
anchor: const Rect.fromLTWH(12, 40, 44, 44),
reduceMotion: true,
onResult: results.add,
);
await tester.tapAt(const Offset(8, 800));
await tester.pump();
expect(results, [null]);
expect(find.byType(MornyeContextMenu), findsNothing);
expect(tester.takeException(), isNull);
});
}
+145
View File
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
void main() {
for (final brightness in Brightness.values) {
for (final scale in [1.0, 2.0]) {
testWidgets(
'album actions and pinned navigation work in $brightness at $scale',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.view.padding = FakeViewPadding(top: 59, bottom: 34);
addTearDown(tester.view.reset);
final controller = ScrollController();
addTearDown(controller.dispose);
var plays = 0;
var shuffles = 0;
var backs = 0;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(brightness),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(scale)),
child: child!,
),
home: CollectionScaffold(
scrollController: controller,
isSelectionMode: false,
onExitSelectionMode: () {},
bottomInset: 0,
appBar: AlbumDetailHeader(
immersive: true,
title:
'A long album title that must wrap without hiding any of its controls',
expandedHeight: 400,
showTitleInAppBar: false,
background: const ColoredBox(
key: ValueKey('album-artwork'),
color: Colors.orange,
),
coverBuilder: (_, _) =>
const ColoredBox(color: Colors.orange),
subtitle: const Text('Artist name'),
leading: HeaderCircleButton(
icon: Icons.arrow_back,
tooltip: 'Back',
onPressed: () => backs++,
),
actions: AlbumPlayActions(
playLabel: 'Play',
shuffleTooltip: 'Shuffle',
onPlay: () => plays++,
onShuffle: () => shuffles++,
),
),
slivers: [
SliverList.builder(
itemCount: 40,
itemBuilder: (_, index) =>
SizedBox(height: 64, child: Text('Track $index')),
),
],
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getSize(find.byKey(const ValueKey('album-artwork'))),
const Size(390, 390),
);
await tester.ensureVisible(find.text('Play'));
await tester.pumpAndSettle();
await tester.tap(find.text('Play'));
await tester.tap(find.text('Shuffle'));
expect(plays, 1);
expect(shuffles, 1);
controller.jumpTo(1300);
await tester.pumpAndSettle();
expect(find.byTooltip('Back').hitTestable(), findsOneWidget);
await tester.tap(find.byTooltip('Back'));
expect(backs, 1);
expect(tester.takeException(), isNull);
},
);
}
}
testWidgets(
'artist identity grows with text and retains navigation after scroll',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final controller = ScrollController();
addTearDown(controller.dispose);
var favorites = 0;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.dark),
home: Builder(
builder: (context) => Scaffold(
body: CustomScrollView(
controller: controller,
slivers: [
...MornyeArtistHeader(
name: 'An artist with a name spanning several lines',
showTitle: true,
artwork: const ColoredBox(color: Colors.orange),
actions: [
HeaderCircleButton(
icon: Icons.favorite_border,
tooltip: 'Favorite',
onPressed: () => favorites++,
),
],
).buildSlivers(context),
const SliverToBoxAdapter(child: SizedBox(height: 2000)),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Favorite'));
expect(favorites, 1);
controller.jumpTo(1000);
await tester.pumpAndSettle();
expect(find.byTooltip('Back').hitTestable(), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
}
+344
View File
@@ -0,0 +1,344 @@
import 'dart:convert';
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart';
import 'package:spotiflac_android/services/batch_track_actions.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_action_button.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_switch.dart';
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/destructive_selection_button.dart';
Widget _app(
Widget child, {
Brightness brightness = Brightness.dark,
TargetPlatform? platform,
}) => ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(brightness).copyWith(platform: platform),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: child),
),
);
Future<void> _tapVisible(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pumpAndSettle();
await tester.tap(finder);
await tester.pumpAndSettle();
}
void _mockMetadataFile(WidgetTester tester) {
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger = tester.binding.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
channel,
(call) async => switch (call.method) {
'safStat' => jsonEncode({'exists': true, 'size': 100}),
'readAudioMetadata' ||
'readFileMetadata' => jsonEncode({'title': 'Track', 'artist': 'Artist'}),
'getLyricsLRCWithSource' => jsonEncode({'lyrics': '', 'source': ''}),
'getSafFileModTimes' => '{}',
_ => null,
},
);
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
}
void main() {
setUpAll(() async {
await (FontLoader(
'Inter',
)..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load();
await (FontLoader('packages/cupertino_icons/CupertinoIcons')..addFont(
rootBundle.load('packages/cupertino_icons/assets/CupertinoIcons.ttf'),
))
.load();
});
setUp(() {
SharedPreferences.setMockInitialValues({});
FlutterSecureStorage.setMockInitialValues({});
});
testWidgets('Mornye selection actions remain accessible on narrow screens', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 780));
addTearDown(() => tester.binding.setSurfaceSize(null));
var selectedAll = false;
var closed = false;
var deleted = false;
var converted = false;
await tester.pumpWidget(
_app(
Builder(
builder: (context) => Align(
alignment: Alignment.bottomCenter,
child: SelectionBottomBar(
selectedCount: 1,
allSelected: false,
onClose: () => closed = true,
onToggleSelectAll: () => selectedAll = true,
bottomPadding: 0,
children: [
SelectionActionButton(
icon: Icons.swap_horiz,
label: 'Convert 1 track',
onPressed: () => converted = true,
colorScheme: Theme.of(context).colorScheme,
),
const SizedBox(height: 8),
DestructiveSelectionButton(
count: 1,
colorScheme: Theme.of(context).colorScheme,
onPressed: () => deleted = true,
),
],
),
),
),
),
);
await tester.pumpAndSettle();
await _tapVisible(tester, find.text('Select All'));
await _tapVisible(tester, find.text('Convert 1 track'));
await _tapVisible(tester, find.byIcon(CupertinoIcons.trash));
await _tapVisible(tester, find.byIcon(CupertinoIcons.xmark));
expect((selectedAll, converted, deleted, closed), (true, true, true, true));
expect(tester.takeException(), isNull);
});
for (final brightness in Brightness.values) {
testWidgets('Mornye conversion preserves selected options ($brightness)', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(390, 844));
addTearDown(() => tester.binding.setSurfaceSize(null));
(String, String, bool)? result;
await tester.pumpWidget(
_app(
BatchConvertSheet(
formats: const [
'ALAC',
'FLAC',
'WAV',
'AIFF',
'AAC',
'MP3',
'Opus',
],
title: 'Batch Convert',
confirmLabel: 'Convert tracks',
sourceBitDepth: 24,
sourceSampleRate: 48000,
onConvert: (format, bitrate, quality, processing, keepOriginal) {
result = (format, bitrate, keepOriginal);
},
),
brightness: brightness,
),
);
await tester.pumpAndSettle();
await _tapVisible(tester, find.text('AAC'));
await _tapVisible(tester, find.text('128k'));
await _tapVisible(tester, find.byType(AppSwitch));
await _tapVisible(tester, find.text('Convert tracks'));
expect(result, ('AAC', '128k', true));
expect(tester.takeException(), isNull);
});
}
testWidgets(
'Mornye Re-enrich retains field choices and disables empty review',
(tester) async {
await tester.binding.setSurfaceSize(const Size(390, 844));
addTearDown(() => tester.binding.setSurfaceSize(null));
ReEnrichFieldSelection? result;
await tester.pumpWidget(
_app(
Builder(
builder: (context) => CupertinoButton(
onPressed: () async {
result = await showReEnrichFieldDialog(
context,
selectedCount: 2,
);
},
child: const Text('Open'),
),
),
),
);
await _tapVisible(tester, find.text('Open'));
await _tapVisible(tester, find.text('Update selected tags'));
await _tapVisible(tester, find.text('Select All'));
final review = find.widgetWithText(AppActionButton, 'Review changes');
expect(tester.widget<AppActionButton>(review).onPressed, isNull);
await _tapVisible(tester, find.text('Cover Art'));
await _tapVisible(tester, find.text('Review changes'));
expect(result?.mode, ReEnrichBatchMode.selectedFields);
expect(result?.fields, [ReEnrichFields.cover]);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'Mornye ReplayGain cancellation restores selection without analysis',
(tester) async {
bool? confirmed;
var exitedSelection = false;
await tester.pumpWidget(
_app(
Builder(
builder: (context) => CupertinoButton(
onPressed: () => runBatchReplayGain(
context,
[
UnifiedLibraryItem(
id: 'track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: '/missing/track.flac',
addedAt: DateTime(2026),
source: LibraryItemSource.local,
),
],
onExitSelectionMode: () => exitedSelection = true,
onConfirmClosed: (value) async => confirmed = value,
),
child: const Text('Open'),
),
),
),
);
await _tapVisible(tester, find.text('Open'));
expect(find.byType(AppAlertDialog), findsOneWidget);
await _tapVisible(tester, find.text('Cancel'));
expect(confirmed, isFalse);
expect(exitedSelection, isFalse);
expect(tester.takeException(), isNull);
},
);
for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
testWidgets('Metadata menu scroll reaches the last action ($platform)', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(390, 540));
addTearDown(() => tester.binding.setSurfaceSize(null));
_mockMetadataFile(tester);
await tester.pumpWidget(
_app(
TrackMetadataScreen(
item: DownloadHistoryItem(
id: 'menu-track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: 'content://library/document/menu-track.flac',
service: 'provider-a',
downloadedAt: DateTime(2026),
),
),
platform: platform,
),
);
await tester.pumpAndSettle();
await _tapVisible(tester, find.byIcon(CupertinoIcons.ellipsis));
final lastAction = find.text('Remove from device');
expect(lastAction.hitTestable(), findsNothing);
expect(find.byType(MornyeContextMenu), findsOneWidget);
expect(find.byType(BottomSheet), findsNothing);
for (var drag = 0; drag < 4; drag++) {
await tester.dragFrom(const Offset(195, 400), const Offset(0, -320));
await tester.pumpAndSettle();
}
expect(find.text('Share').hitTestable(), findsOneWidget);
expect(lastAction.hitTestable(), findsOneWidget);
// Device Hub / Simulator users can also scroll with a trackpad or wheel.
await tester.sendEventToBinding(
const PointerScrollEvent(
position: Offset(195, 400),
scrollDelta: Offset(0, -2000),
),
);
await tester.pumpAndSettle();
expect(find.text('Play next').hitTestable(), findsOneWidget);
await tester.sendEventToBinding(
const PointerScrollEvent(
position: Offset(195, 400),
scrollDelta: Offset(0, 2000),
),
);
await tester.pumpAndSettle();
expect(lastAction.hitTestable(), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
testWidgets('Mornye metadata editor keeps edited text and Save available', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(390, 844));
addTearDown(() => tester.binding.setSurfaceSize(null));
_mockMetadataFile(tester);
await tester.pumpWidget(
_app(
TrackMetadataScreen(
item: DownloadHistoryItem(
id: 'edit-track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: 'content://library/document/edit-track.flac',
service: 'provider-a',
downloadedAt: DateTime(2026),
),
),
),
);
await tester.pumpAndSettle();
await _tapVisible(tester, find.byIcon(CupertinoIcons.ellipsis));
await tester.tap(find.text('Edit Metadata'));
await tester.pump(const Duration(milliseconds: 400));
// The editor extracts its preview through real temporary-file I/O.
await tester.runAsync(() async {
for (var attempt = 0; attempt < 100; attempt++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
await tester.pump();
if (find.byType(LinearProgressIndicator).evaluate().isEmpty) break;
}
});
expect(find.byType(LinearProgressIndicator), findsNothing);
await tester.pumpAndSettle();
final title = find.byWidgetPredicate(
(widget) =>
widget is CupertinoTextField && widget.controller?.text == 'Track',
);
await tester.ensureVisible(title);
await tester.enterText(title, 'Edited track');
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
expect(find.text('Edited track'), findsOneWidget);
expect(find.widgetWithText(AppActionButton, 'Save'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
void main() {
for (final brightness in Brightness.values) {
testWidgets('Mornye loading layouts fit a narrow screen in $brightness', (
tester,
) async {
tester.view.physicalSize = const Size(320, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
for (final skeleton in const [
ArtistScreenSkeleton(showCoverHeader: false),
AlbumTrackListSkeleton(showCoverHeader: true),
TrackListSkeleton(showCoverHeader: true),
]) {
await tester.pumpWidget(
MaterialApp(
theme: MornyeTheme.build(brightness),
home: Scaffold(body: skeleton),
),
);
await tester.pump(const Duration(milliseconds: 100));
expect(tester.takeException(), isNull);
await tester.drag(
find.byType(SingleChildScrollView).first,
const Offset(0, -350),
);
await tester.pump(const Duration(milliseconds: 100));
expect(tester.takeException(), isNull);
}
await tester.pumpWidget(const SizedBox.shrink());
});
}
testWidgets(
'chrome follows the visible artist and restores for albums and tabs',
(tester) async {
final key = ShellNavigationService.homeTabNavigatorKey;
final observer = ShellChromeObserver(key);
ShellNavigationService.syncState(currentTabIndex: 0, showRepoTab: true);
addTearDown(observer.detach);
await tester.pumpWidget(
MaterialApp(
theme: MornyeTheme.build(Brightness.light),
home: Navigator(
key: key,
observers: [observer],
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => const Scaffold(body: Text('Home')),
),
),
),
);
await tester.pumpAndSettle();
expect(ShellNavigationService.chromeBrightness.value, isNull);
key.currentState!.push(
MaterialPageRoute<void>(
builder: (_) => const MornyeArtistSurface(
imageSource: null,
child: Scaffold(body: Text('Artist')),
),
),
);
await tester.pumpAndSettle();
expect(ShellNavigationService.chromeBrightness.value, Brightness.dark);
final artistSurface = Theme.of(
tester.element(find.text('Artist')),
).colorScheme.surface;
expect(ShellNavigationService.chromeSurface.value, artistSurface);
// Menus do not turn the bars light over a dark artist.
showDialog<void>(
context: tester.element(find.text('Artist')),
useRootNavigator: false,
builder: (_) => const AlertDialog(title: Text('Options')),
);
await tester.pumpAndSettle();
expect(ShellNavigationService.chromeBrightness.value, Brightness.dark);
key.currentState!.pop();
await tester.pumpAndSettle();
// The mounted artist under a pushed album must not keep a stale override.
key.currentState!.push(
MaterialPageRoute<void>(
builder: (_) => const Scaffold(body: Text('Album')),
),
);
await tester.pumpAndSettle();
expect(ShellNavigationService.chromeBrightness.value, isNull);
expect(ShellNavigationService.chromeSurface.value, isNull);
key.currentState!.pop();
await tester.pumpAndSettle();
expect(ShellNavigationService.chromeBrightness.value, Brightness.dark);
expect(ShellNavigationService.chromeSurface.value, artistSurface);
ShellNavigationService.syncState(currentTabIndex: 3, showRepoTab: true);
await tester.pumpAndSettle();
expect(ShellNavigationService.chromeBrightness.value, isNull);
expect(ShellNavigationService.chromeSurface.value, isNull);
ShellNavigationService.syncState(currentTabIndex: 0, showRepoTab: true);
await tester.pumpAndSettle();
expect(ShellNavigationService.chromeBrightness.value, Brightness.dark);
key.currentState!.pop();
await tester.pumpAndSettle();
expect(ShellNavigationService.chromeBrightness.value, isNull);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
+105
View File
@@ -0,0 +1,105 @@
import 'package:flutter/cupertino.dart' show CupertinoIcons;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/widgets/mornye_playback_button.dart';
void main() {
Future<void> pumpButton(
WidgetTester tester, {
required VoidCallback onPressed,
IconData icon = CupertinoIcons.play_fill,
bool reduceMotion = false,
}) => tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: MediaQueryData(disableAnimations: reduceMotion),
child: Scaffold(
body: Center(
child: MornyePlaybackButton(
icon: icon,
tooltip: 'Playback action',
color: Colors.white,
onPressed: onPressed,
),
),
),
),
),
);
testWidgets('press feedback keeps the tap target fixed and cancels safely', (
tester,
) async {
var calls = 0;
await pumpButton(tester, onPressed: () => calls++);
final button = find.byType(IconButton);
final icon = find.byIcon(CupertinoIcons.play_fill);
final bounds = tester.getRect(button);
final iconBounds = tester.getRect(icon);
final press = await tester.startGesture(bounds.center);
await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 200));
expect(tester.getRect(icon).width, lessThan(iconBounds.width));
expect(tester.getRect(button), bounds);
await press.up();
await tester.pumpAndSettle();
expect(calls, 1);
expect(tester.getRect(icon), iconBounds);
final cancelled = await tester.startGesture(bounds.center);
await tester.pump(const Duration(milliseconds: 100));
await cancelled.cancel();
await tester.pumpAndSettle();
expect(calls, 1);
expect(tester.getRect(icon), iconBounds);
});
for (final icon in [
CupertinoIcons.forward_fill,
CupertinoIcons.backward_fill,
]) {
testWidgets(
'skip $icon animates without delaying repeated playback actions',
(tester) async {
var calls = 0;
await pumpButton(tester, icon: icon, onPressed: () => calls++);
final button = find.byType(IconButton);
final bounds = tester.getRect(button);
for (var index = 0; index < 3; index++) {
await tester.tap(button);
await tester.pump(const Duration(milliseconds: 60));
expect(calls, index + 1);
expect(find.byIcon(CupertinoIcons.play_fill), findsNWidgets(3));
expect(tester.getRect(button), bounds);
}
await tester.pumpAndSettle();
expect(find.byIcon(icon), findsOneWidget);
expect(find.byIcon(CupertinoIcons.play_fill), findsNothing);
expect(tester.takeException(), isNull);
},
);
}
testWidgets('Reduce Motion keeps skip glyphs stationary and actions usable', (
tester,
) async {
var calls = 0;
await pumpButton(
tester,
icon: CupertinoIcons.forward_fill,
reduceMotion: true,
onPressed: () => calls++,
);
final icon = find.byIcon(CupertinoIcons.forward_fill);
final bounds = tester.getRect(icon);
final press = await tester.startGesture(tester.getCenter(icon));
await tester.pump(const Duration(milliseconds: 200));
expect(tester.getRect(icon), bounds);
await press.up();
await tester.pump(const Duration(milliseconds: 60));
expect(calls, 1);
expect(find.byIcon(CupertinoIcons.play_fill), findsNothing);
expect(tester.getRect(icon), bounds);
await tester.pumpAndSettle();
});
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/widgets/mornye_playback_time.dart';
Widget _app(int seconds, {bool remaining = false, bool reduceMotion = false}) =>
MaterialApp(
home: MediaQuery(
data: MediaQueryData(disableAnimations: reduceMotion),
child: Scaffold(
body: Align(
alignment: Alignment.centerRight,
child: MornyePlaybackTime(
seconds: seconds,
remaining: remaining,
style: const TextStyle(fontSize: 14),
),
),
),
),
);
void main() {
for (final increasing in [true, false]) {
testWidgets(
'Time digits roll ${increasing ? 'up' : 'down'} without drift',
(tester) async {
final oldDigit = increasing ? '4' : '5';
final newDigit = increasing ? '5' : '4';
await tester.pumpWidget(_app(increasing ? 34 : 35));
final oldPosition = tester.getTopLeft(find.text(oldDigit));
final colonPosition = tester.getTopLeft(find.text(':'));
final initialSize = tester.getSize(find.byType(MornyePlaybackTime));
await tester.pumpWidget(_app(increasing ? 35 : 34));
await tester.pump(const Duration(milliseconds: 80));
final oldY = tester.getTopLeft(find.text(oldDigit)).dy;
final newY = tester.getTopLeft(find.text(newDigit)).dy;
expect(
oldY,
increasing ? lessThan(oldPosition.dy) : greaterThan(oldPosition.dy),
);
expect(
newY,
increasing ? greaterThan(oldPosition.dy) : lessThan(oldPosition.dy),
);
expect(tester.getTopLeft(find.text(':')), colonPosition);
expect(tester.getSize(find.byType(MornyePlaybackTime)), initialSize);
await tester.pumpAndSettle();
expect(find.text(oldDigit), findsNothing);
expect(tester.getTopLeft(find.text(newDigit)), oldPosition);
expect(tester.takeException(), isNull);
},
);
}
testWidgets('Minute rollover and rapid seeks expose only the current time', (
tester,
) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(_app(599, remaining: true));
final colonPosition = tester.getTopLeft(find.text(':'));
await tester.pumpWidget(_app(600, remaining: true));
await tester.pump(const Duration(milliseconds: 80));
expect(find.bySemanticsLabel('-10:00'), findsOneWidget);
expect(find.bySemanticsLabel('-9:59'), findsNothing);
expect(find.text('-'), findsOneWidget);
expect(tester.getTopLeft(find.text(':')), colonPosition);
await tester.pumpWidget(_app(598, remaining: true));
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('-9:58'), findsOneWidget);
expect(find.text('0'), findsNothing);
expect(find.text('-'), findsOneWidget);
expect(tester.takeException(), isNull);
semantics.dispose();
});
testWidgets('Reduce Motion updates time without rolling digits', (
tester,
) async {
await tester.pumpWidget(_app(34));
await tester.pumpWidget(_app(35));
await tester.pump(const Duration(milliseconds: 80));
await tester.pumpWidget(_app(36, reduceMotion: true));
expect(find.text('0:36'), findsOneWidget);
expect(find.byType(AnimatedSwitcher), findsNothing);
expect(tester.hasRunningAnimations, isFalse);
});
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/mornye_player_queue.dart';
class _Player extends MusicPlayerController {
int? jumpedTo;
(int, int)? moved;
bool? shuffled;
AudioServiceRepeatMode? repeat;
@override
Future<void> jumpTo(int index) async => jumpedTo = index;
@override
void moveQueueItem(int oldIndex, int newIndex) =>
moved = (oldIndex, newIndex);
@override
Future<void> setShuffle(bool enabled) async => shuffled = enabled;
@override
Future<void> setRepeatMode(AudioServiceRepeatMode mode) async =>
repeat = mode;
}
void main() {
testWidgets(
'upcoming queue maps taps and reorders to the complete playback queue',
(tester) async {
final player = _Player();
var libraryShuffles = 0;
final queue = [
for (final name in ['Previous', 'Current', 'Next', 'Last'])
MediaItem(id: name, title: name, artist: 'Artist'),
];
final theme = MornyeTheme.build(Brightness.dark);
await tester.pumpWidget(
ProviderScope(
overrides: [
musicPlayerControllerProvider.overrideWithValue(player),
currentMediaItemProvider.overrideWith(
(ref) => Stream.value(queue[1]),
),
playQueueProvider.overrideWith((ref) => Stream.value(queue)),
playbackStateProvider.overrideWith(
(ref) => Stream.value(PlaybackState(queueIndex: 1)),
),
],
child: MaterialApp(
theme: theme,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: MornyePlayerQueue(
colorScheme: theme.colorScheme,
onShuffleLibrary: () => libraryShuffles++,
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Previous'), findsNothing);
expect(find.text('Current'), findsNothing);
await tester.tap(find.text('Last'));
expect(player.jumpedTo, 3);
final firstHandle = find.byType(ReorderableDragStartListener).first;
final drag = await tester.startGesture(tester.getCenter(firstHandle));
await tester.pump(const Duration(milliseconds: 100));
await drag.moveBy(const Offset(0, 30));
await tester.pump();
await drag.moveBy(const Offset(0, 180));
await tester.pump(const Duration(milliseconds: 300));
await drag.up();
await tester.pumpAndSettle();
expect(player.moved, (2, 3));
await tester.tap(find.byIcon(CupertinoIcons.shuffle));
expect(player.shuffled, isTrue);
await tester.tap(find.byIcon(CupertinoIcons.repeat));
expect(player.repeat, AudioServiceRepeatMode.all);
await tester.tap(find.byIcon(CupertinoIcons.ellipsis));
await tester.pumpAndSettle();
final shuffleLibrary = AppLocalizations.of(
tester.element(find.byType(MornyePlayerQueue)),
).nowPlayingShuffleLibrary;
await tester.tap(find.text(shuffleLibrary));
await tester.pumpAndSettle();
expect(libraryShuffles, 1);
expect(find.text(shuffleLibrary), findsNothing);
expect(find.text('Next'), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
}
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
void main() {
for (final brightness in Brightness.values) {
testWidgets(
'search glass cards scroll and filters remain usable in $brightness',
(tester) async {
var selected = false;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(brightness),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) => Column(
children: [
MornyeGlassPanel.overlay(
radius: 24,
child: MornyeFilterChip(
label: 'Albums',
selected: selected,
glass: false,
tonal: selected,
onTap: () => setState(() => selected = !selected),
),
),
Expanded(
child: ListView.builder(
itemCount: 12,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: MornyeGlassPanel.overlay(
radius: 24,
child: SizedBox(
height: 88,
child: Text('Result $index'),
),
),
),
),
),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await tester.tap(find.text('Albums'));
await tester.pumpAndSettle();
expect(selected, isTrue);
await tester.scrollUntilVisible(find.text('Result 11'), 200);
await tester.pumpAndSettle();
expect(find.text('Result 11').hitTestable(), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/widgets/mornye_volume_control.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('In-app volume writes suppress the system volume panel', () async {
const channel = MethodChannel('com.kurenai7968.volume_controller.method');
final calls = <MethodCall>[];
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
channel,
(call) async => calls.add(call),
);
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(systemVolumeWriterProvider)(0.7);
expect(calls.single.method, 'setVolume');
expect(calls.single.arguments, {'volume': 0.7, 'showSystemUI': false});
});
testWidgets('Slow volume writes coalesce and cannot reset an active drag', (
tester,
) async {
final writes = <double>[];
final completions = <Completer<void>>[];
final volumes = StreamController<double>();
addTearDown(volumes.close);
await tester.pumpWidget(
ProviderScope(
overrides: [
systemVolumeProvider.overrideWith((ref) => volumes.stream),
systemVolumeWriterProvider.overrideWith(
(ref) => (value) {
writes.add(value);
final completion = Completer<void>();
completions.add(completion);
return completion.future;
},
),
],
child: const MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: MornyeVolumeControl()),
),
),
);
volumes.add(0.5);
await tester.pumpAndSettle();
Slider slider() => tester.widget(find.byType(Slider));
slider().onChangeStart!(0.5);
slider().onChanged!(0.6);
slider().onChanged!(0.7);
slider().onChanged!(0.8);
await tester.pump();
expect(writes, [0.6]);
expect(slider().value, 0.8);
completions.first.complete();
await tester.pump();
expect(writes, [0.6, 0.8]);
volumes.add(0.6);
completions.last.complete();
await tester.pump();
expect(slider().value, 0.8);
slider().onChangeEnd!(0.8);
slider().onChangeStart!(0.8);
slider().onChanged!(0.9);
completions.last.complete();
await tester.pump();
expect(writes.last, 0.9);
completions.last.complete();
await tester.pump();
expect(slider().value, 0.9);
slider().onChangeEnd!(0.9);
volumes.add(0.9);
completions.last.complete();
await tester.pump();
volumes.add(0.3);
await tester.pumpAndSettle();
expect(slider().value, 0.3);
expect(tester.takeException(), isNull);
});
}
+8 -2
View File
@@ -6,6 +6,8 @@ import 'package:video_player_platform_interface/video_player_platform_interface.
class _VideoPlatform extends VideoPlayerPlatform {
bool playing = false;
int playCalls = 0;
bool looping = false;
double volume = 1;
@override
Future<void> init() async {}
@@ -32,10 +34,12 @@ class _VideoPlatform extends VideoPlayerPlatform {
Future<void> pause(int playerId) async => playing = false;
@override
Future<void> setLooping(int playerId, bool looping) async {}
Future<void> setLooping(int playerId, bool looping) async =>
this.looping = looping;
@override
Future<void> setVolume(int playerId, double volume) async {}
Future<void> setVolume(int playerId, double volume) async =>
this.volume = volume;
@override
Future<void> setPlaybackSpeed(int playerId, double speed) async {}
@@ -91,6 +95,8 @@ void main() {
await tester.pumpWidget(app());
await tester.pump();
expect(platform.playing, isTrue);
expect(platform.looping, isTrue);
expect(platform.volume, 0);
final initialPlayCalls = platform.playCalls;
scroll.jumpTo(50);
@@ -37,6 +37,65 @@ void main() {
expect(restored.explicit, isTrue);
});
test(
'restored iOS artwork follows the container without changing external audio',
() {
const previous =
'/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111';
const current =
'/var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222';
const externalAudio =
'/var/mobile/Containers/Data/Application/33333333-3333-4333-8333-333333333333/Documents/track.flac';
final saved = {
...media.toJson(),
'source': externalAudio,
'artUri': Uri.file(
'$previous/Library/Caches/covers/曲 #cover.jpg',
).toString(),
};
final restored = PlayableMedia.fromJson(
saved,
iosDocumentsPath: '$current/Documents',
)!;
expect(restored.source, externalAudio);
expect(restored.id, media.id);
expect(
restored.artUri,
Uri.file('$current/Library/Caches/covers/曲 #cover.jpg').toString(),
);
expect(
restored.toMediaItem().artUri?.toFilePath(),
'$current/Library/Caches/covers/曲 #cover.jpg',
);
expect(
PlayableMedia.fromJson(
restored.toJson(),
iosDocumentsPath: '$current/Documents',
)!.artUri,
restored.artUri,
);
},
);
test('artwork restore preserves network, Android and external artwork', () {
const documents =
'/var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents';
for (final artwork in [
null,
'https://example.com/cover.jpg',
'content://media/covers/123',
'file:///data/user/0/example/cache/cover.jpg',
'file:///private/var/mobile/Library/Mobile%20Documents/cover.jpg',
]) {
final saved = {...media.toJson(), 'artUri': artwork};
expect(
PlayableMedia.fromJson(saved, iosDocumentsPath: documents)!.artUri,
artwork,
);
expect(PlayableMedia.fromJson(saved)!.artUri, artwork);
}
});
test('file probe cannot erase valid queue quality with empty values', () {
final merged = mergePlaybackFileMetadata(
playbackAudioMetadataFromMediaItem(media.toMediaItem()),
+110
View File
@@ -0,0 +1,110 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
void main() {
for (final brightness in Brightness.values) {
testWidgets('localized inset preserves collection pixels in $brightness', (
tester,
) async {
final capture = GlobalKey();
final inset = ValueNotifier(140.0);
final scroll = ScrollController();
addTearDown(inset.dispose);
addTearDown(scroll.dispose);
Future<void> pumpPage({required bool explicitInset}) async {
await tester.pumpWidget(
MaterialApp(
theme: MornyeTheme.build(brightness),
home: ValueListenableBuilder<double>(
valueListenable: inset,
builder: (context, value, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(padding: EdgeInsets.only(top: 24, bottom: value)),
child: child!,
),
child: RepaintBoundary(
key: capture,
child: Builder(
builder: (context) => CollectionScaffold(
scrollController: scroll,
isSelectionMode: false,
onExitSelectionMode: () {},
bottomInset: explicitInset
? context.navBarBottomInset
: null,
appBar: const SliverToBoxAdapter(
child: SizedBox(height: 120, child: Text('Collection')),
),
slivers: [
SliverFixedExtentList.builder(
itemExtent: 56,
itemCount: 30,
itemBuilder: (_, index) => ListTile(
leading: const Icon(Icons.music_note),
title: Text('Track $index'),
),
),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
}
Future<Uint8List> pixels() async {
return (await tester.runAsync(() async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(capture),
);
final image = await boundary.toImage();
try {
final data = await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
);
return Uint8List.fromList(data!.buffer.asUint8List());
} finally {
image.dispose();
}
}))!;
}
// Compare the previous, parent-owned inset with the localized spacer at
// intermediate animation heights, including the final scroll position.
final expected = <double, Uint8List>{};
final extents = <double, double>{};
await pumpPage(explicitInset: true);
for (final height in [140.0, 118.0, 86.0, 64.0, 0.0]) {
inset.value = height;
await tester.pumpAndSettle();
scroll.jumpTo(scroll.position.maxScrollExtent);
await tester.pumpAndSettle();
expected[height] = await pixels();
extents[height] = scroll.position.maxScrollExtent;
}
await pumpPage(explicitInset: false);
for (final height in expected.keys) {
inset.value = height;
await tester.pumpAndSettle();
scroll.jumpTo(scroll.position.maxScrollExtent);
await tester.pumpAndSettle();
expect(scroll.position.maxScrollExtent, extents[height]);
expect(await pixels(), orderedEquals(expected[height]!));
}
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
});
}
}
File diff suppressed because it is too large. Load diff
+166
View File
@@ -0,0 +1,166 @@
import 'dart:ui' as ui;
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:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/screens/now_playing_screen.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
const _sheet = Key('player-sheet');
class _TestPlayerRoute extends NowPlayingRoute {
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) => const ColoredBox(key: _sheet, color: Colors.black);
}
void main() {
for (final brightness in Brightness.values) {
testWidgets(
'player fills safe areas and reveals the page only when dragged in $brightness',
(tester) async {
tester.view.physicalSize = const Size(393, 852);
tester.view.devicePixelRatio = 1;
tester.view.padding = FakeViewPadding(top: 59, bottom: 34);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPadding);
final navigator = GlobalKey<NavigatorState>();
final capture = GlobalKey();
const background = Color(0xffff00ff);
final theme = MornyeTheme.build(brightness);
final playerBackground = theme.colorScheme.surface;
await tester.pumpWidget(
RepaintBoundary(
key: capture,
child: ProviderScope(
overrides: [
currentMediaItemProvider.overrideWith(
(ref) => Stream.value(null),
),
],
child: MaterialApp(
navigatorKey: navigator,
theme: theme,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: ColoredBox(
color: background,
child: const SizedBox.expand(),
),
),
),
),
);
final route = NowPlayingRoute();
navigator.currentState!.push(route);
await tester.pumpAndSettle();
Future<void> expectColorAt(int x, int y, Color expected) async {
final pixel = 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 offset = (y * image.width + x) * 4;
final color = Color.fromARGB(
bytes.getUint8(offset + 3),
bytes.getUint8(offset),
bytes.getUint8(offset + 1),
bytes.getUint8(offset + 2),
);
image.dispose();
return color;
});
expect(pixel, expected);
}
await expectColorAt(196, 30, playerBackground);
await expectColorAt(1, 1, playerBackground);
await expectColorAt(196, 840, playerBackground);
expect(tester.getTopLeft(find.byType(AppBar).last).dy, 0);
expect(
tester.getTopLeft(find.byIcon(Icons.keyboard_arrow_down)).dy,
greaterThanOrEqualTo(59),
);
route.startDrag();
route.updateDrag(
DragUpdateDetails(
globalPosition: const Offset(0, 213),
delta: const Offset(0, 213),
primaryDelta: 213,
),
852,
);
await tester.pump();
await expectColorAt(196, 100, background);
await expectColorAt(196, 243, playerBackground);
route.cancelDrag();
await tester.pumpAndSettle();
await expectColorAt(196, 30, playerBackground);
expect(tester.takeException(), isNull);
},
);
}
for (final dismiss in [false, true]) {
testWidgets('Player drag settles without a position jump (pop: $dismiss)', (
tester,
) async {
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(navigatorKey: navigator, home: const SizedBox()),
);
final route = _TestPlayerRoute();
navigator.currentState!.push(route);
await tester.pumpAndSettle();
final height = tester.getSize(find.byKey(_sheet)).height;
route.startDrag();
route.updateDrag(
DragUpdateDetails(
globalPosition: Offset(0, height * 0.4),
primaryDelta: height * 0.4,
delta: Offset(0, height * 0.4),
),
height,
);
await tester.pump();
final releasedTop = tester.getTopLeft(find.byKey(_sheet)).dy;
if (dismiss) {
route.endDrag(DragEndDetails(primaryVelocity: 0), height);
} else {
route.cancelDrag();
}
await tester.pump();
expect(
tester.getTopLeft(find.byKey(_sheet)).dy,
closeTo(releasedTop, 0.1),
);
var previousTop = releasedTop;
for (var frame = 0; frame < 5; frame++) {
await tester.pump(const Duration(milliseconds: 16));
final top = tester.getTopLeft(find.byKey(_sheet)).dy;
expect(top, dismiss ? greaterThan(previousTop) : lessThan(previousTop));
previousTop = top;
}
await tester.pumpAndSettle();
if (dismiss) {
expect(find.byKey(_sheet), findsNothing);
} else {
expect(tester.getTopLeft(find.byKey(_sheet)).dy, 0);
}
expect(tester.takeException(), isNull);
});
}
}
+76
View File
@@ -0,0 +1,76 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/utils/playback_artwork.dart';
void main() {
late Directory directory;
setUp(() async {
directory = await Directory.systemTemp.createTemp('restored-artwork-');
});
tearDown(() => directory.delete(recursive: true));
test(
'restored queue replaces a removed scan cover with current Library art',
() async {
final removed = File(
'${directory.path}/old-scan/cover.jpg',
).uri.toString();
final current = File('${directory.path}/current cover #1.jpg');
await current.writeAsBytes([1]);
final resolved = await resolveRestoredArtworkUri(
removed,
loadLibraryCoverPath: () async => current.path,
);
expect(resolved, current.uri.toString());
expect(File(Uri.parse(resolved!).toFilePath()).existsSync(), isTrue);
},
);
test(
'valid local and remote covers do not trigger Library lookups',
() async {
final current = File('${directory.path}/cover.jpg');
await current.writeAsBytes([1]);
for (final artwork in [
current.path,
current.uri.toString(),
'https://example.com/cover.jpg',
]) {
var lookups = 0;
final resolved = await resolveRestoredArtworkUri(
artwork,
loadLibraryCoverPath: () async {
lookups++;
return null;
},
);
expect(resolved, artwork);
expect(lookups, 0);
}
},
);
test(
'a missing Library image or lookup failure leaves the session usable',
() async {
final missing = File('${directory.path}/missing.jpg').uri.toString();
expect(
await resolveRestoredArtworkUri(
missing,
loadLibraryCoverPath: () async =>
'${directory.path}/also-missing.jpg',
),
missing,
);
expect(
await resolveRestoredArtworkUri(
missing,
loadLibraryCoverPath: () async =>
throw StateError('Library unavailable'),
),
missing,
);
},
);
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
void main() {
const album = (album: 'An Album', artist: 'An Artist');
test(
'motion lookup checks album and artist before loading metadata',
() async {
final loaded = <String>[];
final result = await findPlayerMotionArtwork(
album: album,
providerIds: ['provider-a'],
search: (_, _) async => [
{
'id': 'wrong-artist',
'name': 'An Album',
'artists': 'Not An Artist',
},
{
'id': 'wrong-album',
'name': 'Another Album',
'artists': 'An Artist',
},
{'id': 'match', 'name': 'AN ALBUM', 'artists': 'An Artist'},
],
loadAlbum: (_, id) async {
loaded.add(id);
return {
'album_info': {'header_video': 'https://example.com/cover.m3u8'},
};
},
);
expect(loaded, ['match']);
expect(result, 'https://example.com/cover.m3u8');
},
);
test('motion lookup falls back to another provider after an error', () async {
final result = await findPlayerMotionArtwork(
album: album,
providerIds: ['provider-a', 'provider-b'],
search: (provider, _) async {
if (provider == 'provider-a') throw StateError('offline');
return [
{
'name': 'An Album',
'artists': 'An Artist',
'header_video': 'https://example.com/motion.m3u8',
},
];
},
loadAlbum: (_, _) async =>
throw StateError('Direct artwork needs no lookup'),
);
expect(result, 'https://example.com/motion.m3u8');
});
test('unavailable or unsupported motion keeps the static cover', () async {
final result = await findPlayerMotionArtwork(
album: album,
providerIds: ['provider-a'],
search: (_, _) async => [
{
'name': 'An Album',
'artists': 'An Artist',
'header_video': 'file:///invalid.m3u8',
},
],
loadAlbum: (_, _) async => {},
);
expect(result, isNull);
});
}
+164
View File
@@ -5,6 +5,71 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart';
void main() {
testWidgets('first search request reaches a lazily mounted Home tab', (
tester,
) async {
await tester.pumpWidget(const MaterialApp(home: _SearchShell()));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsNothing);
await tester.tap(find.text('Search'));
await tester.pumpAndSettle();
final field = tester.widget<TextField>(find.byType(TextField));
expect(field.focusNode!.hasFocus, isTrue);
expect(tester.testTextInput.isVisible, isTrue);
});
testWidgets('one search request returns from an album and focuses Home', (
tester,
) async {
await tester.pumpWidget(const MaterialApp(home: _SearchShell()));
await tester.tap(find.text('Search'));
await tester.pumpAndSettle();
final navigator = ShellNavigationService.homeTabNavigatorKey.currentState!;
navigator.push(
MaterialPageRoute<void>(
builder: (_) => const Scaffold(body: Text('Album')),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Search'));
await tester.pumpAndSettle();
expect(find.text('Album'), findsNothing);
final field = tester.widget<TextField>(find.byType(TextField));
expect(field.focusNode!.hasFocus, isTrue);
expect(tester.testTextInput.isVisible, isTrue);
});
testWidgets(
'search selects Home before requesting focus and stops when shell is absent',
(tester) async {
final owner = Object();
var selectedHome = false;
var focusRequests = 0;
void onSearch() {
expect(selectedHome, isTrue);
focusRequests++;
}
ShellNavigationService.homeSearchRequests.addListener(onSearch);
addTearDown(() {
ShellNavigationService.homeSearchRequests.removeListener(onSearch);
ShellNavigationService.unregisterTabSelectionHandler(owner);
});
ShellNavigationService.registerTabSelectionHandler(
owner: owner,
handler: (tab) => selectedHome = tab == ShellTab.home,
);
ShellNavigationService.requestHomeSearch();
expect(focusRequests, 0);
await tester.pump();
expect(focusRequests, 1);
ShellNavigationService.unregisterTabSelectionHandler(owner);
ShellNavigationService.requestHomeSearch();
expect(focusRequests, 1);
},
);
group('ShellNavigationService tab requests', () {
test('forwards a named tab request to the registered shell', () {
final owner = Object();
@@ -88,3 +153,102 @@ void main() {
});
});
}
class _SearchShell extends StatefulWidget {
const _SearchShell();
@override
State<_SearchShell> createState() => _SearchShellState();
}
class _SearchShellState extends State<_SearchShell> {
final _pages = PageController(initialPage: 1);
late final _observer = ShellChromeObserver(
ShellNavigationService.homeTabNavigatorKey,
);
@override
void initState() {
super.initState();
ShellNavigationService.syncState(currentTabIndex: 1, showRepoTab: false);
ShellNavigationService.registerTabSelectionHandler(
owner: this,
handler: (_) {
ShellNavigationService.syncState(
currentTabIndex: 0,
showRepoTab: false,
);
_pages.jumpToPage(0);
},
);
}
@override
void dispose() {
ShellNavigationService.unregisterTabSelectionHandler(this);
_observer.detach();
_pages.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
body: Column(
children: [
TextButton(
onPressed: ShellNavigationService.requestHomeSearch,
child: const Text('Search'),
),
Expanded(
child: PageView.builder(
controller: _pages,
itemCount: 2,
itemBuilder: (_, index) => index == 1
? const Text('Library')
: Navigator(
key: ShellNavigationService.homeTabNavigatorKey,
observers: [_observer],
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => const _SearchHome(),
),
),
),
),
],
),
);
}
class _SearchHome extends StatefulWidget {
const _SearchHome();
@override
State<_SearchHome> createState() => _SearchHomeState();
}
class _SearchHomeState extends State<_SearchHome> {
final _focus = FocusNode();
@override
void initState() {
super.initState();
ShellNavigationService.homeSearchRequests.addListener(_onSearch);
}
void _onSearch() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focus.requestFocus();
});
}
@override
void dispose() {
ShellNavigationService.homeSearchRequests.removeListener(_onSearch);
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) =>
Scaffold(body: TextField(focusNode: _focus));
}
@@ -1,13 +1,153 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/widgets/mornye_metadata_row.dart';
void main() {
for (final useFileUri in [false, true]) {
testWidgets('Mornye metadata renders local artwork (URI=$useFileUri)', (
tester,
) async {
final cover = await tester.runAsync(() async {
final directory = await Directory.systemTemp.createTemp(
'metadata-cover-',
);
addTearDown(() => directory.delete(recursive: true));
final recorder = ui.PictureRecorder();
Canvas(recorder).drawColor(Colors.green, BlendMode.src);
final picture = recorder.endRecording();
final image = await picture.toImage(32, 32);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
picture.dispose();
return File('${directory.path}/cover with spaces #1.png')
..writeAsBytesSync(bytes!.buffer.asUint8List());
});
final item = LocalLibraryItem(
id: 'local-cover-track',
trackName: 'Track with artwork',
artistName: 'Artist',
albumName: 'Album',
filePath: '/missing/local-cover-track.flac',
coverPath: useFileUri ? cover!.uri.toString() : cover!.path,
scannedAt: DateTime(2026),
);
await tester.runAsync(() async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.dark),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: TrackMetadataScreen(localItem: item),
),
),
);
await precacheImage(
FileImage(cover),
tester.element(find.byType(TrackMetadataScreen)),
);
});
await tester.pumpAndSettle();
final artwork = find.descendant(
of: find.byType(Hero),
matching: find.byType(RawImage),
);
expect(artwork, findsOneWidget);
expect(tester.widget<RawImage>(artwork).image, isNotNull);
expect(find.byIcon(Icons.music_note), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
await FileImage(cover).evict();
});
}
for (final brightness in Brightness.values) {
testWidgets(
'Mornye metadata has single-level sections and stacked values ($brightness)',
(tester) async {
await tester.binding.setSurfaceSize(const Size(320, 780));
addTearDown(() => tester.binding.setSurfaceSize(null));
final item = DownloadHistoryItem(
id: 'metadata-track',
trackName: 'A long track title for a narrow screen',
artistName: 'Artist',
albumName: 'Album',
filePath: '/missing/track.flac',
service: 'provider-a',
downloadedAt: DateTime(2026),
isrc: 'USAAA2600001',
);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(brightness),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(1.6)),
child: child!,
),
home: TrackMetadataScreen(item: item),
),
),
);
await tester.pumpAndSettle();
expect(find.byIcon(CupertinoIcons.chevron_back), findsOneWidget);
expect(find.byIcon(CupertinoIcons.ellipsis), findsOneWidget);
expect(find.byType(Card), findsNothing);
final row = find.widgetWithText(MornyeMetadataRow, 'Track name');
expect(
find.ancestor(of: row, matching: find.byType(MornyeGlass)),
findsNothing,
);
expect(
find.ancestor(
of: row,
matching: find.byWidgetPredicate(
(widget) =>
widget is Material &&
widget.color == MornyeTheme.controlFill(tester.element(row)),
),
),
findsOneWidget,
);
final value = find.descendant(
of: row,
matching: find.text(item.trackName),
);
expect(
tester.getTopLeft(value).dy,
greaterThan(tester.getBottomLeft(find.text('Track name')).dy),
);
expect(
tester.widget<Text>(value).style?.color,
MornyeTheme.build(brightness).colorScheme.onSurface,
);
await tester.tap(find.byIcon(CupertinoIcons.ellipsis));
await tester.pumpAndSettle();
expect(find.byIcon(CupertinoIcons.square_stack), findsOneWidget);
expect(find.byIcon(CupertinoIcons.gear_alt), findsNothing);
expect(tester.takeException(), isNull);
},
);
}
testWidgets('metadata hero keeps technical text legible in light theme', (
tester,
) async {