Flutter zh_Hant_TW Resolves to Simplified Chinese: The Fix
A tester in Taipei opens your app and reads Simplified Chinese. Your Hong Kong users file the same bug. You have an app_zh_Hant.arb file, so the translations exist. The problem is not the translations. It is how Flutter picks a locale, and how the entries in supportedLocales were written.
This post walks the exact failure chain, then gives the copy-paste fix: the ARB file set, the supportedLocales list, a localeListResolutionCallback that honours the user's full language list, and a widget test that pins each device locale to the variant it should get.
How Flutter resolves a locale
MaterialApp and WidgetsApp fall back to basicLocaleListResolution when you do not pass a callback. The source is explicit that it "prioritizes speed at the cost of slightly less appropriate resolutions for edge cases" and is not a full Unicode TR35 language-distance matcher.
It builds hash maps from supportedLocales, keyed on language_script_country, language_script, language_country and language. A null subtag becomes the literal text null, so Locale('zh') is keyed as zh_null_null. Duplicate keys keep the first entry, so order matters.
Then, for each locale in the user's preferred list, in order:
- Exact match on language, script and country.
- Language plus script, only if the device locale carries a script.
- Language plus country, only if the device locale carries a country.
- Language only. This match is held, not returned, in case the next preferred locale has a better match.
- Country only, as a last resort.
If nothing matches, you get supportedLocales.first.
The failure chain for a Taiwan device
An iPhone set to Traditional Chinese (Taiwan) reports zh_Hant_TW. Modern Android does the same, since the embedding passes Locale.getScript(). Here is the most common broken setup:
supportedLocales: const [
Locale('zh'),
Locale('zh', 'Hant'), // bug: 'Hant' lands in countryCode
],
The two-argument Locale constructor takes a language and a country. Hant is stored as a country code, so the maps contain zh_null_Hant and zh_Hant under the language_country key, and nothing under the language_script key.
Walk the device locale zh_Hant_TW through the steps:
- Exact key
zh_Hant_TW: missing. - Language plus script key
zh_Hant: missing, because no supported entry has a script. - Language plus country key
zh_TW: missing. - Language only
zh: hits the first zh entry, which isLocale('zh').
Your Simplified file wins. Nothing crashes and no assertion fires, which is why this ships to production.
A second common setup, Locale('zh') plus Locale('zh', 'TW'), looks fine on a Taiwan device because step 3 matches zh_TW. It still fails for Hong Kong (zh_Hant_HK) and Macau, which fall to language-only and land on Simplified again.
The ARB file set gen-l10n needs
Name the files by the subtags you actually want to match. gen-l10n parses the file name by splitting on underscores. A subtag of four or more characters is a script, a shorter one is a country, so app_zh_Hant_TW.arb is read correctly.
lib/l10n/
app_en.arb # template
app_zh.arb # generic Chinese, Simplified content
app_zh_Hans.arb # explicit Simplified
app_zh_Hant.arb # Traditional, shared by TW, HK, MO
app_zh_Hant_TW.arb # Taiwan-only overrides
app_zh_Hant_HK.arb # Hong Kong-only overrides (optional)
Set @@locale in each file so the editor and the generator agree:
{
"@@locale": "zh_Hant_TW",
"settingsTitle": "設定"
}
The regional files only need the keys that differ. gen-l10n generates AppLocalizationsZhHantTw as a subclass of AppLocalizationsZhHant, so a missing key in the Taiwan file falls back to the shared Traditional text, never to Simplified.
Why include app_zh.arb at all? Older Android builds and some emulators report a bare zh_CN or zh_TW with no script. For zh_CN, step 3 misses and step 4 picks the first zh entry, which is app_zh.arb. For a script-less zh_TW, the language_country key zh_TW is populated by your zh_Hant_TW entry, so step 3 returns Traditional. That is the real reason to ship the country-specific file even when its content is nearly empty.
The ordered supportedLocales list
Let the generator write the list and construct every locale with Locale.fromSubtags:
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
// ...
);
If you prefer to write it by hand, keep zh first so the language-only fallback is deliberate, and never pass a script as the second positional argument:
supportedLocales: const [
Locale('en'),
Locale.fromSubtags(languageCode: 'zh'),
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW'),
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'HK'),
],
The Material and Cupertino delegates already ship zh, zh_Hans, zh_Hant, zh_Hant_HK and zh_Hant_TW, so date pickers and tooltips follow the same variant.
A localeListResolutionCallback that respects the user's list
The default resolver is good enough once the list above is in place. Two gaps remain. A script-less zh_HK or zh_MO is not covered by a Taiwan file, and a user whose list is [zh_Hant_HK, en] with no Hong Kong file should get Traditional, not drop to English because a later entry matched better. This callback derives the script from the region, tries each preferred locale in order, and refuses to cross scripts:
Locale? resolveLocaleList(List<Locale>? preferred, Iterable<Locale> supported) {
if (preferred == null || preferred.isEmpty) return supported.first;
for (final raw in preferred) {
final locale = _withDerivedScript(raw);
for (final s in supported) {
if (s == locale) return s;
}
for (final s in supported) {
if (s.languageCode == locale.languageCode &&
s.scriptCode == locale.scriptCode &&
s.countryCode == null) {
return s;
}
}
if (locale.scriptCode == null) {
for (final s in supported) {
if (s.languageCode == locale.languageCode && s.scriptCode == null) {
return s;
}
}
}
}
return supported.first;
}
Locale _withDerivedScript(Locale l) {
if (l.languageCode != 'zh' || l.scriptCode != null) return l;
const hant = {'TW', 'HK', 'MO'};
return Locale.fromSubtags(
languageCode: 'zh',
scriptCode: hant.contains(l.countryCode) ? 'Hant' : 'Hans',
countryCode: l.countryCode,
);
}
Wire it in with localeListResolutionCallback: resolveLocaleList. When both callbacks are set, Flutter calls the list version first and only consults localeResolutionCallback if it returns null.
If your app also offers an in-app language picker, pass the stored choice through the locale parameter instead of fighting the resolver. The runtime switch is covered in Change Flutter App Language at Runtime with gen-l10n.
Widget test: each device locale lands on the right variant
TestPlatformDispatcher lets a test fake the device language list. Use localesTestValue and clear it in teardown so the override does not leak.
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';
import 'package:my_app/locale_resolution.dart';
void main() {
const cases = <List<Locale>, Locale>{
[Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW')]:
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW'),
[Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'HK')]:
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'HK'),
[Locale('zh', 'MO'), Locale('en')]:
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
[Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN')]:
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
[Locale('zh', 'TW')]:
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW'),
[Locale('ja'), Locale('en')]: Locale('en'),
};
for (final entry in cases.entries) {
testWidgets('${entry.key} resolves to ${entry.value}', (tester) async {
tester.platformDispatcher.localesTestValue = entry.key;
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
late Locale resolved;
await tester.pumpWidget(MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: resolveLocaleList,
home: Builder(builder: (context) {
resolved = Localizations.localeOf(context);
return const SizedBox();
}),
));
await tester.pumpAndSettle();
expect(resolved, entry.value);
});
}
}
Run the same table against the default resolver by removing the callback line and you will see the zh_MO and bare zh_HK rows fail. That is the regression this test guards.
Before you ship to iOS
On iOS the resolver only ever sees languages that are also declared in Info.plist. If CFBundleLocalizations lists zh but not zh-Hant, the device may offer Flutter a trimmed list and you are back to Simplified. The fix is in Flutter App Stuck in English on iOS? Sync Your Info.plist, and the same zh-Hans and zh-Hant entries belong there.
Keeping four Chinese ARB files in sync
The setup above means every new string touches app_zh.arb, app_zh_Hans.arb and app_zh_Hant.arb, plus any regional overrides. Editing those as raw JSON is where @@locale typos and missing keys creep in. The FlutterLocalisation ARB editor shows all locales for a key side by side, so a Traditional translation that is still Simplified stands out, and its ICU plural validation flags a locale that dropped a category. The free tier covers a small project; see pricing for team sizes.
Try FlutterLocalisation free and get your zh_Hant files right the first time.