← Back to Blog

Flutter Plurals for Arabic, Polish, Russian: Fix few/many

fluttergen-l10npluralsarbiculocalization

Flutter Plurals for Arabic, Polish, Russian: Fix few/many

You wrote {count, plural, one{1 file} other{{count} files}} in app_en.arb, copied the structure into app_pl.arb, app_ru.arb and app_ar.arb, translated the two strings, and shipped. flutter gen-l10n ran clean. The app runs fine. And Polish users now read "5 pliki" (wrong) where it should say "5 plików", while Arabic users see one generic form for 3, 11 and 100.

This post covers why Flutter doesn't catch this, the exact plural categories each language needs, ARB blocks you can copy, and a short test that fails when a locale is missing a form.

Why gen-l10n compiles happily with only one/other

gen-l10n checks ICU syntax, not linguistics. In the flutter_tools source, the plural parser checks that each case name is one of =0, =1, =2, zero, one, two, few, many or other, and it warns when a case appears twice. It never checks whether a locale actually needs few or many. The only cross-locale check compares placeholder types against the template ARB.

The generated code then hands everything to Intl.pluralLogic from package:intl. Roughly, for each locale:

String filesCount(int count) {
  String _temp0 = intl.Intl.pluralLogic(
    count,
    locale: localeName,
    one: '$count plik',
    other: '$count pliki',
  );
  return _temp0;
}

pluralLogic works in two steps:

  1. Exact matches first: if count is 0, 1 or 2 and you supplied zero, one or two, that string wins. In gen-l10n, =0, =1 and =2 map onto those same parameters.
  2. Otherwise it asks the CLDR rules for the locale which category count falls into. If that category is few or many and you didn't supply it, it silently returns other.

So nothing crashes, nothing warns, and every few and many number falls through to other. That is the classic "flutter localization plural not working" bug: the code is valid and the grammar is wrong.

The CLDR plural categories you actually need

These come from the Unicode CLDR plural rules chart, which is what package:intl implements.

Arabic (ar) uses all six:

  • zero: 0
  • one: 1
  • two: 2
  • few: 3 to 10, 103 to 110, 1003...
  • many: 11 to 99, 111, 1011...
  • other: 100 to 102, 200 to 202, 1000...

Polish (pl):

  • one: 1 only
  • few: 2 to 4, 22 to 24, 32 to 34... (but not 12 to 14)
  • many: 0, 5 to 21, 25 to 31, 100, 1000...
  • other: fractions only (1.5, 0.5)

Russian (ru) and Ukrainian (uk):

  • one: 1, 21, 31, 101... (not 11)
  • few: 2 to 4, 22 to 24... (not 12 to 14)
  • many: 0, 5 to 20, 25 to 30, 100...
  • other: fractions only

Czech (cs) is the odd one out:

  • one: 1
  • few: 2 to 4
  • many: fractions only
  • other: 0, 5 and up

Two traps fall out of this. In Polish and Russian, other is never used for whole numbers, so the English habit of putting the "normal" plural in other puts it in the slot that only 1.5 reaches. And in Russian, one is not "exactly one": 21 and 101 also use it, so never write a literal "1" inside one. Use {count}.

Copy-paste ARB blocks

The English template stays simple:

{
  "filesCount": "{count, plural, =0{No files} =1{1 file} other{{count} files}}",
  "@filesCount": {
    "description": "Number of files in the folder",
    "placeholders": {
      "count": { "type": "int" }
    }
  }
}

Arabic: zero/one/two/few/many/other (flutter plural arabic arb)

{
  "filesCount": "{count, plural, zero{لا توجد ملفات} one{ملف واحد} two{ملفان} few{{count} ملفات} many{{count} ملفًا} other{{count} ملف}}"
}

Polish: one/few/many/other (flutter intl plural polish)

{
  "filesCount": "{count, plural, =0{Brak plików} one{{count} plik} few{{count} pliki} many{{count} plików} other{{count} pliku}}"
}

Russian

{
  "filesCount": "{count, plural, =0{Нет файлов} one{{count} файл} few{{count} файла} many{{count} файлов} other{{count} файла}}"
}

Ukrainian

{
  "filesCount": "{count, plural, =0{Немає файлів} one{{count} файл} few{{count} файли} many{{count} файлів} other{{count} файлу}}"
}

Czech

{
  "filesCount": "{count, plural, =0{Žádné soubory} one{{count} soubor} few{{count} soubory} many{{count} souboru} other{{count} souborů}}"
}

Keep other in every locale. Intl.pluralLogic takes other as a required argument, and it is the fallback for every category you leave out. For Polish, Russian and Ukrainian with an int placeholder, other is never reached, but it still has to be there. Fill it with the fraction form so it's correct if you later switch the placeholder to num or double.

Run flutter gen-l10n (or just flutter run with generate: true in pubspec.yaml) after editing.

A tiny test that loops 0 to 111

The range 0 to 111 hits every integer category in all five languages: Arabic few (3 to 10), many (11 to 99), other (100 to 102), and the 12 to 14 exception in Polish and Russian. Here's the idea: count how many distinct strings each locale produces (after removing the digits) and compare that with the number of integer categories CLDR defines. A locale that copied English one/other produces too few.

import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
// Adjust to wherever your generated file lives.
import 'package:my_app/l10n/app_localizations.dart';

void main() {
  // Integer forms CLDR defines per locale (fraction-only categories excluded).
  // An explicit =0 case counts as its own form.
  const expectedForms = <String, int>{
    'ar': 6, // zero, one, two, few, many, other
    'pl': 4, // =0, one, few, many
    'ru': 4, // =0, one, few, many
    'uk': 4, // =0, one, few, many
    'cs': 4, // =0, one, few, other
  };

  for (final entry in expectedForms.entries) {
    test('${entry.key} uses every plural form for 0..111', () {
      final l10n = lookupAppLocalizations(Locale(entry.key));
      final forms = <String>{
        for (var n = 0; n <= 111; n++)
          l10n.filesCount(n).replaceAll(RegExp(r'\d'), '').trim(),
      };
      expect(forms.length, entry.value,
          reason: '${entry.key} forms seen: $forms');
    });
  }

  test('Polish spot checks', () {
    final pl = lookupAppLocalizations(const Locale('pl'));
    expect(pl.filesCount(2), '2 pliki');
    expect(pl.filesCount(5), '5 plików');
    expect(pl.filesCount(12), '12 plików');
    expect(pl.filesCount(22), '22 pliki');
  });
}

lookupAppLocalizations is generated by gen-l10n next to AppLocalizations, so you don't need to pump a widget tree to test strings. If the Polish ARB still has only one/other, the first Polish test fails and the reason prints which forms it actually saw, which tells you straight away that few and many are missing. Run it with flutter test.

Add a spot-check block for each language your translators touch. Most real bugs are swapped forms (a few string in the many slot), and those only show up in spot checks.

Catching this across every locale at once

The test above covers one key. A real app has dozens of plural strings across many ARB files, and adding a locale means re-checking all of them. That is the job FlutterLocalisation's ICU plural validation does: it flags every locale that is missing a plural category its language needs, such as a dropped few or many in Arabic, Polish or Russian, so you see the whole list before you ship.

You can then fix those strings in the ARB editor instead of hand-editing nested ICU braces in raw JSON, where one missing } breaks the whole file. Translation management across all your app_<locale>.arb files sits in the same place. Plans are on the pricing page, and there are more gen-l10n guides on the blog.

Checklist

  • Never copy the English one/other shape into another locale. Look up the language's CLDR categories.
  • Use {count} inside one for Russian and Ukrainian, since 21 and 101 land there.
  • Always keep other. It's required, and it's the silent fallback.
  • Test 0 to 111 per locale, plus spot checks for 2, 5, 12, 22 and 111.

Try FlutterLocalisation free

Stop finding missing few and many forms in user reviews. Try FlutterLocalisation free and let ICU plural validation check every locale in your Flutter app.