Accessibility & Localization
Semantics, screen readers, text scaling, flutter_localizations, ARB, intl, plurals, RTL
- 01
TalkBack lands on the icon in your app bar and says only "button" — what is Flutter reporting, and what is the smallest fix?
EasyAn
Iconis a glyph from a font with no text in it, so the semantics node gets a button role and a tap action but no name for the screen reader to read.// ❌ announced as "button" — the user has no idea what it does AppBar( actions: [ IconButton(icon: const Icon(Icons.search), onPressed: _openSearch), ], ); … - 02
You wire up your first ARB file and
AppLocalizations.of(context)comes back null at runtime — what is missing?EasyAppLocalizations.ofis anInheritedWidgetlookup, so it finds nothing unless the delegate is registered strictly above the context you handed it.// l10n.yaml (project root) // arb-dir: lib/l10n // template-arb-file: app_en.arb // output-localization-file: app_localizations.dart // nullable-getter: false // … - 03
A product card with a title, a price and a tap target is announced as three separate stops — how do you make a screen reader treat it as one button?
MediumGive the card one semantics node of its own with a role and a hand-written label, and stop the children from contributing nodes underneath it.
// ❌ three stops: title, price, and an unnamed tappable region GestureDetector( onTap: _open, child: Column(children: [Text(name), Text('$price EUR'), const Icon(Icons.chevron_right)]), ); … - 04
A filter changes the result count but a screen reader stays silent — how do you announce a change that has no widget of its own?
MediumMark the widget whose text changed as a live region; the direct announcement API is a last resort, and Android now actively discourages it.
// ✅ status text the screen reader re-reads whenever it changes class ResultCount extends StatelessWidget { const ResultCount(this.count, {super.key}); final int count; @override … - 05
Tab and switch control jump around your form in an order that makes no sense, though it looks fine on screen — what decides that order?
MediumGeometry decides it, not the order you wrote the widgets in: the default
ReadingOrderTraversalPolicysorts focusable nodes by position under the currentDirectionality, so any layout that is not a clean reading order traverses badly.// ❌ two visual columns; reading-order traversal zigzags left-right-left-right Row(children: [ Column(children: [TextField(controller: firstName), TextField(controller: lastName)]), Column(children: [TextField(controller: street), TextField(controller: city)]), ]); … - 06
An audit flags your custom chip row for tap target size and contrast but passes the Material buttons beside it — what is Material doing that your widget is not?
MediumMaterial pads a control's hit area out to 48x48 independently of how big it looks, and takes its colours from a
ColorSchemewhose pairs are contrast-checked; aGestureDetectoraround a 24 dp icon gets exactly 24 dp and whatever colour you typed.// ❌ 24 dp target, and the padding is outside the gesture detector Padding( padding: const EdgeInsets.all(12), child: GestureDetector( onTap: _remove, child: const Icon(Icons.close, size: 24, color: Color(0xFFBDBDBD)), // ~2:1 … - 07
A user turns on Reduce Motion and your onboarding still slides and parallaxes — which MediaQuery flags exist, and what should the UI actually change?
MediumFlutter surfaces the platform's accessibility switches on
MediaQueryData, but nothing reads them for you — the single exception isText, which merges inFontWeight.boldon its own when bold text is requested.class Reveal extends StatelessWidget { const Reveal({super.key, required this.child, required this.animation}); final Widget child; final Animation<double> animation; @override … - 08
The Russian build renders "5 товар" instead of "5 товаров" — what is wrong with that message, and how do you write it so a translator can fix it?
MediumThe sentence was assembled in Dart by gluing a number onto a noun, so no translator ever saw the whole message and there was nowhere to express Russian's plural rules; plural selection belongs inside the ARB message as ICU.
// lib/l10n/app_en.arb // { // "@@locale": "en", // "cartItems": "{count, plural, =0{Cart is empty} one{{count} item} other{{count} items}}", // "@cartItems": { // "description": "Item count shown in the cart header", … - 09
A German user sees a price as $1,234.50 and a date as 12/03/2026, and a scheduled event shows the wrong hour — what did the formatting code get wrong?
MediumBoth bugs come from the same shortcut: formatting without a locale, and treating the device's timezone as the user's.
// ❌ pattern and separators frozen to one country, zone taken from the handset final when = DateTime.parse(json['startsAt'] as String).toLocal(); Text('${DateFormat('dd/MM/yyyy HH:mm').format(when)} ' '${NumberFormat('#,##0.00').format(price)} EUR'); // ✅ skeletons + the app's locale, which is not always the device's … - 10
The app comes up in English on a Spanish phone even though es.arb exists — how does Flutter pick a locale, and where does an in-app language switch hook in?
MediumFlutter matches the user's ordered list of preferred locales against
supportedLocales, and an app that is stubbornly English almost always never gotsupportedLocalesandlocalizationsDelegatesat all —WidgetsAppthen defaults to[Locale('en', 'US')].final localeStore = ValueNotifier<Locale?>(null); // null = follow the system Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final prefs = await SharedPreferences.getInstance(); final saved = prefs.getString('locale'); … - 11
In Arabic your row's icon stays on the left and the padding sits on the wrong side — what mirrors automatically in Flutter and what never will?
MediumEverything that reads the ambient
Directionalitymirrors itself, and everything that names a physical side does not — soRowandEdgeInsetsDirectionalflip whileEdgeInsets.only(left:)andPositioned(left:)stay exactly where you put them.// ❌ four physical sides — identical in Arabic, and wrong in all four places Padding( padding: const EdgeInsets.only(left: 16, right: 8), child: Stack(children: [ Align(alignment: Alignment.centerLeft, child: Text(name)), Positioned(left: 0, child: Icon(Icons.chevron_left)), … - 12
Your widget tests assert on find.text('Save') and a German screen still shipped untranslated — how should localized UI actually be tested?
MediumPump the widget inside a real
Localizationsscope and assert against the same generated getter the UI calls, so the test proves the wiring instead of pinning one language's copy.import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:myapp/l10n/app_localizations.dart'; Widget wrap(Widget child, Locale locale) => MaterialApp( locale: locale, // pins this test's language … - 13
The backend returns {"code": "INSUFFICIENT_FUNDS"} and your Bloc stores the sentence it will show — what breaks, and where does translation belong?
HardTranslation belongs at the presentation edge: the data layer turns the wire code into a typed failure, the widget turns that failure into a string with
AppLocalizations, and nothing in between ever holds display text.// Data layer: wire code -> typed failure. Params stay structured. Failure mapFailure(Map<String, dynamic> json) { final params = (json['params'] as Map<String, dynamic>?) ?? const {}; return switch (json['code'] as String) { 'INSUFFICIENT_FUNDS' => InsufficientFunds(params['shortfall'] as String), 'SESSION_EXPIRED' => const SessionExpired(), … - 14
A PR adds an icon-only toolbar — what can a widget test actually prove about its accessibility, and what still needs a device with TalkBack on?
HardWidget tests can prove the mechanical rules — tap-target size, contrast, that every tappable node carries a label — through
meetsGuideline, but nothing automated can tell you whether what the screen reader says makes sense in the order it says it.testWidgets('the editor toolbar meets the mechanical guidelines', (tester) async { final handle = tester.ensureSemantics(); // no client asked -> no semantics tree at all addTearDown(handle.dispose); await tester.pumpWidget(const MaterialApp(home: EditorPage())); … - 15
TalkBack reads your revenue chart as one empty box — how do you give a CustomPainter or a custom RenderObject real semantics?
HardA
CustomPaintcontributes nothing to the semantics tree unless you supplysemanticsBuilder, because a canvas produces pixels and the accessibility bridge only ships nodes.class SparklinePainter extends CustomPainter { SparklinePainter(this.points, this.labels); final List<double> points; final List<String> labels; @override … - 16
You are adding nineteen languages to an app that has one — what goes into CI, and what will break first in German and Japanese?
HardMake one English ARB the single source of truth, move it to and from a translation system through CI, gate the build on completeness — and find the layout damage with pseudo-localization long before a translator sees the app.
// tool/pseudo_loc.dart — run in CI before `flutter gen-l10n` on debug builds. // Turns app_en.arb into a locale that exposes overflow and hardcoded strings. import 'dart:convert'; import 'dart:io'; const _accents = { …