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:
- Full match on
languageCode+scriptCode+countryCode(returns the user's locale object, not the supported one). languageCode+scriptCode, only ifuserLocale.scriptCode != null.languageCode+countryCode, only ifcountryCode != null.languageCodeonly. This one is stored rather than returned instantly, unless it is the first preferred locale and the next preferred locale has a different language.countryCodeonly, after every preferred locale has failed.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,
scriptCodeis null. - Language + country
zh_TW: miss. - Language only:
languageLocales['zh']was filled by the firstzhentry, which iszh_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 (noapp_pt_BR.arb). - Language only: first
ptentry wins, and that ispt_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:
zhwithCN,SG, or no country becomesHanszhwithTW,HK, orMObecomesHantsrwith no country becomesCyrl
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.