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.title→homeTitle, since ARB keys become Dart getters), - detects plural maps (objects whose keys are only
zero/one/two/few/many/other), - maps
zero→=0andtwo→=2to preserve easy_localization’s exact-match behavior, keepingone/few/many/otheras CLDR keywords, - rewrites
{}positional args to named placeholders and{}inside plural forms to{count}, - emits
@keyplaceholder 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 ICUselectmessages, which gen-l10n supports natively.few/manyaudit: 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.