Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 122 additions & 94 deletions lib/ui/anytime_podcast_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ class _AnytimeHomePageState extends State<AnytimeHomePage> with WidgetsBindingOb
final log = Logger('_AnytimeHomePageState');
bool handledInitialLink = false;
Widget? library;
final GlobalKey<PopupMenuButtonState<String>> _overflowMenuKey = GlobalKey<PopupMenuButtonState<String>>();

@override
void initState() {
Expand Down Expand Up @@ -352,6 +353,9 @@ class _AnytimeHomePageState extends State<AnytimeHomePage> with WidgetsBindingOb
final pager = Provider.of<PagerBloc>(context);
final searchBloc = Provider.of<EpisodeBloc>(context);
final backgroundColour = Theme.of(context).scaffoldBackgroundColor;
final searchLabel = L.of(context)!.search_for_podcasts_hint;
final menuLabel = L.of(context)!.podcast_options_overflow_menu_semantic_label;
final openSearch = () => _openSearch(context);

return AnnotatedRegion<SystemUiOverlayStyle>(
value: Theme.of(context).appBarTheme.systemOverlayStyle!,
Expand All @@ -373,108 +377,112 @@ class _AnytimeHomePageState extends State<AnytimeHomePage> with WidgetsBindingOb
pinned: true,
snap: false,
actions: <Widget>[
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @0xc05m1c0wl,

VoiceOver is reading out the labels twice as it is first reading the IconButton label and then the Icon label. The simpler solution is to remove the semanticLabel from the button leaving just the icons one. I think this would be a simpler solution and more in line with the rest of the app.

IconButton(
tooltip: L.of(context)!.search_for_podcasts_hint,
icon: Icon(
Icons.search,
semanticLabel: L.of(context)!.search_for_podcasts_hint,
Tooltip(
message: searchLabel,
excludeFromSemantics: true,
child: Semantics(
label: searchLabel,
button: true,
onTap: openSearch,
child: ExcludeSemantics(
child: IconButton(
icon: const Icon(Icons.search),
onPressed: openSearch,
),
),
),
onPressed: () async {
await Navigator.push(
context,
defaultTargetPlatform == TargetPlatform.iOS
? MaterialPageRoute<void>(
fullscreenDialog: false,
settings: const RouteSettings(name: 'search'),
builder: (context) => const Search())
: SlideRightRoute(
widget: const Search(),
settings: const RouteSettings(name: 'search'),
),
);
},
),
PopupMenuButton<String>(
onSelected: _menuSelect,
icon: Icon(
Icons.more_vert,
semanticLabel: L.of(context)!.podcast_options_overflow_menu_semantic_label,
),
itemBuilder: (BuildContext context) {
return <PopupMenuEntry<String>>[
if (feedbackUrl.isNotEmpty)
PopupMenuItem<String>(
textStyle: Theme.of(context).textTheme.titleMedium,
value: 'feedback',
child: Focus(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.feedback_outlined, size: 18.0),
Tooltip(
message: menuLabel,
excludeFromSemantics: true,
child: Semantics(
label: menuLabel,
button: true,
onTap: _showOverflowMenu,
child: ExcludeSemantics(
child: PopupMenuButton<String>(
key: _overflowMenuKey,
tooltip: null,
onSelected: _menuSelect,
icon: const Icon(Icons.more_vert),
itemBuilder: (BuildContext context) {
return <PopupMenuEntry<String>>[
if (feedbackUrl.isNotEmpty)
PopupMenuItem<String>(
textStyle: Theme.of(context).textTheme.titleMedium,
value: 'feedback',
child: Focus(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.feedback_outlined, size: 18.0),
),
Text(L.of(context)!.feedback_menu_item_label),
],
),
),
Text(L.of(context)!.feedback_menu_item_label),
],
),
PopupMenuItem<String>(
textStyle: Theme.of(context).textTheme.titleMedium,
value: 'layout',
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.dashboard, size: 18.0),
),
Text(L.of(context)!.layout_label),
],
),
),
),
),
PopupMenuItem<String>(
textStyle: Theme.of(context).textTheme.titleMedium,
value: 'layout',
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.dashboard, size: 18.0),
PopupMenuItem<String>(
textStyle: Theme.of(context).textTheme.titleMedium,
value: 'rss',
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.rss_feed, size: 18.0),
),
Text(L.of(context)!.add_rss_feed_option),
],
),
),
Text(L.of(context)!.layout_label),
],
),
),
PopupMenuItem<String>(
textStyle: Theme.of(context).textTheme.titleMedium,
value: 'rss',
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.rss_feed, size: 18.0),
PopupMenuItem<String>(
textStyle: Theme.of(context).textTheme.titleMedium,
value: 'settings',
child: Row(
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.settings, size: 18.0),
),
Text(L.of(context)!.settings_label),
],
),
),
Text(L.of(context)!.add_rss_feed_option),
],
),
),
PopupMenuItem<String>(
textStyle: Theme.of(context).textTheme.titleMedium,
value: 'settings',
child: Row(
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.settings, size: 18.0),
),
Text(L.of(context)!.settings_label),
],
),
),
PopupMenuItem<String>(
textStyle: Theme.of(context).textTheme.titleMedium,
value: 'about',
child: Row(
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.info_outline, size: 18.0),
PopupMenuItem<String>(
textStyle: Theme.of(context).textTheme.titleMedium,
value: 'about',
child: Row(
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.info_outline, size: 18.0),
),
Text(L.of(context)!.about_label),
],
),
),
Text(L.of(context)!.about_label),
],
),
];
},
),
];
},
),
),
),
],
),
Expand Down Expand Up @@ -544,6 +552,26 @@ class _AnytimeHomePageState extends State<AnytimeHomePage> with WidgetsBindingOb
}
}

Future<void> _openSearch(BuildContext context) async {
await Navigator.push(
context,
defaultTargetPlatform == TargetPlatform.iOS
? MaterialPageRoute<void>(
fullscreenDialog: false,
settings: const RouteSettings(name: 'search'),
builder: (context) => const Search(),
)
: SlideRightRoute(
widget: const Search(),
settings: const RouteSettings(name: 'search'),
),
);
}

void _showOverflowMenu() {
_overflowMenuKey.currentState?.showButtonMenu();
}

void _menuSelect(String choice) async {
var textFieldController = TextEditingController();
var podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
Expand Down
97 changes: 97 additions & 0 deletions test/accessibility/search_menu_semantics_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great! I really would like to start adding more tests that includes checking the accessibility labels.

import 'package:flutter_test/flutter_test.dart';

void main() {
testWidgets(
'Search and menu buttons should not expose duplicate tooltip semantics',
(tester) async {
final semantics = tester.ensureSemantics();

await tester.pumpWidget(const _TestApp());

final searchSemantics = tester.getSemantics(find.byIcon(Icons.search));
final menuSemantics = tester.getSemantics(find.byIcon(Icons.more_vert));

expect(searchSemantics.label, isNotEmpty);
expect(searchSemantics.tooltip ?? '', isEmpty);

expect(menuSemantics.label, isNotEmpty);
expect(menuSemantics.tooltip ?? '', isEmpty);

semantics.dispose();
},
);
}

class _TestAppBarWrapper extends StatelessWidget {
const _TestAppBarWrapper();

@override
Widget build(BuildContext context) {
return const Scaffold(
body: SizedBox.shrink(),
appBar: _TestAppBar(),
);
}
}

class _TestApp extends StatelessWidget {
const _TestApp();

@override
Widget build(BuildContext context) {
return const MaterialApp(
home: _TestAppBarWrapper(),
);
}
}

class _TestAppBar extends StatelessWidget implements PreferredSizeWidget {
const _TestAppBar();

@override
Widget build(BuildContext context) {
void handleSearch() {}
void handleMenu() {}

return AppBar(
title: const Text('Anytime'),
actions: [
Tooltip(
message: 'Search for podcasts',
excludeFromSemantics: true,
child: Semantics(
label: 'Search for podcasts',
button: true,
onTap: handleSearch,
child: ExcludeSemantics(
child: IconButton(
icon: const Icon(Icons.search),
onPressed: handleSearch,
),
),
),
),
Tooltip(
message: 'Options menu',
excludeFromSemantics: true,
child: Semantics(
label: 'Options menu',
button: true,
onTap: handleMenu,
child: ExcludeSemantics(
child: PopupMenuButton<int>(
tooltip: null,
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => const [],
),
),
),
),
],
);
}

@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}