Flutter Shows Arabic Numbers Instead of English: The Fix
You add ar to supportedLocales, ship the build, and a tester in Cairo sends a screenshot: the price says ١٬٥٠٠ ر.س. Or the opposite happens, your Arabic UI stubbornly prints 1,500 and the client wants Eastern Arabic numerals. Both are the same bug, and neither is about the language. In Dart's intl package the digit system comes from the exact locale tag string you hand to NumberFormat or DateFormat, and the data behind those tags is narrower than most people assume.
What intl actually ships for Arabic
package:intl does not carry every CLDR locale. Its number data (number_symbols_data.dart) contains exactly three Arabic entries. Here is what each one does in intl 0.20.3, which ships CLDR v48:
| Tag you pass | Digits | 1500 formats as |
|---|---|---|
ar |
Latin (ZERO_DIGIT: '0') |
1,500 |
ar_EG |
Arabic-Indic (ZERO_DIGIT: '٠') |
١٬٥٠٠ |
ar_DZ |
Latin, European separators | 1.500 |
Everything else, ar_SA, ar_AE, ar_JO, ar_MA, ar_QA, is not in the data at all. Intl.verifiedLocale falls back through canonicalisation, then language + region, then language only, so ar_SA resolves to plain ar and gets Latin digits. That is the single fact that explains most of the confusion online: ar_SA and ar_EG behave differently not because Saudi Arabia prefers ASCII digits, but because one tag exists in the bundled data and the other does not.
import 'package:intl/intl.dart';
void main() {
print(NumberFormat.decimalPattern('ar').format(1500)); // 1,500
print(NumberFormat.decimalPattern('ar_SA').format(1500)); // 1,500 (falls back to ar)
print(NumberFormat.decimalPattern('ar_AE').format(1500)); // 1,500 (falls back to ar)
print(NumberFormat.decimalPattern('ar_EG').format(1500)); // ١٬٥٠٠
print(NumberFormat.decimalPattern('ar_DZ').format(1500)); // 1.500
print(NumberFormat.decimalPattern('fa').format(1500)); // ۱٬۵۰۰ (Persian, U+06F0)
print(NumberFormat.decimalPattern('ur').format(1500)); // 1,500
}
Note ar_EG also swaps the separators: the group separator is the Arabic thousands separator ٬ (U+066C) and the decimal is ٫ (U+066B), not , and .. If you are string-matching on commas anywhere, that breaks too. And note ur: Urdu in intl formats with Latin digits, so "Urdu shows English numbers" is expected behaviour, not a misconfiguration.
One more thing that trips people coming from JavaScript or ICU: intl has no numbering-system override. NumberFormat('#,##0', 'ar-u-nu-arab') does not work the way Intl.NumberFormat does on the web. The unicode extension is not parsed; the tag fails to match, falls back to ar, and you silently get Latin digits.
Why dates and numbers disagreed, and when that changed
For years the classic complaint was that DateFormat('ar') printed ٢٠٢٦/٩/٢١ while NumberFormat('ar') printed 1,500 in the same screen (dart-lang/i18n#477). That was real: the ar entry in date_symbol_data_local.dart used to carry ZERODIGIT: '٠' while the number symbols did not.
That data changed. In intl 0.20.0 the ar date symbols still had the Arabic-Indic zero digit; in 0.20.1, which updated to CLDR v46, it was dropped. So if you bumped intl and your Arabic dates flipped from ٢٠٢٦ to 2026 without a code change, that is where it happened. In 0.20.3 the only Arabic date locale with native digits is ar_EG.
DateFormat does give you an explicit switch that NumberFormat lacks:
// Per instance
final df = DateFormat.yMd('ar_EG')..useNativeDigits = false;
print(df.format(DateTime(2026, 9, 21))); // Latin digits, ar_EG pattern
// Or globally, once at startup, before any DateFormat is built
DateFormat.useNativeDigitsByDefaultFor('ar_EG', false);
There is no NumberFormat.useNativeDigits. For numbers you either pick the tag that has the digits you want, or you convert the output string.
Where the tag comes from in a real Flutter app
Three places decide the tag, and they do not have to agree:
- gen-l10n / ARB files. A placeholder declared with
"type": "int", "format": "decimalPattern"compiles intointl.NumberFormat.decimalPattern(localeName), wherelocaleNameis the locale of the ARB file.app_ar.arbgivesar(Latin digits). Addapp_ar_EG.arband Egyptian users get ١٬٥٠٠, because the generatedAppLocalizationsArEgpasses'ar_EG'. - Your own
NumberFormatcalls. If you passLocalizations.localeOf(context).toString()you get the resolved device locale,ar_EGon an Egyptian phone,ar_SAon a Saudi one, hence different digits on two phones running the same build. GlobalMaterialLocalizations. Its delegate callsNumberFormat.decimalPattern(localeName)whenNumberFormat.localeExists(localeName)is true, and that format backsformatDecimal, the date picker and the time picker. ListLocale('ar', 'EG')insupportedLocalesand the Material pickers switch to Arabic-Indic digits; list onlyLocale('ar')and they stay Latin.
Copy-paste helper: one digit system, app-wide
Pick a policy and apply it at the edge of your formatting layer. This converts digits and the Arabic separators in both directions, which is what you need for strings you did not format yourself (server-rendered totals, DateFormat output, pasted input).
/// Digit-system normalisation for Arabic / Persian / Urdu builds.
class Digits {
static const _arabicIndicZero = 0x0660; // ٠ ar
static const _extendedZero = 0x06F0; // ۰ fa, ps, ur-PK fonts
static const _asciiZero = 0x30;
static final _nonLatin = RegExp(r'[٠-٩۰-۹]');
static final _latin = RegExp(r'[0-9]');
/// ١٬٥٠٠ -> 1,500 · ۱۲٫۵ -> 12.5
static String toLatin(String input) => input
.replaceAllMapped(_nonLatin, (m) {
final c = m.group(0)!.codeUnitAt(0);
final base = c >= _extendedZero ? _extendedZero : _arabicIndicZero;
return String.fromCharCode(_asciiZero + c - base);
})
.replaceAll('٬', ',') // ٬ thousands
.replaceAll('٫', '.'); // ٫ decimal
/// 1,500 -> ١٬٥٠٠ (Arabic-Indic, with Arabic separators)
static String toArabicIndic(String input) => input
.replaceAllMapped(_latin, (m) => String.fromCharCode(
_arabicIndicZero + m.group(0)!.codeUnitAt(0) - _asciiZero))
.replaceAll(',', '٬')
.replaceAll('.', '٫');
}
Then one place decides the policy:
enum DigitPolicy { latin, arabicIndic, followLocale }
class AppFormat {
static DigitPolicy policy = DigitPolicy.latin;
static String decimal(num value, String locale) =>
_apply(NumberFormat.decimalPattern(locale).format(value));
static String currency(num value, String locale, {String? symbol}) =>
_apply(NumberFormat.currency(locale: locale, symbol: symbol).format(value));
static String _apply(String s) => switch (policy) {
DigitPolicy.latin => Digits.toLatin(s),
DigitPolicy.arabicIndic => Digits.toArabicIndic(s),
DigitPolicy.followLocale => s,
};
}
Two caveats worth knowing. The conversion is one UTF-16 code unit per digit, so lengths and selection offsets are preserved. And it rewrites . and , wholesale, so do not feed it a full sentence that also contains ordinary punctuation; run it on formatted number strings only. If you want Arabic-Indic digits for genuinely everything, the cleaner route is to format with 'ar_EG' and skip the converter.
The TextField trap on iOS
Display is only half of it. On iOS, a user with the Arabic keyboard typing into TextInputType.number emits ٠-٩, not 0-9. Two things break at once:
FilteringTextInputFormatter.digitsOnlymatches[0-9]only, so every keystroke is discarded and the field looks dead. This is flutter/flutter#147257, closed as not planned: it is input data, not a framework bug.int.parse/double.parseaccept ASCII digits only and throwFormatExceptionon١٥٠٠.
Normalise before you filter:
class LatinDigitsFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(TextEditingValue old, TextEditingValue next) {
final text = Digits.toLatin(next.text);
return text == next.text
? next
: TextEditingValue(
text: text,
selection: next.selection,
composing: TextRange.empty,
);
}
}
TextField(
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
LatinDigitsFormatter(), // must come first
FilteringTextInputFormatter.digitsOnly, // now sees ASCII
],
)
Order matters: the formatters run in list order, so the converter has to sit before digitsOnly or there is nothing left to convert. For parsing free-form input, prefer NumberFormat.decimalPattern(locale).tryParse(text) (added in intl 0.19.0) over double.tryParse, since it understands the locale's separators, then fall back to double.tryParse(Digits.toLatin(text)).
A quick checklist before you ship
- Decide the digit policy per market, not per language. Gulf clients usually want Latin digits in fintech UIs; Egyptian editorial content often wants ١٬٥٠٠.
- Test on a device set to ar-EG specifically. An ar-SA simulator will never show you the Arabic-Indic path.
- Grep for
double.parseandint.parseon anything a user can type. - Keep RTL marks in mind: the
arcurrency pattern starts with U+200F, so a trimmed or byte-compared price string may not match what you expect.
Keep the ARB side honest
Digits are one half of shipping Arabic; the strings are the other. FlutterLocalisation is an ARB editor and translation manager for app_<locale>.arb files, so adding app_ar_EG.arb next to app_ar.arb is an edit in a UI rather than hand-merged JSON, and its ICU plural validation flags the Arabic categories (zero, one, two, few, many, other) that are easy to drop when you copy an English file. If you are still wiring up the basics, the Flutter intl package guide covers setup and formatting end to end.
Try FlutterLocalisation free and get your Arabic ARB files in order, then let NumberFormat do exactly what you told it to.