feat(material): adopt expressive controls across the app

This commit is contained in:
zarzet
2026-09-27 17:01:11 +07:00
parent a9ed5a19cd
commit d390a214c3
23 changed files with 988 additions and 138 deletions
+2 -1
View File
@@ -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'));
+2 -1
View File
@@ -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(
+2 -1
View File
@@ -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'));
+361
View File
@@ -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 {