Arabic plurals and dates in Flutter with gen-l10n
Youssef Mohamed14 min read
Why Arabic plurals break simple code
English has two forms: 1 message, 2 messages. Arabic has six. The noun changes after 1, after 2, from 3 to 10, from 11 to 99, and again at 100. Code like count == 1 ? 'message' : 'messages' reads wrong to every Arabic speaker, and it usually sits on the screens people see most: the cart, the inbox and notifications.
I build Flutter apps in Arabic and English for clients in Egypt, the Gulf and Europe, and plural strings are on my review list for every project. This is the setup I use: gen-l10n, ARB files with ICU messages, and the intl package for numbers and dates. The code was checked against current stable Flutter.
The six Arabic plural categories
- zero, for 0: لا توجد رسائل (no messages). Most apps use a full sentence here rather than the digit.
- one, for 1: رسالة واحدة. Arabic drops the digit and says ‘one message’.
- two, for 2: رسالتان. This is the dual form, also written without a digit.
- few, for 3 to 10: 3 رسائل, 10 رسائل. The plural noun.
- many, for 11 to 99: 11 رسالة, 99 رسالة. Back to the singular noun.
- other, for 100, 101, 102, 1000 and anything else: 100 رسالة.
These categories come from Unicode's CLDR plural rules, which intl follows. few and many are not about size: the rule looks at the number modulo 100. So 103 takes the plural (103 رسائل), 111 takes the singular (111 رسالة), and 100 to 102 fall into other. That matches Arabic grammar, and intl applies the rule for you.
many and other look the same for رسالة, but not for every noun: 11 كتابًا takes the accusative alif, while 100 كتاب does not. Keep both cases in the file even when the text matches, so a translator can change one without touching the other.
Set up gen-l10n in a current Flutter project
Add flutter_localizations from the SDK and intl. Use intl: any, because flutter_localizations pins the exact intl version and pub picks the one that matches. Turn on generation in pubspec.yaml and add l10n.yaml in the project root. nullable-getter: false lets you drop the ! after AppLocalizations.of(context), and untranslated-messages-file writes a report of missing translations.
Older tutorials import package:flutter_gen/gen_l10n/app_localizations.dart. That synthetic package has been removed. gen-l10n now writes the Dart files into your project, next to the ARB files by default, so you import package:my_app/l10n/app_localizations.dart. flutter pub get and every build regenerate them, and flutter gen-l10n runs the tool on demand.
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: any
flutter:
generate: true
# l10n.yaml, in the project root
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
nullable-getter: false
untranslated-messages-file: untranslated.jsonHow ICU plural syntax works in an ARB file
An ARB file is JSON. Each key is a message, and a key starting with @ holds that message's metadata. A plural message has three parts inside braces: the placeholder name, the word plural, and a list of cases, each one a selector followed by its text in braces. Inside the text, {count} prints the number.
The English file is the template. It declares each placeholder and its type, so the Arabic file only carries strings. Add a description to every message, because it is often the only context a translator gets. Every plural also needs an other case, or gen-l10n stops with a syntax error.
{
"@@locale": "en",
"unreadMessages": "{count, plural, =0{No messages} =1{1 message} other{{count} messages}}",
"@unreadMessages": {
"description": "Inbox badge. count is the number of unread messages.",
"placeholders": {
"count": {
"type": "int"
}
}
}
}=0, =1, =2 versus zero, one, two
In the ICU standard, =0 means exactly zero, while zero is the name of a CLDR category. In Flutter's gen-l10n they end up in the same place: =0 and zero both become the zero argument of Intl.pluralLogic, and the same goes for =1 and one, and for =2 and two.
intl then checks 0, 1 and 2 as exact values before it applies the language rule. That is why =0 works in English, which has no zero category. In Arabic, where zero, one and two mean exactly 0, 1 and 2, both spellings behave the same.
Two consequences. Exact matches stop at 2, so =5 or =100 makes gen-l10n refuse to generate the file. And if a message has both =1 and one, only the later case is kept, with a warning that a plural part was overridden. I use =0 and =1 in English and the category names in Arabic.
The Arabic ARB file, form by form
The Arabic file repeats the key with all six cases and needs no @ metadata. one and two have no {count}: رسالة واحدة and رسالتان already say the number, and 2 رسالتان reads like a mistake. From 11 on, the noun goes back to رسالة.
Write these strings with your translator. The dual changes with its role in the sentence: لديك رسالتان as a subject, أرسلت رسالتين as an object, so the translator needs the full sentence for each case. I write interface text in simple Modern Standard Arabic, which reads naturally in Egypt and across the Gulf.
{
"@@locale": "ar",
"unreadMessages": "{count, plural, zero{لا توجد رسائل} one{رسالة واحدة} two{رسالتان} few{{count} رسائل} many{{count} رسالة} other{{count} رسالة}}"
}Using the generated method in widgets
gen-l10n turns each message into a method on AppLocalizations, so this plural becomes unreadMessages(int count) and a missing argument is a compile error, not a blank label. Register the delegates once in MaterialApp. AppLocalizations.localizationsDelegates already includes the Material, Cupertino and Widgets delegates, so you do not list them by hand.
import 'package:flutter/material.dart';
import 'package:my_app/l10n/app_localizations.dart';
extension L10nX on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const InboxScreen(),
);
}
}
class InboxBadge extends StatelessWidget {
const InboxBadge({super.key, required this.count});
final int count;
@override
Widget build(BuildContext context) {
return Text(context.l10n.unreadMessages(count));
}
}Gender with select messages
Arabic marks gender in verbs, adjectives and the word ‘you’: هل أنت متأكد؟ to a man, هل أنتِ متأكدة؟ to a woman. ICU handles this with select, which picks a case by a string value instead of a number.
English does not need it here, so the template keeps a plain sentence and only declares the gender placeholder. Any value without its own case falls back to other, which is required. The masculine is the grammatical default in Arabic, so I make other masculine, or neutral when a neutral sentence reads naturally.
In Dart, pass a string: l10n.confirmDelete(user.gender.name). With an enum of male, female and unknown, unknown has no case and lands on other. Only ask users for their gender if the product really needs it.
// app_en.arb
"confirmDelete": "Are you sure you want to delete this?",
"@confirmDelete": {
"description": "Delete dialog title. gender is the signed-in user's gender.",
"placeholders": {
"gender": { "type": "String" }
}
}
// app_ar.arb
"confirmDelete": "{gender, select, male{هل أنت متأكد من الحذف؟} female{هل أنتِ متأكدة من الحذف؟} other{هل أنت متأكد من الحذف؟}}"Combining select and plural in one message
In ‘Sara added 3 photos’, the Arabic verb follows the person (أضاف or أضافت) and the noun follows the number. Put the select on the outside, a full plural inside each case, and repeat the whole sentence in every branch. It looks long, but it is the only way every combination comes out right.
The dual here is صورتين, not صورتان, because the photos are the object of the verb. The English key needs only the plural. The generated method takes placeholders in the order the template declares them: photosAdded(name, gender, count).
// app_en.arb
"photosAdded": "{count, plural, =1{{name} added a photo} other{{name} added {count} photos}}",
"@photosAdded": {
"placeholders": {
"name": { "type": "String" },
"gender": { "type": "String" },
"count": { "type": "int" }
}
}
// app_ar.arb
"photosAdded": "{gender, select, female{{count, plural, one{أضافت {name} صورة واحدة} two{أضافت {name} صورتين} few{أضافت {name} {count} صور} many{أضافت {name} {count} صورة} other{أضافت {name} {count} صورة}}} other{{count, plural, one{أضاف {name} صورة واحدة} two{أضاف {name} صورتين} few{أضاف {name} {count} صور} many{أضاف {name} {count} صورة} other{أضاف {name} {count} صورة}}}}"Formatting numbers inside a message
A placeholder without a format is printed with toString, so 1250 shows as 1250. Add a format in the template and gen-l10n builds an intl NumberFormat for the current locale: decimalPattern prints 1,250. Formats with options, such as decimalPatternDigits or currency, take them from optionalParameters. The plural case is still chosen from the raw number, and only the printed text changes.
That is why compact and plurals do not mix well in Arabic. compact prints 1.25 ألف, and after ألف Arabic uses the singular noun, but for 1,205 the rule says few and picks the plural. For big counters I use a separate message without a plural and switch in Dart: count < 1000 ? l10n.views(count) : l10n.viewsCompact(count).
// app_en.arb
"views": "{count, plural, =0{No views yet} =1{1 view} other{{count} views}}",
"@views": {
"placeholders": {
"count": { "type": "int", "format": "decimalPattern" }
}
},
"viewsCompact": "{count} views",
"@viewsCompact": {
"placeholders": {
"count": { "type": "int", "format": "compact" }
}
},
"rating": "Rated {rating} out of 5",
"@rating": {
"placeholders": {
"rating": {
"type": "double",
"format": "decimalPatternDigits",
"optionalParameters": { "decimalDigits": 1 }
}
}
}
// app_ar.arb
"views": "{count, plural, zero{لا توجد مشاهدات بعد} one{مشاهدة واحدة} two{مشاهدتان} few{{count} مشاهدات} many{{count} مشاهدة} other{{count} مشاهدة}}",
"viewsCompact": "{count} مشاهدة",
"rating": "التقييم {rating} من 5"Arabic dates with DateFormat
Material widgets such as the date picker show Arabic month names on their own. For your own text, give a DateTime placeholder a format such as yMMMd, or call DateFormat with l10n.localeName rather than a hard-coded 'ar'. Prefer named skeletons like yMMMMd and jm: they order day, month and year correctly for each language and use month names like أكتوبر. For your own pattern in an ARB placeholder, set isCustomDateFormat to true.
The Material delegates load Arabic date data for your widgets. Code outside the widget tree, like a plain unit test or main() before runApp, must call initializeDateFormatting('ar') from package:intl/date_symbol_data_local.dart first, or it throws a LocaleDataException. The comments below use Western digits; the next section explains why your app may print ٢٠ instead.
import 'package:intl/intl.dart';
// app_en.arb
// "lastOrder": "Last order on {date}",
// "@lastOrder": { "placeholders": { "date": { "type": "DateTime", "format": "yMMMd" } } }
// app_ar.arb
// "lastOrder": "آخر طلب في {date}"
Text(l10n.lastOrder(order.createdAt)); // آخر طلب في 20 أكتوبر 2026
// Your own text, in the current language.
final date = DateTime(2026, 10, 20, 15, 30);
DateFormat.yMMMMd(l10n.localeName).format(date); // 20 أكتوبر 2026
DateFormat.yMMMMEEEEd(l10n.localeName).format(date); // الثلاثاء، 20 أكتوبر 2026
DateFormat.jm(l10n.localeName).format(date); // 3:30 مWestern or Arabic-Indic digits
Arabic can be written with Western digits (0123456789) or Arabic-Indic digits (٠١٢٣٤٥٦٧٨٩). Both are correct, and you will see both in apps used in Egypt and the Gulf. It is a product decision, so I ask the client what their website and printed material use, then apply that choice to numbers and dates alike.
The defaults are not consistent. NumberFormat with ar, ar_SA or ar_AE prints Western digits, ar_EG prints Arabic-Indic digits, and a placeholder without a format always prints Western digits. But the Arabic date data that flutter_localizations loads uses Arabic-Indic digits, so one screen can show ٢٠ أكتوبر next to 1,250 مشاهدة.
For Western digits everywhere, turn off native digits for Arabic dates in main(), as below. For Arabic-Indic digits, give number placeholders a format and add app_ar_EG.arb containing only the @@locale line set to ar_EG: gen-l10n reuses the Arabic strings but formats with ar_EG. Set the MaterialApp locale to Locale('ar', 'EG') when a user picks Arabic. That file's keys will show up in the untranslated report, since it inherits them.
If you also call initializeDateFormatting('ar') at startup, date digits can depend on which data was used first, so set the style on purpose.
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
void main() {
// Western digits (0-9) in Arabic dates, to match the numbers.
DateFormat.useNativeDigitsByDefaultFor('ar', false);
runApp(const MyApp());
}
// Or for one formatter only:
final formatter = DateFormat.yMMMMd('ar')..useNativeDigits = false;Common mistakes with Arabic plurals
These are the ones I find most often when I review an existing Flutter app:
- Building the sentence in Dart, such as '$count ' + l10n.messages. Keep the whole sentence in one message.
- Hardcoding an s with count == 1 ? 'message' : 'messages'.
- Forgetting the dual: 2 رسائل instead of رسالتان.
- Using the plural noun above 10: 11 رسائل instead of 11 رسالة.
- Putting the digit in the one and two cases, such as 1 رسالة واحدة.
- Trying =3 or =11. Only =0, =1 and =2 work, so use few and many for the rest.
- Splitting one sentence across two keys. Translators cannot reorder words they never see together.
- Ignoring missing translations. A missing Arabic key silently shows the English text.
- Wrapping a compact number in a plural.
Catching missing translations before release
A missing Arabic key only produces a one-line note from gen-l10n, and the app still builds. With untranslated-messages-file set, the tool writes the missing keys to a JSON file grouped by locale, or {} when nothing is missing. Fail the CI build when the file is not {}.
Keep the file in the project root, because gen-l10n will not create a missing folder for it, and add it to .gitignore. required-resource-attributes: true is worth turning on as well: it stops generation when a template key has no @ entry, which is where the translator's description lives.
flutter gen-l10n
if [ "$(cat untranslated.json)" != "{}" ]; then
echo "Missing translations:"
cat untranslated.json
exit 1
fiTesting plural output in a unit test
Plural strings break quietly in later edits. lookupAppLocalizations returns the generated class for a locale without building a widget, so a plain test can check them in a moment. I test one value per category, 0, 1, 2, 3, 11 and 100, plus 103 to prove the rule reads the last two digits. Intl.plural can check the rule itself without ARB files.
Test dates separately, in a widget test that pumps MaterialApp with the real delegates. A plain test loads intl's own Arabic date data, which is not the data Flutter uses inside the app, so the digits can differ.
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';
void main() {
final ar = lookupAppLocalizations(const Locale('ar'));
final en = lookupAppLocalizations(const Locale('en'));
group('unreadMessages in Arabic', () {
const expected = {
0: 'لا توجد رسائل',
1: 'رسالة واحدة',
2: 'رسالتان',
3: '3 رسائل',
11: '11 رسالة',
100: '100 رسالة',
103: '103 رسائل',
};
for (final MapEntry(key: count, value: text) in expected.entries) {
test('count = $count', () {
expect(ar.unreadMessages(count), text);
});
}
});
test('unreadMessages in English', () {
expect(en.unreadMessages(0), 'No messages');
expect(en.unreadMessages(1), '1 message');
expect(en.unreadMessages(2), '2 messages');
expect(en.unreadMessages(11), '11 messages');
});
test('gender changes the Arabic sentence', () {
expect(ar.confirmDelete('female'), 'هل أنتِ متأكدة من الحذف؟');
expect(ar.confirmDelete('male'), 'هل أنت متأكد من الحذف؟');
});
}A checklist and what to do next
- flutter_localizations and intl: any in pubspec.yaml, plus generate: true
- l10n.yaml with nullable-getter: false and untranslated-messages-file
- Every Arabic count message has zero, one, two, few, many and other
- No digit in the one and two cases
- Whole sentences in one message, never joined in Dart
- A select for gender wherever the text speaks to or about a person
- decimalPattern on number placeholders, and compact only outside plurals
- One digit style, applied to numbers and dates alike
- Dates formatted with l10n.localeName and named skeletons
- A unit test for 0, 1, 2, 3, 11, 100 and 103
- untranslated.json is {} before every release
- A native Arabic reader has seen every plural string on a real screen
If your app already has Arabic, start with the strings people see most: cart totals, notification counts and order history. Move them into plural messages, add the test, then go screen by screen.
If you are starting a new Flutter app for Arabic and English speakers, set this up on day one, because it is quick at the start and slow to retrofit. And if you would like me to review your app's Arabic or build it with you, you can reach me through the contact page on this site.
Common questions
- How many plural forms does Arabic have?
- Six: zero, one, two, few, many and other. That is 0, 1, 2, 3 to 10, 11 to 99, and everything else, such as 100. The rule uses the last two digits, so 103 is few and 111 is many.
- What is the difference between =0 and zero in a Flutter ARB file?
- In ICU, =0 is an exact value and zero is a language category. In Flutter's gen-l10n both map to the same case, and intl checks 0, 1 and 2 as exact values first. Exact matches beyond =2 are not supported.
- Why does my Flutter app show English text when the language is Arabic?
- The key is missing from app_ar.arb, so the generated Arabic class falls back to the English template text. Set untranslated-messages-file in l10n.yaml to get the full list of missing keys.
- Why can't I import package:flutter_gen/gen_l10n/app_localizations.dart?
- The flutter_gen synthetic package has been removed from current Flutter. gen-l10n now writes the files into your project, next to your ARB files by default. Import them from your own package, such as package:my_app/l10n/app_localizations.dart.
- How do I show Arabic-Indic digits in a Flutter app?
- Give number placeholders a format such as decimalPattern and format them with ar_EG, for example through an app_ar_EG.arb file. Dates from flutter_localizations already use Arabic-Indic digits for ar unless you turn native digits off.
- Do I need to call initializeDateFormatting in Flutter?
- Not inside widgets, because the flutter_localizations delegates load the Arabic date data. You do need it in plain unit tests and in code that formats a date before the localizations load, such as main().
- Can one Arabic message use both gender and plural?
- Yes. Put a select on gender outside and a full plural inside each case. Each branch repeats the whole sentence, so the verb and the noun both come out right.