← Back to Blog

Fix Wrong Currency Symbols in Flutter ARB Files

flutterarbintlcurrencygen-l10ni18n

Fix Wrong Currency Symbols in Flutter ARB Files

You ship a subscription screen. In en it reads $9.99. In fr it reads $9.99. In ja it reads $1000.00 for a ¥1,000 plan. So someone adds a helper:

// Don't do this.
String priceFor(double price, Locale locale) {
  if (locale.languageCode == 'fr') return '${price.toStringAsFixed(2)} €';
  if (locale.languageCode == 'ja') return ${price.toStringAsFixed(0)}';
  return '\$${price.toStringAsFixed(2)}';
}

That helper is wrong for French (the group separator is a narrow no-break space, not a comma), wrong for Jordan (JOD has three decimal places), and it will keep growing an else if per market forever.

ARB already solves this. A number placeholder can carry "format": "currency" and an optionalParameters map, and gen-l10n wires it straight into intl's NumberFormat.currency. Below is the correct setup — and the three traps that make most people give up on it and go back to if/else.

The ARB-native fix

In lib/l10n/app_en.arb:

{
  "@@locale": "en",
  "priceLabel": "Just {price} / month",
  "@priceLabel": {
    "description": "Subscription price on the paywall",
    "placeholders": {
      "price": {
        "type": "double",
        "format": "currency",
        "optionalParameters": {
          "symbol": "$",
          "decimalDigits": 2
        }
      }
    }
  }
}

Run flutter gen-l10n (or just flutter run, which triggers it) and open the generated app_localizations_en.dart. You get exactly this:

String priceLabel(double price) {
  final intl.NumberFormat priceNumberFormat = intl.NumberFormat.currency(
    locale: localeName,
    symbol: '\$',
    decimalDigits: 2
  );
  final String priceString = priceNumberFormat.format(price);

  return 'Just $priceString / month';
}

Note locale: localeName — the decimal separator, group separator and symbol placement come from CLDR for that locale automatically. You only supply the parts CLDR can't know: which currency you're charging in.

The full set of format values for int, double and num placeholders is compact, compactCurrency, compactSimpleCurrency, compactLong, currency, decimalPattern, decimalPatternDigits, decimalPercentPattern, percentPattern, scientificPattern and simpleCurrency. Only some of them accept named arguments; currency, simpleCurrency, compactCurrency, compactSimpleCurrency, compactLong, compact, decimalPatternDigits and decimalPercentPattern are the ones where optionalParameters actually does something.

Trap 1: omit symbol and you get a currency code, not $

This is the single most common "flutter localization currency symbol wrong" bug. Drop symbol and 1200000 renders as USD1,200,000.00 — the ISO code, glued to the number.

The reason is in intl's own source. NumberFormat.currency resolves its arguments like this:

name ??= symbols.DEF_CURRENCY_CODE;   // per-locale default currency
currencySymbol ??= name;              // no symbol -> print the code

So the fallback isn't a hardcoded USD — it's whatever currency CLDR thinks that locale spends. From number_symbols_data.dart:

locale DEF_CURRENCY_CODE
en USD
en_GB GBP
fr, de, es EUR
ja JPY
ar EGP

Which means an app with no symbol silently reprices itself per locale: the same 9.99 shows as USD9.99 in English and 9,99 EUR in French. Two different amounts of money, one number. Always set symbol, or set name and use simpleCurrency.

Trap 2: decimal digits follow name, not symbol

If you don't pass decimalDigits, intl looks the value up in a currencyFractionDigits table — keyed on the currency name, never on the symbol you supplied:

int get _defaultDecimalDigits =>
    currencyFractionDigits[currencyName.toUpperCase()] ??
    currencyFractionDigits['DEFAULT']!;   // DEFAULT is 2

Zero-decimal entries include JPY, KRW, CLP, ISK and HUF. Three-decimal entries include JOD, KWD, BHD, OMR and LYD.

So this ARB is broken even though it looks right:

"price": { "type": "double", "format": "currency",
           "optionalParameters": { "symbol": "¥" } }

In an en build, name is still USD, so you get ¥1,000.00 — a yen symbol with American decimals. Be explicit about both:

"optionalParameters": { "name": "JPY", "symbol": "¥", "decimalDigits": 0 }

For Jordan, the same fix with the opposite sign: {"name": "JOD", "symbol": "JOD", "decimalDigits": 3} renders JOD 9.990, which is the amount your payment processor actually charges. Passing decimalDigits yourself is the safest habit — it survives CLDR data updates and makes the intent reviewable in the ARB file.

Trap 3: symbol belongs in every locale ARB, not just the template

For a while, gen-l10n only read placeholder metadata from the template ARB, which is why people assumed currency had to be hand-rolled. That changed: since flutter/flutter#163690 (Flutter 3.32 and later), each bundle gets its own placeholder map, and the generator falls back to the template placeholder only when a locale doesn't declare one.

That's the hook you want. app_fr.arb:

{
  "@@locale": "fr",
  "priceLabel": "Seulement {price} / mois",
  "@priceLabel": {
    "placeholders": {
      "price": {
        "type": "double",
        "format": "currency",
        "optionalParameters": {
          "name": "EUR",
          "symbol": "€",
          "decimalDigits": 2
        }
      }
    }
  }
}

app_localizations_fr.dart now emits symbol: '€', and CLDR's French currency pattern (#,##0.00\u00A0¤) puts it after the number: 9,99 €.

The sharp edge: a per-locale override replaces the whole placeholder object, and gen-l10n only emits a NumberFormat when the placeholder has both a numeric type and a non-null format:

bool get requiresNumFormatting =>
    <String>['int', 'num', 'double'].contains(type) && format != null;

So if app_fr.arb lists only optionalParameters and forgets "type": "double", "format": "currency", there is no error — the generator just interpolates the raw double and French users see Seulement 9.99 / mois. Restate type and format in every locale override. This is exactly the class of drift a UI catches and a text editor doesn't: FlutterLocalisation's ARB editor shows every locale's version of a key side by side, so a fr entry that lost its placeholder metadata is visible instead of silent.

When simpleCurrency is enough — and when it lies

simpleCurrency skips the symbol argument by looking the symbol up for you:

"optionalParameters": { "name": "GBP" }

with "format": "simpleCurrency" gives £9.99. Convenient, but the lookup table is deliberately ambiguous — USD, CAD and AUD all map to $, and JOD, KWD, BHD, DZD and IQD all map to din. If you sell in more than one dollar, use currency with an explicit symbol such as CA$.

Custom placement with customPattern

When CLDR's pattern isn't what your designer drew — accounting parentheses, a hard space, symbol forced to one side — pass a pattern. ¤ is the currency placeholder:

"optionalParameters": {
  "name": "JOD",
  "symbol": "JOD",
  "decimalDigits": 3,
  "customPattern": "¤ #,##0.000"
}

Use it sparingly: a customPattern in the template ARB overrides the locale's pattern for that locale's generated file, so if you set one, set it deliberately per locale rather than once globally.

Test it, because the separators aren't what you think

French doesn't use a plain space. CLDR 48 gives fr a narrow no-break space (U+202F) as group separator and a no-break space (U+00A0) before the symbol. A test asserting '1 234,50 €' typed with ordinary spaces will fail and look like a formatter bug.

testWidgets('prices render per locale', (tester) async {
  for (final (locale, expected) in [
    (const Locale('en'), r'Just $9.99 / month'),
    (const Locale('fr'), 'Seulement 9,99\u00A0€ / mois'),
  ]) {
    await tester.pumpWidget(MaterialApp(
      locale: locale,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: Builder(builder: (c) => Text(AppLocalizations.of(c)!.priceLabel(9.99))),
    ));
    expect(find.text(expected), findsOneWidget);
  }
});

One thing ARB still can't do

The formatter is baked into the generated method, so you can't pass a currency at call time — an en_US build can't format a user-selected CAD balance through the same key. That's an open request, flutter/flutter#114237. Until it lands, keep ARB for your prices (which are fixed per market) and construct a NumberFormat.currency(locale: ..., name: userCurrency) directly for user-chosen currencies.

Keep the currency metadata honest across locales

The fix above is only durable if every locale ARB keeps its type, format and optionalParameters intact as translators edit files. That's what FlutterLocalisation is for: an ARB editor and translation-management platform for app_<locale>.arb files, with ICU plural-syntax validation so a locale missing a plural category its language needs gets flagged rather than shipped. More on placeholders in our complete ARB placeholder guide.

Try FlutterLocalisation free — edit your ARB files in a UI, see every locale's placeholders side by side, and stop shipping USD9.99 to Paris.