← Back to Blog

Flutter zh_Hant Not Working? Fix Locale Resolution

flutterlocalizationarblocale-resolutioni18n

Flutter zh_Hant Not Working? Fix Locale Resolution

The device says Chinese (Taiwan). The app ships app_zh_Hant.arb. The app shows 简体. Nothing crashed, no warning was printed, and the file you paid a translator for was never consulted.

Same story with Portuguese: a Brazilian user opens the app and reads European Portuguese wording, even though pt-BR is first in their system language list.

Both come from the same place: Flutter's default resolver, basicLocaleListResolution. It is fast, it is documented, and it is explicitly not a full language-matching implementation. Here is what it really does, and how to replace it with something script-aware.

The default algorithm, as it is actually written

basicLocaleListResolution(List<Locale>? preferredLocales, Iterable<Locale> supportedLocales) first hashes supportedLocales into five lookup maps: full (language_script_country), language_script, language_country, language, and country. Each map keeps the first supported locale that claims a key, which is why supportedLocales order changes the outcome.

Then, for each preferred locale in order, it tries:

  1. Full match on languageCode + scriptCode + countryCode (returns the user's locale object, not the supported one).
  2. languageCode + scriptCode, only if userLocale.scriptCode != null.
  3. languageCode + countryCode, only if countryCode != null.
  4. languageCode only. This one is stored rather than returned instantly, unless it is the first preferred locale and the next preferred locale has a different language.
  5. countryCode only, after every preferred locale has failed.
  6. supportedLocales.first.

Step 2 is the whole problem. The script branch is skipped entirely when the incoming locale carries no scriptCode, and step 4 will happily hand a Traditional Chinese user a Simplified bundle because zh is zh. The API docs say it plainly: the algorithm "does not implement a full algorithm (such as the one defined in Unicode TR35) that takes distances between languages into account."

Trace 1: zh_TW arrives without a script code

Supported list, in this order:

[
  Locale('en'),
  Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
  Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
]

Preferred: [Locale('zh', 'TW')].

  • Full key zh_null_TW: miss.
  • Language + script: skipped, scriptCode is null.
  • Language + country zh_TW: miss.
  • Language only: languageLocales['zh'] was filled by the first zh entry, which is zh_Hans. Index 0, no next locale, so it returns immediately.

Result: zh_Hans. Swap the order of the two zh lines and the same user gets zh_Hant. That is the entire mechanism behind "flutter supportedLocales order" bug reports.

Where does a script-less zh_TW come from? Usually from you. An in-app language picker that stores Locale('zh', 'TW'), a saved preference from SharedPreferences, or a test. When you pass locale: to MaterialApp, Flutter runs resolution with that single locale as the only preferred entry, so a script-less pick goes straight down the path above.

Trace 2: pt_BR falls back to European Portuguese

Supported: [Locale('en'), Locale('pt', 'PT'), Locale('pt')]. Preferred: [Locale('pt', 'BR')].

  • Full pt_null_BR: miss.
  • Script: null, skipped.
  • Language + country pt_BR: miss (no app_pt_BR.arb).
  • Language only: first pt entry wins, and that is pt_PT.

Result: Locale('pt', 'PT'). No algorithm can invent Brazilian wording you never wrote, but note the convention flutter_localizations itself uses: the base pt bundle is Brazilian and pt_PT is the European override. Mirror that in your ARB files and even a missing file degrades sensibly.

Your Locale objects are probably wrong

This is the single most common cause:

// WRONG: 'Hant' lands in countryCode
const Locale('zh', 'Hant');       // zh_Hant in toString(), but scriptCode == null

// WRONG: not a locale, it is one language subtag
const Locale('zh_Hant');

// RIGHT
const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant');
const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW');

Locale('zh', 'Hant') prints as zh_Hant, so it looks right in a debug log and fails every scriptCode comparison in the framework. It also breaks Material and Cupertino strings: GlobalMaterialLocalizations switches on locale.scriptCode, so a null script drops to MaterialLocalizationZh, whose aboutListTileTitleRaw is 关于$applicationName. Simplified. Your buttons and date pickers go Simplified while your own strings may not, which is how "half the app is Traditional" tickets start.

The framework ships zh, zh_Hans, zh_Hant, zh_Hant_HK, zh_Hant_TW, plus sr, sr_Cyrl, sr_Latn, pt, pt_PT and es_419. All of it is reachable only if the resolved locale carries the right subtags.

ARB naming: app_zh_Hant.arb vs app_zh_TW.arb

flutter gen-l10n parses the locale from the file name, splitting on _, and treats a 4-character middle subtag as the script code. It also derives missing scripts:

  • zh with CN, SG, or no country becomes Hans
  • zh with TW, HK, or MO becomes Hant
  • sr with no country becomes Cyrl

So app_zh_TW.arb generates the class for zh_Hant_TW and puts Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW') into AppLocalizations.supportedLocales. It works, but you cannot also keep app_zh_Hant_TW.arb, and the derived name is invisible in your file tree. Name them explicitly instead:

lib/l10n/app_en.arb
lib/l10n/app_zh.arb          # @@locale: zh   (keep this Simplified, like the framework does)
lib/l10n/app_zh_Hant.arb     # @@locale: zh_Hant
lib/l10n/app_zh_Hant_TW.arb  # @@locale: zh_Hant_TW
lib/l10n/app_pt.arb          # Brazilian base
lib/l10n/app_pt_PT.arb
lib/l10n/app_sr_Cyrl.arb
lib/l10n/app_sr_Latn.arb

Write @@locale in underscore form matching the file name. gen_l10n compares the two and fails the build on a mismatch.

One more trap in the generated code: the delegate's isSupported compares language code only.

@override
bool isSupported(Locale locale) => <String>['en', 'zh', 'pt'].contains(locale.languageCode);

So any zh variant is "supported", and the nested lookup switch decides which class you actually get. Being supported tells you nothing about which strings load.

A script-aware resolution callback

Two parts: normalise the incoming locale so a script is always present where it matters, then fall back without ever crossing scripts.

// lib/l10n/locale_resolution.dart
import 'package:flutter/widgets.dart';

const _latinAmerica = <String>{
  'MX', 'AR', 'CO', 'CL', 'PE', 'VE', 'EC', 'GT', 'CU', 'BO',
  'DO', 'HN', 'PY', 'SV', 'NI', 'CR', 'PA', 'UY', 'PR',
};

/// Fills in the script code the platform (or your own settings screen) omitted.
Locale normalizeLocale(Locale locale) {
  if (locale.scriptCode != null) return locale;
  switch ((locale.languageCode, locale.countryCode)) {
    case ('zh', 'TW' || 'HK' || 'MO'):
      return Locale.fromSubtags(
        languageCode: 'zh',
        scriptCode: 'Hant',
        countryCode: locale.countryCode,
      );
    case ('zh', 'CN' || 'SG' || null):
      return Locale.fromSubtags(
        languageCode: 'zh',
        scriptCode: 'Hans',
        countryCode: locale.countryCode,
      );
    case ('sr', null):
      return const Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Cyrl');
    case ('es', final String c) when _latinAmerica.contains(c):
      return const Locale('es', '419');
    default:
      return locale;
  }
}

Locale? _best(Locale want, List<Locale> supported) {
  bool sameLang(Locale s) => s.languageCode == want.languageCode;

  for (final s in supported) {
    if (s == want) return s;
  }
  if (want.scriptCode != null) {
    // Generic script bucket first: zh_Hant_HK -> zh_Hant.
    for (final s in supported) {
      if (sameLang(s) && s.scriptCode == want.scriptCode && s.countryCode == null) return s;
    }
    // Then same script, any country: zh_Hant_HK -> zh_Hant_TW.
    for (final s in supported) {
      if (sameLang(s) && s.scriptCode == want.scriptCode) return s;
    }
  }
  if (want.countryCode != null) {
    for (final s in supported) {
      if (sameLang(s) && s.countryCode == want.countryCode && s.scriptCode == want.scriptCode) {
        return s;
      }
    }
  }
  // Neutral base (app_pt.arb) beats a different region (app_pt_PT.arb).
  for (final s in supported) {
    if (sameLang(s) && s.scriptCode == null && s.countryCode == null) return s;
  }
  // Last resort, still never crossing scripts.
  for (final s in supported) {
    if (sameLang(s) && s.scriptCode == want.scriptCode) return s;
  }
  return null;
}

Locale resolveLocales(List<Locale>? preferred, Iterable<Locale> supported) {
  final List<Locale> list = supported.toList();
  for (final Locale raw in preferred ?? const <Locale>[]) {
    final Locale? match = _best(normalizeLocale(raw), list);
    if (match != null) return match;
  }
  return list.first;
}

Wire it up with localeListResolutionCallback, not localeResolutionCallback. The list version receives every locale the user ranked; the single version sees only the first one. Flutter tries the list callback, then the single one, then the basic algorithm.

MaterialApp(
  onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: AppLocalizations.supportedLocales,
  locale: settings.locale, // already normalised, or null to follow the system
  localeListResolutionCallback: resolveLocales,
);

If your settings screen stores a locale, store normalizeLocale(picked) so a saved zh_TW never reaches the framework bare.

Prove each case with a test

resolveLocales is a plain function, so no widget pumping is needed.

// test/locale_resolution_test.dart
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/locale_resolution.dart';

const supported = <Locale>[
  Locale('en'),
  Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
  Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
  Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW'),
  Locale('pt'),
  Locale('pt', 'PT'),
  Locale('es'),
  Locale('es', '419'),
  Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Cyrl'),
  Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Latn'),
];

const zhHans = Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans');
const zhHant = Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant');
const zhHantTw =
    Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW');

void main() {
  test('the default algorithm sends a script-less zh_TW to Simplified', () {
    expect(
      basicLocaleListResolution(const [Locale('zh', 'TW')], supported),
      zhHans,
    );
  });

  test('script-aware resolution keeps Traditional', () {
    expect(resolveLocales(const [Locale('zh', 'TW')], supported), zhHantTw);
  });

  test('zh_Hant_HK degrades to generic zh_Hant, never to Hans', () {
    const hk = Locale.fromSubtags(
        languageCode: 'zh', scriptCode: 'Hant', countryCode: 'HK');
    expect(resolveLocales(const [hk], supported), zhHant);
  });

  test('the default algorithm sends pt_BR to European Portuguese', () {
    const ptOnly = <Locale>[Locale('en'), Locale('pt', 'PT'), Locale('pt')];
    expect(
      basicLocaleListResolution(const [Locale('pt', 'BR')], ptOnly),
      const Locale('pt', 'PT'),
    );
  });

  test('script-aware resolution prefers the neutral pt base', () {
    expect(resolveLocales(const [Locale('pt', 'BR')], supported),
        const Locale('pt'));
  });

  test('es_MX routes to es_419', () {
    expect(resolveLocales(const [Locale('es', 'MX')], supported),
        const Locale('es', '419'));
  });

  test('sr with no country is Cyrillic, sr_Latn stays Latin', () {
    expect(resolveLocales(const [Locale('sr')], supported),
        const Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Cyrl'));
    expect(
      resolveLocales(
        const [Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Latn')],
        supported,
      ),
      const Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Latn'),
    );
  });
}

The first and fourth tests are the bug, pinned. Keep them: they document why the callback exists and they fail loudly if someone deletes it.

Two platform checks before you blame Flutter

iOS. The system only offers your app the languages the bundle declares. If ios/Runner/Info.plist has no CFBundleLocalizations array covering your locales, a device set to zh-Hant-TW may hand you English and your resolver never sees the real preference.

<key>CFBundleLocalizations</key>
<array>
  <string>en</string>
  <string>zh-Hans</string>
  <string>zh-Hant</string>
  <string>pt-BR</string>
  <string>pt-PT</string>
</array>

Everywhere. Print the raw list once at startup: WidgetsBinding.instance.platformDispatcher.locales, and log languageCode, scriptCode and countryCode separately. toString() hides the Locale('zh', 'Hant') mistake; the fields do not.

Keep the files themselves honest

Resolution only matters if the bundle it lands on is complete. A zh_Hant file that silently inherits half its keys, or a pt_BR file missing plural categories, produces the same "wrong strings" report from a different cause. That is the part FlutterLocalisation handles: an ARB editor for your app_<locale>.arb files so you edit app_zh_Hant.arb and app_pt_BR.arb side by side instead of diffing raw JSON, translation management across every locale you support, and ICU plural-syntax validation that flags a locale missing a plural category its language actually needs. More walkthroughs are in the blog, and the pricing page has the free tier.

Try FlutterLocalisation free and get your script variants organised before the next Taiwanese review tells you the app is in Simplified Chinese.