feat(a11y): label controls and improve settings contrast and targets

This commit is contained in:
zarzet committed 2026-10-17 16:18:25 +07:00
1 parent 1e73ffea3b
commit befcd2adec
29 files changed
+470 -276

No files matched your search

+8 -5
View File
@@ -1285,11 +1285,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
final rowHeight = _recentDownloadsRowHeight(context);
final header = Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
context.l10n.homeRecent,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant),
child: Semantics(
header: true,
child: Text(
context.l10n.homeRecent,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
),
);
+7 -4
View File
@@ -46,10 +46,13 @@ extension _HomeTabRecentUI on _HomeTabState {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.homeRecent,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
Semantics(
header: true,
child: Text(
context.l10n.homeRecent,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
if (uniqueItems.isNotEmpty)
+12 -6
View File
@@ -153,6 +153,8 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
])
ListTile(
title: Text(option.$2),
selected: _sort == option.$1,
selectedColor: Theme.of(context).colorScheme.onSurface,
trailing: _sort == option.$1 ? const Icon(Icons.check) : null,
onTap: () => Navigator.pop(context, option.$1),
),
@@ -183,6 +185,8 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
])
ListTile(
title: Text(option.$2),
selected: (_completeness ?? '') == option.$1,
selectedColor: Theme.of(context).colorScheme.onSurface,
trailing: (_completeness ?? '') == option.$1
? const Icon(Icons.check)
: null,
@@ -419,12 +423,14 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
24 + listInset,
16,
),
child: Text(
_query.isEmpty
? context.l10n.libraryRecentlyAdded
: context.l10n.searchAlbums,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
child: Semantics(
header: true,
child: Text(
_query.isEmpty
? context.l10n.libraryRecentlyAdded
: context.l10n.searchAlbums,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
),
),
+1
View File
@@ -38,6 +38,7 @@ import 'package:material_3_expressive/material_3_expressive.dart'
M3EButtonGroup,
M3EButtonGroupAction,
M3EButtonGroupType,
M3EButtonSize,
M3EButtonStyle;
import 'package:spotiflac_android/providers/library_browse_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
+13 -7
View File
@@ -223,6 +223,7 @@ extension _QueueTabSections on _QueueTabState {
if (sheetContext.isMornye)
SettingsItem(
title: _sortLabel(sheetContext, mode),
selected: mode == _sortMode,
trailing: mode == _sortMode
? AppIcon(
Icons.check,
@@ -273,6 +274,8 @@ extension _QueueTabSections on _QueueTabState {
child: M3EButtonGroup(
type: M3EButtonGroupType.connected,
style: M3EButtonStyle.filled,
// Connected segments get no inflated target; keep 48 dp.
size: M3EButtonSize.sm.copyWith(height: 48),
selectedIndex: selected,
selectionRequired: true,
onSelectedIndexChanged: (index) {
@@ -446,13 +449,16 @@ class _LibrarySectionHeader extends StatelessWidget {
color: theme.scaffoldBackgroundColor,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 6),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.primary,
child: Semantics(
header: true,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.primary,
),
),
),
),
@@ -982,6 +982,7 @@ class _LanguageSelector extends StatelessWidget {
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
selected: isSelected,
trailing: isSelected
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
@@ -432,6 +432,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
'opus' => context.l10n.downloadAutoConvertOpusSubtitle,
_ => context.l10n.downloadAutoConvertMp3Subtitle,
}),
selected: normalizedCurrent == format,
trailing: normalizedCurrent == format
? AppIcon(
Icons.check,
@@ -471,6 +472,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
AppSheetOption(
leading: const AppIcon(Icons.speed_outlined),
title: Text(bitrate.replaceAll('k', ' kbps')),
selected: normalizedCurrent == bitrate,
trailing: normalizedCurrent == bitrate
? AppIcon(
Icons.check,
@@ -511,6 +513,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
leading: const AppIcon(Icons.signal_cellular_alt),
title: Text(context.l10n.settingsDownloadNetworkAny),
subtitle: Text(context.l10n.downloadNetworkAnySubtitle),
selected: current == 'any',
trailing: current == 'any'
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
@@ -525,6 +528,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
leading: const AppIcon(Icons.wifi),
title: Text(context.l10n.settingsDownloadNetworkWifiOnly),
subtitle: Text(context.l10n.downloadNetworkWifiOnlySubtitle),
selected: current == 'wifi_only',
trailing: current == 'wifi_only'
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
@@ -572,6 +576,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
? context.l10n.concurrentDownloadsOne
: context.l10n.concurrentDownloadsCount(count),
),
selected: current == count,
trailing: current == count
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
@@ -884,6 +889,7 @@ class _RegionPickerSheetState extends State<_RegionPickerSheet> {
return AppSheetOption(
title: Text(countryName != code ? countryName : code),
subtitle: countryName != code ? Text(code) : null,
selected: isSelected,
trailing: isSelected
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
@@ -921,6 +927,7 @@ class _QualityOption extends StatelessWidget {
icon: icon,
title: title,
subtitle: subtitle,
selected: isSelected,
trailing: isSelected
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
@@ -847,6 +847,7 @@ class _SearchProviderSelector extends ConsumerWidget {
ext.searchBehavior?.placeholder ??
ctx.l10n.extensionsCustomSearch,
),
selected: settings.searchProvider == ext.id,
trailing: settings.searchProvider == ext.id
? AppIcon(Icons.check_circle, color: colorScheme.primary)
: AppIcon(
@@ -1004,6 +1005,8 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
leading: AppIcon(Icons.block, color: colorScheme.error),
title: Text(ctx.l10n.extensionsHomeFeedOff),
subtitle: Text(ctx.l10n.extensionsHomeFeedOffSubtitle),
selected:
settings.homeFeedProvider == AppSettings.homeFeedProviderOff,
trailing:
settings.homeFeedProvider == AppSettings.homeFeedProviderOff
? AppIcon(Icons.check_circle, color: colorScheme.primary)
@@ -1030,6 +1033,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
leading: AppIcon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(ctx.l10n.extensionsHomeFeedUse(ext.displayName)),
selected: settings.homeFeedProvider == ext.id,
trailing: settings.homeFeedProvider == ext.id
? AppIcon(Icons.check_circle, color: colorScheme.primary)
: AppIcon(
@@ -539,6 +539,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
),
title: Text(context.l10n.storageAutomaticFolder),
subtitle: Text(context.l10n.storageAutomaticDownloadFolder),
selected: !isSafMode,
trailing: !isSafMode ? const AppIcon(Icons.check) : null,
onTap: () async {
Navigator.pop(ctx);
@@ -553,6 +554,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
leading: AppIcon(Icons.folder_open, color: colorScheme.primary),
title: Text(context.l10n.storageSafRecommended),
subtitle: Text(context.l10n.storageDownloadFolderHint),
selected: isSafMode,
trailing: isSafMode ? const AppIcon(Icons.check) : null,
onTap: () async {
Navigator.pop(ctx);
@@ -759,6 +761,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
leading: AppIcon(option.$4),
title: Text(option.$2),
subtitle: Text(option.$3),
selected: current == option.$1,
trailing: current == option.$1
? const AppIcon(Icons.check)
: null,
@@ -876,6 +879,7 @@ class _FolderOption extends StatelessWidget {
),
],
),
selected: isSelected,
trailing: isSelected
? AppIcon(Icons.check_circle, color: colorScheme.primary)
: AppIcon(Icons.circle_outlined, color: colorScheme.outline),
@@ -1557,6 +1557,7 @@ class _AutoScanOption extends StatelessWidget {
return AppSheetOption(
leading: AppIcon(icon),
title: Text(title),
selected: selected,
trailing: selected
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
+1
View File
@@ -245,6 +245,7 @@ class _LogScreenState extends State<LogScreen> {
for (final level in _levels)
SettingsItem(
title: level,
selected: level == _selectedLevel,
trailing: level == _selectedLevel
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
@@ -244,6 +244,7 @@ class LyricsSettingsPage extends ConsumerWidget {
leading: const AppIcon(Icons.audiotrack),
title: Text(context.l10n.lyricsModeEmbed),
subtitle: Text(context.l10n.lyricsModeEmbedSubtitle),
selected: current == 'embed',
trailing: current == 'embed' ? const AppIcon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLyricsMode('embed');
@@ -254,6 +255,7 @@ class LyricsSettingsPage extends ConsumerWidget {
leading: const AppIcon(Icons.insert_drive_file_outlined),
title: Text(context.l10n.lyricsModeExternal),
subtitle: Text(context.l10n.lyricsModeExternalSubtitle),
selected: current == 'external',
trailing: current == 'external'
? const AppIcon(Icons.check)
: null,
@@ -266,6 +268,7 @@ class LyricsSettingsPage extends ConsumerWidget {
leading: const AppIcon(Icons.library_music_outlined),
title: Text(context.l10n.lyricsModeBoth),
subtitle: Text(context.l10n.lyricsModeBothSubtitle),
selected: current == 'both',
trailing: current == 'both' ? const AppIcon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLyricsMode('both');
@@ -204,6 +204,7 @@ class MetadataSettingsPage extends ConsumerWidget {
: Icons.compress_outlined,
),
title: Text(_getEmbeddedCoverSizeLabel(context, maxDimension)),
selected: currentMaxDimension == maxDimension,
trailing: currentMaxDimension == maxDimension
? const AppIcon(Icons.check)
: null,
@@ -240,6 +241,7 @@ class MetadataSettingsPage extends ConsumerWidget {
leading: const AppIcon(Icons.segment_outlined),
title: Text(context.l10n.optionsArtistTagModeJoined),
subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle),
selected: currentMode == artistTagModeJoined,
trailing: currentMode == artistTagModeJoined
? const AppIcon(Icons.check)
: null,
@@ -256,6 +258,7 @@ class MetadataSettingsPage extends ConsumerWidget {
subtitle: Text(
context.l10n.optionsArtistTagModeSplitVorbisSubtitle,
),
selected: currentMode == artistTagModeSplitVorbis,
trailing: currentMode == artistTagModeSplitVorbis
? const AppIcon(Icons.check)
: null,
@@ -270,6 +273,7 @@ class MetadataSettingsPage extends ConsumerWidget {
leading: const AppIcon(Icons.person_outline),
title: Text(context.l10n.optionsArtistTagModePrimary),
subtitle: Text(context.l10n.optionsArtistTagModePrimarySubtitle),
selected: currentMode == artistTagModePrimary,
trailing: currentMode == artistTagModePrimary
? const AppIcon(Icons.check)
: null,
@@ -65,6 +65,7 @@ class _PlaybackSettingsPageState extends ConsumerState<PlaybackSettingsPage> {
icon: Icons.open_in_new,
title: context.l10n.libraryExternalPlayer,
subtitle: context.l10n.libraryExternalPlayerSubtitle,
selected: settings.playerMode == 'external',
trailing: settings.playerMode == 'external'
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
@@ -76,6 +77,7 @@ class _PlaybackSettingsPageState extends ConsumerState<PlaybackSettingsPage> {
icon: Icons.play_circle_outline,
title: context.l10n.libraryBuiltInPreviewPlayer,
subtitle: context.l10n.libraryBuiltInPreviewPlayerSubtitle,
selected: settings.playerMode == 'internal',
trailing: settings.playerMode == 'internal'
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
+8 -5
View File
@@ -597,11 +597,14 @@ class _DonationGoalProgress extends StatelessWidget {
),
if (goal.showPercentage) ...[
const SizedBox(width: 8),
Text(
percentage,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
// The progress bar already reads the percentage as its value.
ExcludeSemantics(
child: Text(
percentage,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
+3 -4
View File
@@ -66,7 +66,8 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
}
static const lightAccent = Color.fromRGBO(204, 46, 51, 1);
static const darkAccent = Color.fromRGBO(224, 61, 60, 1);
// iOS dark systemRed: accent text stays readable on #1C1C1E cells.
static const darkAccent = Color.fromRGBO(255, 69, 58, 1);
static Color accentColor(MornyeAccent accent, Brightness brightness) {
final dark = brightness == Brightness.dark;
@@ -223,9 +224,7 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
brightness: brightness,
).copyWith(
primary: accent,
onPrimary:
selectedAccent == MornyeAccent.red ||
accent.computeLuminance() < 0.179
onPrimary: accent.computeLuminance() < 0.179
? Colors.white
: Colors.black,
primaryContainer: grouped,
+1
View File
@@ -220,6 +220,7 @@ class _AccountFormDialogState extends State<_AccountFormDialog> {
selected: option == _choices[key],
child: AppSheetOption(
title: Text(option),
selected: option == _choices[key],
trailing: option == _choices[key]
? const Icon(Icons.check)
: null,
+20 -3
View File
@@ -354,6 +354,7 @@ class AppSheetOption extends StatelessWidget {
this.onTap,
this.contentPadding,
this.enabled = true,
this.selected,
});
final Widget title;
@@ -364,21 +365,37 @@ class AppSheetOption extends StatelessWidget {
final EdgeInsetsGeometry? contentPadding;
final bool enabled;
/// Announces a checkmark choice's state to screen readers; null for
/// options that are actions rather than choices.
final bool? selected;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
if (!context.isMornye) {
return ListTile(
title: title,
title: this.title,
subtitle: subtitle,
leading: leading == null ? null : symbolIconWidget(leading!),
trailing: trailing == null ? null : symbolIconWidget(trailing!),
onTap: onTap,
contentPadding: contentPadding,
enabled: enabled,
// The checkmark already shows the choice; selection only feeds
// screen readers, so the row keeps its ordinary text color.
selected: selected ?? false,
selectedColor: scheme.onSurface,
);
}
final theme = Theme.of(context);
final scheme = theme.colorScheme;
// Annotated on the title so it merges into the option's button node.
final title = selected == null
? this.title
: Semantics(
selected: selected,
inMutuallyExclusiveGroup: true,
child: this.title,
);
Widget adaptedIcon(Widget widget, double size) {
final (icon, color) = switch (widget) {
Icon(:final icon?, :final color) => (icon, color),
+25 -19
View File
@@ -176,28 +176,34 @@ class AppIconButton extends StatelessWidget {
data: IconThemeData(size: iconSize),
child: icon,
);
final button = MaterialExpressiveScope(
child: M3EIconButton(
icon: sized(icon),
selectedIcon: selectedIcon == null ? null : sized(selectedIcon),
semanticLabel: tooltip,
onPressed: onPressed,
isSelected: isSelected,
variant: switch (_variant) {
_AppIconButtonVariant.standard => M3EIconButtonVariant.standard,
_AppIconButtonVariant.filled => M3EIconButtonVariant.filled,
_AppIconButtonVariant.tonal => M3EIconButtonVariant.tonal,
_AppIconButtonVariant.outlined => M3EIconButtonVariant.outlined,
},
decoration: M3EIconButtonDecoration(
foregroundColor: foreground,
backgroundColor: style?.backgroundColor,
overlayColor: style?.overlayColor,
side: style?.side,
// M3EIconButton labels an outer node and leaves its inner tap target
// unnamed; merge them so screen readers find one labeled button.
final button = MergeSemantics(
child: MaterialExpressiveScope(
child: M3EIconButton(
icon: sized(icon),
selectedIcon: selectedIcon == null ? null : sized(selectedIcon),
semanticLabel: tooltip,
onPressed: onPressed,
isSelected: isSelected,
variant: switch (_variant) {
_AppIconButtonVariant.standard => M3EIconButtonVariant.standard,
_AppIconButtonVariant.filled => M3EIconButtonVariant.filled,
_AppIconButtonVariant.tonal => M3EIconButtonVariant.tonal,
_AppIconButtonVariant.outlined => M3EIconButtonVariant.outlined,
},
decoration: M3EIconButtonDecoration(
foregroundColor: foreground,
backgroundColor: style?.backgroundColor,
overlayColor: style?.overlayColor,
side: style?.side,
),
),
),
);
final message = tooltip;
return message == null ? button : Tooltip(message: message, child: button);
return message == null
? button
: Tooltip(message: message, excludeFromSemantics: true, child: button);
}
}
+88 -15
View File
@@ -17,6 +17,9 @@ class AppSlider extends StatelessWidget {
this.semanticFormatterCallback,
this.trackThickness,
this.thumbLength,
this.centered = false,
this.vertical = false,
this.semanticLabel,
});
final double value;
@@ -36,10 +39,29 @@ class AppSlider extends StatelessWidget {
/// the handle stays in proportion.
final double? thumbLength;
/// Grows the active track from the middle of the range, for bipolar values
/// such as gain or balance.
final bool centered;
/// Lays the slider out bottom (min) to top (max).
final bool vertical;
/// What the slider adjusts, read before its value; [label] only names the
/// value bubble.
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final slider = _buildSlider(context);
if (semanticLabel == null) return slider;
return MergeSemantics(
child: Semantics(label: semanticLabel, child: slider),
);
}
Widget _buildSlider(BuildContext context) {
if (!materialExpressiveEnabled(context)) {
return Slider(
final slider = Slider(
value: value,
min: min,
max: max,
@@ -49,21 +71,72 @@ class AppSlider extends StatelessWidget {
onChanged: onChanged,
onChangeEnd: onChangeEnd,
);
return vertical ? RotatedBox(quarterTurns: 3, child: slider) : slider;
}
return MaterialExpressiveScope(
child: M3ESlider(
value: value,
min: min,
max: max,
divisions: divisions,
label: label,
semanticFormatterCallback: semanticFormatterCallback,
trackThickness: trackThickness,
thumbLength: thumbLength,
enabled: onChanged != null,
onChanged: onChanged,
onChangeEnd: onChangeEnd,
),
final enabled = onChanged != null;
final slider = MaterialExpressiveScope(
child: switch ((vertical, centered)) {
(true, true) => M3ESlider.verticalCentered(
value: value,
min: min,
max: max,
divisions: divisions,
label: label,
semanticFormatterCallback: semanticFormatterCallback,
trackThickness: trackThickness,
thumbLength: thumbLength,
enabled: enabled,
onChanged: onChanged,
onChangeEnd: onChangeEnd,
),
(true, false) => M3ESlider.vertical(
value: value,
min: min,
max: max,
divisions: divisions,
label: label,
semanticFormatterCallback: semanticFormatterCallback,
trackThickness: trackThickness,
thumbLength: thumbLength,
enabled: enabled,
onChanged: onChanged,
onChangeEnd: onChangeEnd,
),
(false, true) => M3ESlider.centered(
value: value,
min: min,
max: max,
divisions: divisions,
label: label,
semanticFormatterCallback: semanticFormatterCallback,
trackThickness: trackThickness,
thumbLength: thumbLength,
enabled: enabled,
onChanged: onChanged,
onChangeEnd: onChangeEnd,
),
(false, false) => M3ESlider(
value: value,
min: min,
max: max,
divisions: divisions,
label: label,
semanticFormatterCallback: semanticFormatterCallback,
trackThickness: trackThickness,
thumbLength: thumbLength,
enabled: enabled,
onChanged: onChanged,
onChangeEnd: onChangeEnd,
),
},
);
// The expressive slider lays out 44 dp across; stretch it to the 48 dp
// touch target the standard Slider already has.
return vertical
? ConstrainedBox(
constraints: const BoxConstraints(minWidth: 48),
child: slider,
)
: SizedBox(height: 48, child: slider);
}
}
+14 -11
View File
@@ -145,17 +145,20 @@ class AppSliverHeader extends StatelessWidget {
right: tokens.gapLg,
bottom: tokens.gapLg,
),
title: Text(
title,
// The title grows as the header expands; without a cap a long
// localized title overflowed instead of ellipsizing.
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
letterSpacing: context.isMornye ? -0.7 * expandRatio : null,
color: colorScheme.onSurface,
title: Semantics(
header: true,
child: Text(
title,
// The title grows as the header expands; without a cap a long
// localized title overflowed instead of ellipsizing.
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
letterSpacing: context.isMornye ? -0.7 * expandRatio : null,
color: colorScheme.onSurface,
),
),
),
);
+2
View File
@@ -47,6 +47,7 @@ class _AutoMixSettingsState extends ConsumerState<AutoMixSettings> {
AutoMixEffect.custom => Icons.tune,
}),
title: Text(names[effect]!),
selected: effect == current,
trailing: effect == current
? Icon(
Icons.check,
@@ -101,6 +102,7 @@ class _AutoMixSettingsState extends ConsumerState<AutoMixSettings> {
max: max,
divisions: divisions,
label: value.toStringAsFixed(2),
semanticLabel: label,
onChanged: enabled ? draft : null,
onChangeEnd: enabled ? save : null,
),
+24 -20
View File
@@ -60,26 +60,30 @@ class ExpressiveIconButton extends StatelessWidget {
}
return Tooltip(
message: tooltip,
child: MaterialExpressiveScope(
child: M3EIconButton(
icon: IconTheme.merge(
data: IconThemeData(size: iconSize),
child: icon,
),
semanticLabel: tooltip,
onPressed: onPressed,
isSelected: selected,
// Widens with an expressive button group's squish.
visualSize: Size(
ExpressiveButtonGroupSlot.widthOf(context) ?? size,
size,
),
variant: backgroundColor == null
? M3EIconButtonVariant.standard
: M3EIconButtonVariant.filled,
decoration: M3EIconButtonDecoration(
foregroundColor: foreground,
backgroundColor: background,
excludeFromSemantics: true,
// One labeled node instead of a label plus an unnamed tap target.
child: MergeSemantics(
child: MaterialExpressiveScope(
child: M3EIconButton(
icon: IconTheme.merge(
data: IconThemeData(size: iconSize),
child: icon,
),
semanticLabel: tooltip,
onPressed: onPressed,
isSelected: selected,
// Widens with an expressive button group's squish.
visualSize: Size(
ExpressiveButtonGroupSlot.widthOf(context) ?? size,
size,
),
variant: backgroundColor == null
? M3EIconButtonVariant.standard
: M3EIconButtonVariant.filled,
decoration: M3EIconButtonDecoration(
foregroundColor: foreground,
backgroundColor: background,
),
),
),
),
+28 -24
View File
@@ -206,30 +206,34 @@ class _ExpressivePlaybackTransportState
);
return Tooltip(
message: tooltip,
child: MaterialExpressiveScope(
child: M3EIconButton(
icon: sized(icon),
selectedIcon: selectedIcon == null ? null : sized(selectedIcon),
isSelected: selected,
semanticLabel: tooltip,
onPressed: onPressed,
size: widget.compact ? M3EIconButtonSize.md : M3EIconButtonSize.lg,
shape: round
? M3EIconButtonShapeVariant.round
: M3EIconButtonShapeVariant.square,
variant: M3EIconButtonVariant.filled,
matchParentConstraints: true,
inflateHitTarget: false,
decoration: M3EIconButtonDecoration(
foregroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.disabled)
? scheme.onSurface.withValues(alpha: 0.38)
: foreground,
),
backgroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.disabled)
? scheme.onSurface.withValues(alpha: 0.12)
: background,
excludeFromSemantics: true,
// One labeled node instead of a label plus an unnamed tap target.
child: MergeSemantics(
child: MaterialExpressiveScope(
child: M3EIconButton(
icon: sized(icon),
selectedIcon: selectedIcon == null ? null : sized(selectedIcon),
isSelected: selected,
semanticLabel: tooltip,
onPressed: onPressed,
size: widget.compact ? M3EIconButtonSize.md : M3EIconButtonSize.lg,
shape: round
? M3EIconButtonShapeVariant.round
: M3EIconButtonShapeVariant.square,
variant: M3EIconButtonVariant.filled,
matchParentConstraints: true,
inflateHitTarget: false,
decoration: M3EIconButtonDecoration(
foregroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.disabled)
? scheme.onSurface.withValues(alpha: 0.38)
: foreground,
),
backgroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.disabled)
? scheme.onSurface.withValues(alpha: 0.12)
: background,
),
),
),
),
+1
View File
@@ -51,6 +51,7 @@ class LyricsSizeSetting extends ConsumerWidget {
max: 1.4,
divisions: 12,
label: percent,
semanticLabel: context.l10n.lyricsTextSize,
semanticFormatterCallback: (value) => '${(value * 100).round()}%',
onChanged: (value) =>
notifier.setLyricsTextScale(value, persist: false),
+5 -1
View File
@@ -25,7 +25,11 @@ class MornyeSegmentedControl extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final height = MediaQuery.textScalerOf(context).scale(15) + 36;
// At least 56 so each segment keeps a 48 dp target inside the pill inset.
final height = (MediaQuery.textScalerOf(context).scale(15) + 36).clamp(
56.0,
double.infinity,
);
final contrastBlocked = ref.watch(
mornyeGlassContrastBlockedProvider(MediaQuery.highContrastOf(context)),
);
+2
View File
@@ -373,6 +373,8 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
selected: _mode == mode,
selectedColor: scheme.onSurface,
trailing: _mode == mode
? Icon(Icons.check, color: scheme.primary)
: null,
+171 -149
View File
@@ -409,6 +409,10 @@ class SettingsItem extends StatelessWidget {
final VoidCallback? onTap;
final bool showDivider;
/// Announces a checkmark choice's state to screen readers; null for rows
/// that are not choices.
final bool? selected;
const SettingsItem({
super.key,
this.icon,
@@ -421,6 +425,7 @@ class SettingsItem extends StatelessWidget {
this.trailing,
this.onTap,
this.showDivider = true,
this.selected,
});
@override
@@ -431,84 +436,90 @@ class SettingsItem extends StatelessWidget {
final content = Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: Colors.transparent,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: context.tokens.rowPaddingH,
vertical: context.tokens.rowPaddingV,
),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: context.tokens.rowMinHeight,
Semantics(
selected: selected,
inMutuallyExclusiveGroup: selected != null,
child: InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: Colors.transparent,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: context.tokens.rowPaddingH,
vertical: context.tokens.rowPaddingV,
),
child: Row(
children: [
if (showIcon) ...[
AppIcon.adaptive(
icon!,
color: iconColor ?? colorScheme.onSurfaceVariant,
size: 24,
),
SizedBox(width: context.tokens.rowIconGap),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
title,
style: Theme.of(context).textTheme.bodyLarge,
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: context.tokens.rowMinHeight,
),
child: Row(
children: [
if (showIcon) ...[
AppIcon.adaptive(
icon!,
color: iconColor ?? colorScheme.onSurfaceVariant,
size: 24,
),
SizedBox(width: context.tokens.rowIconGap),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
title,
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
if (titleTrailing != null) ...[
const SizedBox(width: 8),
titleTrailing!,
if (titleTrailing != null) ...[
const SizedBox(width: 8),
titleTrailing!,
],
],
],
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style:
(context.isMornye
? Theme.of(context).textTheme.bodySmall
: Theme.of(
context,
).textTheme.bodyMedium)
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style:
(context.isMornye
? Theme.of(
context,
).textTheme.bodySmall
: Theme.of(
context,
).textTheme.bodyMedium)
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
if (footer != null) ...[
const SizedBox(height: 8),
footer!,
],
],
if (footer != null) ...[
const SizedBox(height: 8),
footer!,
],
],
),
),
if (trailing != null) ...[
const SizedBox(width: 8),
trailing!,
] else if (onTap != null) ...[
const SizedBox(width: 8),
AppIcon(
context.isMornye
? CupertinoIcons.chevron_forward
: Icons.chevron_right,
size: context.tokens.rowChevronSize,
color: colorScheme.onSurfaceVariant,
),
),
if (trailing != null) ...[
const SizedBox(width: 8),
trailing!,
] else if (onTap != null) ...[
const SizedBox(width: 8),
AppIcon(
context.isMornye
? CupertinoIcons.chevron_forward
: Icons.chevron_right,
size: context.tokens.rowChevronSize,
color: colorScheme.onSurfaceVariant,
),
],
],
],
),
),
),
),
@@ -561,75 +572,77 @@ class SettingsSwitchItem extends StatelessWidget {
final content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
opacity: isDisabled ? 0.5 : 1.0,
child: InkWell(
onTap: isDisabled ? null : () => onChanged!(!value),
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: Colors.transparent,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: context.tokens.rowPaddingH,
vertical: context.tokens.rowPaddingVCompact,
),
child: Row(
children: [
if (showIcon) ...[
AppIcon(
icon,
color: isDisabled
? colorScheme.outline
: colorScheme.onSurfaceVariant,
size: 24,
),
SizedBox(width: context.tokens.rowIconGap),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
title,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: isDisabled
? colorScheme.outline
: null,
),
),
),
if (titleTrailing != null) ...[
const SizedBox(width: 8),
titleTrailing!,
],
],
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: isDisabled
? colorScheme.outline
: colorScheme.onSurfaceVariant,
// One focus stop that reads the title, subtitle and on/off state.
MergeSemantics(
child: Opacity(
opacity: isDisabled ? 0.5 : 1.0,
child: InkWell(
onTap: isDisabled ? null : () => onChanged!(!value),
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: Colors.transparent,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: context.tokens.rowPaddingH,
vertical: context.tokens.rowPaddingVCompact,
),
child: Row(
children: [
if (showIcon) ...[
AppIcon(
icon,
color: isDisabled
? colorScheme.outline
: colorScheme.onSurfaceVariant,
size: 24,
),
SizedBox(width: context.tokens.rowIconGap),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
title,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: isDisabled
? colorScheme.outline
: null,
),
),
),
if (titleTrailing != null) ...[
const SizedBox(width: 8),
titleTrailing!,
],
],
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: isDisabled
? colorScheme.outline
: colorScheme.onSurfaceVariant,
),
),
],
],
],
),
),
),
const SizedBox(width: 8),
AppSwitch(
value: value,
semanticLabel: title,
onChanged: isDisabled ? null : onChanged,
),
],
const SizedBox(width: 8),
AppSwitch(
value: value,
onChanged: isDisabled ? null : onChanged,
),
],
),
),
),
),
@@ -677,7 +690,7 @@ class SettingsSectionHeader extends StatelessWidget {
final inset = settingsHorizontalInset(constraints.maxWidth) + 16;
return Padding(
padding: EdgeInsets.fromLTRB(inset, 24, inset, 8),
child: label,
child: Semantics(header: true, child: label),
);
},
);
@@ -744,7 +757,11 @@ class SettingsChoiceChip extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: foreground,
// Like an iOS segment, the filled chip marks the choice; accent text
// on Mornye's grey fill falls below readable contrast.
color: context.isMornye && isSelected
? colorScheme.onSurface
: foreground,
),
);
@@ -778,18 +795,23 @@ class SettingsChoiceChip extends StatelessWidget {
: colorScheme.primaryContainer)
: unselectedColor,
borderRadius: borderRadius,
child: InkWell(
onTap: onTap,
borderRadius: borderRadius,
child: ConstrainedBox(
// Keeps every chip a comfortable tap target regardless of layout.
constraints: BoxConstraints(minHeight: tokens.minTouchTarget),
child: Padding(
padding: EdgeInsets.symmetric(
vertical: tokens.gapMd,
horizontal: tokens.gapMd,
child: Semantics(
button: true,
selected: isSelected,
inMutuallyExclusiveGroup: true,
child: InkWell(
onTap: onTap,
borderRadius: borderRadius,
child: ConstrainedBox(
// Keeps every chip a comfortable tap target regardless of layout.
constraints: BoxConstraints(minHeight: tokens.minTouchTarget),
child: Padding(
padding: EdgeInsets.symmetric(
vertical: tokens.gapMd,
horizontal: tokens.gapMd,
),
child: content,
),
child: content,
),
),
),
+10 -3
View File
@@ -49,9 +49,11 @@ void main() {
}
for (final brightness in Brightness.values) {
for (final accent in MornyeAccent.values.where(
(accent) => accent != MornyeAccent.red,
)) {
// Grouped settings cells, where tinted action rows sit.
final cell = brightness == Brightness.dark
? const Color(0xff1c1c1e)
: Colors.white;
for (final accent in MornyeAccent.values) {
final scheme = MornyeTheme.build(
brightness,
accent: accent,
@@ -61,6 +63,11 @@ void main() {
greaterThanOrEqualTo(4.5),
reason: '$accent on $brightness',
);
expect(
contrast(scheme.primary, cell),
greaterThanOrEqualTo(4.5),
reason: '$accent on a $brightness grouped cell',
);
expect(
contrast(scheme.onPrimary, scheme.primary),
greaterThanOrEqualTo(4.5),