From 6742fe886f01f8173e95421e09c86fd88df4bd95 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 4 Aug 2026 16:40:48 +0700 Subject: [PATCH] fix(a11y): label interactive controls --- lib/l10n/app_localizations.dart | 12 +++ lib/l10n/app_localizations_de.dart | 6 ++ lib/l10n/app_localizations_en.dart | 6 ++ lib/l10n/app_localizations_es.dart | 12 +++ lib/l10n/app_localizations_fr.dart | 6 ++ lib/l10n/app_localizations_id.dart | 6 ++ lib/l10n/app_localizations_ja.dart | 6 ++ lib/l10n/app_localizations_ko.dart | 6 ++ lib/l10n/app_localizations_pt.dart | 12 +++ lib/l10n/app_localizations_ru.dart | 6 ++ lib/l10n/app_localizations_tr.dart | 6 ++ lib/l10n/app_localizations_uk.dart | 6 ++ lib/l10n/arb/app_de.arb | 2 + lib/l10n/arb/app_en.arb | 8 ++ lib/l10n/arb/app_es.arb | 4 +- lib/l10n/arb/app_es_ES.arb | 2 + lib/l10n/arb/app_fr.arb | 2 + lib/l10n/arb/app_id.arb | 8 ++ lib/l10n/arb/app_ja.arb | 2 + lib/l10n/arb/app_ko.arb | 2 + lib/l10n/arb/app_pt.arb | 4 +- lib/l10n/arb/app_pt_PT.arb | 2 + lib/l10n/arb/app_ru.arb | 2 + lib/l10n/arb/app_tr.arb | 2 + lib/l10n/arb/app_uk.arb | 2 + lib/screens/library_tracks_folder_screen.dart | 1 + lib/screens/now_playing_screen.dart | 6 ++ lib/screens/queue_tab_collection_items.dart | 30 ++++-- lib/screens/queue_tab_item_widgets.dart | 20 ++-- lib/screens/repo_tab.dart | 6 +- lib/widgets/mini_player.dart | 5 + lib/widgets/selection_action_button.dart | 74 +++++++------ pubspec.lock | 2 +- pubspec.yaml | 1 + test/design_system_test.dart | 100 ++++++++++++++++++ 35 files changed, 319 insertions(+), 58 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d5e0fcc6..aaa0241f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7434,6 +7434,18 @@ abstract class AppLocalizations { /// **'Up next'** String get nowPlayingUpNext; + /// Tooltip for the previous-track playback control + /// + /// In en, this message translates to: + /// **'Previous track'** + String get nowPlayingPreviousTrack; + + /// Tooltip for the next-track playback control + /// + /// In en, this message translates to: + /// **'Next track'** + String get nowPlayingNextTrack; + /// Menu item and section title for track metadata details /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 88d985b5..140cf693 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4580,6 +4580,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get nowPlayingUpNext => 'Up next'; + @override + String get nowPlayingPreviousTrack => 'Vorheriger Titel'; + + @override + String get nowPlayingNextTrack => 'Nächster Titel'; + @override String get nowPlayingDetails => 'Details'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a9c025b0..b790b9f6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4535,6 +4535,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get nowPlayingUpNext => 'Up next'; + @override + String get nowPlayingPreviousTrack => 'Previous track'; + + @override + String get nowPlayingNextTrack => 'Next track'; + @override String get nowPlayingDetails => 'Details'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 9b5b571e..f667ed1e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4529,6 +4529,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get nowPlayingUpNext => 'Up next'; + @override + String get nowPlayingPreviousTrack => 'Pista anterior'; + + @override + String get nowPlayingNextTrack => 'Pista siguiente'; + @override String get nowPlayingDetails => 'Details'; @@ -9255,6 +9261,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get nowPlayingUpNext => 'Up next'; + @override + String get nowPlayingPreviousTrack => 'Pista anterior'; + + @override + String get nowPlayingNextTrack => 'Pista siguiente'; + @override String get nowPlayingDetails => 'Details'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 24a30cda..ccbc9c83 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4649,6 +4649,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get nowPlayingUpNext => 'À suivre'; + @override + String get nowPlayingPreviousTrack => 'Piste précédente'; + + @override + String get nowPlayingNextTrack => 'Piste suivante'; + @override String get nowPlayingDetails => 'Détails'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index f3f653c5..a522787e 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -4533,6 +4533,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get nowPlayingUpNext => 'Up next'; + @override + String get nowPlayingPreviousTrack => 'Lagu sebelumnya'; + + @override + String get nowPlayingNextTrack => 'Lagu berikutnya'; + @override String get nowPlayingDetails => 'Details'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 9cd8b080..b1008ee3 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -4524,6 +4524,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get nowPlayingUpNext => 'Up next'; + @override + String get nowPlayingPreviousTrack => '前の曲'; + + @override + String get nowPlayingNextTrack => '次の曲'; + @override String get nowPlayingDetails => 'Details'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 8710bc4c..7be832a4 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4409,6 +4409,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get nowPlayingUpNext => '다음 곡'; + @override + String get nowPlayingPreviousTrack => '이전 곡'; + + @override + String get nowPlayingNextTrack => '다음 곡'; + @override String get nowPlayingDetails => '트랙 세부 정보'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 8bfeeaa6..4c92b07d 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4529,6 +4529,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get nowPlayingUpNext => 'Up next'; + @override + String get nowPlayingPreviousTrack => 'Faixa anterior'; + + @override + String get nowPlayingNextTrack => 'Próxima faixa'; + @override String get nowPlayingDetails => 'Details'; @@ -9220,6 +9226,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get nowPlayingUpNext => 'Up next'; + @override + String get nowPlayingPreviousTrack => 'Faixa anterior'; + + @override + String get nowPlayingNextTrack => 'Próxima faixa'; + @override String get nowPlayingDetails => 'Details'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 23145da2..4d48f8c2 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4566,6 +4566,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get nowPlayingUpNext => 'Up next'; + @override + String get nowPlayingPreviousTrack => 'Предыдущий трек'; + + @override + String get nowPlayingNextTrack => 'Следующий трек'; + @override String get nowPlayingDetails => 'Details'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index b8722a6a..f2505e5f 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -4565,6 +4565,12 @@ class AppLocalizationsTr extends AppLocalizations { @override String get nowPlayingUpNext => 'Up next'; + @override + String get nowPlayingPreviousTrack => 'Önceki parça'; + + @override + String get nowPlayingNextTrack => 'Sonraki parça'; + @override String get nowPlayingDetails => 'Details'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index ccdb3b1d..fa799746 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4583,6 +4583,12 @@ class AppLocalizationsUk extends AppLocalizations { @override String get nowPlayingUpNext => 'Up next'; + @override + String get nowPlayingPreviousTrack => 'Попередній трек'; + + @override + String get nowPlayingNextTrack => 'Наступний трек'; + @override String get nowPlayingDetails => 'Details'; diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 88ce5e5b..5de4dc03 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -4395,6 +4395,8 @@ "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, + "nowPlayingPreviousTrack": "Vorheriger Titel", + "nowPlayingNextTrack": "Nächster Titel", "logAutoScrollOff": "Auto-Scrollen AUS", "trackConvertFailed": "Konvertierung fehlgeschlagen", "@trackConvertFailed": { diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 02f97357..34582816 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5861,6 +5861,14 @@ "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, + "nowPlayingPreviousTrack": "Previous track", + "@nowPlayingPreviousTrack": { + "description": "Tooltip for the previous-track playback control" + }, + "nowPlayingNextTrack": "Next track", + "@nowPlayingNextTrack": { + "description": "Tooltip for the next-track playback control" + }, "nowPlayingDetails": "Details", "@nowPlayingDetails": { "description": "Menu item and section title for track metadata details" diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 77abaa9d..46088232 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -4286,5 +4286,7 @@ "settingsGroupContent": "Contenido y metadatos", "settingsGroupDownloads": "Descargas y archivos", "settingsGroupSystem": "Sistema", - "settingsGroupHelp": "Información y soporte" + "settingsGroupHelp": "Información y soporte", + "nowPlayingPreviousTrack": "Pista anterior", + "nowPlayingNextTrack": "Pista siguiente" } diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index eccaa24f..a0cbbd5e 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -5502,6 +5502,8 @@ "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, + "nowPlayingPreviousTrack": "Pista anterior", + "nowPlayingNextTrack": "Pista siguiente", "nowPlayingDetails": "Details", "@nowPlayingDetails": { "description": "Menu item and section title for track metadata details" diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 65ec814a..54902c54 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -4395,6 +4395,8 @@ "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, + "nowPlayingPreviousTrack": "Piste précédente", + "nowPlayingNextTrack": "Piste suivante", "logAutoScrollOff": "Défilement automatique désactivé", "trackConvertFailed": "Échec de la conversion", "@trackConvertFailed": { diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 578e60be..c3df424c 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -4440,6 +4440,14 @@ "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, + "nowPlayingPreviousTrack": "Lagu sebelumnya", + "@nowPlayingPreviousTrack": { + "description": "Tooltip for the previous-track playback control" + }, + "nowPlayingNextTrack": "Lagu berikutnya", + "@nowPlayingNextTrack": { + "description": "Tooltip for the next-track playback control" + }, "logAutoScrollOff": "Auto-scroll OFF", "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index 92837503..a82497b6 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -4395,6 +4395,8 @@ "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, + "nowPlayingPreviousTrack": "前の曲", + "nowPlayingNextTrack": "次の曲", "logAutoScrollOff": "Auto-scroll OFF", "trackConvertFailed": "変換に失敗しました", "@trackConvertFailed": { diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index d43aab97..d687b555 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -5641,6 +5641,8 @@ "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, + "nowPlayingPreviousTrack": "이전 곡", + "nowPlayingNextTrack": "다음 곡", "nowPlayingDetails": "트랙 세부 정보", "@nowPlayingDetails": { "description": "Menu item and section title for track metadata details" diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index bc28d947..c4e97cd1 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -4286,5 +4286,7 @@ "settingsGroupContent": "Conteúdo e metadados", "settingsGroupDownloads": "Downloads e arquivos", "settingsGroupSystem": "Sistema", - "settingsGroupHelp": "Sobre e suporte" + "settingsGroupHelp": "Sobre e suporte", + "nowPlayingPreviousTrack": "Faixa anterior", + "nowPlayingNextTrack": "Próxima faixa" } diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index d12125f0..5802990f 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -4395,6 +4395,8 @@ "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, + "nowPlayingPreviousTrack": "Faixa anterior", + "nowPlayingNextTrack": "Próxima faixa", "logAutoScrollOff": "Auto-scroll OFF", "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index cba00585..2ce23872 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -4395,6 +4395,8 @@ "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, + "nowPlayingPreviousTrack": "Предыдущий трек", + "nowPlayingNextTrack": "Следующий трек", "logAutoScrollOff": "Auto-scroll OFF", "trackConvertFailed": "Ошибка конвертации", "@trackConvertFailed": { diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index 3e576e8d..80f80062 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -4395,6 +4395,8 @@ "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, + "nowPlayingPreviousTrack": "Önceki parça", + "nowPlayingNextTrack": "Sonraki parça", "logAutoScrollOff": "Auto-scroll OFF", "trackConvertFailed": "Dönüştürme başarısız oldu", "@trackConvertFailed": { diff --git a/lib/l10n/arb/app_uk.arb b/lib/l10n/arb/app_uk.arb index 15bf9e98..5c27b5df 100644 --- a/lib/l10n/arb/app_uk.arb +++ b/lib/l10n/arb/app_uk.arb @@ -4395,6 +4395,8 @@ "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, + "nowPlayingPreviousTrack": "Попередній трек", + "nowPlayingNextTrack": "Наступний трек", "logAutoScrollOff": "Auto-scroll OFF", "trackConvertFailed": "Конвертація не вдалася", "@trackConvertFailed": { diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index a314ccd5..140d9a33 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -639,6 +639,7 @@ class _LibraryTracksFolderScreenState }, ), IconButton( + tooltip: context.l10n.collectionPlaylistChangeCover, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 93fc71e2..6a281343 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -337,6 +337,7 @@ class _NowPlayingScreenState extends ConsumerState { return Scaffold( appBar: AppBar( leading: IconButton( + tooltip: context.l10n.nowPlayingMinimize, icon: const Icon(Icons.keyboard_arrow_down), onPressed: () => Navigator.of(context).maybePop(), ), @@ -1217,6 +1218,7 @@ class _PlaybackControls extends ConsumerWidget { const SizedBox(width: 8), IconButton( iconSize: 44, + tooltip: context.l10n.nowPlayingPreviousTrack, icon: const Icon(Icons.skip_previous), onPressed: controller.previous, ), @@ -1230,6 +1232,9 @@ class _PlaybackControls extends ConsumerWidget { 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, @@ -1244,6 +1249,7 @@ class _PlaybackControls extends ConsumerWidget { const SizedBox(width: 20), IconButton( iconSize: 44, + tooltip: context.l10n.nowPlayingNextTrack, icon: const Icon(Icons.skip_next), onPressed: controller.next, ), diff --git a/lib/screens/queue_tab_collection_items.dart b/lib/screens/queue_tab_collection_items.dart index 1ab97ee3..0cf627d6 100644 --- a/lib/screens/queue_tab_collection_items.dart +++ b/lib/screens/queue_tab_collection_items.dart @@ -627,16 +627,26 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { child: Row( children: [ if (_isPlaylistSelectionMode) - GestureDetector( - onTap: () => _togglePlaylistSelection(playlist.id), - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.only(left: 8), - child: AnimatedSelectionCheckbox( - visible: true, - selected: isSelected, - colorScheme: colorScheme, - size: 24, + Semantics( + button: true, + checked: isSelected, + label: playlist.name, + child: GestureDetector( + onTap: () => _togglePlaylistSelection(playlist.id), + behavior: HitTestBehavior.opaque, + child: ConstrainedBox( + constraints: BoxConstraints.tightFor( + width: context.tokens.minTouchTarget, + height: context.tokens.minTouchTarget, + ), + child: Center( + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, + ), + ), ), ), ), diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 78521065..3de30c49 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -1063,21 +1063,17 @@ extension _QueueTabItemWidgets on _QueueTabState { valueListenable: fileExistsListenable, builder: (context, fileExists, child) { if (fileExists) { - return Semantics( - button: true, - label: context.l10n.a11yPlayTrackByArtist( + return IconButton.filled( + tooltip: context.l10n.a11yPlayTrackByArtist( item.trackName, item.artistName, ), - child: IconButton.filled( - onPressed: () => _playLibraryItem(item, libraryItems), - visualDensity: VisualDensity.compact, - iconSize: 18, - icon: const Icon(Icons.play_arrow), - style: IconButton.styleFrom( - minimumSize: const Size.square(36), - padding: EdgeInsets.zero, - ), + onPressed: () => _playLibraryItem(item, libraryItems), + iconSize: 18, + icon: const Icon(Icons.play_arrow), + style: IconButton.styleFrom( + minimumSize: Size.square(context.tokens.minTouchTarget), + padding: EdgeInsets.zero, ), ); } diff --git a/lib/screens/repo_tab.dart b/lib/screens/repo_tab.dart index 5154eb79..273ff659 100644 --- a/lib/screens/repo_tab.dart +++ b/lib/screens/repo_tab.dart @@ -728,7 +728,7 @@ class _ExtensionItem extends StatelessWidget { onPressed: onUpdate, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 12), - minimumSize: const Size(0, 40), + minimumSize: Size(0, context.tokens.minTouchTarget), ), child: Text(context.l10n.storeUpdate), ) @@ -737,7 +737,7 @@ class _ExtensionItem extends StatelessWidget { onPressed: null, style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 12), - minimumSize: const Size(0, 40), + minimumSize: Size(0, context.tokens.minTouchTarget), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -755,7 +755,7 @@ class _ExtensionItem extends StatelessWidget { onPressed: onInstall, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 12), - minimumSize: const Size(0, 40), + minimumSize: Size(0, context.tokens.minTouchTarget), ), child: Text(context.l10n.storeInstall), ), diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index a6bc9916..17032ddd 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/screens/now_playing_screen.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; @@ -113,6 +114,9 @@ class _MiniPlayerState extends ConsumerState { ), ), IconButton( + tooltip: isPlaying + ? context.l10n.actionPause + : context.l10n.tooltipPlay, icon: isLoading ? const SizedBox.square( dimension: 20, @@ -126,6 +130,7 @@ class _MiniPlayerState extends ConsumerState { : () => controller.togglePlayPause(isPlaying), ), IconButton( + tooltip: context.l10n.nowPlayingNextTrack, icon: const Icon(Icons.skip_next), onPressed: controller.next, ), diff --git a/lib/widgets/selection_action_button.dart b/lib/widgets/selection_action_button.dart index 426240c3..c6a97d46 100644 --- a/lib/widgets/selection_action_button.dart +++ b/lib/widgets/selection_action_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/app_tokens.dart'; /// Icon+label pill used in selection-mode bottom bars. Disabled state dims /// both the fill and the content to 50% alpha. @@ -19,42 +20,55 @@ class SelectionActionButton extends StatelessWidget { @override Widget build(BuildContext context) { final isDisabled = onPressed == null; - return Material( - color: isDisabled - ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) - : colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: onPressed, + return Semantics( + button: true, + enabled: !isDisabled, + label: label, + excludeSemantics: true, + child: Material( + color: isDisabled + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) + : colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(14), - 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, + 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, + ), + ), + ), + ], ), - ], + ), ), ), ), diff --git a/pubspec.lock b/pubspec.lock index 79a99611..85666b7a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -18,7 +18,7 @@ packages: source: hosted version: "0.3.14" analyzer: - dependency: transitive + dependency: "direct dev" description: name: analyzer sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" diff --git a/pubspec.yaml b/pubspec.yaml index abc16aa5..6248f6a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + analyzer: ^12.1.0 flutter_lints: ^6.0.0 build_runner: ^2.15.1 riverpod_lint: ^3.1.4 diff --git a/test/design_system_test.dart b/test/design_system_test.dart index 1baa8a25..6ad8a100 100644 --- a/test/design_system_test.dart +++ b/test/design_system_test.dart @@ -1,5 +1,10 @@ import 'dart:io'; +import 'package:analyzer/dart/analysis/features.dart'; +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/source/line_info.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; @@ -10,6 +15,7 @@ import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/collection_scaffold.dart'; +import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; @@ -27,6 +33,34 @@ List _libSources() { String _basename(File file) => file.uri.pathSegments.last; +class _IconButtonTooltipVisitor extends RecursiveAstVisitor { + _IconButtonTooltipVisitor({ + required this.file, + required this.lineInfo, + required this.offenders, + }); + + final File file; + final LineInfo lineInfo; + final List offenders; + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + if (node.constructorName.type.name.lexeme == 'IconButton') { + final hasTooltip = node.argumentList.arguments.any( + (argument) => + argument is NamedExpression && + argument.name.label.name == 'tooltip', + ); + if (!hasTooltip) { + final line = lineInfo.getLocation(node.offset).lineNumber; + offenders.add('${file.path}:$line'); + } + } + super.visitInstanceCreationExpression(node); + } +} + Widget _hostSliver(Widget sliver) { return MaterialApp( theme: AppTheme.light(), @@ -84,6 +118,72 @@ void main() { }); }); + group('accessibility contracts', () { + test('every IconButton has a tooltip-backed accessible name', () { + final offenders = []; + for (final file in _libSources()) { + final result = parseFile( + path: file.absolute.path, + featureSet: FeatureSet.latestLanguageVersion(), + ); + result.unit.accept( + _IconButtonTooltipVisitor( + file: file, + lineInfo: result.lineInfo, + offenders: offenders, + ), + ); + } + + expect( + offenders, + isEmpty, + reason: + 'Icon-only controls must expose a tooltip so TalkBack, VoiceOver, ' + 'keyboard users, and pointer users receive the same action name.', + ); + }); + + testWidgets('selection actions expose their label and disabled state', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Center( + child: SelectionActionButton( + icon: Icons.delete, + label: 'Delete selected tracks', + onPressed: null, + colorScheme: AppTheme.light().colorScheme, + ), + ), + ), + ), + ); + + expect( + tester.getSemantics(find.byType(SelectionActionButton)), + matchesSemantics( + label: 'Delete selected tracks', + isButton: true, + hasEnabledState: true, + isEnabled: false, + ), + ); + expect( + tester.getSize(find.byType(SelectionActionButton)).height, + greaterThanOrEqualTo(AppTokens.standard.minTouchTarget), + ); + } finally { + semantics.dispose(); + } + }); + }); + group('AppSearchField', () { testWidgets('uses the shared filled search style and clears input', ( tester,