Lesson 47 of 77 intermediate

Internationalization & Localization

flutter_localizations, ARB files, intl package, RTL, plurals, dynamic locale switching

Open interactive version (quiz + challenge)

Real-world analogy

Internationalization (i18n) is building a restaurant kitchen that can cook any cuisine. Localization (l10n) is actually cooking French food for French customers — writing the specific menus, using local ingredients (date formats, currencies), and respecting table customs (RTL for Arabic). The kitchen infrastructure is i18n; each specific cuisine is l10n.

What is it?

Internationalization and localization make your app usable by people in different countries and languages. For global products — SaaS tools, NGO field apps, fintech platforms — this is a day-one architectural decision. Retrofitting i18n into an established codebase is expensive; doing it from the start is cheap.

Real-world relevance

The NGO survey app deploys in 12 countries. Each field worker sees their device's language automatically. Arabic workers see RTL layout. Survey question counts use proper plural rules ('1 question remaining' vs '3 questions remaining'). Sync timestamps are formatted in local date order. The translation team updates ARB files via Lokalise; CI runs flutter gen-l10n and fails if any keys are missing.

Key points

Code example

// === l10n.yaml ===
/*
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
*/

// === app_en.arb ===
/*
{
  "appTitle": "Survey App",
  "@appTitle": { "description": "Application title" },

  "greetUser": "Hello, {name}!",
  "@greetUser": {
    "placeholders": { "name": { "type": "String" } }
  },

  "pendingCount": "{count, plural, =0{No pending surveys} =1{1 pending survey} other{{count} pending surveys}}",
  "@pendingCount": {
    "placeholders": { "count": { "type": "int" } }
  },

  "claimAmount": "Amount: {amount}",
  "@claimAmount": {
    "placeholders": { "amount": { "type": "String" } }
  }
}
*/

// === app_ar.arb ===
/*
{
  "appTitle": "تطبيق الاستبيان",
  "greetUser": "مرحباً، {name}!",
  "pendingCount": "{count, plural, =0{لا توجد استبيانات} =1{استبيان واحد} =2{استبيانان} few{{count} استبيانات} many{{count} استبياناً} other{{count} استبيان}}",
  "claimAmount": "المبلغ: {amount}"
}
*/

// === MaterialApp setup ===
class MyApp extends StatefulWidget {
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  Locale? _locale;

  void _setLocale(Locale locale) {
    setState(() => _locale = locale);
    SharedPreferences.getInstance().then(
      (prefs) => prefs.setString('locale', locale.languageCode),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      locale: _locale,
      supportedLocales: AppLocalizations.supportedLocales,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      localeResolutionCallback: (deviceLocale, supportedLocales) {
        // Custom fallback: pt_BR -> pt -> en
        if (deviceLocale == null) return supportedLocales.first;
        for (final locale in supportedLocales) {
          if (locale.languageCode == deviceLocale.languageCode) return locale;
        }
        return supportedLocales.first; // Default: English
      },
      home: HomeScreen(onLocaleChange: _setLocale),
    );
  }
}

// === USING LOCALIZATIONS ===
class HomeScreen extends StatelessWidget {
  final void Function(Locale) onLocaleChange;
  const HomeScreen({required this.onLocaleChange, super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);

    return Scaffold(
      appBar: AppBar(title: Text(l10n.appTitle)),
      body: Column(children: [
        Text(l10n.greetUser(name: 'Ahmad')),
        Text(l10n.pendingCount(count: 3)),

        // Locale-aware number formatting
        Text(NumberFormat.currency(
          locale: locale.toLanguageTag(), symbol: '$',
        ).format(1234567.89)),  // -> $1,234,567.89 or locale equivalent

        // Locale-aware date formatting
        Text(DateFormat.yMMMMd(locale.toLanguageTag())
            .format(DateTime.now())),

        // Language switcher
        DropdownButton<Locale>(
          value: locale,
          items: AppLocalizations.supportedLocales.map((l) =>
            DropdownMenuItem(value: l, child: Text(l.languageCode))
          ).toList(),
          onChanged: (l) { if (l != null) onLocaleChange(l); },
        ),
      ]),
    );
  }
}

Line-by-line walkthrough

  1. 1. l10n.yaml tells flutter gen-l10n where ARB files live and what output file to generate — must be at project root
  2. 2. @greetUser object in ARB provides metadata (description, placeholder types) that gen-l10n uses to generate type-safe method signatures
  3. 3. Plural ARB syntax {count, plural, =0{...} =1{...} other{...}} — ICU message format that intl applies correct CLDR plural rules per locale
  4. 4. AppLocalizations.of(context)! — retrieves the localizations instance; throws if localizationsDelegates not configured in MaterialApp
  5. 5. Localizations.localeOf(context) — reads the current active locale for use in intl formatting calls
  6. 6. NumberFormat.currency(locale: locale.toLanguageTag()) — formats according to local numeric conventions (decimal/thousands separators)
  7. 7. DateFormat.yMMMMd(locale.toLanguageTag()) — formats date in locale order (e.g., January 15, 2025 in en; 15 janvier 2025 in fr)
  8. 8. localeResolutionCallback implements custom fallback logic — critical for regional variants like pt_BR not having a separate ARB

Spot the bug

// In HomeScreen
Widget build(BuildContext context) {
  final count = pendingSurveys.length;
  return Text(
    count == 1
        ? '1 pending survey'
        : '$count pending surveys',
  );
}
Need a hint?
This works in English but will break in other languages. What are the two issues?
Show answer
Bug 1 (Hardcoded strings): The text is hardcoded English — it will not translate to any other language. All user-visible strings must be in ARB files and accessed via AppLocalizations.of(context). Bug 2 (Incorrect plural logic): The if count == 1 / else pattern only handles English two-form plurals. Languages like Arabic have six plural forms; Russian has different rules for 11. Use the ARB plural syntax and Intl.plural() which applies CLDR plural rules per locale. Fix: Define 'pendingCount' in all ARB files using ICU plural syntax and use l10n.pendingCount(count: count) in the widget.

Explain like I'm 5

Imagine your app as a multilingual waiter at a restaurant. i18n is training the waiter to be able to speak multiple languages — learning grammar, number rules, how to read a menu backwards (RTL). l10n is actually writing the French menu, the Arabic menu, the Spanish menu. The waiter (app) is infrastructure; the menus (ARB files) are the content.

Fun fact

The word 'internationalization' has 18 characters between the first 'i' and the last 'n' — which is why developers abbreviated it 'i18n'. Similarly, 'localization' has 10 characters between 'l' and 'n' — 'l10n'. These numeronyms were popularised at Digital Equipment Corporation in the 1970s and are now universal in the software industry.

Hands-on challenge

Design the i18n architecture for the NGO survey app supporting English, Arabic (RTL), French, and Spanish: (1) ARB file structure for a plural string 'You have N unanswered questions', (2) how dynamic locale switching works without app restart, (3) what you would check in CI to prevent shipping with missing translations.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery