From b552e9843c3b664fe9b439117ed42c362990b40f Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 27 Feb 2026 15:41:57 -0500 Subject: [PATCH] fix(accessibility): label icon controls and semantic tap targets --- lib/features/explore/view/explore_page.dart | 2 + .../view/library_item_actions.dart | 10 ++- .../view/user_login_with_password.dart | 26 ++++--- .../player/view/audiobook_player.dart | 2 + .../player/view/player_when_expanded.dart | 13 ++-- .../player/view/player_when_minimized.dart | 39 ++++++---- .../widgets/audiobook_player_seek_button.dart | 3 + .../audiobook_player_seek_chapter_button.dart | 1 + .../player/view/widgets/speed_selector.dart | 2 + .../sleep_timer/view/sleep_timer_button.dart | 72 ++++++++++--------- lib/features/you/view/server_manager.dart | 1 + lib/settings/view/app_settings_page.dart | 1 + .../view/notification_settings_page.dart | 1 + .../view/shake_detector_settings_page.dart | 1 + lib/shared/widgets/shelves/book_shelf.dart | 10 +++ 15 files changed, 118 insertions(+), 66 deletions(-) diff --git a/lib/features/explore/view/explore_page.dart b/lib/features/explore/view/explore_page.dart index e747d26..14cee64 100644 --- a/lib/features/explore/view/explore_page.dart +++ b/lib/features/explore/view/explore_page.dart @@ -257,6 +257,7 @@ class BookSearchResultMini extends HookConsumerWidget { ); }, trailing: IconButton( + tooltip: 'More options', icon: const Icon(Icons.more_vert), onPressed: () { // TODO: show a menu with options for the book @@ -311,6 +312,7 @@ class SearchResultMiniSection extends HookConsumerWidget { ), const Spacer(), IconButton( + tooltip: 'View more ${category.toString().split('.').last}', icon: const Icon(Icons.arrow_forward_ios), onPressed: onTap ?? openSearch, ), diff --git a/lib/features/item_viewer/view/library_item_actions.dart b/lib/features/item_viewer/view/library_item_actions.dart index 50ef9c2..b169522 100644 --- a/lib/features/item_viewer/view/library_item_actions.dart +++ b/lib/features/item_viewer/view/library_item_actions.dart @@ -24,6 +24,7 @@ import 'package:vaani/settings/api_settings_provider.dart'; import 'package:vaani/settings/app_settings_provider.dart'; import 'package:vaani/shared/extensions/model_conversions.dart'; import 'package:vaani/shared/utils.dart'; +import 'package:vaani/shared/widgets/not_implemented.dart'; class LibraryItemActions extends HookConsumerWidget { const LibraryItemActions({super.key, required this.id}); @@ -64,11 +65,15 @@ class LibraryItemActions extends HookConsumerWidget { children: [ // read list button IconButton( - onPressed: () {}, + tooltip: 'Add to playlist', + onPressed: () { + showNotImplementedToast(context); + }, icon: const Icon(Icons.playlist_add_rounded), ), // share button IconButton( + tooltip: 'Share item', onPressed: () { appLogger.fine('Sharing'); var currentServerUrl = @@ -97,6 +102,7 @@ class LibraryItemActions extends HookConsumerWidget { // more button IconButton( + tooltip: 'More options', onPressed: () { // show the bottom sheet with download history showModalBottomSheet( @@ -215,6 +221,7 @@ class LibItemDownloadButton extends HookConsumerWidget { return isItemDownloading ? ItemCurrentlyInDownloadQueue(item: item) : IconButton( + tooltip: 'Download item', onPressed: () { appLogger.fine('Pressed download button'); @@ -280,6 +287,7 @@ class AlreadyItemDownloadedButton extends HookConsumerWidget { final isBookPlaying = ref.watch(audiobookPlayerProvider).book != null; return IconButton( + tooltip: 'Downloaded item options', onPressed: () { appLogger.fine('Pressed already downloaded button'); // manager.openDownloadedFile(item); diff --git a/lib/features/onboarding/view/user_login_with_password.dart b/lib/features/onboarding/view/user_login_with_password.dart index 20d2613..918e93b 100644 --- a/lib/features/onboarding/view/user_login_with_password.dart +++ b/lib/features/onboarding/view/user_login_with_password.dart @@ -137,16 +137,22 @@ class UserLoginWithPassword extends HookConsumerWidget { ).colorScheme.primary.withValues(alpha: 0.8), BlendMode.srcIn, ), - child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: () { - isPasswordVisible.value = !isPasswordVisible.value; - }, - child: Container( - margin: const EdgeInsets.only(left: 8, right: 8), - child: Lottie.asset( - 'assets/animations/Animation - 1714930099660.json', - controller: isPasswordVisibleAnimationController, + child: Semantics( + button: true, + label: isPasswordVisible.value + ? 'Hide password' + : 'Show password', + child: InkWell( + borderRadius: BorderRadius.circular(50), + onTap: () { + isPasswordVisible.value = !isPasswordVisible.value; + }, + child: Container( + margin: const EdgeInsets.only(left: 8, right: 8), + child: Lottie.asset( + 'assets/animations/Animation - 1714930099660.json', + controller: isPasswordVisibleAnimationController, + ), ), ), ), diff --git a/lib/features/player/view/audiobook_player.dart b/lib/features/player/view/audiobook_player.dart index e80202a..6ce5337 100644 --- a/lib/features/player/view/audiobook_player.dart +++ b/lib/features/player/view/audiobook_player.dart @@ -163,6 +163,7 @@ class AudiobookPlayerPlayPauseButton extends HookConsumerWidget { child: CircularProgressIndicator(), ), ProcessingState.completed => IconButton( + tooltip: 'Replay', onPressed: () async { await player.seek(const Duration(seconds: 0)); await player.play(); @@ -170,6 +171,7 @@ class AudiobookPlayerPlayPauseButton extends HookConsumerWidget { icon: const Icon(Icons.replay), ), ProcessingState.ready => IconButton( + tooltip: player.playing ? 'Pause' : 'Play', onPressed: () async { await player.togglePlayPause(); }, diff --git a/lib/features/player/view/player_when_expanded.dart b/lib/features/player/view/player_when_expanded.dart index 28e6993..3e28ad9 100644 --- a/lib/features/player/view/player_when_expanded.dart +++ b/lib/features/player/view/player_when_expanded.dart @@ -64,6 +64,7 @@ class PlayerWhenExpanded extends HookConsumerWidget { IconButton( iconSize: 30, icon: const Icon(Icons.keyboard_arrow_down), + tooltip: 'Minimize player', onPressed: () { // minimize the player audioBookMiniplayerController.animateToHeight( @@ -75,6 +76,7 @@ class PlayerWhenExpanded extends HookConsumerWidget { // the cast button IconButton( icon: const Icon(Icons.cast), + tooltip: 'Cast', onPressed: () { showNotImplementedToast(context); }, @@ -108,14 +110,11 @@ class PlayerWhenExpanded extends HookConsumerWidget { ), child: SizedBox( height: imageSize, - child: InkWell( - onTap: () {}, - child: ClipRRect( - borderRadius: BorderRadius.circular( - AppElementSizes.borderRadiusRegular * earlyPercentage, - ), - child: img, + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppElementSizes.borderRadiusRegular * earlyPercentage, ), + child: img, ), ), ), diff --git a/lib/features/player/view/player_when_minimized.dart b/lib/features/player/view/player_when_minimized.dart index 9729fe2..3bc6e3b 100644 --- a/lib/features/player/view/player_when_minimized.dart +++ b/lib/features/player/view/player_when_minimized.dart @@ -51,20 +51,24 @@ class PlayerWhenMinimized extends HookConsumerWidget { horizontal: ((availWidth - maxImgSize) / 2) * percentageMiniplayer, ), - child: InkWell( - onTap: () { - // navigate to item page - context.pushNamed( - Routes.libraryItem.name, - pathParameters: { - Routes.libraryItem.pathParamName!: - player.book!.libraryItemId, - }, - ); - }, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxImgSize), - child: imgWidget, + child: Semantics( + button: true, + label: 'Open current book details', + child: InkWell( + onTap: () { + // navigate to item page + context.pushNamed( + Routes.libraryItem.name, + pathParameters: { + Routes.libraryItem.pathParamName!: + player.book!.libraryItemId, + }, + ); + }, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxImgSize), + child: imgWidget, + ), ), ), ), @@ -112,11 +116,16 @@ class PlayerWhenMinimized extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.only(left: 8), child: IconButton( + tooltip: 'Rewind 30 seconds', icon: const Icon( Icons.replay_30, size: AppElementSizes.iconSizeSmall, ), - onPressed: () {}, + onPressed: () { + player.seek( + player.positionInBook - const Duration(seconds: 30), + ); + }, ), ), ), diff --git a/lib/features/player/view/widgets/audiobook_player_seek_button.dart b/lib/features/player/view/widgets/audiobook_player_seek_button.dart index d1d1edc..c069cfb 100644 --- a/lib/features/player/view/widgets/audiobook_player_seek_button.dart +++ b/lib/features/player/view/widgets/audiobook_player_seek_button.dart @@ -13,6 +13,9 @@ class AudiobookPlayerSeekButton extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final player = ref.watch(audiobookPlayerProvider); return IconButton( + tooltip: isForward + ? 'Seek forward 30 seconds' + : 'Seek backward 30 seconds', icon: Icon( isForward ? Icons.forward_30 : Icons.replay_30, size: AppElementSizes.iconSizeSmall, diff --git a/lib/features/player/view/widgets/audiobook_player_seek_chapter_button.dart b/lib/features/player/view/widgets/audiobook_player_seek_chapter_button.dart index 16127ad..14b339c 100644 --- a/lib/features/player/view/widgets/audiobook_player_seek_chapter_button.dart +++ b/lib/features/player/view/widgets/audiobook_player_seek_chapter_button.dart @@ -49,6 +49,7 @@ class AudiobookPlayerSeekChapterButton extends HookConsumerWidget { } return IconButton( + tooltip: isForward ? 'Next chapter' : 'Previous chapter', icon: Icon( isForward ? Icons.skip_next : Icons.skip_previous, size: AppElementSizes.iconSizeSmall, diff --git a/lib/features/player/view/widgets/speed_selector.dart b/lib/features/player/view/widgets/speed_selector.dart index f14604a..c836e4b 100644 --- a/lib/features/player/view/widgets/speed_selector.dart +++ b/lib/features/player/view/widgets/speed_selector.dart @@ -162,6 +162,7 @@ class SpeedWheel extends StatelessWidget { // a minus button to decrease the speed if (showIncrementButtons) IconButton.filledTonal( + tooltip: 'Decrease speed', icon: const Icon(Icons.remove), onPressed: () { // animate to index - 1 @@ -198,6 +199,7 @@ class SpeedWheel extends StatelessWidget { if (showIncrementButtons) // a plus button to increase the speed IconButton.filledTonal( + tooltip: 'Increase speed', icon: const Icon(Icons.add), onPressed: () { // animate to index + 1 diff --git a/lib/features/sleep_timer/view/sleep_timer_button.dart b/lib/features/sleep_timer/view/sleep_timer_button.dart index 0f33b72..825e955 100644 --- a/lib/features/sleep_timer/view/sleep_timer_button.dart +++ b/lib/features/sleep_timer/view/sleep_timer_button.dart @@ -24,39 +24,43 @@ class SleepTimerButton extends HookConsumerWidget { // if the sleep timer is active, show the remaining time in a pill shaped container return Tooltip( message: 'Sleep Timer', - child: InkWell( - onTap: () async { - appLogger.fine('Sleep Timer button pressed'); - pendingPlayerModals++; - // show the sleep timer dialog - await showModalBottomSheet( - context: context, - barrierLabel: 'Sleep Timer', - builder: (context) { - return SleepTimerBottomSheet( - onDurationSelected: (duration) { - durationState.value = duration; - // ref - // .read(sleepTimerProvider.notifier) - // .setTimer(duration, notifyListeners: false); - }, - ); - }, - ); - pendingPlayerModals--; - ref.read(sleepTimerProvider.notifier).setTimer(durationState.value); - appLogger.fine( - 'Sleep Timer dialog closed with ${durationState.value}', - ); - }, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: sleepTimer == null - ? Icon( - Symbols.bedtime, - color: Theme.of(context).colorScheme.onSurface, - ) - : RemainingSleepTimeDisplay(timer: sleepTimer), + child: Semantics( + button: true, + label: 'Sleep timer', + child: InkWell( + onTap: () async { + appLogger.fine('Sleep Timer button pressed'); + pendingPlayerModals++; + // show the sleep timer dialog + await showModalBottomSheet( + context: context, + barrierLabel: 'Sleep Timer', + builder: (context) { + return SleepTimerBottomSheet( + onDurationSelected: (duration) { + durationState.value = duration; + // ref + // .read(sleepTimerProvider.notifier) + // .setTimer(duration, notifyListeners: false); + }, + ); + }, + ); + pendingPlayerModals--; + ref.read(sleepTimerProvider.notifier).setTimer(durationState.value); + appLogger.fine( + 'Sleep Timer dialog closed with ${durationState.value}', + ); + }, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: sleepTimer == null + ? Icon( + Symbols.bedtime, + color: Theme.of(context).colorScheme.onSurface, + ) + : RemainingSleepTimeDisplay(timer: sleepTimer), + ), ), ), ); @@ -256,6 +260,7 @@ class SleepTimerWheel extends StatelessWidget { // a minus button to decrease the speed if (showIncrementButtons) IconButton.filledTonal( + tooltip: 'Decrease sleep timer', icon: const Icon(Icons.remove), onPressed: () { // animate to index - 1 @@ -294,6 +299,7 @@ class SleepTimerWheel extends StatelessWidget { if (showIncrementButtons) // a plus button to increase the speed IconButton.filledTonal( + tooltip: 'Increase sleep timer', icon: const Icon(Icons.add), onPressed: () { // animate to index + 1 diff --git a/lib/features/you/view/server_manager.dart b/lib/features/you/view/server_manager.dart index f8d13b0..b75813d 100644 --- a/lib/features/you/view/server_manager.dart +++ b/lib/features/you/view/server_manager.dart @@ -283,6 +283,7 @@ class AvailableUserTile extends HookConsumerWidget { context.goNamed(Routes.home.name); }, trailing: IconButton( + tooltip: 'Remove user', icon: const Icon(Icons.delete), onPressed: () { showDialog( diff --git a/lib/settings/view/app_settings_page.dart b/lib/settings/view/app_settings_page.dart index a193752..30980ef 100644 --- a/lib/settings/view/app_settings_page.dart +++ b/lib/settings/view/app_settings_page.dart @@ -235,6 +235,7 @@ class RestoreDialogue extends HookConsumerWidget { hintText: 'Paste the backup here', // clear button suffixIcon: IconButton( + tooltip: 'Clear backup', icon: Icon(Icons.clear), onPressed: () { settingsInputController.clear(); diff --git a/lib/settings/view/notification_settings_page.dart b/lib/settings/view/notification_settings_page.dart index c306f87..632077e 100644 --- a/lib/settings/view/notification_settings_page.dart +++ b/lib/settings/view/notification_settings_page.dart @@ -350,6 +350,7 @@ class NotificationTitlePicker extends HookConsumerWidget { decoration: InputDecoration( helper: const Text('Select a field below to insert it'), suffix: IconButton( + tooltip: 'Clear title', icon: const Icon(Icons.clear), onPressed: () { controller.clear(); diff --git a/lib/settings/view/shake_detector_settings_page.dart b/lib/settings/view/shake_detector_settings_page.dart index fe39b53..efe997a 100644 --- a/lib/settings/view/shake_detector_settings_page.dart +++ b/lib/settings/view/shake_detector_settings_page.dart @@ -283,6 +283,7 @@ class ShakeForceSelector extends HookConsumerWidget { decoration: InputDecoration( // clear button suffix: IconButton( + tooltip: 'Clear threshold', icon: const Icon(Icons.clear), onPressed: () { controller.clear(); diff --git a/lib/shared/widgets/shelves/book_shelf.dart b/lib/shared/widgets/shelves/book_shelf.dart index 0919161..e3da74f 100644 --- a/lib/shared/widgets/shelves/book_shelf.dart +++ b/lib/shared/widgets/shelves/book_shelf.dart @@ -218,6 +218,15 @@ class _BookOnShelfPlayButton extends HookConsumerWidget { (element) => element.libraryItemId == libraryItemId, ); final isBookCompleted = userProgress?.isFinished ?? false; + final playTooltip = isCurrentBookSetInPlayer + ? isPlayingThisBook + ? 'Pause' + : 'Resume' + : isBookCompleted + ? 'Listen again' + : userProgress?.progress != null + ? 'Continue listening' + : 'Start listening'; const size = 40.0; @@ -268,6 +277,7 @@ class _BookOnShelfPlayButton extends HookConsumerWidget { // the play button IconButton( + tooltip: playTooltip, color: Theme.of(context).colorScheme.primary, style: ButtonStyle( padding: WidgetStateProperty.all(EdgeInsets.zero),