feat(material): adopt expressive controls across the app

This commit is contained in:
zarzet committed 2026-09-27 17:01:11 +07:00
1 parent a9ed5a19cd
commit d390a214c3
23 files changed
+988 -138

No files matched your search

+2
View File
@@ -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,
],
+3 -4
View File
@@ -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),
+34 -37
View File
@@ -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
+4 -11
View File
@@ -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),
),
);
+132
View File
@@ -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,
),
);
}
}
+18 -20
View File
@@ -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,
);
}
}
+9 -13
View File
@@ -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;
+13
View File
@@ -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(
+26
View File
@@ -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,
+37
View File
@@ -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,
),
);
}
+12 -1
View File
@@ -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,
+77
View File
@@ -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,
),
);
}
}
+81
View File
@@ -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 => '';
}
+16 -49
View File
@@ -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),
),
);
}
+28
View File
@@ -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.