Internationalization
Flutter i18n — ARB files, intl, dynamic locale switching, RTL support, number/date formatting
Internationalization
Flutter ships a first-party localization system that generates type-safe accessors from ARB (Application Resource Bundle) files at build time. This page covers the full production workflow: setup, ARB authoring, runtime locale switching, RTL layout, locale-aware formatting, translation pipelines, testing, and the common mistakes that ship broken text to users.
Architecture Overview
How Flutter Localizations Work
The localization pipeline has three layers:
MaterialApp
└── Localizations widget
└── LocalizationsDelegate<T> (one per localizable resource class)
└── Generated AppLocalizations (type-safe accessors for your strings)MaterialApp.localizationsDelegatesregisters one or moreLocalizationsDelegateinstances.- When the platform locale changes (or the app explicitly sets a locale), the
Localizationswidget calls each delegate'sload(Locale)method. - The delegate returns a resource class instance — for your app strings, this is the generated
AppLocalizationsobject. - Widgets access strings via
AppLocalizations.of(context)!.someKey.
The Official Approach
The recommended stack is:
flutter_localizations(SDK package) — providesMaterialLocalizations,CupertinoLocalizations, andWidgetsLocalizationsfor built-in widget text (e.g. "OK", "Cancel", date picker labels).intl— ICU message format support for plurals, gender, select, and number/date formatting.flutter gen-l10n— code generator that reads ARB files and produces type-safe Dart classes.
Alternative Packages
| Feature | Official (gen-l10n) | easy_localization | slang |
|---|---|---|---|
| Source format | ARB (JSON-based) | JSON, YAML, CSV, ARB | YAML, JSON, ARB, CSV |
| Code generation | Yes (flutter gen-l10n) | No (runtime parsing) | Yes (build_runner) |
| Type-safe keys | Yes (generated methods) | No (string keys at runtime) | Yes (generated classes) |
| Plural/gender/select | Full ICU via intl | Full ICU | Full ICU |
| Hot reload | Requires regeneration | Yes (runtime) | Requires regeneration |
| Bundle size impact | Minimal (compiled Dart) | Slightly larger (ships raw files) | Minimal (compiled Dart) |
| Linked translations | Not built-in | Not built-in | Yes (native feature) |
| Namespace support | Not built-in | Partial | Yes |
| CI integration | flutter gen-l10n --no-synthetic-package | N/A | dart run slang |
| Maintenance | Flutter team | Community | Community |
When to consider alternatives. The official tooling covers 90% of production needs. Reach for slang if you need linked translations, namespaces, or prefer YAML source files. Reach for easy_localization if you need hot-reload of translation files during development without restarting the app, but be aware that runtime string lookup loses compile-time safety.
Setup with Official Tooling
Step 1: pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: any # version managed by the SDK
flutter:
generate: true # enables synthetic package for gen-l10n outputStep 2: l10n.yaml
Create l10n.yaml in the project root:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: false # AppLocalizations.of(context) returns non-nullable
synthetic-package: true # output to .dart_tool (default, no manual import path needed)Set nullable-getter: false in new projects. By default, AppLocalizations.of(context) returns AppLocalizations?, forcing a ! at every call site. Setting this to false makes the getter non-nullable, which is safe as long as AppLocalizations.delegate is registered in your app — and it always should be.
Step 3: Create ARB Files
lib/l10n/
├── app_en.arb (template — must contain all keys + metadata)
├── app_zh.arb
├── app_es.arb
└── app_ar.arbMinimal template ARB (app_en.arb):
{
"@@locale": "en",
"appTitle": "My Shop",
"@appTitle": {
"description": "The title shown in the app bar on the home page"
},
"itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
"@itemCount": {
"description": "Label showing how many items are in the cart",
"placeholders": {
"count": {
"type": "int"
}
}
}
}Step 4: Generate
flutter gen-l10nThis produces AppLocalizations and one subclass per locale under .dart_tool/flutter_gen/gen_l10n/.
Step 5: Wire into the App
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomePage(),
);
}
}
class HomePage extends StatelessWidget {
const HomePage();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.appTitle)),
body: Center(child: Text(l10n.itemCount(3))),
);
}
}The generated localizationsDelegates list already includes GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, and GlobalWidgetsLocalizations.delegate, so you do not need to add them manually.
ARB File Format
ARB is JSON with conventions defined by the Application Resource Bundle Specification. Every key starting with @ is metadata for the preceding key.
Basic Strings
{
"greeting": "Hello!",
"@greeting": {
"description": "Simple greeting shown on launch"
}
}Parameterized Strings
{
"welcome": "Welcome, {userName}!",
"@welcome": {
"description": "Greeting with the user's name",
"placeholders": {
"userName": {
"type": "String",
"example": "Alice"
}
}
}
}Generated accessor: l10n.welcome('Alice') returns "Welcome, Alice!".
Plurals
ICU plural categories: zero, one, two, few, many, other. Not every language uses every category — other is always required.
{
"inboxMessages": "{count, plural, =0{No messages} =1{1 message} other{{count} messages}}",
"@inboxMessages": {
"description": "Inbox badge count",
"placeholders": {
"count": {
"type": "int"
}
}
}
}Arabic uses all six categories:
{
"inboxMessages": "{count, plural, =0{لا رسائل} =1{رسالة واحدة} =2{رسالتان} few{{count} رسائل} many{{count} رسالة} other{{count} رسالة}}"
}Gender
{
"profileHeader": "{gender, select, male{His profile} female{Her profile} other{Their profile}}",
"@profileHeader": {
"description": "Profile page heading, gendered",
"placeholders": {
"gender": {
"type": "String"
}
}
}
}Select (Arbitrary Categories)
{
"orderStatus": "{status, select, pending{Order pending} shipped{Order shipped} delivered{Order delivered} other{Unknown status}}",
"@orderStatus": {
"description": "Human-readable order status label",
"placeholders": {
"status": {
"type": "String"
}
}
}
}Nested Placeholders with Types
Placeholders can carry type, format, optionalParameters, and example:
{
"salesReport": "Revenue: {revenue} on {reportDate}. Growth: {growthRate}",
"@salesReport": {
"description": "Sales dashboard summary line",
"placeholders": {
"revenue": {
"type": "double",
"format": "compactCurrency",
"optionalParameters": {
"decimalDigits": 2,
"symbol": "$"
}
},
"reportDate": {
"type": "DateTime",
"format": "yMMMd"
},
"growthRate": {
"type": "double",
"format": "percentPattern"
}
}
}
}Generated accessor: l10n.salesReport(1234567.89, DateTime(2025, 3, 15), 0.127) produces locale-appropriate output such as "Revenue: $1.2M on Mar 15, 2025. Growth: 13%" in English.
Number Formatting Options
format value | Example (en_US) | Notes |
|---|---|---|
compact | 1.2M | Short numeric representation |
compactCurrency | $1.2M | Compact with currency symbol |
compactSimpleCurrency | $1.2M | Uses simple currency name |
compactLong | 1.2 million | Written-out compact form |
currency | $1,234,567.89 | Full currency format |
decimalPattern | 1,234,567.89 | Grouped decimal |
decimalPercentPattern | 12.70% | Percent with decimals |
percentPattern | 13% | Rounded percent |
scientificPattern | 1.23E6 | Scientific notation |
Date Formatting Options
format value | Example (en_US) | Notes |
|---|---|---|
yMd | 3/15/2025 | Numeric date |
yMMMd | Mar 15, 2025 | Abbreviated month |
yMMMMd | March 15, 2025 | Full month name |
yMMMMEEEEd | Saturday, March 15, 2025 | Full weekday + month |
Hm | 14:30 | 24-hour time |
jms | 2:30:00 PM | Locale-aware time with seconds |
EEEE | Saturday | Weekday name |
Comprehensive ARB Example
{
"@@locale": "en",
"appTitle": "My Shop",
"@appTitle": {
"description": "Application title in the app bar"
},
"welcome": "Welcome, {userName}!",
"@welcome": {
"description": "Personalized greeting",
"placeholders": {
"userName": { "type": "String", "example": "Alice" }
}
},
"cartItemCount": "{count, plural, =0{Your cart is empty} =1{1 item in your cart} other{{count} items in your cart}}",
"@cartItemCount": {
"description": "Cart item count badge",
"placeholders": {
"count": { "type": "int" }
}
},
"lastLogin": "Last login: {date}",
"@lastLogin": {
"description": "Shows when the user last logged in",
"placeholders": {
"date": { "type": "DateTime", "format": "yMMMd" }
}
},
"totalPrice": "Total: {price}",
"@totalPrice": {
"description": "Cart total price",
"placeholders": {
"price": {
"type": "double",
"format": "compactCurrency",
"optionalParameters": { "symbol": "$" }
}
}
},
"orderShippedBy": "{gender, select, male{He shipped your order} female{She shipped your order} other{They shipped your order}}",
"@orderShippedBy": {
"description": "Shipping confirmation with gendered pronoun",
"placeholders": {
"gender": { "type": "String" }
}
}
}Dynamic Locale Switching
Changing Locale at Runtime
The simplest approach is to hold the current Locale in app-level state and pass it to MaterialApp.locale. When this value changes, the framework rebuilds the Localizations widget and reloads all delegates.
Provider-Based Locale Switcher
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class LocaleNotifier extends ChangeNotifier {
static const _prefKey = 'app_locale';
Locale _locale;
Locale get locale => _locale;
LocaleNotifier(Locale initial) : _locale = initial;
/// Load persisted locale, falling back to platform default.
static Future<LocaleNotifier> create() async {
final prefs = await SharedPreferences.getInstance();
final tag = prefs.getString(_prefKey);
final locale = tag != null ? Locale(tag) : WidgetsBinding.instance.platformDispatcher.locale;
return LocaleNotifier(locale);
}
Future<void> setLocale(Locale newLocale) async {
if (_locale == newLocale) return;
_locale = newLocale;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefKey, newLocale.languageCode);
}
}Wire it into the app:
import 'package:provider/provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final localeNotifier = await LocaleNotifier.create();
runApp(
ChangeNotifierProvider.value(
value: localeNotifier,
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp();
@override
Widget build(BuildContext context) {
final locale = context.watch<LocaleNotifier>().locale;
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomePage(),
);
}
}Locale Picker Widget
class LocalePicker extends StatelessWidget {
const LocalePicker();
@override
Widget build(BuildContext context) {
final notifier = context.read<LocaleNotifier>();
return DropdownButton<Locale>(
value: context.watch<LocaleNotifier>().locale,
items: AppLocalizations.supportedLocales.map((locale) {
return DropdownMenuItem(
value: locale,
child: Text(_labelFor(locale)),
);
}).toList(),
onChanged: (locale) {
if (locale != null) notifier.setLocale(locale);
},
);
}
String _labelFor(Locale locale) {
switch (locale.languageCode) {
case 'en': return 'English';
case 'zh': return 'Chinese';
case 'es': return 'Spanish';
case 'ar': return 'Arabic';
default: return locale.languageCode;
}
}
}Riverpod Equivalent
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
final localeProvider = AsyncNotifierProvider<LocaleNotifier, Locale>(LocaleNotifier.new);
class LocaleNotifier extends AsyncNotifier<Locale> {
static const _prefKey = 'app_locale';
@override
Future<Locale> build() async {
final prefs = await SharedPreferences.getInstance();
final tag = prefs.getString(_prefKey);
return tag != null ? Locale(tag) : const Locale('en');
}
Future<void> setLocale(Locale newLocale) async {
state = AsyncData(newLocale);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefKey, newLocale.languageCode);
}
}Do not restart the entire app to switch locales. Rebuilding MaterialApp via state management is sufficient. If you see advice to call Phoenix.rebirth(context) or similar app-restart hacks, it means some widget is caching a localized string in initState instead of reading it in build — fix the widget, not the locale pipeline.
RTL (Right-to-Left) Support
Flutter automatically sets TextDirection based on the active locale when flutter_localizations delegates are registered. Arabic (ar), Hebrew (he), Persian (fa), and Urdu (ur) trigger RTL.
Directionality Widget
You can override direction locally:
Directionality(
textDirection: TextDirection.rtl,
child: MyWidget(),
)In most cases you should not need this — the framework propagates the correct direction from MaterialApp.
EdgeInsetsDirectional
This is the single most common source of RTL bugs. EdgeInsets uses absolute left/right, which do not flip in RTL. Use EdgeInsetsDirectional with start/end instead.
// WRONG: does not flip in RTL
const EdgeInsets.only(left: 16)
// CORRECT: flips start/end based on text direction
const EdgeInsetsDirectional.only(start: 16)The same applies to AlignmentDirectional, BorderDirectional, PositionedDirectional, and PaddingDirectional.
Directional Icons
Some icons are inherently directional:
// WRONG: arrow always points left, even in RTL
Icon(Icons.arrow_back)
// CORRECT: arrow_back_ios flips automatically with Directionality,
// but only if you use it through a widget that respects Directionality.
// Safest approach: use the dedicated directional variants
Icon(Icons.arrow_back) // Does NOT auto-flip
Icon(Icons.arrow_forward) // Does NOT auto-flip
// Use the BackButton widget instead — it handles direction automatically
const BackButton()RTL-Safe Layout Example
class OrderCard extends StatelessWidget {
final String title;
final String price;
final String timeAgo;
const OrderCard({
required this.title,
required this.price,
required this.timeAgo,
});
@override
Widget build(BuildContext context) {
return Card(
// Use EdgeInsetsDirectional, not EdgeInsets
margin: const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 8),
child: Padding(
padding: const EdgeInsetsDirectional.all(12),
child: Row(
children: [
// Leading icon — Row handles ordering in RTL automatically
const Icon(Icons.receipt_long),
const SizedBox(width: 12),
Expanded(
child: Column(
// CrossAxisAlignment.start is direction-aware
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
Text(timeAgo, style: Theme.of(context).textTheme.bodySmall),
],
),
),
Text(price, style: Theme.of(context).textTheme.titleMedium),
],
),
),
);
}
}Common RTL Bugs
| Bug | Symptom | Fix |
|---|---|---|
EdgeInsets.only(left: 16) | Content hugs the left in RTL | Replace with EdgeInsetsDirectional.only(start: 16) |
Row with hardcoded leading/trailing | Icon/text order does not flip | Row already respects Directionality — do not force order with Align |
| Canvas drawing with hardcoded x-offsets | Custom paint does not mirror | Read Directionality.of(context) and flip x calculations |
Positioned(left: 0) in Stack | Pinned to wrong side | Use PositionedDirectional(start: 0) |
TextAlign.left | Text aligned to wrong side in RTL | Use TextAlign.start |
Transform.translate(offset: Offset(-10, 0)) | Shift goes wrong direction | Multiply x by direction factor: Directionality.of(context) == TextDirection.rtl ? 1 : -1 |
Testing RTL
testWidgets('OrderCard renders correctly in RTL', (tester) async {
await tester.pumpWidget(
const Directionality(
textDirection: TextDirection.rtl,
child: MaterialApp(
home: Scaffold(
body: OrderCard(
title: 'Test Order',
price: '\$42.00',
timeAgo: '2h ago',
),
),
),
),
);
// Verify the price appears to the left of the title in RTL
final titleOffset = tester.getTopLeft(find.text('Test Order'));
final priceOffset = tester.getTopLeft(find.text('\$42.00'));
expect(priceOffset.dx, lessThan(titleOffset.dx));
});Number, Date, and Currency Formatting
NumberFormat
The intl package provides NumberFormat with locale-aware output:
import 'package:intl/intl.dart';
// Decimal grouping: locale-dependent separators
NumberFormat.decimalPattern('en_US').format(1234567.89); // "1,234,567.89"
NumberFormat.decimalPattern('de_DE').format(1234567.89); // "1.234.567,89"
// Currency
NumberFormat.currency(locale: 'en_US', symbol: '\$').format(1234.5); // "\$1,234.50"
NumberFormat.currency(locale: 'ja_JP', symbol: '¥').format(1234); // "¥1,234"
// Compact
NumberFormat.compact(locale: 'en').format(1200000); // "1.2M"
NumberFormat.compact(locale: 'zh').format(1200000); // "120万"
// Percent
NumberFormat.percentPattern('en').format(0.127); // "13%"DateFormat
import 'package:intl/intl.dart';
final now = DateTime(2025, 3, 15, 14, 30);
DateFormat.yMMMd('en_US').format(now); // "Mar 15, 2025"
DateFormat.yMMMd('zh_CN').format(now); // "2025年3月15日"
DateFormat.yMMMd('de_DE').format(now); // "15. Mär. 2025"
DateFormat.jms('en_US').format(now); // "2:30:00 PM"
DateFormat.jms('de_DE').format(now); // "14:30:00"
DateFormat.EEEE('en').format(now); // "Saturday"
DateFormat.EEEE('ar').format(now); // "السبت"Locale-Aware Formatter Utility
class AppFormatters {
final String locale;
AppFormatters(this.locale);
/// Creates an instance from the current BuildContext locale.
factory AppFormatters.of(BuildContext context) {
return AppFormatters(Localizations.localeOf(context).toString());
}
String decimal(num value) => NumberFormat.decimalPattern(locale).format(value);
String currency(double value, {String symbol = '\$'}) =>
NumberFormat.currency(locale: locale, symbol: symbol).format(value);
String compact(num value) => NumberFormat.compact(locale: locale).format(value);
String percent(double value) => NumberFormat.percentPattern(locale).format(value);
String dateShort(DateTime dt) => DateFormat.yMd(locale).format(dt);
String dateMedium(DateTime dt) => DateFormat.yMMMd(locale).format(dt);
String dateLong(DateTime dt) => DateFormat.yMMMMEEEEd(locale).format(dt);
String time(DateTime dt) => DateFormat.jms(locale).format(dt);
/// Relative time ("2 hours ago", "just now").
String relativeTime(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inSeconds < 60) return 'just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return dateMedium(dt);
}
}The relativeTime method above is English-only. For production relative-time formatting across locales, use a package like timeago which ships its own locale data, or build locale-specific ICU messages in your ARB files with plural forms for each time unit.
Using Formatters in Widgets
class PriceBanner extends StatelessWidget {
final double price;
final DateTime lastUpdated;
const PriceBanner({required this.price, required this.lastUpdated});
@override
Widget build(BuildContext context) {
final fmt = AppFormatters.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(fmt.currency(price), style: Theme.of(context).textTheme.headlineMedium),
Text('Updated: ${fmt.dateMedium(lastUpdated)}'),
],
);
}
}Translation Workflow
ARB Metadata for Translators
Good descriptions and examples save costly back-and-forth with translators:
{
"checkoutButton": "Checkout ({count})",
"@checkoutButton": {
"description": "Label on the checkout button in the cart. The count is the number of items being purchased.",
"placeholders": {
"count": {
"type": "int",
"example": "3"
}
}
}
}The description field is your translator's only context. Write it as if the translator has never seen your app.
Translation Management Platforms
| Platform | ARB Support | Git Integration | Key Features |
|---|---|---|---|
| Crowdin | Native | GitHub/GitLab/Bitbucket sync | OTA updates, QA checks, MT |
| Lokalise | Native | CLI + GitHub Actions | Screenshots, branching |
| Phrase (formerly PhraseApp) | Native | CLI + CI plugins | In-context editor, glossary |
| Weblate | Via plugin | Git-native | Open source, self-hostable |
| POEditor | Native | API + GitHub | Simple UI, affordable |
CI Integration
A typical workflow:
- Developers add/modify keys in the template ARB (
app_en.arb). - CI pushes updated ARB to the translation platform.
- Translators work in the platform UI.
- CI pulls translated ARBs back into the repo via a scheduled job or PR webhook.
flutter gen-l10nruns in CI and fails the build if any ARB is malformed.
# .github/workflows/l10n.yml (simplified)
name: Sync translations
on:
push:
paths:
- 'lib/l10n/app_en.arb'
jobs:
upload:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Upload source to Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
crowdin_branch_name: ${{ github.ref_name }}
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_TOKEN }}Handling Missing Translations
When a key exists in app_en.arb but not in app_es.arb, gen-l10n falls back to the template locale. You can control this:
# l10n.yaml
untranslated-messages-file: l10n_untranslated.txt # writes missing keys to a fileUse this file in CI to fail the build or warn when translations are incomplete:
#!/bin/bash
# ci/check_translations.sh
if [ -s l10n_untranslated.txt ]; then
echo "ERROR: Untranslated keys found:"
cat l10n_untranslated.txt
exit 1
fiNever ship a release with untranslated-messages-file errors silenced. Users seeing English strings in an otherwise fully translated app is a poor experience. Either translate all keys or explicitly mark partial-support locales as beta in your app's language picker.
Testing i18n
Testing Localized Widgets
Every testWidgets that exercises localized strings must supply the delegates and locale:
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
Widget buildTestableWidget(Widget child, {Locale locale = const Locale('en')}) {
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: child,
);
}
testWidgets('home page shows localized title in Spanish', (tester) async {
await tester.pumpWidget(
buildTestableWidget(const HomePage(), locale: const Locale('es')),
);
await tester.pumpAndSettle(); // wait for async delegate loading
expect(find.text('Mi Tienda'), findsOneWidget);
});Golden Tests with Multiple Locales
Golden tests catch layout breakage caused by longer translations:
testWidgets('checkout button golden - English', (tester) async {
await tester.pumpWidget(
buildTestableWidget(const CheckoutButton(itemCount: 3)),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(CheckoutButton),
matchesGoldenFile('goldens/checkout_button_en.png'),
);
});
testWidgets('checkout button golden - German', (tester) async {
await tester.pumpWidget(
buildTestableWidget(
const CheckoutButton(itemCount: 3),
locale: const Locale('de'),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(CheckoutButton),
matchesGoldenFile('goldens/checkout_button_de.png'),
);
});Validating ARB Key Completeness
Write a test that parses all ARB files and asserts every key from the template exists in every translation:
import 'dart:convert';
import 'dart:io';
import 'package:test/test.dart';
void main() {
test('all ARB files contain every key from the template', () {
final l10nDir = Directory('lib/l10n');
final templateFile = File('${l10nDir.path}/app_en.arb');
final templateKeys = _extractKeys(templateFile);
for (final file in l10nDir.listSync().whereType<File>()) {
if (file.path == templateFile.path) continue;
if (!file.path.endsWith('.arb')) continue;
final translatedKeys = _extractKeys(file);
final missing = templateKeys.difference(translatedKeys);
expect(
missing,
isEmpty,
reason: '${file.path} is missing keys: $missing',
);
}
});
}
Set<String> _extractKeys(File file) {
final map = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
return map.keys.where((k) => !k.startsWith('@')).toSet();
}CI Check Script
# In your CI pipeline
- name: Verify translations
run: |
flutter gen-l10n
dart test test/l10n_completeness_test.dartAnti-Patterns
1. Hardcoded Strings in Widgets
// WRONG
Text('Welcome back!')
// CORRECT
Text(AppLocalizations.of(context).welcomeBack)Enable a custom lint or grep-based CI check to catch raw string literals in widget files. Some teams use avoid_hard_coded_strings from the custom_lint package or a simple regex in CI:
# Catches obvious hardcoded user-facing strings in lib/
grep -rn "Text('[A-Z]" lib/ --include="*.dart" && echo "FAIL: hardcoded strings found" && exit 12. Concatenating Translated Strings
Word order varies across languages. Never assemble sentences from parts:
// WRONG: word order breaks in Japanese, Arabic, German, etc.
Text(l10n.hello + ', ' + userName + '!')
// CORRECT: use a parameterized message
Text(l10n.greeting(userName))The ARB entry handles word order per locale:
{
"greeting": "Hello, {userName}!",
"greeting_ja": "{userName}さん、こんにちは!"
}3. Assuming Text Length
German text is roughly 30% longer than English. Chinese text is roughly 50% shorter but may be taller. Never hardcode widths for text containers:
// WRONG: will overflow in German
SizedBox(width: 100, child: Text(l10n.submitButton))
// CORRECT: let the layout breathe
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 80),
child: Text(l10n.submitButton),
)
// Or simply use Flexible/Expanded in a RowRun golden tests in your longest-text locale (often German or Finnish) to catch overflow early. If a layout breaks at 130% English length, it will break in production for some language.
4. Hardcoded Date and Number Formats
// WRONG: assumes US date format
Text('${date.month}/${date.day}/${date.year}')
// CORRECT: locale-aware
Text(DateFormat.yMd(Localizations.localeOf(context).toString()).format(date))Users in Germany expect 15.03.2025, Japan expects 2025/03/15, and the US expects 3/15/2025. There is no universal format — always use DateFormat with the current locale.
5. Using EdgeInsets Instead of EdgeInsetsDirectional
Covered in the RTL section above, but worth repeating as an anti-pattern because it is the most frequently shipped RTL bug:
// WRONG
padding: const EdgeInsets.only(left: 16, right: 8)
// CORRECT
padding: const EdgeInsetsDirectional.only(start: 16, end: 8)6. Caching Localized Strings in initState
// WRONG: string is captured once and never updates on locale change
class _MyState extends State<MyWidget> {
late final String title;
@override
void initState() {
super.initState();
title = AppLocalizations.of(context).pageTitle; // also crashes — no InheritedWidget in initState
}
}
// CORRECT: read in build, which re-runs on locale change
class _MyState extends State<MyWidget> {
@override
Widget build(BuildContext context) {
final title = AppLocalizations.of(context).pageTitle;
return Text(title);
}
}7. Ignoring Locale in Tests
Tests that do not set a locale get the platform default, which varies across CI machines. Always set the locale explicitly in widget tests to get deterministic results.
Summary: i18n Checklist
| Item | Status |
|---|---|
| All user-facing strings in ARB files | Required |
nullable-getter: false in l10n.yaml | Recommended |
EdgeInsetsDirectional everywhere | Required for RTL |
| Parameterized messages (no concatenation) | Required |
DateFormat / NumberFormat with locale | Required |
| Golden tests for longest-text locale | Recommended |
| ARB completeness test in CI | Recommended |
| Locale persistence (SharedPreferences) | Recommended |
untranslated-messages-file checked in CI | Recommended |
| Translation platform integrated | Recommended for teams |