← Back to Blog

Migrate easy_localization to gen-l10n Without Plural Bugs

fluttergen-l10neasy_localizationarbi18nmigration

Migrate easy_localization to gen-l10n Without Plural Bugs

Your app ships with easy_localization 3.x, and it works — until a typo in a context.tr('hom.title') call renders a raw key in production, or a translator asks why Russian plurals look wrong. The official gen-l10n workflow fixes both classes of problem: every string becomes a compile-time-checked getter (a typo is a build error, not a silent runtime fallback), you get IDE autocomplete, and translations compile into your app instead of loading as runtime JSON assets.

The migration itself is mostly mechanical — except for plurals, where easy_localization and gen-l10n follow different resolution rules. Convert your files naively and strings like “No items” silently become “0 items” in production. This playbook walks through the whole migration and includes a conversion script that handles the plural semantics correctly.

Why plurals break silently in this migration

An easy_localization JSON plural looks like this:

{
  "items": {
    "zero": "No items",
    "one": "One item",
    "two": "A pair of items",
    "other": "{} items"
  }
}

By default, easy_localization 3.x resolves that with exact numeric matching: its ignorePluralRules flag defaults to true, so 0 picks zero, 1 picks one, 2 picks two, and everything else picks other — in every language. The few and many keys are only consulted if you explicitly set ignorePluralRules: false.

gen-l10n compiles ICU MessageFormat, which follows CLDR plural rules strictly. A category keyword like zero or two only matches in languages whose grammar actually has that category. English has exactly two: one and other. So if your conversion produces:

"items": "{count, plural, zero{No items} one{One item} other{{count} items}}"

the zero branch is dead code in English. Passing 0 renders the other branch — “0 items” — and nothing warns you. The same happens to two.

ICU’s escape hatch is the exact-match syntax: =0, =1, =2 always win, in any locale. That’s what the script below emits for zero and two.

There’s a second, subtler shift. Because easy_localization ignored few/many by default, those keys in your Russian, Polish, Ukrainian, or Arabic files were never exercised — they may be missing or wrong, and nobody noticed. Under gen-l10n they’re suddenly live, and a missing few silently falls back to other, which is grammatically wrong for 2–4 in Russian. Also note that CLDR’s one category in Russian covers 21, 31, 41… — so .plural(21) changes output after migration (correctly, per CLDR, but have a native speaker review it).

Step 1: Add gen-l10n next to easy_localization

You don’t need a big-bang cutover — both systems can coexist while you migrate screen by screen.

# pubspec.yaml
dependencies:
  flutter_localizations:
    sdk: flutter
  intl: any

flutter:
  generate: true
# l10n.yaml (project root)
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

Since Flutter 3.32, gen-l10n generates code into your source tree (lib/l10n/) instead of the old synthetic package:flutter_gen, so you import it as import 'l10n/app_localizations.dart';. For every knob available here, see our complete l10n.yaml configuration guide.

Step 2: Convert JSON to ARB with this script

Save as tool/json_to_arb.dart and run dart run tool/json_to_arb.dart assets/translations lib/l10n. It:

  • flattens nested keys (home.titlehomeTitle, since ARB keys become Dart getters),
  • detects plural maps (objects whose keys are only zero/one/two/few/many/other),
  • maps zero=0 and two=2 to preserve easy_localization’s exact-match behavior, keeping one/few/many/other as CLDR keywords,
  • rewrites {} positional args to named placeholders and {} inside plural forms to {count},
  • emits @key placeholder metadata so gen-l10n generates typed method parameters.
import 'dart:convert';
import 'dart:io';

const pluralForms = {'zero', 'one', 'two', 'few', 'many', 'other'};
const icuCategory = {
  'zero': '=0', 'two': '=2', // exact matches: work in every locale
  'one': 'one', 'few': 'few', 'many': 'many', 'other': 'other',
};

void main(List<String> args) {
  final outDir = Directory(args[1])..createSync(recursive: true);
  for (final file in Directory(args[0]).listSync().whereType<File>()) {
    if (!file.path.endsWith('.json')) continue;
    final locale = file.uri.pathSegments.last
        .replaceAll('.json', '')
        .replaceAll('-', '_');
    final arb = <String, dynamic>{'@@locale': locale};
    flatten('', jsonDecode(file.readAsStringSync()) as Map<String, dynamic>, arb);
    File('${outDir.path}/app_$locale.arb')
        .writeAsStringSync(const JsonEncoder.withIndent('  ').convert(arb));
    print('Wrote app_$locale.arb');
  }
}

void flatten(String prefix, Map<String, dynamic> node, Map<String, dynamic> arb) {
  node.forEach((key, value) {
    final name = prefix.isEmpty ? key : '${prefix}_$key';
    if (value is Map<String, dynamic>) {
      value.keys.every(pluralForms.contains) && value.containsKey('other')
          ? writePlural(name, value, arb)
          : flatten(name, value, arb);
    } else if (value is String) {
      var i = 0;
      final text = value.replaceAllMapped('{}', (_) => '{arg${i++}}');
      final key = camelCase(name);
      arb[key] = text;
      final names = RegExp(r'\{(\w+)\}')
          .allMatches(text)
          .map((m) => m.group(1)!)
          .toSet();
      if (names.isNotEmpty) {
        arb['@$key'] = {
          'placeholders': {for (final n in names) n: {'type': 'String'}},
        };
      }
    }
  });
}

void writePlural(String name, Map<String, dynamic> forms, Map<String, dynamic> arb) {
  final icu = StringBuffer('{count, plural,');
  forms.forEach((form, text) {
    final body = (text as String).replaceAll('{}', '{count}');
    icu.write(' ${icuCategory[form]}{$body}');
  });
  icu.write('}');
  final key = camelCase(name);
  arb[key] = icu.toString();
  arb['@$key'] = {'placeholders': {'count': {'type': 'num'}}};
}

String camelCase(String key) {
  final parts = key.split(RegExp(r'[._\-]'))..removeWhere((p) => p.isEmpty);
  return parts.first +
      parts.skip(1).map((p) => p[0].toUpperCase() + p.substring(1)).join();
}

Run flutter gen-l10n afterwards. If a key starts with a digit or a translation contains literal { characters used as text, gen-l10n will fail loudly with the file and key name — fix those by hand.

Step 3: Update MaterialApp — and keep locale switching

During the transition, register both systems:

MaterialApp(
  localizationsDelegates: [
    ...context.localizationDelegates, // easy_localization, during migration
    AppLocalizations.delegate,
  ],
  supportedLocales: AppLocalizations.supportedLocales,
  locale: context.locale,
)

One thing gen-l10n deliberately does not do: runtime locale switching and persistence. easy_localization saved the user’s chosen locale to disk by default; after you remove it, you own the locale: property — store the choice yourself (e.g. shared_preferences) and rebuild MaterialApp when it changes.

Step 4: Swap the call sites

A tiny extension keeps the diff readable:

extension L10nX on BuildContext {
  AppLocalizations get l10n => AppLocalizations.of(this)!;
}

// context.tr('home.title')                        → context.l10n.homeTitle
// 'items'.plural(21)                              → context.l10n.items(21)
// context.tr('greet', namedArgs: {'name': n})     → context.l10n.greet(n)

Migrate feature by feature; when grep -r "\.tr()\|\.plural(" lib/ comes back empty, drop the EasyLocalization wrapper, the package, and the JSON assets.

Step 5: Catch plural regressions before release

Don’t eyeball this — assert it. AppLocalizations.delegate.load() works in plain unit tests, no widget tree needed:

test('plural categories survive migration', () async {
  final en = await AppLocalizations.delegate.load(const Locale('en'));
  expect(en.items(0), 'No items'); // fails if zero wasn't converted to =0

  final ru = await AppLocalizations.delegate.load(const Locale('ru'));
  for (final n in [0, 1, 2, 3, 5, 11, 21, 22, 25, 101]) {
    expect(ru.items(n), isNot(contains('{')));
  }
});

Test the boundary values per language: for Russian and Polish that’s 1 vs 2–4 vs 5+ plus 11–14 and 21; for Arabic, 0, 1, 2, 3–10, 11–99.

The harder problem is coverage you can’t see: a Polish file that never had a few form because easy_localization never used it. FlutterLocalisation’s ARB editor validates ICU plural syntax and flags locales that are missing a plural category their language actually requires — exactly the gap this migration exposes — and gives translators a UI instead of raw ARB.

What you’ll still do by hand

  • Linked translations (@:some.key): ICU has no equivalent; inline the text or restructure.
  • gender() calls: convert to ICU select messages, which gen-l10n supports natively.
  • few/many audit: for every Slavic and Arabic locale, have the forms reviewed — they’re load-bearing now.

Once migrated, your translations are compile-time checked, refactor-safe, and on the toolchain Flutter itself maintains. To keep the ARB files valid as translators edit them across locales, try FlutterLocalisation free — the free tier covers exactly this workflow.