Steven's Knowledge

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)
  1. MaterialApp.localizationsDelegates registers one or more LocalizationsDelegate instances.
  2. When the platform locale changes (or the app explicitly sets a locale), the Localizations widget calls each delegate's load(Locale) method.
  3. The delegate returns a resource class instance — for your app strings, this is the generated AppLocalizations object.
  4. Widgets access strings via AppLocalizations.of(context)!.someKey.

The Official Approach

The recommended stack is:

  • flutter_localizations (SDK package) — provides MaterialLocalizations, CupertinoLocalizations, and WidgetsLocalizations for 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

FeatureOfficial (gen-l10n)easy_localizationslang
Source formatARB (JSON-based)JSON, YAML, CSV, ARBYAML, JSON, ARB, CSV
Code generationYes (flutter gen-l10n)No (runtime parsing)Yes (build_runner)
Type-safe keysYes (generated methods)No (string keys at runtime)Yes (generated classes)
Plural/gender/selectFull ICU via intlFull ICUFull ICU
Hot reloadRequires regenerationYes (runtime)Requires regeneration
Bundle size impactMinimal (compiled Dart)Slightly larger (ships raw files)Minimal (compiled Dart)
Linked translationsNot built-inNot built-inYes (native feature)
Namespace supportNot built-inPartialYes
CI integrationflutter gen-l10n --no-synthetic-packageN/Adart run slang
MaintenanceFlutter teamCommunityCommunity

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 output

Step 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.arb

Minimal 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-l10n

This 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 valueExample (en_US)Notes
compact1.2MShort numeric representation
compactCurrency$1.2MCompact with currency symbol
compactSimpleCurrency$1.2MUses simple currency name
compactLong1.2 millionWritten-out compact form
currency$1,234,567.89Full currency format
decimalPattern1,234,567.89Grouped decimal
decimalPercentPattern12.70%Percent with decimals
percentPattern13%Rounded percent
scientificPattern1.23E6Scientific notation

Date Formatting Options

format valueExample (en_US)Notes
yMd3/15/2025Numeric date
yMMMdMar 15, 2025Abbreviated month
yMMMMdMarch 15, 2025Full month name
yMMMMEEEEdSaturday, March 15, 2025Full weekday + month
Hm14:3024-hour time
jms2:30:00 PMLocale-aware time with seconds
EEEESaturdayWeekday 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

BugSymptomFix
EdgeInsets.only(left: 16)Content hugs the left in RTLReplace with EdgeInsetsDirectional.only(start: 16)
Row with hardcoded leading/trailingIcon/text order does not flipRow already respects Directionality — do not force order with Align
Canvas drawing with hardcoded x-offsetsCustom paint does not mirrorRead Directionality.of(context) and flip x calculations
Positioned(left: 0) in StackPinned to wrong sideUse PositionedDirectional(start: 0)
TextAlign.leftText aligned to wrong side in RTLUse TextAlign.start
Transform.translate(offset: Offset(-10, 0))Shift goes wrong directionMultiply 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

PlatformARB SupportGit IntegrationKey Features
CrowdinNativeGitHub/GitLab/Bitbucket syncOTA updates, QA checks, MT
LokaliseNativeCLI + GitHub ActionsScreenshots, branching
Phrase (formerly PhraseApp)NativeCLI + CI pluginsIn-context editor, glossary
WeblateVia pluginGit-nativeOpen source, self-hostable
POEditorNativeAPI + GitHubSimple UI, affordable

CI Integration

A typical workflow:

  1. Developers add/modify keys in the template ARB (app_en.arb).
  2. CI pushes updated ARB to the translation platform.
  3. Translators work in the platform UI.
  4. CI pulls translated ARBs back into the repo via a scheduled job or PR webhook.
  5. flutter gen-l10n runs 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 file

Use 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
fi

Never 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.dart

Anti-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 1

2. 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 Row

Run 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

ItemStatus
All user-facing strings in ARB filesRequired
nullable-getter: false in l10n.yamlRecommended
EdgeInsetsDirectional everywhereRequired for RTL
Parameterized messages (no concatenation)Required
DateFormat / NumberFormat with localeRequired
Golden tests for longest-text localeRecommended
ARB completeness test in CIRecommended
Locale persistence (SharedPreferences)Recommended
untranslated-messages-file checked in CIRecommended
Translation platform integratedRecommended for teams

On this page