Localization is the feature clients ask for last and regret budgeting for last. Someone sells into Germany, the Gulf, or Quebec, and a codebase full of hard-coded English strings, "$count items" interpolations, and EdgeInsets.only(left: 16) suddenly needs a rewrite that nobody scheduled.
This tutorial is the version of Flutter localization we actually deploy on client work: ARB files driven by gen_l10n, ICU plurals and placeholders, locale-aware formatting, real right-to-left support, a translator handoff that does not run through a spreadsheet, and CI checks that fail the build before an untranslated string reaches a store listing. Examples target Flutter 3.4x with Dart 3.x.
Turn on gen_l10n first
Flutter's first-party pipeline generates typed accessors from ARB (Application Resource Bundle) files at build time. No third-party package, no runtime file loading, no missing-key crashes at runtime — a missing key is a compile error.
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: any # let the Flutter SDK pin the version
flutter:
generate: true
# l10n.yaml (project root)
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: false
untranslated-messages-file: build/l10n_untranslated.json
synthetic-package: false
output-dir: lib/l10n/generated
Two of those options matter more than the rest. nullable-getter: false means AppLocalizations.of(context) returns a non-nullable object, so you stop writing ! on every call site. untranslated-messages-file writes a machine-readable report of every key missing from a locale — that file is what CI will read later.
Wire it into the app:
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
home: const HomeScreen(),
);
Use onGenerateTitle, not title. The app title needs a BuildContext below the localization delegates, and it needs to change when the user changes locale.
ARB conventions that survive a translator
An ARB file is JSON with metadata. The template locale (usually app_en.arb) carries descriptions; other locales carry only translations.
{
"@@locale": "en",
"appTitle": "Field Service",
"@appTitle": {
"description": "Application name shown in the task switcher and app bar."
},
"jobsDueToday": "{count, plural, =0{No jobs due today} =1{1 job due today} other{{count} jobs due today}}",
"@jobsDueToday": {
"description": "Dashboard summary of jobs scheduled for the current day.",
"placeholders": {
"count": { "type": "int", "format": "decimalPattern" }
}
},
"greeting": "Hi {firstName}",
"@greeting": {
"description": "Greeting on the dashboard. firstName is user-entered and may be any script.",
"placeholders": {
"firstName": { "type": "String" }
}
},
"invoiceTotal": "Total {amount}",
"@invoiceTotal": {
"description": "Invoice footer total.",
"placeholders": {
"amount": {
"type": "double",
"format": "currency",
"optionalParameters": { "decimalDigits": 2 }
}
}
}
}
Rules we enforce on every project:
- Key by meaning, not by screen.
jobsDueToday, notdashboardLine2. Screens get redesigned; meanings do not. - Always write a
description. A translator seeing"Open"cannot tell an adjective from a verb. German needsOffenorÖffnenand will pick wrong without context. - Never concatenate.
l10n.jobsDue + " " + count.toString()is unfixable in languages where the number leads or the noun inflects. One key, one whole sentence, placeholders inside it. - Keep the template locale as the only source of new keys. Developers edit
app_en.arb; every other file is written by the translation pipeline.
Plurals, select, and the languages that break your assumptions
English has two plural categories. Arabic has six (zero, one, two, few, many, other). Russian has four. Japanese has one. Any code that does count == 1 ? singular : plural is already wrong for most of the market you are localizing into.
ICU plural handles it, and translators can add categories their language needs without a code change:
"unreadMessages": "{count, plural, =0{No unread messages} one{1 unread message} other{{count} unread messages}}"
Use select for grammatical gender or enum-like variation rather than branching in Dart:
"assignedBy": "{gender, select, male{Assigned by {name}} female{Assigned by {name}} other{Assigned by {name}}}"
That looks redundant in English and is exactly the point: French, Spanish, and Hebrew translators need the fork to exist, and it must exist in the ARB, not in a widget.
Call sites stay clean:
final l10n = AppLocalizations.of(context);
Text(l10n.unreadMessages(unread.length));
Text(l10n.invoiceTotal(invoice.total));
Formatting: never hand-roll a date or a number
intl formats dates, numbers, and currency per locale. The locale must come from the widget tree, not from the device default, or a user who overrides in-app language sees mixed formats.
String formatDueDate(BuildContext context, DateTime due) {
final locale = Localizations.localeOf(context).toString();
return DateFormat.yMMMEd(locale).format(due.toLocal());
}
String formatMoney(BuildContext context, double amount, String currencyCode) {
final locale = Localizations.localeOf(context).toString();
return NumberFormat.simpleCurrency(locale: locale, name: currencyCode).format(amount);
}
Three traps we see repeatedly:
- Currency code is data, not locale. A euro price shown to a US user is still euros. Format with the user's locale, but the currency's code.
- Store and transmit UTC; format in local time.
toLocal()at the render boundary only. - Locale-aware sorting and casing.
toUpperCase()on Turkish text turnsiintoIinstead ofİ. Avoid forcing case on user data; if you must, pass a locale.
RTL is a layout problem, not a translation problem
Add Arabic or Hebrew and the entire UI mirrors. Flutter does most of it for you — but only if you have written directional code.
Replace every left/right with start/end.
// wrong: stays on the physical left in Arabic
padding: const EdgeInsets.only(left: 16),
alignment: Alignment.centerLeft,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(12)),
// right: mirrors automatically
padding: const EdgeInsetsDirectional.only(start: 16),
alignment: AlignmentDirectional.centerStart,
borderRadius: const BorderRadiusDirectional.only(topStart: Radius.circular(12)),
The same applies to PositionedDirectional, TextAlign.start/end instead of left/right, and Directionality.of(context) when you genuinely need to branch.
Mirror the icons that encode direction, not the ones that don't. A back chevron mirrors; a play button and a company logo do not.
Icon(Icons.arrow_back) // auto-mirrors in RTL
Transform.flip(flipX: Directionality.of(context) == TextDirection.rtl, child: customChevron)
Watch bidirectional text. An Arabic sentence containing a Latin product name or a phone number will reorder in ways that look like a bug but are correct Unicode bidi behaviour. Wrap genuinely LTR content — code snippets, IDs, URLs — in its own Directionality(textDirection: TextDirection.ltr, ...).
Check the fonts. Roboto covers Latin and Cyrillic but not Arabic, Hebrew, Thai, or CJK. Either bundle a font with the coverage you need or set a fontFamilyFallback chain, then test on both platforms — silently rendering tofu boxes on Android while iOS looks fine is a classic release-day surprise.
You can preview RTL without changing your device language:
MaterialApp(
locale: const Locale('ar'),
builder: (context, child) => Directionality(
textDirection: TextDirection.rtl,
child: child!,
),
);
Text expansion will break your layout
German runs 20–35% longer than English; Finnish and Russian are worse; short button labels can double. Design reviews done in English hide all of it.
Mitigations that actually hold:
- Avoid fixed-width buttons and single-line
Rows of chips. PreferWrap,Flexible, and intrinsic sizing. - Allow two lines on primary actions rather than truncating:
maxLines: 2, overflow: TextOverflow.ellipsis. - Test at large text scale and a long locale together:
MediaQuery(data: MediaQueryData(textScaler: TextScaler.linear(1.3)))withdeloaded. That combination is where overflow actually appears. - Use a pseudo-locale during development. Generate an
app_en_XA.arbwhere every string is padded and accented ([!!! Ĵöƀš ďüé ťöďàý !!!]). It surfaces hard-coded strings and tight layouts in one pass, before you spend money on translators.
Translator handoff without spreadsheets
ARB files are the interchange format, so the pipeline is simple:
- Developers only ever edit
lib/l10n/app_en.arb. - CI pushes the template to the translation platform (Crowdin, Lokalise, Localazy, Transifex — all of them speak ARB) on merge to
main. - Translators work there; a bot opens a PR back with updated
app_de.arb,app_ar.arb, and friends. flutter gen-l10nruns in the build; nobody hand-edits generated Dart.
If a client is not ready to pay for a platform, the same shape works with a l10n/ directory in the repo and a CODEOWNERS rule — the important part is that the template file is the single write path.
One governance rule saves the most pain: an untranslated key must not fall back silently forever. gen_l10n falls back to the template locale, which is correct behaviour at runtime but hides debt. Track it, in CI.
CI checks that stop the bleeding
Four cheap gates, all runnable in GitHub Actions alongside your existing Flutter workflow.
1. Generation is clean and the untranslated report is empty for shipping locales.
flutter gen-l10n
test ! -s build/l10n_untranslated.json || {
echo "Untranslated strings:"; cat build/l10n_untranslated.json; exit 1; }
For projects mid-rollout, allow-list locales that are still in translation instead of disabling the check entirely.
2. Every ARB key has a description in the template. A ten-line Dart script in tool/ that parses app_en.arb and fails on any key whose @key.description is missing or empty. Cheap, and it stops the "what does 'Open' mean?" email thread.
3. No hard-coded user-facing strings. Enable the lint and treat it as an error in CI:
# analysis_options.yaml
linter:
rules:
- prefer_const_constructors
analyzer:
errors:
# flags string literals passed where a localized value belongs
prefer_text_rich: ignore
The practical version is a repo grep in tool/check_hardcoded_strings.dart that flags Text('...') with a non-empty literal outside test/ and lib/l10n/, with an opt-out comment for the genuine exceptions (brand names, debug screens). Imperfect, and still catches most regressions.
4. Golden tests per locale. You already have goldens if you followed our test-suite tutorial; render the two or three densest screens in en, de, and ar:
for (final locale in const [Locale('en'), Locale('de'), Locale('ar')]) {
testWidgets('invoice screen renders in $locale', (tester) async {
await tester.pumpWidget(MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const InvoiceScreen(),
));
await tester.pumpAndSettle();
await expectLater(
find.byType(InvoiceScreen),
matchesGoldenFile('goldens/invoice_$locale.png'),
);
});
}
The Arabic golden is the valuable one: it catches un-mirrored padding, missing glyphs, and clipped text in a single image diff.
Store metadata and the parts outside your Dart code
The app can be perfectly localized and still ship broken:
- App name and permission strings. iOS
InfoPlist.stringsper.lproj, Androidres/values-de/strings.xml.gen_l10ndoes not touch either. - Push notification copy. Payload text is built on the server; the server needs the user's locale, so store it on the profile at sign-in and update it when the user changes language in-app.
- Store listings. Play Console and App Store Connect localizations are separate uploads; add them to your release checklist.
- In-app language switcher. If you offer one, persist the choice and expose it to the OS on iOS via
CFBundleLocalizations, and remember thatLocalizations.localeOf— notPlatform.localeName— is the truth inside the app.
A rollout order that works
For a mid-size app being localized after the fact, we sequence it like this:
- Wire up
gen_l10nand move strings screen by screen behind a pseudo-locale, merging continuously. No big-bang branch. - Add the CI checks as warnings, then flip them to errors once the backlog is clear.
- Convert layout to directional insets and add the
argolden — before any Arabic translation exists, using pseudo-text. - Only then engage translators. Paying for translation of strings that are still being reworded is the most common way to waste the localization budget.
Done in that order, adding the fourth and fifth language costs a pull request instead of a project.
Localizing a Flutter app, or planning a launch into new markets? AviaryApps supplies senior Flutter consultants who have taken apps through RTL rollouts, translation pipelines, and store-level localization. Get in touch to talk scope and staffing.