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_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,
|
||||
],
|
||||
|
||||
@@ -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<MainShell>
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<material_ui.ThemeData>();
|
||||
static final _expressiveThemes = Expando<M3EThemeData>();
|
||||
|
||||
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<material_ui.MaterialLocalizations>(
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = <WidgetState>{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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Color?>((states) {
|
||||
if (states.contains(WidgetState.disabled)) {
|
||||
return scheme.onSurface.withValues(alpha: 0.38);
|
||||
}
|
||||
return foregroundColor;
|
||||
});
|
||||
final background = WidgetStateProperty.resolveWith<Color?>((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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<NavigationDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> 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 => '';
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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