Flutter App Stuck in English on iOS? Sync Your Info.plist
Your gen-l10n setup works. Android shows French, German and Japanese. The simulator looks fine too. Then someone opens the app on a real iPhone set to Spanish and some or all of it is in English, and there is no Language row under Settings > [Your App].
This is the most common reason people search "flutter localization not working ios". The usual answer is "add CFBundleLocalizations by hand". That works until somebody adds app_ko.arb and forgets the plist. This post explains what iOS actually checks, then gives you a Dart script that builds the plist entry from your ARB files so the two can't drift apart.
Why iOS falls back to English
There are two separate locale decisions on iOS, and Flutter only controls one of them.
The Dart side. On every locale change the Flutter iOS embedder reads [NSLocale preferredLanguages] and sends the list to dart:ui. MaterialApp then matches that list against the supportedLocales that gen-l10n built from your ARB files. Your AppLocalizations strings come from this path.
The native side. iOS works out which languages your bundle supports from the union of three things: the .lproj folders in the app, the CFBundleLocalizations array in Info.plist, and CFBundleDevelopmentRegion. A stock Flutter project ships only en in CFBundleDevelopmentRegion and has no other .lproj folders, so as far as iOS is concerned your app is English only. That has real effects:
Bundle.main.preferredLocalizationsresolves toen. Native plugins, system sheets, permission prompts and localizedInfoPlist.stringsall follow it.- The per-app language picker (added in iOS 13) only appears for apps that declare more than one language. With just
enthere is nothing to choose, so the row never shows. - The App Store listing shows English only.
In practice that leaves you with a half-translated app on device: Dart strings in Spanish, native UI in English, and no way for the user to switch.
The quick fix, and why it drifts
The hand-written fix looks like this in ios/Runner/Info.plist:
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>es</string>
<string>fr</string>
</array>
It is correct, but it's a second copy of your locale list. Your source of truth is lib/l10n/app_*.arb. Every time a locale is added there, someone has to remember the plist, and nobody notices when they don't, because Android, the simulator and your tests all keep working.
Generate CFBundleLocalizations from your ARB files
Save this as tool/sync_ios_locales.dart. It has no dependencies beyond the Dart SDK.
// tool/sync_ios_locales.dart
// Usage:
// dart run tool/sync_ios_locales.dart (rewrite Info.plist)
// dart run tool/sync_ios_locales.dart --check (exit 1 if out of date, for CI)
import 'dart:convert';
import 'dart:io';
const arbDir = 'lib/l10n';
const plistPath = 'ios/Runner/Info.plist';
final arbName = RegExp(r'^app_(.+)\.arb$');
final existingBlock = RegExp(
r'<key>CFBundleLocalizations</key>\s*<array>[\s\S]*?</array>',
);
/// Flutter writes zh_Hant and pt_BR. Apple expects zh-Hant and pt-BR.
String toAppleCode(String flutterLocale) {
final parts = flutterLocale.split(RegExp('[_-]'));
final out = <String>[parts.first.toLowerCase()];
for (final part in parts.skip(1)) {
if (part.length == 4) {
// Script subtag: Hant, Hans, Latn, Cyrl.
out.add(part[0].toUpperCase() + part.substring(1).toLowerCase());
} else {
// Region subtag: BR, TW, 419.
out.add(part.toUpperCase());
}
}
return out.join('-');
}
void main(List<String> args) {
final checkOnly = args.contains('--check');
final codes = <String>{};
for (final entity in Directory(arbDir).listSync()) {
if (entity is! File) continue;
final match = arbName.firstMatch(entity.uri.pathSegments.last);
if (match == null) continue;
final json = jsonDecode(entity.readAsStringSync()) as Map<String, dynamic>;
// gen-l10n prefers @@locale over the file name, so we do too.
final locale = (json['@@locale'] as String?) ?? match.group(1)!;
codes.add(toAppleCode(locale));
}
if (codes.isEmpty) {
stderr.writeln('No app_*.arb files found in $arbDir');
exit(1);
}
final sorted = codes.toList()..sort();
final block = StringBuffer('<key>CFBundleLocalizations</key>\n\t<array>\n');
for (final code in sorted) {
block.write('\t\t<string>$code</string>\n');
}
block.write('\t</array>');
final plistFile = File(plistPath);
final original = plistFile.readAsStringSync();
String updated;
if (existingBlock.hasMatch(original)) {
updated = original.replaceFirst(existingBlock, block.toString());
} else {
final end = original.lastIndexOf('</dict>');
if (end == -1) {
stderr.writeln('Could not find the root </dict> in $plistPath');
exit(1);
}
updated =
'${original.substring(0, end)}\t$block\n${original.substring(end)}';
}
if (updated == original) {
stdout.writeln('Info.plist already lists: ${sorted.join(', ')}');
return;
}
if (checkOnly) {
stderr.writeln('Info.plist is out of date. Expected: ${sorted.join(', ')}');
exit(1);
}
plistFile.writeAsStringSync(updated);
stdout.writeln('Wrote CFBundleLocalizations: ${sorted.join(', ')}');
}
Run it from the project root:
dart run tool/sync_ios_locales.dart
If your ARB files live somewhere else (check arb-dir in l10n.yaml), change arbDir. If your template file isn't named app_en.arb, change the arbName pattern to match.
Make drift impossible
The --check flag never writes anything. It exits non-zero when the plist doesn't match the ARB set, so you can put it in CI next to flutter gen-l10n:
- run: flutter gen-l10n
- run: dart run tool/sync_ios_locales.dart --check
Now a pull request that adds app_ko.arb without touching the plist fails the build, rather than shipping an iOS app that ignores Korean.
zh_Hant and pt_BR: two naming systems
Flutter and Apple both use BCP 47 subtags, but they write them differently, and that difference catches people out.
| ARB file | Flutter Locale |
Info.plist / .lproj |
|---|---|---|
app_zh_Hant.arb |
zh_Hant |
zh-Hant |
app_zh_Hans.arb |
zh_Hans |
zh-Hans |
app_pt_BR.arb |
pt_BR |
pt-BR |
app_es_419.arb |
es_419 |
es-419 |
Flutter uses underscores and Apple uses hyphens. The toAppleCode function above does the conversion, and it keeps script subtags in title case (Hant), because that is how Apple writes them.
Two more traps sit on the Flutter side:
gen-l10nrefuses to generatept_BRunless a fallbackapp_pt.arbexists. Keep the base language file, even if it is mostly a copy.- An iPhone set to Traditional Chinese in Taiwan reports
zh-Hant-TW. The generated lookup checks language plus script before language plus country, soapp_zh_Hant.arbmatches. If you only shipapp_zh.arborapp_zh_TW.arb, Hong Kong and Taiwan users can end up on the wrong script. The full breakdown is in Flutter zh_Hant not working? Fix locale resolution.
The App Store listing needs .lproj folders
CFBundleLocalizations sorts out runtime resolution and the Settings picker. The App Store's Languages row is a different matter. Developers on Apple's forums report that it is built from the .lproj folders in the binary, and the Flutter docs tell you to add each language under Runner > Info > Localizations in Xcode. That creates the .lproj folders and updates project.pbxproj.
Do both. The script keeps the plist honest, and adding the languages in Xcode once per new locale keeps the store listing right. Adding them in Xcode also gives you InfoPlist.strings per language, which is where a translated home-screen name goes. See Localize your Flutter app name on iOS and Android.
Verify the iOS per-app language setting
Don't rely on the simulator's default locale. Check on a real device:
- Run
dart run tool/sync_ios_locales.dart, thenflutter run --releaseon a plugged-in iPhone. - Open Settings > [Your App]. A Language row should now be there. Tap it and pick a language your app supports.
- iOS restarts the app in that language. Your Dart strings and native UI (permission prompts, system menus) should both switch.
To confirm what Flutter received, log it once at startup:
import 'package:flutter/widgets.dart';
void logLocales(BuildContext context) {
debugPrint('Device locales: ${WidgetsBinding.instance.platformDispatcher.locales}');
debugPrint('Resolved locale: ${Localizations.localeOf(context)}');
}
If the device locales are right but the resolved locale is en, the mismatch is in supportedLocales (usually a missing script or region file). If the device list itself starts with en, check the per-app setting and the plist.
In the simulator you can test without changing system settings: in Xcode, open Product > Scheme > Edit Scheme > Run > Options and set App Language.
Keep the ARB set clean as well
The script trusts your ARB folder, so whatever is in there ends up in the plist. A half-finished app_ar.arb will now appear in the iOS language picker. The FlutterLocalisation ARB editor lets you manage translations across all your locales in one UI. Its ICU plural validation flags locales that are missing a category the language needs, such as Arabic's few and many, before those files ship.
Try FlutterLocalisation free
Your ARB files are already the source of truth for Android, iOS and now Info.plist. Edit and manage them in one place with FlutterLocalisation. There's a free tier, and pricing covers larger teams.