← Back to Blog

Flutter gen-l10n Currency Format: Fix the Wrong Symbol

fluttergen-l10narbcurrencynumberformatintl

Flutter gen-l10n Currency Format: Fix the Wrong Symbol

You add "format": "currency" to an ARB placeholder, run the app, and the price reads USD12.00. Switch the device to German and it becomes 12,00 EUR. Switch to Japanese and it is JPY12, with the cents gone.

Nothing is broken. That is exactly what a bare currency placeholder is specified to do. This post explains why, then gives ARB you can paste for currency, simpleCurrency and compactCurrency, a pattern for currency codes that come from your backend, and a widget test that pins the output.

Every output below was produced on Flutter 3.47 with intl 0.20.3.

Why a bare currency placeholder prints the wrong thing

Here is the placeholder most people start with:

"productPrice": "Price: {amount}",
"@productPrice": {
  "placeholders": {
    "amount": { "type": "double", "format": "currency" }
  }
}

flutter gen-l10n turns it into this Dart:

final intl.NumberFormat amountNumberFormat = intl.NumberFormat.currency(
  locale: localeName,
);

Only the locale is passed. NumberFormat.currency then fills in the rest with two defaults that cause the trouble:

  1. No name means the currency is the default one for the locale: USD for en, EUR for de and fr, JPY for ja, EGP for ar.
  2. No symbol means the ISO code is printed in place of a symbol. The intl docs say it directly: if no symbol is specified, the currency name is used in the formatted result.

So for 1234.5 you get:

Locale Bare currency output
en USD1,234.50
de 1.234,50 EUR
fr 1 234,50 EUR
ja JPY1,235
ar 1,234.50 EGP

The ugly code is the small problem. The real bug is that the same number is labelled as dollars, euros, yen and Egyptian pounds depending on the phone. No conversion happens. If you charge 1234.50 USD, a Japanese user sees a price of 1,235 yen.

One more trap: localeName comes from the ARB file that matched, not from the device. If you ship only app_en.arb, a user on en_GB is formatted as en and gets USD, not pounds.

Fix 1: currency with name and symbol

Say which currency you charge in, and which symbol to print. Both go in optionalParameters:

"productPrice": "Price: {amount}",
"@productPrice": {
  "placeholders": {
    "amount": {
      "type": "double",
      "format": "currency",
      "optionalParameters": {
        "name": "EUR",
        "symbol": "€"
      }
    }
  }
}

The locale now controls only what it should: separators and symbol position.

Locale Output
en Price: €1,234.50
de Preis: 1.234,50 €
fr Prix : 1 234,50 €
ja 価格: €1,234.50
ar السعر: 1,234.50 €

currency accepts four optional parameters in ARB: name, symbol, decimalDigits and customPattern. The placeholder metadata only needs to live in the template ARB. The other locale files just carry the translated string.

Setting symbol without name is a half fix. The symbol is pinned, but the decimal count still follows the locale's default currency, so Japanese users see €1,235. Always set name.

Fix 2: simpleCurrency when you only have the code

simpleCurrency looks the symbol up from the ISO code, so you do not have to hardcode it:

"planPrice": "{amount} per month",
"@planPrice": {
  "placeholders": {
    "amount": {
      "type": "double",
      "format": "simpleCurrency",
      "optionalParameters": { "name": "USD" }
    }
  }
}

That gives $1,234.50 per month in English, 1.234,50 $ pro Monat in German and 1 234,50 $ par mois in French. It takes name and decimalDigits only, with no symbol or customPattern.

A bare simpleCurrency is nicer to look at than a bare currency ($1,234.50, 1.234,50 €, ¥1,235) but it has the same bug underneath: the currency still changes with the locale.

Controlling decimals with decimalDigits

By default the decimal count comes from the currency: two for USD and EUR, zero for JPY. The currency's default wins over the locale's, so "name": "JPY" prints ¥1,235 even in an English app.

Override it when you want whole prices:

"wholePrice": "From {amount}",
"@wholePrice": {
  "placeholders": {
    "amount": {
      "type": "double",
      "format": "simpleCurrency",
      "optionalParameters": { "name": "USD", "decimalDigits": 0 }
    }
  }
}

1234.5 becomes From $1,235 in English and Ab 1.235 $ in German. The value is rounded, not truncated.

Forcing symbol position with customPattern

Symbol position is a locale convention, and you should normally leave it alone. German readers expect 1.234,50 €. If a design or legal requirement says the symbol must lead everywhere, use customPattern, where ¤ stands for the symbol:

"optionalParameters": {
  "name": "EUR",
  "symbol": "€",
  "customPattern": "¤#,##0.00"
}

German output changes from 1.234,50 € to €1.234,50. The separators still follow the locale. Only the layout is fixed.

compactCurrency for dashboards

For large totals, compactCurrency takes name, symbol and decimalDigits:

"totalRevenue": "Revenue: {amount}",
"@totalRevenue": {
  "placeholders": {
    "amount": {
      "type": "int",
      "format": "compactCurrency",
      "optionalParameters": {
        "name": "USD",
        "symbol": "$",
        "decimalDigits": 1
      }
    }
  }
}

For 1200000:

Locale Output
en Revenue: $1.2M
de Umsatz: 1,2 Mio. $
fr Revenus : 1,2 M $
ja 売上: $120万
ar الإيرادات: 1.2 مليون $

Leave out symbol and you are back to USD1.2M. If you would rather not hardcode the symbol, compactSimpleCurrency with just a name resolves it for you.

When the currency code comes from the backend

Everything in optionalParameters is a compile-time constant. gen-l10n writes the values into the generated Dart as literals, so you cannot point name at a runtime variable. A marketplace or a multi-currency subscription app gets the code from an API response.

The answer is to format in Dart and pass the finished string into a String placeholder:

"orderTotal": "Total: {price}",
"@orderTotal": {
  "placeholders": {
    "price": { "type": "String" }
  }
}
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';

import 'l10n/app_localizations.dart';

String formatPrice(BuildContext context, num amount, String currencyCode) {
  final locale = AppLocalizations.of(context)!.localeName;
  return NumberFormat.simpleCurrency(
    locale: locale,
    name: currencyCode,
  ).format(amount);
}
final l10n = AppLocalizations.of(context)!;
Text(l10n.orderTotal(formatPrice(context, order.total, order.currency)));

Using localeName keeps the number formatting in step with the translation that is actually on screen. If your backend sends amounts in minor units (cents), divide before formatting.

A widget test that pins the output

Currency strings are full of characters you cannot see. German puts a no-break space (U+00A0) before the symbol. French uses a narrow no-break space (U+202F) as the thousands separator. Arabic output starts with a right-to-left mark (U+200F). A test that compares against a normal space fails with two strings that look identical in the log, so name the characters:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';
import 'package:my_app/price_format.dart';

const nbsp = '\u00A0'; // no-break space before the symbol
const nnbsp = '\u202F'; // narrow no-break space, French thousands separator
const rlm = '\u200F'; // right-to-left mark that prefixes Arabic currency

void main() {
  final expected = <Locale, String>{
    const Locale('en', 'US'): r'$1,234.50',
    const Locale('de', 'DE'): '1.234,50$nbsp\$',
    const Locale('fr', 'FR'): '1${nnbsp}234,50$nbsp\$',
    const Locale('ja', 'JP'): r'$1,234.50',
    const Locale('ar', 'EG'): '${rlm}1,234.50$nbsp\$',
  };

  expected.forEach((locale, price) {
    testWidgets('USD price in $locale', (tester) async {
      late BuildContext context;
      await tester.pumpWidget(
        MaterialApp(
          locale: locale,
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Builder(
            builder: (c) {
              context = c;
              return Text(AppLocalizations.of(c)!.planPrice(1234.5));
            },
          ),
        ),
      );

      expect(find.textContaining(price), findsOneWidget);
      expect(formatPrice(context, 1234.5, 'USD'), price);
    });
  });
}

This assumes app_en.arb, app_de.arb, app_fr.arb, app_ja.arb and app_ar.arb with the planPrice message from above. The test checks both paths: the ARB placeholder and the runtime helper.

The Arabic row uses Western digits because the matched ARB is app_ar.arb, so the format locale is ar. Formatting with the full ar_EG locale gives Arabic-Indic digits (١٬٢٣٤٫٥٠ $). Decide which one you want and let the test hold it in place.

Quick checklist

  • Never ship a bare "format": "currency" or "format": "simpleCurrency" for a real price.
  • Always set name to the currency you charge in.
  • Add symbol for currency and compactCurrency, or switch to simpleCurrency and compactSimpleCurrency.
  • Use decimalDigits only to override the currency's default.
  • Runtime currency code: String placeholder plus NumberFormat in Dart.
  • Pin the output per locale in a test, invisible characters included.

Keep the ARB side tidy

Price messages mix translated text with placeholder metadata, and a missing key in one locale file is easy to overlook in raw JSON. The FlutterLocalisation ARB editor lets you edit your app_<locale>.arb translations in a UI and manage them across many locales, with ICU plural-syntax validation for messages like "3 items in your cart". There are more Flutter i18n guides on the blog, and plans are on the pricing page.

Try FlutterLocalisation free and stop hand-editing ARB files.