Skip to content

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:

flutter pub add flutter_localizations --sdk=flutter
flutter pub add intl
pubspec.yaml (excerpt)
flutter:
  uses-material-design: true
  generate: true
l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
lib/l10n/app_en.arb
{
  "@@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"}
}
lib/l10n/app_es.arb
{
  "@@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 have few/many/two); translators add the forms their language needs.
  • Typed formats — a DateTime with "format": "yMMMd" or a double with decimalPercentPattern — are formatted per locale by intl.
  • Descriptions (@key.description) are context for translators. Write them; "Archive" alone could be a noun or a verb.

The app and screen

a11y_demo.dart
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 bare Icon inside a GestureDetector provides 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: MergeSemantics turns a row of separate texts into one node read as a sentence; ExcludeSemantics hides decoration.
  • Text scaling: never fix heights around text; let it wrap.
  • Motion: respect MediaQuery.disableAnimationsOf(context) for users who reduce motion.

Testing both

a11y_test.dart
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 same DateTime printed as Oct 9, 2026 and 9 oct 2026; the same 0.425 as 43% and 43 % — 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 #BBBBBB on white is far below 4.5:1. The "good" row passed all three with an IconButton with 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, GestureDetector instead 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

  1. Add Arabic (app_ar.arb) with a translation of unreadCount that includes Arabic's plural categories, and a test that pumps the header in ar and asserts the text direction is RTL.
  2. Make the "bad" row pass all guidelines while keeping a custom look (not an IconButton): you'll need Semantics, a minimum size, and a colour.
  3. Write a test that runs the three guideline checks on the Level 2 reading-list shelves page. Fix what fails.
  4. Add a greeting header with the user's name and check that a long German-style name doesn't overflow at 200 % text.