Comprehensive accessibility guidelines for the Cambridge Beer Festival app.
- Overview
- Legal & Compliance Requirements
- Implementation Status
- Quick Start Guide
- Detailed Implementation Guide
- Testing Procedures
- Common Patterns
- Troubleshooting
Accessibility is mandatory, not optional. This app must be usable by everyone, including people with:
- Visual impairments (using screen readers)
- Motor impairments (using alternative input devices)
- Cognitive differences (requiring clear, simple interfaces)
- Hearing impairments (if audio is added)
- Legal Compliance - Required by ADA, Section 508, and similar laws
- Ethical Responsibility - Technology should be inclusive
- Better UX for Everyone - Accessible design benefits all users
- Larger Audience - ~15% of world population has some form of disability
- ✅ WCAG 2.1 Level AA - Web Content Accessibility Guidelines (international standard)
- ✅ ADA - Americans with Disabilities Act (US legal requirement)
- ✅ Section 508 - US Federal accessibility standards
- ✅ EN 301 549 - European accessibility standard
-
Perceivable
- Text alternatives for non-text content
- Captions for audio/video
- Content can be presented in different ways
- Sufficient color contrast (4.5:1 for text)
-
Operable
- All functionality available via keyboard
- Users have enough time to read/use content
- No content causes seizures
- Users can navigate and find content
-
Understandable
- Text is readable and understandable
- Content appears and operates predictably
- Help users avoid and correct mistakes
-
Robust
- Content compatible with assistive technologies
- Works across different devices/platforms
53+ Semantics widgets are implemented across the app, with 9 dedicated accessibility tests in test/accessibility_test.dart.
Coverage by file:
- ✅
lib/widgets/drink_card.dart-- card semantic labels, favorite button semantics - ✅
lib/screens/drinks_screen.dart-- search clear button, filter chips - ✅
lib/screens/festival_info_screen.dart-- map, website, and GitHub buttons - ✅
lib/main.dart-- bottom navigation bar with descriptive labels for both tabs - ✅
lib/widgets/star_rating.dart-- parent rating label, individual star semantics - ✅
lib/widgets/bottom_action_bar.dart-- action button semantics - ✅
lib/widgets/breadcrumb_bar.dart-- back navigation semantics - ✅
lib/widgets/overflow_menu.dart-- menu button withExcludeSemanticson decorative icons - ✅
lib/widgets/info_chip.dart-- chip semantics - ✅
lib/widgets/festival_menu_sheets.dart-- festival selector, settings, theme selector - ✅
lib/widgets/environment_badge.dart-- environment indicator semantics - ✅
lib/screens/about_screen.dart-- theme, GitHub, issues, licenses buttons - ✅
lib/screens/drink_detail_screen.dart-- action buttons, brewery link
test/accessibility_test.dart verifies:
- Favorite button semantic labels (add/remove states)
- ABV chip
ExcludeSemanticsfor decorative elements - Card semantic structure and labels
- Environment badge semantics
- Button property (
button: true) on interactive elements - Hint instructions for screen reader users
ExcludeSemanticsusage on decorative icons- Filter chip selection state announcements
- Retry button semantics on error states
Before:
IconButton(
icon: Icon(Icons.favorite),
onPressed: () => toggleFavorite(),
)After:
Semantics(
label: isFavorite ? 'Remove from favorites' : 'Add to favorites',
button: true,
hint: 'Double tap to toggle',
child: IconButton(
icon: Icon(isFavorite ? Icons.favorite : Icons.favorite_border),
onPressed: () => toggleFavorite(),
),
)-
Enable TalkBack (Android):
- Settings → Accessibility → TalkBack → Enable
- Navigate with swipe gestures
- Listen to announcements
-
Enable VoiceOver (iOS):
- Settings → Accessibility → VoiceOver → Enable
- Swipe to navigate
- Verify all elements announce
-
Test Large Text:
- Android: Settings → Display → Font size → Largest
- iOS: Settings → Display & Brightness → Text Size → Largest
- Ensure no text overflow or clipping
Problem: Icons don't convey meaning to screen readers.
Solution:
Semantics(
label: 'Clear search query',
button: true,
child: IconButton(
icon: Icon(Icons.close),
onPressed: () => _clearSearch(),
),
)Key Points:
- Use
labelto describe the action (not the icon) - Set
button: trueto announce as a button - Optionally add
hintfor usage instructions
For state-changing buttons (like favorites):
Semantics(
label: isFavorite
? 'Remove ${drink.name} from favorites'
: 'Add ${drink.name} to favorites',
button: true,
toggled: isFavorite,
child: IconButton(...),
)Key Points:
- Label changes based on current state
- Use
toggledproperty for toggle state - Be specific - include what you're toggling
Problem: Users don't know which filters are active.
Solution:
Semantics(
label: 'Filter by $styleName',
value: isSelected ? 'Selected' : 'Not selected',
button: true,
selected: isSelected,
child: FilterChip(
label: Text(styleName),
selected: isSelected,
onSelected: (value) => _toggleStyle(styleName),
),
)Key Points:
- Use
valueto announce current state - Set
selectedproperty for filter state - Keep labels concise but descriptive
Problem: Screen readers read every element separately.
Solution:
Semantics(
label: '${drink.name}, ${drink.abv}% ABV, brewed by ${drink.breweryName} from ${drink.breweryLocation}',
hint: 'Double tap to view details',
button: true,
child: InkWell(
onTap: () => _navigateToDetail(drink),
child: DrinkCard(drink: drink),
),
)Alternative - Merge semantics inside the card:
MergeSemantics(
child: Column(
children: [
Text(drink.name),
Text('${drink.abv}% ABV'),
Text(drink.breweryName),
],
),
)Key Points:
- Provide a concise summary of card content
- Add navigation hint for tappable items
- Balance detail vs brevity (3-5 key facts)
Problem: Star icons meaningless to screen readers.
Solution:
Semantics(
label: 'Rate ${drink.name}',
value: rating > 0
? '$rating out of 5 stars'
: 'Not rated',
hint: 'Tap a star to set rating from 1 to 5',
child: Row(
children: List.generate(5, (index) {
final starValue = index + 1;
return Semantics(
label: '$starValue stars',
button: true,
child: IconButton(
icon: Icon(
starValue <= rating ? Icons.star : Icons.star_border,
),
onPressed: () => _setRating(starValue),
),
);
}),
),
)Key Points:
- Parent Semantics announces overall rating
- Each star is individually tappable with label
- Include rating scale context (out of 5)
Problem: Icons alone don't describe destinations.
Solution:
NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) => setState(() => _currentIndex = index),
destinations: [
NavigationDestination(
icon: Semantics(
label: 'Drinks tab, browse festival drinks and search',
excludeSemantics: true, // Prevent duplicate announcement
child: Icon(Icons.local_bar),
),
label: 'Drinks',
),
NavigationDestination(
icon: Semantics(
label: 'Favorites tab, view saved drinks',
excludeSemantics: true,
child: Icon(Icons.favorite),
),
label: 'Favorites',
),
],
)Key Points:
- Describe what's in each tab, not just the label
- Use
excludeSemanticson icon to prevent double-reading - Keep descriptions action-oriented
Good news: TextFields have built-in accessibility!
Best practices:
TextField(
controller: _searchController,
decoration: InputDecoration(
labelText: 'Search', // ✅ Announced by screen reader
hintText: 'Search drinks, breweries, styles...', // ✅ Read as hint
prefixIcon: ExcludeSemantics( // ❌ Exclude decorative icon
child: Icon(Icons.search),
),
suffixIcon: Semantics(
label: 'Clear search and close',
button: true,
child: IconButton(
icon: Icon(Icons.close),
onPressed: () => _clearSearch(),
),
),
),
onChanged: (value) => _handleSearch(value),
)Key Points:
- Use
labelTextorhintTextfor description - Exclude decorative icons with
ExcludeSemantics - Add semantics to icon buttons inside decoration
For external actions (maps, websites):
Semantics(
label: 'Open ${festival.name} location in Google Maps',
button: true,
hint: 'Opens external app',
child: ElevatedButton.icon(
icon: Icon(Icons.map),
label: Text('Open in Maps'),
onPressed: () => _launchMaps(festival.latitude, festival.longitude),
),
)Key Points:
- Describe the destination, not just the action
- Warn about external app launches
- Be specific about what opens
Dropdowns have good built-in accessibility, but can improve:
Semantics(
label: 'Sort drinks',
value: 'Currently sorted by $currentSort',
child: DropdownButton<String>(
value: sortOption,
items: [
DropdownMenuItem(
value: 'name',
child: Text('Name'),
),
DropdownMenuItem(
value: 'abv',
child: Text('ABV'),
),
],
onChanged: (value) => _updateSort(value),
),
)- Install app on physical device (emulator acceptable for basic testing)
- Enable screen reader (TalkBack/VoiceOver)
- Disable screen (forces screen reader only navigation)
1. Navigation Test
- Launch app with screen reader enabled
- Swipe through all elements on home screen
- Verify each element announces clearly
- Switch to Favorites tab and verify announcement
- Return to Drinks tab
2. Filtering Test
- Focus on category filter
- Verify current state is announced
- Activate filter (double tap)
- Verify new state is announced
- Test style chips similarly
3. Search Test
- Focus on search button
- Activate search
- Type query and verify announcements
- Clear search and verify announcement
4. List Navigation Test
- Swipe through drink list
- Verify each card announces key info
- Activate a drink card
- Verify detail screen is accessible
5. Favorites Test
- Focus on favorite button
- Verify state (favorited or not)
- Toggle favorite
- Verify new state announcement
- Navigate to Favorites tab
- Verify drink appears
6. Rating Test
- Navigate to drink detail
- Focus on star rating
- Verify current rating announced
- Tap different stars
- Verify new rating announced
7. Large Text Test
- Enable largest system text size
- Navigate through all screens
- Verify no text overflow
- Verify no clipped content
- Verify buttons remain tappable
8. Color Contrast Test
- Use contrast checker on all text
- Verify minimum 4.5:1 ratio
- Check disabled state contrast (3:1)
- Test in both light and dark modes
Add to widget tests:
testWidgets('Favorite button has semantic label', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: DrinkCard(drink: testDrink),
),
);
final semantics = tester.getSemantics(
find.widgetWithIcon(IconButton, Icons.favorite_border),
);
expect(
semantics,
matchesSemantics(
label: 'Add to favorites',
isButton: true,
),
);
});Run semantics-enabled tests:
flutter test --enable-semanticsSemantics(
label: isExpanded
? 'Collapse festival information'
: 'Expand festival information',
button: true,
child: IconButton(
icon: Icon(isExpanded ? Icons.expand_less : Icons.expand_more),
onPressed: () => setState(() => isExpanded = !isExpanded),
),
)// Decorative images or icons that don't convey info
ExcludeSemantics(
child: Image.asset('assets/decorative-pattern.png'),
)// When multiple Text widgets should be read as one
MergeSemantics(
child: Column(
children: [
Text(drink.name, style: headlineStyle),
Text('${drink.abv}% ABV', style: subtitleStyle),
Text(drink.breweryName, style: captionStyle),
],
),
)Semantics(
label: 'Filter options',
button: true,
onTap: () => _showFilterSheet(),
customSemanticsActions: {
CustomSemanticsAction(label: 'Reset filters'): () => _resetFilters(),
CustomSemanticsAction(label: 'Save filter preset'): () => _savePreset(),
},
child: IconButton(...),
)Problem: Every tiny element is announced separately.
Solution: Use MergeSemantics or excludeSemantics:
MergeSemantics(
child: Row(
children: [
Text('Name'), // These merge into one announcement
Text('Value'),
],
),
)Problem: Interactive elements are silent.
Solution: Add Semantics wrapper with label:
Semantics(
label: 'Description of what this does',
button: true,
child: YourWidget(),
)Problem: Labels unclear or technical.
Solution: Use plain language, describe action not implementation:
// ❌ Bad
label: 'Toggle boolean favorite state flag'
// ✅ Good
label: 'Add to favorites'Problem: Text cuts off when user increases font size.
Solution: Use flexible widgets:
// ❌ Bad - Fixed width
SizedBox(
width: 200,
child: Text('Long brewery name here'),
)
// ✅ Good - Flexible
Expanded(
child: Text(
'Long brewery name here',
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
)Problem: Text hard to read on background.
Solution: Check contrast ratios, adjust colors:
// Use theme colors which have good contrast
Text(
'Content',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface, // Good contrast
),
)- Android: TalkBack (built-in)
- iOS: VoiceOver (built-in)
- Web: NVDA (Windows, free), JAWS (Windows, paid), ChromeVox (Chrome extension)
If you're unsure about accessibility implementation:
- Test with a screen reader - Best way to understand user experience
- Consult WCAG guidelines - Detailed technical requirements
- Ask the question: "Can someone who can't see the screen complete this task?"
Remember: Accessibility is not a feature, it's a requirement.