fix(accessibility): label icon controls and semantic tap targets

This commit is contained in:
Storm Dragon 2026-02-27 15:41:57 -05:00
parent e30e84ded1
commit b552e9843c
15 changed files with 118 additions and 66 deletions

View file

@ -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,
),

View file

@ -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);

View file

@ -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,
),
),
),
),

View file

@ -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();
},

View file

@ -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,
),
),
),

View file

@ -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),
);
},
),
),
),

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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<Duration?>(
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<Duration?>(
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

View file

@ -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(

View file

@ -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();

View file

@ -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();

View file

@ -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();

View file

@ -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),