mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
feat(material): adopt expressive controls across the app
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_3_expressive/material_3_expressive.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
@@ -90,7 +91,7 @@ void main() {
|
||||
find.byType(MornyeGlassPanel),
|
||||
mornye ? findsOneWidget : findsNothing,
|
||||
);
|
||||
expect(find.byType(FilterChip), mornye ? findsNothing : findsOneWidget);
|
||||
expect(find.byType(M3EChip), mornye ? findsNothing : findsOneWidget);
|
||||
await tester.tap(find.text('Unavailable'));
|
||||
expect(disabledTaps, 0);
|
||||
await tester.tap(find.text('Downloaded tracks'));
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
import 'package:spotiflac_android/models/unified_library_item.dart';
|
||||
import 'package:spotiflac_android/services/batch_track_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
@@ -29,7 +30,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final convertButton = find.widgetWithText(
|
||||
FilledButton,
|
||||
AppActionButton,
|
||||
'Convert 1 track',
|
||||
);
|
||||
await tester.scrollUntilVisible(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_3_expressive/material_3_expressive.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -125,7 +126,7 @@ void main() {
|
||||
expect(button, findsOneWidget);
|
||||
expect(tester.getSize(button).width, greaterThan(300));
|
||||
expect(tester.getCenter(button).dx, closeTo(195, 1));
|
||||
expect(find.byType(Card), mornye ? findsNothing : findsOneWidget);
|
||||
expect(find.byType(M3ECard), mornye ? findsNothing : findsOneWidget);
|
||||
await captureCard(tester, '$name-idle');
|
||||
|
||||
await tester.tap(find.text('Check'));
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
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:material_3_expressive/material_3_expressive.dart';
|
||||
import 'package:material_ui/material_ui.dart' as material_ui;
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/theme/app_theme.dart';
|
||||
import 'package:spotiflac_android/theme/material_expressive.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
||||
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
|
||||
import 'package:spotiflac_android/widgets/app_content_card.dart';
|
||||
import 'package:spotiflac_android/widgets/app_switch.dart';
|
||||
import 'package:spotiflac_android/widgets/expressive_button.dart';
|
||||
import 'package:spotiflac_android/widgets/expressive_icon_button.dart';
|
||||
import 'package:spotiflac_android/widgets/expressive_navigation_bar.dart';
|
||||
import 'package:spotiflac_android/widgets/selection_action_button.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
Widget _host(
|
||||
Widget child, {
|
||||
ThemeData? theme,
|
||||
bool reduceMotion = false,
|
||||
double textScale = 1,
|
||||
Locale locale = const Locale('en'),
|
||||
}) => ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: theme ?? AppTheme.light(),
|
||||
locale: locale,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
disableAnimations: reduceMotion,
|
||||
textScaler: TextScaler.linear(textScale),
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
home: Scaffold(body: Center(child: child)),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('bridge retains dynamic colors, AMOLED, font and locale', (
|
||||
tester,
|
||||
) async {
|
||||
for (final systemFont in [false, true]) {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: Colors.green,
|
||||
brightness: Brightness.dark,
|
||||
).copyWith(primary: const Color(0xffc6f192));
|
||||
final theme = AppTheme.dark(
|
||||
dynamicScheme: scheme,
|
||||
isAmoled: true,
|
||||
useSystemFont: systemFont,
|
||||
);
|
||||
late material_ui.ThemeData adapted;
|
||||
late M3EThemeData expressive;
|
||||
late ThemeData host;
|
||||
late String adaptedCancel;
|
||||
late String hostCancel;
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
MaterialExpressiveScope(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
adapted = material_ui.Theme.of(context);
|
||||
expressive = M3ETheme.of(context);
|
||||
host = Theme.of(context);
|
||||
adaptedCancel = material_ui.MaterialLocalizations.of(
|
||||
context,
|
||||
).cancelButtonLabel;
|
||||
hostCancel = MaterialLocalizations.of(
|
||||
context,
|
||||
).cancelButtonLabel;
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
locale: const Locale('id'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(adapted.colorScheme.primary, scheme.primary);
|
||||
expect(adapted.colorScheme.primaryFixed, scheme.primaryFixed);
|
||||
expect(adapted.colorScheme.surface, scheme.surface);
|
||||
expect(adapted.scaffoldBackgroundColor, Colors.black);
|
||||
expect(
|
||||
expressive.typeScale.bodyMedium.fontFamily,
|
||||
theme.textTheme.bodyMedium!.fontFamily,
|
||||
);
|
||||
expect(host.colorScheme, scheme);
|
||||
expect(adaptedCancel, hostCancel);
|
||||
expect(tester.takeException(), isNull);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('switch row and thumb toggle once and support keyboard', (
|
||||
tester,
|
||||
) async {
|
||||
var value = false;
|
||||
var calls = 0;
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AppSwitchListTile(
|
||||
title: const Text('Autoplay'),
|
||||
value: value,
|
||||
onChanged: (next) => setState(() {
|
||||
value = next;
|
||||
calls++;
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Autoplay'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(value, isTrue);
|
||||
expect(calls, 1);
|
||||
await tester.tap(find.byType(M3ESwitch));
|
||||
await tester.pumpAndSettle();
|
||||
expect(value, isFalse);
|
||||
expect(calls, 2);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.pumpAndSettle();
|
||||
expect(calls, 3);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('chips preserve select and deselect callbacks', (tester) async {
|
||||
var selected = false;
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) => AppChoiceChip(
|
||||
label: const Text('Albums'),
|
||||
selected: selected,
|
||||
singleChoice: true,
|
||||
onSelected: (next) => setState(() => selected = next),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Albums'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected, isTrue);
|
||||
await tester.tap(find.text('Albums'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('actions respect disabled state, custom colors and touch size', (
|
||||
tester,
|
||||
) async {
|
||||
var enabled = false;
|
||||
var calls = 0;
|
||||
late StateSetter update;
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
update = setState;
|
||||
return ExpressiveButton(
|
||||
onPressed: enabled ? () => calls++ : null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.teal,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Play'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Play'));
|
||||
expect(calls, 0);
|
||||
update(() => enabled = true);
|
||||
await tester.pumpAndSettle();
|
||||
final button = tester.widget<M3EButton>(find.byType(M3EButton));
|
||||
expect(button.decoration!.backgroundColor!.resolve({}), Colors.teal);
|
||||
expect(
|
||||
tester.getSize(find.byType(M3EButton)).height,
|
||||
greaterThanOrEqualTo(48),
|
||||
);
|
||||
await tester.tap(find.text('Play'));
|
||||
expect(calls, 1);
|
||||
});
|
||||
|
||||
testWidgets('large labels fit narrow settings and selection actions', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(320, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SettingsGroup(
|
||||
children: [
|
||||
AppSwitchListTile(
|
||||
value: true,
|
||||
onChanged: (_) {},
|
||||
title: const Text('Continue playing music from your Library'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
for (final label in [
|
||||
'Remove ReplayGain (10)',
|
||||
'Convert 10 tracks',
|
||||
])
|
||||
Expanded(
|
||||
child: SelectionActionButton(
|
||||
icon: Icons.music_note,
|
||||
label: label,
|
||||
onPressed: () {},
|
||||
colorScheme: AppTheme.light().colorScheme,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
textScale: 2,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('navigation preserves badges, RTL order and safe area', (
|
||||
tester,
|
||||
) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
var selected = 0;
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) => Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(padding: const EdgeInsets.only(bottom: 24)),
|
||||
child: ExpressiveNavigationBar(
|
||||
selectedIndex: selected,
|
||||
onDestinationSelected: (index) =>
|
||||
setState(() => selected = index),
|
||||
backgroundColor: Colors.black,
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
|
||||
NavigationDestination(
|
||||
icon: Badge(label: Text('3'), child: Icon(Icons.download)),
|
||||
label: 'Downloads',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getSize(find.byType(ExpressiveNavigationBar)).height, 88);
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Home'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Home\nHome'), findsNothing);
|
||||
expect(
|
||||
tester.getCenter(find.text('Home')).dx,
|
||||
greaterThan(tester.getCenter(find.text('Settings')).dx),
|
||||
);
|
||||
await tester.tap(find.text('Downloads'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected, 1);
|
||||
semantics.dispose();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('reduced motion keeps interactive Material fallback', (
|
||||
tester,
|
||||
) async {
|
||||
var calls = 0;
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ExpressiveButton(
|
||||
onPressed: () => calls++,
|
||||
child: const Text('Play'),
|
||||
),
|
||||
ExpressiveIconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
tooltip: 'Next',
|
||||
onPressed: () => calls++,
|
||||
),
|
||||
AppSwitch(value: false, onChanged: (_) => calls++),
|
||||
],
|
||||
),
|
||||
reduceMotion: true,
|
||||
),
|
||||
);
|
||||
expect(find.byType(M3EButton), findsNothing);
|
||||
expect(find.byType(M3EIconButton), findsNothing);
|
||||
expect(find.byType(M3ESwitch), findsNothing);
|
||||
await tester.tap(find.text('Play'));
|
||||
await tester.tap(find.byTooltip('Next'));
|
||||
await tester.tap(find.byType(Switch));
|
||||
expect(calls, 3);
|
||||
});
|
||||
|
||||
testWidgets('Mornye keeps Cupertino actions and its own surfaces', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppActionButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Play'),
|
||||
),
|
||||
AppChoiceChip(
|
||||
label: const Text('Albums'),
|
||||
selected: true,
|
||||
onSelected: (_) {},
|
||||
),
|
||||
const AppContentCard(child: Text('Details')),
|
||||
AppSwitch(value: true, onChanged: (_) {}),
|
||||
],
|
||||
),
|
||||
theme: MornyeTheme.build(Brightness.dark),
|
||||
reduceMotion: true,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(MaterialExpressiveScope), findsNothing);
|
||||
expect(find.byType(CupertinoButton), findsAtLeast(2));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import 'package:spotiflac_android/services/motion_artwork_store.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:spotiflac_android/screens/now_playing_screen.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/theme/app_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/expressive_icon_button.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_volume_control.dart';
|
||||
import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_player_queue.dart';
|
||||
@@ -1709,6 +1711,26 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
for (final width in [320.0, 430.0]) {
|
||||
testWidgets('Material transport fits a $width dp phone', (tester) async {
|
||||
await pumpNowPlaying(
|
||||
tester,
|
||||
theme: AppTheme.light(),
|
||||
size: Size(width, 900),
|
||||
);
|
||||
mediaItems.add(item('first'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ExpressiveIconButton), findsNWidgets(5));
|
||||
for (final element in find.byType(ExpressiveIconButton).evaluate()) {
|
||||
final bounds = tester.getRect(find.byWidget(element.widget));
|
||||
expect(bounds.left, greaterThanOrEqualTo(0));
|
||||
expect(bounds.right, lessThanOrEqualTo(width));
|
||||
expect(bounds.width, greaterThanOrEqualTo(48));
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('Mornye player renders Apple-style transport controls', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
Reference in New Issue
Block a user