08 · Accessibility & Internationalization¶
Two qualities that are cheap to build in and expensive to retrofit. Accessibility (a11y): people using screen readers
(TalkBack, VoiceOver), large text, switch controls, or with low vision or limited dexterity can use your app.
Internationalization (i18n): the app can be translated and formats dates, numbers and plurals correctly for each locale.
Both are testable with flutter test, which is what this lesson does.
Part 1 — Localization with gen-l10n¶
Flutter's built-in tool generates a typed Dart API from ARB files (JSON with metadata).
Setup:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
{
"@@locale": "en",
"inboxTitle": "Inbox",
"@inboxTitle": {"description": "Title of the main mail screen"},
"unreadCount": "{count, plural, =0{No unread messages} =1{1 unread message} other{{count} unread messages}}",
"@unreadCount": {"description": "Shown under the title", "placeholders": {"count": {"type": "int"}}},
"greeting": "Hello, {name}!",
"@greeting": {"placeholders": {"name": {"type": "String"}}},
"lastSync": "Last synced {date}",
"@lastSync": {"placeholders": {"date": {"type": "DateTime", "format": "yMMMd"}}},
"storageUsed": "{amount} used",
"@storageUsed": {"placeholders": {"amount": {"type": "double", "format": "decimalPercentPattern"}}},
"archiveTooltip": "Archive",
"@archiveTooltip": {"description": "Icon button tooltip; also read by screen readers"}
}
{
"@@locale": "es",
"inboxTitle": "Bandeja de entrada",
"unreadCount": "{count, plural, =0{No hay mensajes sin leer} =1{1 mensaje sin leer} other{{count} mensajes sin leer}}",
"greeting": "¡Hola, {name}!",
"lastSync": "Última sincronización: {date}",
"storageUsed": "{amount} usado",
"archiveTooltip": "Archivar"
}
flutter gen-l10n (also run automatically by flutter run/flutter test when generate: true is set) produced
app_localizations.dart plus one file per language. Running it without generate: true first printed a clear error —
"Attempted to generate localizations code without having the flutter: generate flag turned on" — so add the flag before
anything else.
What the ARB syntax gives you:
- Parameters (
{name}) become typed method arguments:t.greeting('Ada'). - ICU plurals (
{count, plural, =0{…} =1{…} other{…}}) select the grammatically right form. Languages have different plural categories (some havefew/many/two); translators add the forms their language needs. - Typed formats — a
DateTimewith"format": "yMMMd"or adoublewithdecimalPercentPattern— are formatted per locale byintl. - Descriptions (
@key.description) are context for translators. Write them; "Archive" alone could be a noun or a verb.
The app and screen¶
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:l2/l10n/app_localizations.dart';
class InboxApp extends StatelessWidget {
const InboxApp({super.key, this.locale, required this.home});
final Locale? locale;
final Widget home;
@override
Widget build(BuildContext context) => MaterialApp(
locale: locale, // null = follow the device
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate, // Material's own strings ("Back", date pickers…)
GlobalWidgetsLocalizations.delegate, // text direction
GlobalCupertinoLocalizations.delegate,
],
home: home,
);
}
class InboxHeader extends StatelessWidget {
const InboxHeader({super.key, required this.unread, required this.lastSync, required this.storage});
final int unread;
final DateTime lastSync;
final double storage;
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context)!;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(t.inboxTitle, style: Theme.of(context).textTheme.headlineSmall),
Text(t.unreadCount(unread)),
Text(t.lastSync(lastSync)),
Text(t.storageUsed(storage)),
]);
}
}
/// An inaccessible row: unlabeled icon, tiny tap target, low-contrast text.
class BadMessageRow extends StatelessWidget {
const BadMessageRow({super.key});
@override
Widget build(BuildContext context) => Row(children: [
const Text('From: Ada', style: TextStyle(color: Color(0xFFBBBBBB))),
GestureDetector(
onTap: () {},
child: const SizedBox(width: 24, height: 24, child: Icon(Icons.archive, size: 20)),
),
]);
}
/// The same row, fixed.
class GoodMessageRow extends StatelessWidget {
const GoodMessageRow({super.key});
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context)!;
return Row(children: [
Text('From: Ada', style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
IconButton(
tooltip: t.archiveTooltip, // becomes the semantics label
icon: const Icon(Icons.archive),
onPressed: () {},
), // IconButton enforces a 48x48 minimum tap target
]);
}
}
/// Merge pieces so a screen reader reads one sensible sentence.
class MessageTile extends StatelessWidget {
const MessageTile({super.key, required this.from, required this.subject, required this.unread});
final String from;
final String subject;
final bool unread;
@override
Widget build(BuildContext context) => MergeSemantics(
child: ListTile(
leading: unread
? Semantics(label: 'Unread', child: const Icon(Icons.circle, size: 10))
: const ExcludeSemantics(child: SizedBox(width: 10)),
title: Text(from),
subtitle: Text(subject),
onTap: () {},
),
);
}
Part 2 — Accessibility¶
Flutter builds a semantics tree alongside the render tree: nodes with labels, values, roles (button, header, text field), states (checked, selected) and actions (tap, scroll, increase). Screen readers read that tree, not your pixels. Most Material widgets fill it in for you; your job is the gaps:
- Icons need labels.
IconButton(tooltip: ...)provides one. A bareIconinside aGestureDetectorprovides none. - Tap targets ≥ 48×48 logical pixels on Android (44×44 points is Apple's guidance). Material buttons enforce this by default.
- Contrast: body text at least 4.5:1 against its background (WCAG AA); large text 3:1.
- Grouping:
MergeSemanticsturns a row of separate texts into one node read as a sentence;ExcludeSemanticshides decoration. - Text scaling: never fix heights around text; let it wrap.
- Motion: respect
MediaQuery.disableAnimationsOf(context)for users who reduce motion.
Testing both¶
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:l2/a/a11y_demo.dart';
void main() {
final header = InboxHeader(unread: 3, lastSync: DateTime(2026, 10, 9), storage: 0.425);
for (final locale in [const Locale('en'), const Locale('es')]) {
testWidgets('header in ${locale.languageCode}', (tester) async {
await tester.pumpWidget(InboxApp(locale: locale, home: Scaffold(body: header)));
await tester.pumpAndSettle(); // localizations load asynchronously
final texts = tester.widgetList<Text>(find.descendant(of: find.byType(InboxHeader), matching: find.byType(Text)));
print('${locale.languageCode}: ${texts.map((t) => t.data).join(' | ')}');
});
}
testWidgets('plural forms', (tester) async {
for (final n in [0, 1, 7]) {
await tester.pumpWidget(InboxApp(locale: const Locale('es'), home: Scaffold(body: InboxHeader(unread: n, lastSync: DateTime(2026), storage: 0))));
await tester.pumpAndSettle();
print('es $n -> ${find.textContaining('leer').evaluate().map((e) => (e.widget as Text).data).single}');
}
});
Future<void> check(WidgetTester tester, Widget row, String name) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(InboxApp(locale: const Locale('en'), home: Scaffold(body: Center(child: row))));
await tester.pumpAndSettle();
final results = <String>[];
for (final (label, guideline) in [
('tap target', androidTapTargetGuideline),
('labeled tap target', labeledTapTargetGuideline),
('text contrast', textContrastGuideline),
]) {
final r = await guideline.evaluate(tester);
results.add('$label=${r.passed ? 'pass' : 'FAIL'}');
}
print('$name: ${results.join(', ')}');
handle.dispose();
}
testWidgets('accessibility guidelines: bad row', (tester) => check(tester, const BadMessageRow(), 'bad '));
testWidgets('accessibility guidelines: good row', (tester) => check(tester, const GoodMessageRow(), 'good'));
testWidgets('merged semantics read as one node', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(const InboxApp(home: Scaffold(body: MessageTile(from: 'Grace', subject: 'Compilers', unread: true))));
await tester.pumpAndSettle();
final node = tester.getSemantics(find.byType(ListTile));
print('screen reader hears: "${node.label.replaceAll('\n', ' / ')}"; tappable=${node.getSemanticsData().hasAction(SemanticsAction.tap)}');
handle.dispose();
});
testWidgets('200% text does not overflow the header', (tester) async {
tester.view.physicalSize = const Size(360, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(InboxApp(
locale: const Locale('es'),
home: Builder(builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(2)),
child: Scaffold(body: header),
)),
));
await tester.pumpAndSettle();
print('overflow errors at 200%: ${tester.takeException() == null ? 'none' : 'yes'}; '
'title height ${tester.getSize(find.text('Bandeja de entrada')).height}');
});
}
$ flutter test test/a/a11y_test.dart
en: Inbox | 3 unread messages | Last synced Oct 9, 2026 | 43% used
es: Bandeja de entrada | 3 mensajes sin leer | Última sincronización: 9 oct 2026 | 43 % usado
es 0 -> No hay mensajes sin leer
es 1 -> 1 mensaje sin leer
es 7 -> 7 mensajes sin leer
bad : tap target=FAIL, labeled tap target=FAIL, text contrast=FAIL
good: tap target=pass, labeled tap target=pass, text contrast=pass
screen reader hears: "Unread / Grace / Compilers"; tappable=true
overflow errors at 200%: none; title height 192.0
00:00 +7: All tests passed!
- Locale formatting came from
intl, not from us. The sameDateTimeprinted asOct 9, 2026and9 oct 2026; the same0.425as43%and43 %— Spanish puts a space before the percent sign. Hand-formatting with string interpolation would have got the Spanish wrong. - Plurals chose the right form for 0, 1 and 7.
- Guidelines: the "bad" row failed all three checks — the 24×24 gesture area is under 48×48, the icon has no label, and
#BBBBBBon white is far below 4.5:1. The "good" row passed all three with anIconButtonwith a tooltip and a theme colour. These guideline checks run in milliseconds; put them in your widget tests for each screen. - Merged semantics: the tile is one node with the label "Unread / Grace / Compilers" (the slashes stand in for the line breaks between merged labels) and a tap action — so a screen reader announces it as one item instead of three.
- 200 % text: the Spanish title wrapped to three lines (192 px tall) on a 360-wide screen, with no overflow, because nothing constrains its height.
Automated checks catch the mechanical problems. They don't tell you whether the experience makes sense: turn on TalkBack or VoiceOver and use your app eyes-closed for five minutes at least once per release.
Right-to-left languages¶
Arabic, Hebrew, Persian and Urdu are written right to left. With GlobalWidgetsLocalizations and an RTL locale, Flutter
mirrors layouts automatically — if you use direction-aware APIs: EdgeInsetsDirectional.only(start: 16) instead of
EdgeInsets.only(left: 16), AlignmentDirectional.centerStart, Rows (which follow text direction). Directional icons need care too:
Material's Icons.arrow_back and Icons.arrow_forward are declared with matchTextDirection: true and flip automatically,
but icons from custom icon fonts only flip if their IconData sets that flag.
How It Actually Works¶
Localization: MaterialApp places a Localizations widget near the root. On startup and when the device locale changes,
it resolves the best supported locale and asks each delegate in localizationsDelegates to load its resources (that's
asynchronous, which is why the tests call pumpAndSettle). The loaded objects are exposed through an InheritedWidget;
AppLocalizations.of(context) looks them up. The generated per-language classes contain plain Dart methods — plural selection
uses intl's plural rules for the locale, and dates and numbers use intl's locale data.
Semantics: when assistive technology is on (or a test calls ensureSemantics), the PipelineOwner adds a semantics
phase after paint. Render objects describe themselves via describeSemanticsConfiguration; the framework combines them
into a tree of SemanticsNodes — merging, excluding and creating boundaries as MergeSemantics, ExcludeSemantics and
Semantics(container: true) direct — and sends incremental updates to the engine, which maps them onto Android's
AccessibilityNodeInfo and iOS's UIAccessibility elements. Guideline tests walk the same tree: the tap-target guideline
checks the size of nodes with tap actions, and the contrast guideline paints the screen and compares text colour with the
pixels behind it.
Common mistakes¶
- Concatenating translated fragments (
t.youHave + ' $n ' + t.messages) — word order and plurals differ by language. Use one message with parameters. - Hard-coded strings left in widgets. Search for
Text('before release. EdgeInsets.only(left:)in apps that will support RTL.- Icons without labels,
GestureDetectorinstead of buttons (no semantics, no focus, no keyboard). - Fixed-height containers around text, which overflow at large text sizes.
- Testing a11y only with automated checks.
Exercise¶
- Add Arabic (
app_ar.arb) with a translation ofunreadCountthat includes Arabic's plural categories, and a test that pumps the header inarand asserts the text direction is RTL. - Make the "bad" row pass all guidelines while keeping a custom look (not an
IconButton): you'll needSemantics, a minimum size, and a colour. - Write a test that runs the three guideline checks on the Level 2 reading-list shelves page. Fix what fails.
- Add a
greetingheader with the user's name and check that a long German-style name doesn't overflow at 200 % text.