← Back to Blog

Flutter Decimal Comma TextField: Parse Numbers per Locale

flutterintlnumberformattextfieldlocalization

Flutter Decimal Comma TextField: Parse Numbers per Locale

A user in Berlin types 12,5 into your price field, and your app either crashes or saves the wrong value. The cause is usually one line:

final value = double.parse(controller.text); // FormatException: Invalid double "12,5"

double.parse only understands the Dart literal format with a . decimal point. German, French, Spanish, Italian, Portuguese (Brazil), Dutch, Polish, Turkish and many other locales write the decimal separator as ,. So the fix is not about display only. You need the whole round trip to be locale-aware: what the keyboard sends, what the field accepts, how you parse it, and how you write it back.

This post builds that round trip with package:intl, plus a widget test that runs the same field under de_DE and en_US.

Why the obvious fixes are wrong

text.replaceAll(',', '.') before double.parse. This works until a user in the US pastes 1,234.5. You now have 1.234.5 and another FormatException.

NumberFormat.parse on raw input. Better, but there is a trap. NumberFormat maps the locale's group separator to nothing and skips it wherever it appears. In de_DE the group separator is ., so:

NumberFormat.decimalPattern('de_DE').parse('12.5'); // 125, not 12.5

No exception, just a value ten times too large. That is worse than a crash in a finance or shopping app. And users do type . in German locales, because the keyboard may not give them a comma at all.

The keyboard does not always match the locale

TextInputType.numberWithOptions(decimal: true) asks the platform for a numeric keypad with a decimal key. What that key shows is up to the keyboard, not Flutter. On iOS the decimal pad follows the device region. On Android it depends on the keyboard app: there are reports of Samsung's keyboard showing a comma that does nothing on German devices, and of decimal keypads that only send ..

So the safe rule is: accept both , and . as the decimal separator while typing, and normalise to the locale's separator. In the input field you do not allow grouping at all, which removes the ambiguity.

Step 1: read the decimal separator from the locale

NumberFormat exposes the locale's symbols. DECIMAL_SEP is the one you need:

import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';

String decimalSeparatorFor(Locale locale) =>
    NumberFormat.decimalPattern(locale.toString()).symbols.DECIMAL_SEP;

// de_DE -> ','   en_US -> '.'   fr_FR -> ','

Number symbols ship with intl, so unlike date formatting there is no initialize... call to await first.

Step 2: a TextInputFormatter that accepts ',' and '.'

import 'package:flutter/services.dart';

class DecimalInputFormatter extends TextInputFormatter {
  DecimalInputFormatter({required this.decimalSeparator, this.decimalDigits = 2});

  final String decimalSeparator;
  final int decimalDigits;

  late final RegExp _valid = RegExp(
    '^\\d*(${RegExp.escape(decimalSeparator)}\\d{0,$decimalDigits})?\$',
  );

  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    // Both keys map to the locale separator. The swap is one character for one,
    // so the cursor position stays valid.
    final text = newValue.text.replaceAll(RegExp('[.,]'), decimalSeparator);
    if (!_valid.hasMatch(text)) return oldValue;
    return newValue.copyWith(text: text);
  }
}

What it does:

  • A German user typing 12.5 sees 12,5. A US user typing 12,5 sees 12.5.
  • A second separator, letters, or a third decimal digit are rejected by returning oldValue.
  • No group separators are ever accepted, so NumberFormat.parse can't silently drop one.

If you need negative numbers, extend the pattern with an optional leading -.

Step 3: parse with NumberFormat, tryParse style

NumberFormat.parse throws a FormatException on bad input. Current intl (0.20.x) also has tryParse, which returns null instead. Use it, and handle the half-typed state 12, explicitly:

double? parseLocalized(String input, Locale locale) {
  var text = input.trim();
  final sep = decimalSeparatorFor(locale);
  if (text.endsWith(sep)) text = text.substring(0, text.length - 1);
  if (text.isEmpty) return null;
  return NumberFormat.decimalPattern(locale.toString()).tryParse(text)?.toDouble();
}

Never let a number field reach double.parse with user text again.

Step 4: the field, with write-back on blur

The field reads the locale from Localizations.localeOf(context), so it follows whatever locale your MaterialApp resolved. When the user leaves the field, the value is written back in the locale's format with fixed decimals. Grouping is turned off so the formatted text can still be edited by the formatter above.

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

class AmountField extends StatefulWidget {
  const AmountField({super.key, required this.onChanged, this.decimalDigits = 2});

  final ValueChanged<double?> onChanged;
  final int decimalDigits;

  @override
  State<AmountField> createState() => _AmountFieldState();
}

class _AmountFieldState extends State<AmountField> {
  final _controller = TextEditingController();
  final _focusNode = FocusNode();
  late Locale _locale;

  @override
  void initState() {
    super.initState();
    _focusNode.addListener(_onFocusChange);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _locale = Localizations.localeOf(context);
  }

  void _onFocusChange() {
    if (_focusNode.hasFocus) return;
    final value = parseLocalized(_controller.text, _locale);
    if (value == null) return;
    final format = NumberFormat.decimalPatternDigits(
      locale: _locale.toString(),
      decimalDigits: widget.decimalDigits,
    )..turnOffGrouping();
    _controller.text = format.format(value);
  }

  @override
  void dispose() {
    _focusNode.dispose();
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final separator = decimalSeparatorFor(_locale);
    return TextField(
      controller: _controller,
      focusNode: _focusNode,
      keyboardType: const TextInputType.numberWithOptions(decimal: true),
      inputFormatters: [
        DecimalInputFormatter(
          decimalSeparator: separator,
          decimalDigits: widget.decimalDigits,
        ),
      ],
      decoration: InputDecoration(hintText: '0${separator}00'),
      onChanged: (text) => widget.onChanged(parseLocalized(text, _locale)),
    );
  }
}

Store the double (or better, integer cents for money) in your state, never the string. The string is only a view of the value in one locale.

Step 5: a widget test under de_DE and en_US

tester.enterText goes through the field's input formatters, so the test exercises the same path as a real keyboard. Add flutter_localizations to your dependencies if it is not there already.

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> pumpField(
  WidgetTester tester,
  Locale locale,
  ValueChanged<double?> onChanged,
) {
  return tester.pumpWidget(MaterialApp(
    locale: locale,
    supportedLocales: const [Locale('en', 'US'), Locale('de', 'DE')],
    localizationsDelegates: GlobalMaterialLocalizations.delegates,
    home: Scaffold(body: AmountField(onChanged: onChanged)),
  ));
}

void main() {
  for (final (locale, typed, shown, blurred) in [
    (const Locale('de', 'DE'), '12,5', '12,5', '12,50'),
    (const Locale('de', 'DE'), '12.5', '12,5', '12,50'),
    (const Locale('en', 'US'), '12.5', '12.5', '12.50'),
    (const Locale('en', 'US'), '12,5', '12.5', '12.50'),
  ]) {
    testWidgets('$locale: "$typed" parses to 12.5', (tester) async {
      double? value;
      await pumpField(tester, locale, (v) => value = v);

      await tester.enterText(find.byType(TextField), typed);
      expect(find.text(shown), findsOneWidget);
      expect(value, 12.5);

      FocusManager.instance.primaryFocus?.unfocus();
      await tester.pumpAndSettle();
      expect(find.text(blurred), findsOneWidget);
    });
  }

  testWidgets('rejects a second separator', (tester) async {
    await pumpField(tester, const Locale('de', 'DE'), (_) {});
    await tester.enterText(find.byType(TextField), '12,5');
    await tester.enterText(find.byType(TextField), '12,5,3');
    expect(find.text('12,5'), findsOneWidget);
  });
}

The de_DE row with 12.5 is the one that matters most: it is exactly the input that would have become 125 with a naive NumberFormat.parse.

Output numbers belong in your translations

Input is half the job. Numbers you show in sentences ("You saved {amount}") should be formatted by gen-l10n through typed ARB placeholders, not by string concatenation. The setup and the common errors are covered in Fix Flutter gen-l10n NumberFormat constructor errors and Fix Flutter gen-l10n placeholder type mismatch. For currency symbols, see NumberFormat.currency showing the wrong symbol.

Checklist

  • No double.parse on user text anywhere.
  • Decimal separator comes from NumberFormat.decimalPattern(locale).symbols.DECIMAL_SEP.
  • Input accepts both , and ., and no group separators.
  • Parse with tryParse, write back with decimalPatternDigits(...)..turnOffGrouping().
  • Widget test runs at least one comma locale and one dot locale.

Try FlutterLocalisation free

Once your number fields work in every locale, the strings around them still need translating. FlutterLocalisation gives you an ARB editor for your app_<locale>.arb files, translation management across all your locales, and ICU plural validation that flags a locale missing a category its language needs. Try FlutterLocalisation free.