From d390a214c3cb8d9cd450efc69e7fb39c73f795d8 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:55:28 +0700 Subject: [PATCH] feat(material): adopt expressive controls across the app --- lib/app.dart | 2 + lib/screens/main_shell.dart | 7 +- lib/screens/now_playing_screen.dart | 71 ++-- lib/theme/app_theme.dart | 15 +- lib/theme/material_expressive.dart | 132 +++++++ lib/widgets/album_detail_header.dart | 38 +- lib/widgets/app_action_button.dart | 22 +- lib/widgets/app_choice_chip.dart | 13 + lib/widgets/app_content_card.dart | 26 ++ lib/widgets/app_loading_indicator.dart | 37 ++ lib/widgets/app_switch.dart | 13 +- lib/widgets/expressive_button.dart | 77 ++++ lib/widgets/expressive_icon_button.dart | 81 +++++ lib/widgets/expressive_navigation_bar.dart | 74 ++++ lib/widgets/selection_action_button.dart | 65 +--- lib/widgets/settings_group.dart | 28 ++ pubspec.lock | 32 ++ pubspec.yaml | 1 + test/adaptive_overlay_test.dart | 3 +- test/batch_track_actions_test.dart | 3 +- test/hires_check_card_test.dart | 3 +- test/material_expressive_test.dart | 361 +++++++++++++++++++ test/now_playing_lyrics_transition_test.dart | 22 ++ 23 files changed, 988 insertions(+), 138 deletions(-) create mode 100644 lib/theme/material_expressive.dart create mode 100644 lib/widgets/app_loading_indicator.dart create mode 100644 lib/widgets/expressive_button.dart create mode 100644 lib/widgets/expressive_icon_button.dart create mode 100644 lib/widgets/expressive_navigation_bar.dart create mode 100644 test/material_expressive_test.dart diff --git a/lib/app.dart b/lib/app.dart index c8016693..9f941149 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart' as material_ui; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:go_router/go_router.dart'; @@ -228,6 +229,7 @@ class SpotiFLACApp extends ConsumerWidget { localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, + material_ui.GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index ce933c8b..2e8a56eb 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -36,6 +36,7 @@ import 'package:spotiflac_android/widgets/update_dialog.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/mini_player.dart'; +import 'package:spotiflac_android/widgets/expressive_navigation_bar.dart'; import 'package:spotiflac_android/widgets/mornye_bottom_bar.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; @@ -1103,12 +1104,10 @@ class _MainShellState extends ConsumerState ), ), ), - child: NavigationBar( + child: ExpressiveNavigationBar( selectedIndex: _currentIndex.clamp(0, maxIndex), onDestinationSelected: _onNavTap, - animationDuration: const Duration(milliseconds: 500), - elevation: 0, - height: isTablet ? 72 : 64, + isTablet: isTablet, backgroundColor: settingsGroupColor( context, ).withValues(alpha: 0.72), diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index d9bb9bea..f23a9f5d 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -33,6 +33,8 @@ import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; +import 'package:spotiflac_android/widgets/expressive_icon_button.dart'; import 'package:spotiflac_android/widgets/aligned_lyric_pronunciation.dart'; import 'package:spotiflac_android/widgets/lyric_supplement_transition.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; @@ -2778,23 +2780,24 @@ class _PlaybackControls extends ConsumerWidget { horizontal: mornye ? (compact ? 16 : 32) : 0, ), child: Row( - mainAxisAlignment: mornye - ? MainAxisAlignment.spaceEvenly - : MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ if (!mornye) - IconButton( + ExpressiveIconButton( iconSize: 24, + selected: shuffleOn, tooltip: shuffleOn ? context.l10n.nowPlayingShuffleOn : context.l10n.nowPlayingPlayInOrder, - color: shuffleOn + foregroundColor: shuffleOn ? colorScheme.primary : colorScheme.onSurfaceVariant, + backgroundColor: shuffleOn + ? colorScheme.primaryContainer + : null, icon: const Icon(Icons.shuffle), onPressed: () => controller.setShuffle(!shuffleOn), ), - if (!mornye) const SizedBox(width: 8), if (mornye) MornyePlaybackButton( icon: CupertinoIcons.backward_fill, @@ -2803,14 +2806,13 @@ class _PlaybackControls extends ConsumerWidget { onPressed: controller.previous, ) else - IconButton( + ExpressiveIconButton( iconSize: 44, - color: colorScheme.onSurface, + foregroundColor: colorScheme.onSurface, tooltip: context.l10n.nowPlayingPreviousTrack, icon: const Icon(Icons.skip_previous), onPressed: controller.previous, ), - if (!mornye) SizedBox(width: context.tokens.playerControlGap), if (mornye) MornyePlaybackButton( icon: isPlaying @@ -2828,30 +2830,22 @@ class _PlaybackControls extends ConsumerWidget { onPressed: () => controller.togglePlayPause(isPlaying), ) else - Container( - decoration: BoxDecoration( - color: colorScheme.primary, - shape: BoxShape.circle, - ), - child: IconButton( - iconSize: 44, - padding: const EdgeInsets.all(12), - color: colorScheme.onPrimary, - tooltip: isPlaying - ? context.l10n.actionPause - : context.l10n.tooltipPlay, - icon: isLoading - ? const SizedBox.square( - dimension: 32, - child: CircularProgressIndicator(strokeWidth: 3), - ) - : Icon(isPlaying ? Icons.pause : Icons.play_arrow), - onPressed: isLoading - ? null - : () => controller.togglePlayPause(isPlaying), - ), + ExpressiveIconButton( + size: 68, + iconSize: 44, + selected: isPlaying, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + tooltip: isPlaying + ? context.l10n.actionPause + : context.l10n.tooltipPlay, + icon: isLoading + ? AppLoadingIndicator(color: colorScheme.onSurfaceVariant) + : Icon(isPlaying ? Icons.pause : Icons.play_arrow), + onPressed: isLoading + ? null + : () => controller.togglePlayPause(isPlaying), ), - if (!mornye) SizedBox(width: context.tokens.playerControlGap), if (mornye) MornyePlaybackButton( icon: CupertinoIcons.forward_fill, @@ -2860,17 +2854,17 @@ class _PlaybackControls extends ConsumerWidget { onPressed: controller.next, ) else - IconButton( + ExpressiveIconButton( iconSize: 44, - color: colorScheme.onSurface, + foregroundColor: colorScheme.onSurface, tooltip: context.l10n.nowPlayingNextTrack, icon: const Icon(Icons.skip_next), onPressed: controller.next, ), - if (!mornye) const SizedBox(width: 8), if (!mornye) - IconButton( + ExpressiveIconButton( iconSize: 24, + selected: repeatMode != AudioServiceRepeatMode.none, tooltip: switch (repeatMode) { AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne, @@ -2878,9 +2872,12 @@ class _PlaybackControls extends ConsumerWidget { context.l10n.nowPlayingRepeatOff, _ => context.l10n.nowPlayingRepeatAll, }, - color: repeatMode == AudioServiceRepeatMode.none + foregroundColor: repeatMode == AudioServiceRepeatMode.none ? colorScheme.onSurfaceVariant : colorScheme.primary, + backgroundColor: repeatMode == AudioServiceRepeatMode.none + ? null + : colorScheme.primaryContainer, icon: Icon( repeatMode == AudioServiceRepeatMode.one ? Icons.repeat_one diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 002c8785..dc90f2ba 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -7,8 +7,7 @@ import 'package:spotiflac_android/theme/app_tokens.dart'; class AppTheme { static const Color defaultSeedColor = Color(kDefaultSeedColor); - /// Component radii resolve from the same tokens the widgets read, so the - /// scale cannot drift between `ThemeData` and hand-built containers. + /// Shared surfaces use one radius scale; actions use round Material shapes. static const AppTokens _tokens = AppTokens.standard; // Override Flutter's default page transitions. Recent Flutter defaults the @@ -148,9 +147,7 @@ class AppTheme { ElevatedButtonThemeData( style: ElevatedButton.styleFrom( elevation: 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_tokens.radiusControl), - ), + shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ); @@ -158,9 +155,7 @@ class AppTheme { static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) => FilledButtonThemeData( style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_tokens.radiusControl), - ), + shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ); @@ -168,9 +163,7 @@ class AppTheme { static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) => OutlinedButtonThemeData( style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_tokens.radiusControl), - ), + shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ); diff --git a/lib/theme/material_expressive.dart b/lib/theme/material_expressive.dart new file mode 100644 index 00000000..23a64285 --- /dev/null +++ b/lib/theme/material_expressive.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:material_ui/material_ui.dart' as material_ui; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +/// Motion-heavy expressive controls use their static Material counterpart when +/// the OS requests reduced motion. Mornye owns its own component system. +bool materialExpressiveEnabled(BuildContext context) => + !context.isMornye && !MediaQuery.disableAnimationsOf(context); + +/// Bridges the app's Flutter theme to the separate Material UI widget library. +/// Local artwork colors, dynamic colors, fonts and AMOLED surfaces stay intact. +class MaterialExpressiveScope extends StatelessWidget { + const MaterialExpressiveScope({super.key, required this.child}); + + final Widget child; + static final _themes = Expando(); + static final _expressiveThemes = Expando(); + + static material_ui.ThemeData _adapt(ThemeData theme) { + final cached = _themes[theme]; + if (cached != null) return cached; + final c = theme.colorScheme; + final t = theme.textTheme; + final adapted = material_ui.ThemeData( + brightness: theme.brightness, + platform: theme.platform, + useMaterial3: true, + scaffoldBackgroundColor: theme.scaffoldBackgroundColor, + colorScheme: material_ui.ColorScheme( + brightness: c.brightness, + primary: c.primary, + onPrimary: c.onPrimary, + primaryContainer: c.primaryContainer, + onPrimaryContainer: c.onPrimaryContainer, + primaryFixed: c.primaryFixed, + primaryFixedDim: c.primaryFixedDim, + onPrimaryFixed: c.onPrimaryFixed, + onPrimaryFixedVariant: c.onPrimaryFixedVariant, + secondary: c.secondary, + onSecondary: c.onSecondary, + secondaryContainer: c.secondaryContainer, + onSecondaryContainer: c.onSecondaryContainer, + secondaryFixed: c.secondaryFixed, + secondaryFixedDim: c.secondaryFixedDim, + onSecondaryFixed: c.onSecondaryFixed, + onSecondaryFixedVariant: c.onSecondaryFixedVariant, + tertiary: c.tertiary, + onTertiary: c.onTertiary, + tertiaryContainer: c.tertiaryContainer, + onTertiaryContainer: c.onTertiaryContainer, + tertiaryFixed: c.tertiaryFixed, + tertiaryFixedDim: c.tertiaryFixedDim, + onTertiaryFixed: c.onTertiaryFixed, + onTertiaryFixedVariant: c.onTertiaryFixedVariant, + error: c.error, + onError: c.onError, + errorContainer: c.errorContainer, + onErrorContainer: c.onErrorContainer, + surface: c.surface, + onSurface: c.onSurface, + onSurfaceVariant: c.onSurfaceVariant, + surfaceDim: c.surfaceDim, + surfaceBright: c.surfaceBright, + surfaceContainerLowest: c.surfaceContainerLowest, + surfaceContainerLow: c.surfaceContainerLow, + surfaceContainer: c.surfaceContainer, + surfaceContainerHigh: c.surfaceContainerHigh, + surfaceContainerHighest: c.surfaceContainerHighest, + outline: c.outline, + outlineVariant: c.outlineVariant, + inverseSurface: c.inverseSurface, + onInverseSurface: c.onInverseSurface, + inversePrimary: c.inversePrimary, + shadow: c.shadow, + scrim: c.scrim, + surfaceTint: c.surfaceTint, + ), + textTheme: material_ui.TextTheme( + displayLarge: t.displayLarge, + displayMedium: t.displayMedium, + displaySmall: t.displaySmall, + headlineLarge: t.headlineLarge, + headlineMedium: t.headlineMedium, + headlineSmall: t.headlineSmall, + titleLarge: t.titleLarge, + titleMedium: t.titleMedium, + titleSmall: t.titleSmall, + bodyLarge: t.bodyLarge, + bodyMedium: t.bodyMedium, + bodySmall: t.bodySmall, + labelLarge: t.labelLarge, + labelMedium: t.labelMedium, + labelSmall: t.labelSmall, + ), + ); + _themes[theme] = adapted; + return adapted; + } + + @override + Widget build(BuildContext context) { + final theme = _adapt(Theme.of(context)); + final expressive = _expressiveThemes[theme] ??= + M3EThemeData.fromMaterial(theme).copyWith( + fontFamily: theme.textTheme.bodyMedium?.fontFamily, + navigationBarTheme: const M3ENavigationBarTheme(heightMedium: 72), + ); + final content = M3ETheme( + data: expressive, + // M3E's projection omits fixed color roles. Keep the complete host theme + // nearest the widgets so it also retains AMOLED and platform typography. + child: material_ui.Theme(data: theme, child: child), + ); + if (Localizations.of( + context, + material_ui.MaterialLocalizations, + ) != + null) { + return content; + } + // Also support isolated sheets/previews hosted by a Flutter MaterialApp. + return Localizations.override( + context: context, + delegates: const [material_ui.GlobalMaterialLocalizations.delegate], + child: Directionality( + textDirection: Directionality.of(context), + child: content, + ), + ); + } +} diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index 89875f7b..fc11310f 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -10,6 +10,8 @@ import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/theme/mornye_icons.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/expressive_button.dart'; +import 'package:spotiflac_android/widgets/expressive_icon_button.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; /// Collapsing album-detail header shared by the album, local-album, and @@ -565,32 +567,30 @@ class AlbumPlayActions extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible( - child: FilledButton.icon( + child: ExpressiveButton( onPressed: onPlay, icon: const Icon(Icons.play_arrow, size: 20), - label: Text( - playLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), style: FilledButton.styleFrom( backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, minimumSize: Size(0, tokens.minTouchTarget), shape: const StadiumBorder(), ), + child: Text( + playLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), ), SizedBox(width: tokens.gapMd), - IconButton.filledTonal( + ExpressiveIconButton( tooltip: shuffleTooltip, onPressed: onShuffle, icon: const Icon(Icons.shuffle), - style: IconButton.styleFrom( - minimumSize: Size.square(tokens.minTouchTarget), - backgroundColor: scheme.secondaryContainer.withValues(alpha: 0.8), - foregroundColor: scheme.onSecondaryContainer, - ), + size: tokens.minTouchTarget, + backgroundColor: scheme.secondaryContainer.withValues(alpha: 0.8), + foregroundColor: scheme.onSecondaryContainer, ), ], ); @@ -625,10 +625,9 @@ class HeaderFilledButton extends StatelessWidget { prominent: !tonal, ); } - return FilledButton.icon( + return ExpressiveButton( onPressed: onPressed, icon: Icon(icon, size: 18), - label: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), style: FilledButton.styleFrom( backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, @@ -637,6 +636,7 @@ class HeaderFilledButton extends StatelessWidget { minimumSize: Size(0, tokens.minTouchTarget), shape: const StadiumBorder(), ), + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), ); } } @@ -730,15 +730,13 @@ class HeaderCircleButton extends ConsumerWidget { ), ); } - return IconButton.filledTonal( + return ExpressiveIconButton( onPressed: onPressed, icon: Icon(icon, size: iconSize), tooltip: tooltip, - style: IconButton.styleFrom( - minimumSize: Size.square(buttonSize ?? tokens.minTouchTarget), - backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.7), - foregroundColor: iconColor ?? scheme.onSurfaceVariant, - ), + size: buttonSize ?? tokens.minTouchTarget, + backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.7), + foregroundColor: iconColor ?? scheme.onSurfaceVariant, ); } } diff --git a/lib/widgets/app_action_button.dart b/lib/widgets/app_action_button.dart index 0ac64493..441fe6b5 100644 --- a/lib/widgets/app_action_button.dart +++ b/lib/widgets/app_action_button.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/theme/mornye_icons.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/expressive_button.dart'; /// Shared sheet actions follow the selected design on every platform. class AppActionButton extends StatelessWidget { @@ -32,19 +33,14 @@ class AppActionButton extends StatelessWidget { @override Widget build(BuildContext context) { if (!context.isMornye) { - return outlined - ? OutlinedButton.icon( - onPressed: onPressed, - icon: icon, - label: label, - style: style, - ) - : FilledButton.icon( - onPressed: onPressed, - icon: icon, - label: label, - style: style, - ); + return ExpressiveButton( + onPressed: onPressed, + icon: icon, + style: style, + outlined: outlined, + tonal: tonal, + child: label, + ); } final scheme = Theme.of(context).colorScheme; final prominent = isDestructive || !outlined; diff --git a/lib/widgets/app_choice_chip.dart b/lib/widgets/app_choice_chip.dart index 01168a07..35ca8018 100644 --- a/lib/widgets/app_choice_chip.dart +++ b/lib/widgets/app_choice_chip.dart @@ -1,5 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; /// A single tonal selection inside a sheet, without a second glass layer. @@ -21,6 +23,17 @@ class AppChoiceChip extends StatelessWidget { @override Widget build(BuildContext context) { + if (materialExpressiveEnabled(context) && label is Text) { + final text = label as Text; + return MaterialExpressiveScope( + child: M3EChip( + label: text.data ?? text.textSpan?.toPlainText() ?? '', + type: M3EChipType.filter, + selected: selected, + onPressed: onSelected == null ? null : () => onSelected!(!selected), + ), + ); + } if (!context.isMornye) { if (singleChoice) { return ChoiceChip( diff --git a/lib/widgets/app_content_card.dart b/lib/widgets/app_content_card.dart index 217c723d..7ec886c0 100644 --- a/lib/widgets/app_content_card.dart +++ b/lib/widgets/app_content_card.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; /// One inline surface, matching the metadata groups and dialog controls. @@ -23,6 +25,30 @@ class AppContentCard extends StatelessWidget { @override Widget build(BuildContext context) { if (!context.isMornye) { + if (materialExpressiveEnabled(context) && + (shape == null || shape is RoundedRectangleBorder)) { + return Padding( + padding: CardTheme.of(context).margin ?? const EdgeInsets.all(4), + child: MaterialExpressiveScope( + child: M3ECard( + variant: M3ECardVariant.filled, + padding: EdgeInsets.zero, + color: color, + elevation: elevation ?? 0, + border: shape is RoundedRectangleBorder + ? (shape as RoundedRectangleBorder).side + : null, + borderRadius: shape is RoundedRectangleBorder + ? (shape as RoundedRectangleBorder).borderRadius.resolve( + Directionality.of(context), + ) + : BorderRadius.circular(20), + clipBehavior: clipBehavior, + child: Material(color: Colors.transparent, child: child), + ), + ), + ); + } return Card( color: color, shape: shape, diff --git a/lib/widgets/app_loading_indicator.dart b/lib/widgets/app_loading_indicator.dart new file mode 100644 index 00000000..4f3a9d9f --- /dev/null +++ b/lib/widgets/app_loading_indicator.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; + +/// Indeterminate loading only. Downloads/analysis keep their real progress. +class AppLoadingIndicator extends StatelessWidget { + const AppLoadingIndicator({ + super.key, + this.size = 32, + this.color, + this.semanticLabel, + }); + + final double size; + final Color? color; + final String? semanticLabel; + + @override + Widget build(BuildContext context) => SizedBox.square( + dimension: size, + child: materialExpressiveEnabled(context) + ? MaterialExpressiveScope( + child: FittedBox( + child: M3ELoadingIndicator( + size: 48, + color: color, + semanticLabel: semanticLabel, + ), + ), + ) + : CircularProgressIndicator( + color: color, + strokeWidth: 3, + semanticsLabel: semanticLabel, + ), + ); +} diff --git a/lib/widgets/app_switch.dart b/lib/widgets/app_switch.dart index b15a6471..07f2a50b 100644 --- a/lib/widgets/app_switch.dart +++ b/lib/widgets/app_switch.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart' show CupertinoColors; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/native_glass_metrics.dart'; @@ -24,6 +26,15 @@ class AppSwitch extends StatelessWidget { @override Widget build(BuildContext context) { + if (materialExpressiveEnabled(context)) { + return MaterialExpressiveScope( + child: M3ESwitch( + value: value, + onChanged: onChanged, + semanticLabel: semanticLabel, + ), + ); + } if (!context.isMornye) { return adaptive ? Switch.adaptive(value: value, onChanged: onChanged) @@ -132,7 +143,7 @@ class AppSwitchListTile extends StatelessWidget { @override Widget build(BuildContext context) { - if (!context.isMornye) { + if (!context.isMornye && !materialExpressiveEnabled(context)) { return adaptive ? SwitchListTile.adaptive( value: value, diff --git a/lib/widgets/expressive_button.dart b/lib/widgets/expressive_button.dart new file mode 100644 index 00000000..cc000838 --- /dev/null +++ b/lib/widgets/expressive_button.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; + +/// Expressive actions retain caller colors, disabled states and flexible labels. +class ExpressiveButton extends StatelessWidget { + const ExpressiveButton({ + super.key, + required this.onPressed, + required this.child, + this.icon, + this.style, + this.outlined = false, + this.tonal = false, + }); + + final VoidCallback? onPressed; + final Widget child; + final Widget? icon; + final ButtonStyle? style; + final bool outlined; + final bool tonal; + + @override + Widget build(BuildContext context) { + final content = Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[icon!, const SizedBox(width: 8)], + Flexible(child: child), + ], + ); + if (!materialExpressiveEnabled(context)) { + if (outlined) { + return OutlinedButton( + onPressed: onPressed, + style: style, + child: content, + ); + } + if (tonal) { + return FilledButton.tonal( + onPressed: onPressed, + style: style, + child: content, + ); + } + return FilledButton(onPressed: onPressed, style: style, child: content); + } + final states = {if (onPressed == null) WidgetState.disabled}; + return MaterialExpressiveScope( + child: M3EButton( + onPressed: onPressed, + style: outlined + ? M3EButtonStyle.outlined + : tonal + ? M3EButtonStyle.tonal + : M3EButtonStyle.filled, + decoration: M3EButtonDecoration( + backgroundColor: style?.backgroundColor, + foregroundColor: style?.foregroundColor, + overlayColor: style?.overlayColor, + side: style?.side, + minimumSize: + style?.minimumSize?.resolve(states) ?? const Size(48, 48), + maximumSize: style?.maximumSize?.resolve(states), + fixedSize: style?.fixedSize?.resolve(states), + padding: style?.padding?.resolve(states), + textStyle: style?.textStyle?.resolve(states), + iconSize: style?.iconSize?.resolve(states), + ), + child: content, + ), + ); + } +} diff --git a/lib/widgets/expressive_icon_button.dart b/lib/widgets/expressive_icon_button.dart new file mode 100644 index 00000000..66964841 --- /dev/null +++ b/lib/widgets/expressive_icon_button.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; + +/// Compact expressive transport/header actions with the same accessible target +/// and tooltip as the standard Material fallback. +class ExpressiveIconButton extends StatelessWidget { + const ExpressiveIconButton({ + super.key, + required this.icon, + required this.tooltip, + required this.onPressed, + this.selected, + this.size = 48, + this.iconSize = 24, + this.foregroundColor, + this.backgroundColor, + }); + + final Widget icon; + final String tooltip; + final VoidCallback? onPressed; + final bool? selected; + final double size; + final double iconSize; + final Color? foregroundColor; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final foreground = WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return scheme.onSurface.withValues(alpha: 0.38); + } + return foregroundColor; + }); + final background = WidgetStateProperty.resolveWith((states) { + if (backgroundColor == null) return Colors.transparent; + return states.contains(WidgetState.disabled) + ? scheme.onSurface.withValues(alpha: 0.12) + : backgroundColor; + }); + if (!materialExpressiveEnabled(context)) { + return IconButton( + icon: icon, + iconSize: iconSize, + tooltip: tooltip, + onPressed: onPressed, + isSelected: selected, + style: ButtonStyle( + minimumSize: WidgetStatePropertyAll(Size.square(size)), + foregroundColor: foreground, + backgroundColor: background, + ), + ); + } + return Tooltip( + message: tooltip, + child: MaterialExpressiveScope( + child: M3EIconButton( + icon: IconTheme.merge( + data: IconThemeData(size: iconSize), + child: icon, + ), + semanticLabel: tooltip, + onPressed: onPressed, + isSelected: selected, + visualSize: Size.square(size), + variant: backgroundColor == null + ? M3EIconButtonVariant.standard + : M3EIconButtonVariant.filled, + decoration: M3EIconButtonDecoration( + foregroundColor: foreground, + backgroundColor: background, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/expressive_navigation_bar.dart b/lib/widgets/expressive_navigation_bar.dart new file mode 100644 index 00000000..6d045b76 --- /dev/null +++ b/lib/widgets/expressive_navigation_bar.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; + +class ExpressiveNavigationBar extends StatelessWidget { + const ExpressiveNavigationBar({ + super.key, + required this.destinations, + required this.selectedIndex, + required this.onDestinationSelected, + required this.backgroundColor, + this.isTablet = false, + }); + + final List destinations; + final int selectedIndex; + final ValueChanged onDestinationSelected; + final Color backgroundColor; + final bool isTablet; + + @override + Widget build(BuildContext context) { + if (!materialExpressiveEnabled(context)) { + return NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: onDestinationSelected, + animationDuration: Duration.zero, + elevation: 0, + height: isTablet ? 72 : 64, + backgroundColor: backgroundColor, + destinations: destinations, + ); + } + return MaterialExpressiveScope( + child: ColoredBox( + color: backgroundColor, + // Use the app's scaled MediaQuery insets, rather than raw view metrics. + child: SafeArea( + top: false, + child: M3ENavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: onDestinationSelected, + size: isTablet ? M3ENavBarSize.medium : M3ENavBarSize.small, + autoLayout: false, + safeArea: false, + padding: EdgeInsets.zero, + backgroundColor: backgroundColor, + destinations: [ + for (final destination in destinations) + _NavigationDestination( + icon: destination.icon, + selectedIcon: destination.selectedIcon, + label: destination.label, + ), + ], + ), + ), + ), + ); + } +} + +class _NavigationDestination extends M3ENavigationBarDestination { + const _NavigationDestination({ + required super.icon, + required super.selectedIcon, + required super.label, + }); + + // The package also merges its visible label into the button semantics. + // Let that text (and any badge) provide the name exactly once. + @override + String get resolvedSemanticLabel => ''; +} diff --git a/lib/widgets/selection_action_button.dart b/lib/widgets/selection_action_button.dart index 644fb2ad..6849921f 100644 --- a/lib/widgets/selection_action_button.dart +++ b/lib/widgets/selection_action_button.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/expressive_button.dart'; -/// Icon+label pill used in selection-mode bottom bars. Disabled state dims -/// both the fill and the content to 50% alpha. +/// Icon+label pill used in selection-mode bottom bars, with the active theme's +/// disabled tones and room for a two-line label. class SelectionActionButton extends StatelessWidget { final IconData icon; final String label; @@ -31,58 +32,24 @@ class SelectionActionButton extends StatelessWidget { fontSize: 15, ); } - final isDisabled = onPressed == null; return Semantics( button: true, - enabled: !isDisabled, + enabled: onPressed != null, label: label, + onTap: onPressed, excludeSemantics: true, - child: Material( - color: isDisabled - ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) - : colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(14), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: context.tokens.minTouchTarget, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 18, - color: isDisabled - ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) - : colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: isDisabled - ? colorScheme.onSurfaceVariant.withValues( - alpha: 0.5, - ) - : colorScheme.onSecondaryContainer, - ), - ), - ), - ], - ), - ), - ), + child: ExpressiveButton( + tonal: true, + onPressed: onPressed, + icon: Icon(icon, size: 18), + style: FilledButton.styleFrom( + backgroundColor: colorScheme.secondaryContainer, + foregroundColor: colorScheme.onSecondaryContainer, + minimumSize: Size(48, context.tokens.minTouchTarget), + padding: const EdgeInsets.all(12), + textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ), + child: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis), ), ); } diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index 07b44f32..ec29ad75 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; @@ -236,6 +238,32 @@ class SettingsGroup extends StatelessWidget { ); } + if (materialExpressiveEnabled(context)) { + return LayoutBuilder( + builder: (context, constraints) => Padding( + padding: + margin ?? + EdgeInsets.symmetric( + horizontal: + 16 + + (constraints.hasBoundedWidth + ? wideInsetForWidth(constraints.maxWidth) + : 0), + vertical: 4, + ), + child: MaterialExpressiveScope( + child: M3ECard( + variant: M3ECardVariant.filled, + color: cardColor, + padding: EdgeInsets.zero, + borderRadius: context.tokens.borderRadiusCard, + child: child, + ), + ), + ), + ); + } + // Explicit caller margin wins as-is. Otherwise center on wide surfaces // using the incoming constraint (not screen width) so groups nested in an // already clamped box, e.g. a bottom sheet, are not over-inset. diff --git a/pubspec.lock b/pubspec.lock index 0b9c7225..636d0269 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -392,6 +392,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" fake_async: dependency: transitive description: @@ -844,6 +852,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.12.20" + material_3_expressive: + dependency: "direct main" + description: + name: material_3_expressive + sha256: "85412c33c79e0d7f2797d82fcc9f51053ee941b4b26a21dc1f3ddd8750928c87" + url: "https://pub.dev" + source: hosted + version: "1.1.3" material_color_utilities: dependency: transitive description: @@ -852,6 +868,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + material_new_shapes: + dependency: transitive + description: + name: material_new_shapes + sha256: e4bc375205e187e8fb232573387112dd8c0dd45b03af8aa2b3c79eb4b9e3e0dc + url: "https://pub.dev" + source: hosted + version: "1.0.0" material_ui: dependency: "direct main" description: @@ -876,6 +900,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + motor: + dependency: transitive + description: + name: motor + sha256: cbd49f21b00e568c2b1a55f134ed803614a107782f4fea7769693bca32940c58 + url: "https://pub.dev" + source: hosted + version: "1.1.0" nm: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 280eda5c..a10e1d3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: # Material Expressive 3 / Dynamic Color dynamic_color: ^2.1.0 material_ui: ^1.4.0 + material_3_expressive: ^1.1.3 # Permissions permission_handler: ^13.0.1 diff --git a/test/adaptive_overlay_test.dart b/test/adaptive_overlay_test.dart index a50b23e8..33157c07 100644 --- a/test/adaptive_overlay_test.dart +++ b/test/adaptive_overlay_test.dart @@ -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')); diff --git a/test/batch_track_actions_test.dart b/test/batch_track_actions_test.dart index 564bd7ab..454b549f 100644 --- a/test/batch_track_actions_test.dart +++ b/test/batch_track_actions_test.dart @@ -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( diff --git a/test/hires_check_card_test.dart b/test/hires_check_card_test.dart index cb6c228b..ad48596e 100644 --- a/test/hires_check_card_test.dart +++ b/test/hires_check_card_test.dart @@ -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')); diff --git a/test/material_expressive_test.dart b/test/material_expressive_test.dart new file mode 100644 index 00000000..46d36674 --- /dev/null +++ b/test/material_expressive_test.dart @@ -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(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); + }); +} diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 734ae2a4..c526fadd 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -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 {