← Back to Blog

Flutter App Stuck in English on iOS? Sync Your Info.plist

flutterioslocalizationinfo-plistgen-l10narb

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.preferredLocalizations resolves to en. Native plugins, system sheets, permission prompts and localized InfoPlist.strings all follow it.
  • The per-app language picker (added in iOS 13) only appears for apps that declare more than one language. With just en there 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-l10n refuses to generate pt_BR unless a fallback app_pt.arb exists. 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, so app_zh_Hant.arb matches. If you only ship app_zh.arb or app_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:

  1. Run dart run tool/sync_ios_locales.dart, then flutter run --release on a plugged-in iPhone.
  2. Open Settings > [Your App]. A Language row should now be there. Tap it and pick a language your app supports.
  3. 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.