diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 97a2328f..1162ff76 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1285,11 +1285,14 @@ class _HomeTabState extends ConsumerState 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), + ), ), ); diff --git a/lib/screens/home_tab_recent.dart b/lib/screens/home_tab_recent.dart index dab580b9..c8522df4 100644 --- a/lib/screens/home_tab_recent.dart +++ b/lib/screens/home_tab_recent.dart @@ -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) diff --git a/lib/screens/mornye_library_screen.dart b/lib/screens/mornye_library_screen.dart index 574ae0de..eb89a81a 100644 --- a/lib/screens/mornye_library_screen.dart +++ b/lib/screens/mornye_library_screen.dart @@ -153,6 +153,8 @@ class _MornyeLibraryScreenState extends ConsumerState { ]) 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 { ]) 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 { 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), ), ), ), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 2b23c491..aa90c8fb 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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'; diff --git a/lib/screens/queue_tab_sections.dart b/lib/screens/queue_tab_sections.dart index a0bce101..216739e4 100644 --- a/lib/screens/queue_tab_sections.dart +++ b/lib/screens/queue_tab_sections.dart @@ -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, + ), ), ), ), diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index e4142cec..f842af5a 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -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, diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index a62424a9..a3cdab76 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -432,6 +432,7 @@ class _DownloadSettingsPageState extends ConsumerState { 'opus' => context.l10n.downloadAutoConvertOpusSubtitle, _ => context.l10n.downloadAutoConvertMp3Subtitle, }), + selected: normalizedCurrent == format, trailing: normalizedCurrent == format ? AppIcon( Icons.check, @@ -471,6 +472,7 @@ class _DownloadSettingsPageState extends ConsumerState { 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 { 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 { 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 { ? 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, diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 8bdd5fd5..89286a3d 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -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( diff --git a/lib/screens/settings/files_settings_page.dart b/lib/screens/settings/files_settings_page.dart index 5c953574..9d7f6d70 100644 --- a/lib/screens/settings/files_settings_page.dart +++ b/lib/screens/settings/files_settings_page.dart @@ -539,6 +539,7 @@ class _FilesSettingsPageState extends ConsumerState { ), 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 { 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 { 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), diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 73900119..22a5983d 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -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, diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index 7e158897..51fcf856 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -245,6 +245,7 @@ class _LogScreenState extends State { for (final level in _levels) SettingsItem( title: level, + selected: level == _selectedLevel, trailing: level == _selectedLevel ? AppIcon(Icons.check, color: colorScheme.primary) : null, diff --git a/lib/screens/settings/lyrics_settings_page.dart b/lib/screens/settings/lyrics_settings_page.dart index 27786b75..cc2846d1 100644 --- a/lib/screens/settings/lyrics_settings_page.dart +++ b/lib/screens/settings/lyrics_settings_page.dart @@ -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'); diff --git a/lib/screens/settings/metadata_settings_page.dart b/lib/screens/settings/metadata_settings_page.dart index 25219d07..3f01e074 100644 --- a/lib/screens/settings/metadata_settings_page.dart +++ b/lib/screens/settings/metadata_settings_page.dart @@ -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, diff --git a/lib/screens/settings/playback_settings_page.dart b/lib/screens/settings/playback_settings_page.dart index 0d53b4a6..4c6af630 100644 --- a/lib/screens/settings/playback_settings_page.dart +++ b/lib/screens/settings/playback_settings_page.dart @@ -65,6 +65,7 @@ class _PlaybackSettingsPageState extends ConsumerState { 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 { 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, diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 4fe63968..13f79a0d 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -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, + ), ), ), ], diff --git a/lib/theme/mornye_theme.dart b/lib/theme/mornye_theme.dart index a0125319..afc762d9 100644 --- a/lib/theme/mornye_theme.dart +++ b/lib/theme/mornye_theme.dart @@ -66,7 +66,8 @@ class MornyeTheme extends ThemeExtension { } 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 { brightness: brightness, ).copyWith( primary: accent, - onPrimary: - selectedAccent == MornyeAccent.red || - accent.computeLuminance() < 0.179 + onPrimary: accent.computeLuminance() < 0.179 ? Colors.white : Colors.black, primaryContainer: grouped, diff --git a/lib/utils/extension_action_forms.dart b/lib/utils/extension_action_forms.dart index cd263d0c..878f701e 100644 --- a/lib/utils/extension_action_forms.dart +++ b/lib/utils/extension_action_forms.dart @@ -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, diff --git a/lib/widgets/app_bottom_sheet.dart b/lib/widgets/app_bottom_sheet.dart index a308e3ab..7441ae1a 100644 --- a/lib/widgets/app_bottom_sheet.dart +++ b/lib/widgets/app_bottom_sheet.dart @@ -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), diff --git a/lib/widgets/app_icon_button.dart b/lib/widgets/app_icon_button.dart index c31a2b02..c4cbca63 100644 --- a/lib/widgets/app_icon_button.dart +++ b/lib/widgets/app_icon_button.dart @@ -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); } } diff --git a/lib/widgets/app_slider.dart b/lib/widgets/app_slider.dart index 5ca79b80..894b769a 100644 --- a/lib/widgets/app_slider.dart +++ b/lib/widgets/app_slider.dart @@ -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); } } diff --git a/lib/widgets/app_sliver_header.dart b/lib/widgets/app_sliver_header.dart index 514c4afc..f2bf3bf3 100644 --- a/lib/widgets/app_sliver_header.dart +++ b/lib/widgets/app_sliver_header.dart @@ -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, + ), ), ), ); diff --git a/lib/widgets/automix_settings.dart b/lib/widgets/automix_settings.dart index b759f4f5..00d1e5dd 100644 --- a/lib/widgets/automix_settings.dart +++ b/lib/widgets/automix_settings.dart @@ -47,6 +47,7 @@ class _AutoMixSettingsState extends ConsumerState { 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 { max: max, divisions: divisions, label: value.toStringAsFixed(2), + semanticLabel: label, onChanged: enabled ? draft : null, onChangeEnd: enabled ? save : null, ), diff --git a/lib/widgets/expressive_icon_button.dart b/lib/widgets/expressive_icon_button.dart index ded9ef82..3cdb8e00 100644 --- a/lib/widgets/expressive_icon_button.dart +++ b/lib/widgets/expressive_icon_button.dart @@ -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, + ), ), ), ), diff --git a/lib/widgets/expressive_playback_transport.dart b/lib/widgets/expressive_playback_transport.dart index ef1e2b48..1aa1bfa5 100644 --- a/lib/widgets/expressive_playback_transport.dart +++ b/lib/widgets/expressive_playback_transport.dart @@ -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, + ), ), ), ), diff --git a/lib/widgets/lyrics_size_setting.dart b/lib/widgets/lyrics_size_setting.dart index 5110dab2..d954507f 100644 --- a/lib/widgets/lyrics_size_setting.dart +++ b/lib/widgets/lyrics_size_setting.dart @@ -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), diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 18709c9e..e54c7bbd 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -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)), ); diff --git a/lib/widgets/re_enrich_field_dialog.dart b/lib/widgets/re_enrich_field_dialog.dart index 22af4201..71585851 100644 --- a/lib/widgets/re_enrich_field_dialog.dart +++ b/lib/widgets/re_enrich_field_dialog.dart @@ -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, diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index 3046245f..bfb14e24 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -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, ), ), ), diff --git a/test/appearance_settings_test.dart b/test/appearance_settings_test.dart index c0670403..cea9dcf3 100644 --- a/test/appearance_settings_test.dart +++ b/test/appearance_settings_test.dart @@ -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),