Fix Flutter Decimal Input: Parse '6,5' in Any Locale
A user in Munich types 6,5 into a weight field, taps Save, and your app throws:
FormatException: Invalid double
6,5
^
The reflex is to blame the keyboard. It isn't the keyboard. It's that double.parse is locale-blind — it accepts only an ASCII dot — while NumberFormat from intl is locale-aware. Reaching for the wrong one produces two bugs: a crash, and a much worse silent one.
The silent bug is worse than the crash
Reach for NumberFormat without thinking and you get this, which is not a crash:
import 'package:intl/intl.dart';
NumberFormat.decimalPattern('de').parse('6.5'); // 65 <-- ten times too big
NumberFormat.decimalPattern('de').parse('6,5'); // 6.5 correct
double.parse('6,5'); // FormatException
In German, . is the grouping separator, and intl's parser strips grouping characters — so 6.5 normalises to 65. A US-region iPhone whose decimal pad prints a ., used inside an app running in German, will happily bill someone 65 € for a 6.50 € item. No exception, no log line.
The trap runs the other way in locales whose digits aren't ASCII:
NumberFormat.decimalPattern('ne').parse('6.5'); // FormatException
NumberFormat.decimalPattern('ar_EG').parse('6.5');// FormatException
NumberFormat.decimalPattern('ar_EG').format(6.5); // '٦٫٥'
intl turns a character into a digit by subtracting the locale's ZERO_DIGIT code point. Nepali's zero is ० and ar_EG's is ٠, so ASCII 6 isn't a digit to those formats at all. NumberFormat.parse is not an "accept whatever the user typed" function — you have to normalise first.
What the separators actually are
Don't hardcode a comma — ask intl. Its CLDR data is compiled into the package, so unlike DateFormat no async initialisation is needed:
final symbols = NumberFormat.decimalPattern('fr').symbols;
symbols.DECIMAL_SEP; // ','
symbols.GROUP_SEP; // U+202F narrow no-break space, not a plain space
symbols.ZERO_DIGIT; // '0'
Values from intl 0.20.3 (CLDR 48), worth knowing before you write a regex:
| Locale | Decimal | Grouping | Zero digit |
|---|---|---|---|
en_US |
. |
, |
0 |
de |
, |
. |
0 |
fr |
, |
U+202F (NNBSP) | 0 |
ar |
. |
, |
0 |
ar_DZ |
, |
. |
0 |
ar_EG |
٫ U+066B |
٬ U+066C |
٠ U+0660 |
ne |
. |
, |
० U+0966 |
Two surprises. French grouping is U+202F NARROW NO-BREAK SPACE — invisible in your editor, untouched by trim() when it sits mid-string, and on its own enough to make double.parse throw. And plain ar uses Latin digits with a dot; only ar_EG gets Eastern-Arabic ones. intl ships just ar, ar_DZ and ar_EG number symbols, so ar_SA falls back to ar — an open, low-priority issue. Arabic keyboards still emit ٦٫٥ whatever CLDR says, so accept those digits everywhere.
One helper: fold digits, then decide the separator
Two files, nothing beyond intl: ^0.20.3 — the version flutter_localizations on stable currently depends on.
// lib/util/locale_number.dart
import 'package:intl/intl.dart';
/// "Zero" for each digit system we accept as input: Latin, Arabic-Indic
/// (ar), Extended Arabic-Indic (fa, ur), Devanagari (hi, ne), Bengali.
const _digitZeros = <int>[0x0030, 0x0660, 0x06F0, 0x0966, 0x09E6];
/// Rewrites any of those to ASCII 0-9, leaving everything else alone.
/// Length-preserving: each of these digits is one UTF-16 code unit.
String foldDigits(String input) {
final out = StringBuffer();
for (final rune in input.runes) {
var handled = false;
for (final zero in _digitZeros) {
final value = rune - zero;
if (value >= 0 && value <= 9) {
out.writeCharCode(0x30 + value);
handled = true;
break;
}
}
if (!handled) out.writeCharCode(rune);
}
return out.toString();
}
/// Characters that can only ever group digits, never mark a fraction:
/// space, NBSP, narrow NBSP (fr), thin space, ٬ (ar), ’ (de_CH).
const _groupingOnly = <String>[
' ', '\u00A0', '\u202F', '\u2009', '\u066C', '\u2019',
];
/// Anything a keyboard might hand you as a decimal point.
const decimalCandidates = <String>['.', ',', '\u066B']; // '\u066B' = ٫
double? parseLocaleNumber(String raw, {String? locale}) {
var text = foldDigits(raw).trim().replaceAll('\u2212', '-'); // MINUS SIGN
for (final ch in _groupingOnly) {
text = text.replaceAll(ch, '');
}
text = text.replaceAll('\u066B', ',');
if (text.isEmpty) return null;
final negative = text.startsWith('-');
if (negative) text = text.substring(1);
if (text.contains('-')) return null;
var localeSep = NumberFormat.decimalPattern(locale).symbols.DECIMAL_SEP;
if (localeSep == '\u066B') localeSep = ',';
final dots = '.'.allMatches(text).length;
final commas = ','.allMatches(text).length;
String? decimalMark;
if (dots > 0 && commas > 0) {
// Both present: rightmost wins. "1.234,56" (de) and "1,234.56" (en).
decimalMark = text.lastIndexOf('.') > text.lastIndexOf(',') ? '.' : ',';
} else if (dots + commas == 1) {
final mark = dots == 1 ? '.' : ',';
final before = text.indexOf(mark);
final after = text.length - before - 1;
// A lone non-local separator with digits before it and exactly three
// after is grouping ("1.500" in de). Everything else is a decimal
// point — including the '.' a US-region decimal pad sends to a de app.
final looksLikeGrouping = mark != localeSep && after == 3 && before > 0;
decimalMark = looksLikeGrouping ? null : mark;
}
final cleaned = StringBuffer(negative ? '-' : '');
for (final ch in text.split('')) {
final code = ch.codeUnitAt(0);
if (code >= 0x30 && code <= 0x39) {
cleaned.write(ch);
} else if (ch == decimalMark) {
cleaned.write('.');
} else if (ch == '.' || ch == ',') {
continue; // grouping — drop
} else {
return null; // stray letter or currency symbol: reject, don't guess
}
}
var out = cleaned.toString();
if (out.startsWith('.')) out = '0$out';
if (out.startsWith('-.')) out = '-0${out.substring(1)}';
if (out.endsWith('.')) out = out.substring(0, out.length - 1); // mid-typing "6,"
return double.tryParse(out);
}
Behaviour worth pinning down in tests:
parseLocaleNumber('6,5', locale: 'de'); // 6.5
parseLocaleNumber('6.5', locale: 'de'); // 6.5 (not 65)
parseLocaleNumber('1.500', locale: 'de'); // 1500.0
parseLocaleNumber('1 234,5', locale: 'fr'); // 1234.5
parseLocaleNumber('٦٫٥', locale: 'ar_EG'); // 6.5
parseLocaleNumber('12kg', locale: 'en_US'); // null
One deliberate ambiguity: in German 1.500 is fifteen hundred, and nothing in the string says the user meant one and a half. Fixed two-decimal money fields make it moot.
The input formatter
parseLocaleNumber is the safety net. The formatter stops garbage from being typed at all and rewrites whichever separator the keyboard emitted into the one this locale displays.
// lib/util/locale_number_input_formatter.dart
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'locale_number.dart';
class LocaleAwareNumberInputFormatter extends TextInputFormatter {
LocaleAwareNumberInputFormatter({
required this.locale,
this.decimalDigits = 2,
this.allowNegative = false,
});
final Locale locale;
final int decimalDigits;
final bool allowNegative;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final separator =
NumberFormat.decimalPattern(locale.toString()).symbols.DECIMAL_SEP;
final text = foldDigits(newValue.text);
final caret =
newValue.selection.end < 0 ? text.length : newValue.selection.end;
final out = StringBuffer();
var droppedBeforeCaret = 0;
var hasSeparator = false;
var fractionDigits = 0;
for (var i = 0; i < text.length; i++) {
final ch = text[i];
final code = ch.codeUnitAt(0);
var keep = '';
if (code >= 0x30 && code <= 0x39) {
if (!hasSeparator) {
keep = ch;
} else if (fractionDigits < decimalDigits) {
keep = ch;
fractionDigits++;
}
} else if (decimalCandidates.contains(ch) || ch == separator) {
if (!hasSeparator && decimalDigits > 0) {
keep = separator; // normalise '.' or ',' onto this locale's mark
hasSeparator = true;
}
} else if ((ch == '-' || ch == '\u2212') && allowNegative && i == 0) {
keep = '-';
}
if (keep.isEmpty && i < caret) droppedBeforeCaret++;
out.write(keep);
}
final result = out.toString();
return TextEditingValue(
text: result,
selection: TextSelection.collapsed(
offset: (caret - droppedBeforeCaret).clamp(0, result.length),
),
);
}
}
Every kept character is one code unit, so the caret arithmetic stays honest — hand-rolled numeric formatters routinely drop a character and leave the cursor a position ahead.
Wire it to the runtime locale, not to a constant
NumberFormat.decimalPattern(null) falls back to Intl.defaultLocale, which is en_US unless you set it. Read the locale from the widget tree instead, so an in-app language switch takes effect immediately:
class WeightField extends StatefulWidget {
const WeightField({super.key, required this.onChanged});
final ValueChanged<double?> onChanged;
@override
State<WeightField> createState() => _WeightFieldState();
}
class _WeightFieldState extends State<WeightField> {
final _controller = TextEditingController();
Locale? _lastLocale;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final locale = Localizations.localeOf(context);
final previous = _lastLocale;
if (previous != null && previous != locale) {
// Language switched mid-form: re-render what is already typed.
final v = parseLocaleNumber(_controller.text, locale: '$previous');
if (v != null) {
_controller.text = NumberFormat.decimalPattern('$locale').format(v);
}
}
_lastLocale = locale;
}
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context);
return TextFormField(
controller: _controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
LocaleAwareNumberInputFormatter(locale: locale, decimalDigits: 3),
],
// A validator should call parseLocaleNumber too, never double.parse.
onChanged: (text) =>
widget.onChanged(parseLocaleNumber(text, locale: locale.toString())),
);
}
}
Localizations.localeOf(context) registers a dependency, so didChangeDependencies fires on a locale change and build rebuilds the formatter with the new separator. Locale.toString() gives de_DE, the underscore form intl expects.
One rule for the boundary: never send the display string to your backend. Format with NumberFormat for humans, send the double. A French 1 234,5 with a U+202F inside it fails server-side JSON number parsing in a way that is genuinely painful to debug.
The keyboard part you can't fix in Dart
The engine maps keyboard types on iOS like this:
if ([inputType isEqualToString:@"TextInputType.number"]) {
if ([type[@"signed"] boolValue]) return UIKeyboardTypeNumbersAndPunctuation;
if ([type[@"decimal"] boolValue]) return UIKeyboardTypeDecimalPad;
return UIKeyboardTypeNumberPad;
}
Three consequences:
decimal: truegivesUIKeyboardTypeDecimalPad, whose separator glyph follows the device region, notLocalizations.localeOf(context). A US-region iPhone shows.inside a German app — exactly why the formatter must accept both characters and rewrite, rather than filter to one.signed: truewins overdecimal: trueon iOS: you get the punctuation keyboard, not the decimal pad. Need negatives? Use a sign toggle button instead.- On Android, some Samsung keyboards disable or duplicate the decimal key in comma locales (#61175, #98200). Accepting both
.and,is the workaround that survives them.
Also remember NumberFormat.decimalPattern uses the pattern #,##0.### — it rounds display to three fraction digits. For money, use NumberFormat.currency(locale: locale.toString(), decimalDigits: 2), and see our guide to Flutter number and currency formatting for the display side.
Don't forget the error messages
The field now parses 6,5. The validation message beside it still needs translating — and "Enter at most 2 decimal places" is a plural-bearing string, carrying categories English never needs but Arabic, Polish and Russian do. FlutterLocalisation's ARB editor lets you edit app_de.arb, app_fr.arb and app_ar.arb in a UI instead of raw JSON, and its ICU plural-syntax validation flags any locale missing a plural category its language actually requires — the failure that ships a literal {count} to production. Setup details are in our Flutter intl package guide; see also features and pricing.
Try FlutterLocalisation free — manage your ARB files, catch broken plurals before your German and Arabic users do, and ship forms that survive a comma.