Internationalization in Flutter: solved exercise with flutter_localizations and ARB files

Internationalization in Flutter: solved exercise with flutter_localizations and ARB files

Internationalization (i18n) in Flutter is built on flutter_localizations + the gen-l10n code generator. ARB (Application Resource Bundle) files are the standard translation format: annotated JSON that the generator converts into typed Dart classes.

Problem statement

Build a multilingual app that:

  • Supports English and Spanish via ARB files.
  • Uses AppLocalizations auto-generated with flutter gen-l10n.
  • Shows translations with variable arguments (username).
  • Uses correct pluralization (itemCount).
  • Shows the date formatted according to the active locale.

Dependencies and configuration

pubspec.yaml

1
2
3
4
5
6
7
8
9
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  intl: ^0.19.0

flutter:
  generate: true # enables gen-l10n

l10n.yaml (in the project root)

1
2
3
4
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations

lib/l10n/app_en.arb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
{
  "@@locale": "en",

  "appTitle": "My Multilanguage App",
  "@appTitle": {
    "description": "Application title"
  },

  "greeting": "Hello, {name}",
  "@greeting": {
    "description": "Personalized greeting",
    "placeholders": {
      "name": {
        "type": "String",
        "example": "Alice"
      }
    }
  },

  "itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
  "@itemCount": {
    "description": "Item count with pluralization",
    "placeholders": {
      "count": {
        "type": "int"
      }
    }
  },

  "currentDate": "Current date: {date}",
  "@currentDate": {
    "description": "Date formatted according to locale",
    "placeholders": {
      "date": {
        "type": "DateTime",
        "format": "yMMMMd",
        "isCustomDateFormat": "true"
      }
    }
  },

  "settingsTitle": "Settings",
  "languageLabel": "Language",
  "addItem": "Add item",
  "removeItem": "Remove item",
  "welcomeMessage": "Welcome to the internationalization app"
}

lib/l10n/app_es.arb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "@@locale": "es",

  "appTitle": "Mi App Multiidioma",
  "greeting": "Hola, {name}",
  "itemCount": "{count, plural, =0{Sin elementos} =1{1 elemento} other{{count} elementos}}",
  "currentDate": "Fecha actual: {date}",
  "settingsTitle": "ConfiguraciΓ³n",
  "languageLabel": "Idioma",
  "addItem": "AΓ±adir elemento",
  "removeItem": "Eliminar elemento",
  "welcomeMessage": "Bienvenido a la app de internacionalizaciΓ³n"
}

After creating the ARB files, run:

1
flutter gen-l10n

This generates lib/gen/app_localizations.dart (or inside .dart_tool/flutter_gen/).

Full solution

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter i18n Demo',
      localizationsDelegates: const [
        AppLocalizations.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      supportedLocales: const [
        Locale('en'), // English β€” default
        Locale('es'), // Spanish
      ],
      home: const LocalizationDemo(),
    );
  }
}

class LocalizationDemo extends StatefulWidget {
  const LocalizationDemo({super.key});
  @override
  State<LocalizationDemo> createState() => _LocalizationDemoState();
}

class _LocalizationDemoState extends State<LocalizationDemo> {
  int _itemCount = 0;
  final String _userName = 'Alice';

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

    return Scaffold(
      appBar: AppBar(title: Text(l10n.appTitle)),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // ── Greeting with argument ───────────────────────────────────
            Card(
              child: ListTile(
                leading: const Icon(Icons.person),
                title: Text(l10n.greeting(_userName)),
                subtitle: Text(l10n.welcomeMessage),
              ),
            ),

            const SizedBox(height: 16),

            // ── Formatted date ───────────────────────────────────────────
            Card(
              child: ListTile(
                leading: const Icon(Icons.calendar_today),
                title: Text(l10n.currentDate(DateTime.now())),
              ),
            ),

            const SizedBox(height: 16),

            // ── Pluralization ────────────────────────────────────────────
            Card(
              child: Column(
                children: [
                  ListTile(
                    leading: const Icon(Icons.list),
                    title: Text(l10n.itemCount(_itemCount)),
                    trailing: Text(
                      '$_itemCount',
                      style: Theme.of(context).textTheme.headlineMedium,
                    ),
                  ),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      TextButton.icon(
                        onPressed: () => setState(() => _itemCount++),
                        icon: const Icon(Icons.add),
                        label: Text(l10n.addItem),
                      ),
                      TextButton.icon(
                        onPressed: _itemCount > 0
                            ? () => setState(() => _itemCount--)
                            : null,
                        icon: const Icon(Icons.remove),
                        label: Text(l10n.removeItem),
                      ),
                    ],
                  ),
                  const SizedBox(height: 8),
                ],
              ),
            ),

            const Spacer(),
            _LocaleInfo(),
          ],
        ),
      ),
    );
  }
}

class _LocaleInfo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final locale = Localizations.localeOf(context);
    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Colors.blue.shade50,
        borderRadius: BorderRadius.circular(8),
        border: Border.all(color: Colors.blue.shade200),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('Active locale: ${locale.toLanguageTag()}',
              style: const TextStyle(fontWeight: FontWeight.bold)),
          Text('Language code: ${locale.languageCode}'),
          if (locale.countryCode != null) Text('Country: ${locale.countryCode}'),
          const Text(
            'Change the device language to see the automatic switch.',
            style: TextStyle(fontSize: 12, color: Colors.grey),
          ),
        ],
      ),
    );
  }
}

ARB file structure

FieldUsage
@@localeDeclares the file language ("en", "es")
"key": "value"Simple translation
"@key"Key metadata (description, placeholders)
{param}Positional String argument
{count, plural, ...}Pluralization via ICU MessageFormat
{date} with "type": "DateTime"Date formatted with intl per locale

Common mistakes

  • Forgetting flutter: generate: true in pubspec.yaml: without this line, flutter gen-l10n does not generate Dart files and the AppLocalizations import does not exist.
  • Using AppLocalizations.of(context) without !: in a correctly configured MaterialApp it never returns null inside the widget tree. Use ! or check for null only for screens outside the localization tree.
  • Not adding GlobalMaterialLocalizations.delegate: without this delegate, native Material strings (OK button, Cancel, date picker) won’t be translated even if your content is.
  • ARB with incorrect plural syntax: the ICU format is {count, plural, =0{...} =1{...} other{...}}. The other case is mandatory β€” if you omit it, gen-l10n fails with a cryptic error.

Practical use

Any app published in multiple markets needs i18n: productivity apps, e-commerce, and SaaS tools. The key is implementing it from the start β€” retrofitting i18n into a large app is expensive.

Guided practice and next step

FAQ

Should I use gen-l10n or the intl_utils package?

gen-l10n is the official Flutter team solution and requires no extra dependencies. intl_utils is a popular alternative with VS Code and Android Studio integration, but adds an extra dev dependency. For new projects, use gen-l10n.

How does pluralization work in languages with more than two forms?

ICU MessageFormat supports the categories zero, one, two, few, many, other. The locale determines how many forms a language uses: English uses only one/other, Polish uses one/few/many/other. The intl library automatically handles selection by locale.

Can ARB files be shared with other platforms?

Yes. ARB is also the standard format for Flutter Web, macOS, and Desktop. Some teams reuse the same ARB files on Android (with android-arb-plugin) and in iOS/macOS projects semi-automatically.