← Back to Blog

Change Flutter App Language at Runtime with gen-l10n

fluttergen-l10nlocaleshared_preferencesi18nlanguage-switcher

Change Flutter App Language at Runtime with gen-l10n

You have gen-l10n wired up, your ARB files compile, and now product wants a language picker in Settings. The common advice is "add easy_localization" or "just restart the app". Neither is needed. MaterialApp.locale is a normal widget property: change it, and every AppLocalizations.of(context) below it rebuilds with the new strings on the next frame.

This post builds that switcher with nothing but the Flutter SDK and shared_preferences, including a "System" option that hands control back to your localeResolutionCallback, and then explains the three bugs that make people believe a restart is required.

What you should already have

A standard gen-l10n setup. Since Flutter 3.32 the generated file lands inside lib/ by default instead of the old synthetic package:flutter_gen, so the imports below assume that layout. If you still import flutter_gen, set synthetic-package: false in l10n.yaml and update the import path.

# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
nullable-getter: false

nullable-getter: false removes the ! after AppLocalizations.of(context). If that bang has ever crashed on you, read Fix Null Check Operator on AppLocalizations.of(context)! first; a language switcher only makes that mistake easier to hit.

Add the persistence dependency:

flutter pub add shared_preferences

Step 1: a tiny locale controller

One ChangeNotifier owns the choice. null means "follow the system". It loads the saved value before the first frame and writes every change straight through.

// lib/locale_controller.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

class LocaleController extends ChangeNotifier {
  LocaleController._(this._prefs, this._locale);

  static const _key = 'app_locale';
  final SharedPreferencesAsync _prefs;
  Locale? _locale;

  /// null = follow the device locale.
  Locale? get locale => _locale;

  static Future<LocaleController> load() async {
    final prefs = SharedPreferencesAsync();
    final tag = await prefs.getString(_key);
    return LocaleController._(prefs, tag == null ? null : _parse(tag));
  }

  Future<void> setLocale(Locale? locale) async {
    if (locale == _locale) return;
    _locale = locale;
    notifyListeners();
    if (locale == null) {
      await _prefs.remove(_key);
    } else {
      await _prefs.setString(_key, locale.toLanguageTag());
    }
  }

  // Stores and parses BCP 47 tags ("zh-Hant-TW"), so script codes survive.
  static Locale _parse(String tag) {
    final parts = tag.split('-');
    return Locale.fromSubtags(
      languageCode: parts[0],
      scriptCode: parts.length == 3 ? parts[1] : null,
      countryCode: parts.length >= 2 ? parts.last : null,
    );
  }
}

SharedPreferencesAsync is the API the package authors now recommend; the old SharedPreferences.getInstance() singleton is marked legacy. Note that notifyListeners() runs before the disk write, so the UI flips immediately and persistence catches up in the background.

Storing toLanguageTag() rather than toString() matters. Locale('zh', 'TW').toString() gives zh_TW, but Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant').toString() gives zh_Hant, and a naive split('_') would read Hant as a country code. The tag form is unambiguous.

Step 2: drive MaterialApp.locale from it

Load the controller in main, then rebuild only MaterialApp when it changes. ListenableBuilder is in the SDK, so no state-management package is needed.

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';
import 'locale_controller.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final controller = await LocaleController.load();
  runApp(MyApp(controller: controller));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key, required this.controller});
  final LocaleController controller;

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: controller,
      builder: (context, _) => MaterialApp(
        locale: controller.locale,
        supportedLocales: AppLocalizations.supportedLocales,
        localizationsDelegates: AppLocalizations.localizationsDelegates,
        localeResolutionCallback: (device, supported) {
          if (device == null) return supported.first;
          for (final l in supported) {
            if (l.languageCode == device.languageCode &&
                (l.scriptCode == null || l.scriptCode == device.scriptCode)) {
              return l;
            }
          }
          return supported.first;
        },
        onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
        home: SettingsPage(controller: controller),
      ),
    );
  }
}

Awaiting the load before runApp costs a few milliseconds and avoids a visible flash from English to the saved language on cold start. That is the whole persistence story: the choice survives force-quit, reboot, and app updates.

How the two paths resolve:

  • System (locale: null). Flutter takes the device's preferred locale list and runs it through localeListResolutionCallback, then localeResolutionCallback, then the built-in basicLocaleListResolution. When the user changes the device language in OS settings, the app follows automatically.
  • Explicit (locale: Locale('de')). Flutter wraps your locale in a one-item list and runs it through the same callback chain and fallback. So your callback still runs; it just receives the chosen locale instead of the device one. Return something sensible for it (the callback above matches by language, so Locale('de') resolves to de if you ship it). If the callback returns null, the basic algorithm takes over, and an unsupported locale ends up as supportedLocales.first.

The practical consequence: a picker should only offer locales from AppLocalizations.supportedLocales, and your callback should not do anything surprising when handed a locale you support exactly.

Step 3: the picker

class LanguagePicker extends StatelessWidget {
  const LanguagePicker({super.key, required this.controller});
  final LocaleController controller;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context);
    return ListenableBuilder(
      listenable: controller,
      builder: (context, _) => DropdownButton<Locale?>(
        value: controller.locale,
        onChanged: controller.setLocale,
        items: [
          DropdownMenuItem(value: null, child: Text(l10n.systemLanguage)),
          for (final locale in AppLocalizations.supportedLocales)
            DropdownMenuItem(
              value: locale,
              child: Text(_nativeName(locale)),
            ),
        ],
      ),
    );
  }

  static String _nativeName(Locale l) => switch (l.toLanguageTag()) {
        'en' => 'English',
        'de' => 'Deutsch',
        'fr' => 'Français',
        'zh-Hant' => '繁體中文',
        _ => l.toLanguageTag(),
      };
}

Show each language in its own script, not translated into the current one. A German speaker stuck in Chinese needs to find "Deutsch", not "德语".

Selecting an item calls setLocale, the controller notifies, MaterialApp rebuilds with a new locale, and the framework's Localizations widget reloads delegates for that locale. Every widget that reads AppLocalizations.of(context) in build repaints. No restart, no Phoenix, no runApp twice.

The three bugs that look like "needs a restart"

If the picker changes but half the screens do not, it is one of these.

1. Strings captured in a const or a field

// Wrong: evaluated once, never updated
class HomePage extends StatelessWidget {
  final String title = 'Home'; // or a static Text widget
  ...
}

// Wrong: cached in initState
late final l10n = AppLocalizations.of(context);

Anything that reads localized text outside build (a late final field, a const widget subtree, a top-level final list of menu labels) keeps the first locale forever. Read AppLocalizations.of(context) inside build, every time. const widgets are fine as long as the text comes from a non-const parent that rebuilds.

2. Context from above MaterialApp

AppLocalizations.of(context) walks up from context to find the nearest Localizations widget, which MaterialApp creates. A context obtained above or outside that subtree (the MyApp.build context, a navigatorKey.currentContext that is null, a global function with a stored context) either throws or returns the old value. Wrap the consumer in a Builder, or move it under home. The same mistake is the usual cause of the null-check crash linked above.

3. supportedLocales out of sync with the ARB files

AppLocalizations.supportedLocales is generated from the ARB files present when you last ran flutter gen-l10n (or built the app). Add app_it.arb without regenerating, and the picker offers Italian, the resolution falls back to English, and nothing looks like it changed. Hot restart does not regenerate either; run flutter gen-l10n or a full build.

The mirror image bites with scripts. If your file is app_zh_Hant.arb but the device or picker sends zh-TW, a language-only match quietly serves Simplified Chinese. The fix is a script-aware callback and correct ARB naming, covered in Flutter zh_Hant Not Working? Fix Locale Resolution. Keeping file names and supportedLocales aligned is exactly the kind of drift the FlutterLocalisation ARB editor prevents: it edits app_<locale>.arb files in a UI, so a new locale is a new file with the right name, and its ICU plural validation flags locales missing a plural category the language needs, so a freshly added Polish or Arabic locale does not throw at runtime on the first count string.

Testing the switch

A single widget test catches all three gotchas:

testWidgets('switching locale updates visible text', (tester) async {
  SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty();
  final controller = await LocaleController.load();
  await tester.pumpWidget(MyApp(controller: controller));
  await tester.pumpAndSettle();
  expect(find.text('Settings'), findsOneWidget);

  await controller.setLocale(const Locale('de'));
  await tester.pumpAndSettle();
  expect(find.text('Einstellungen'), findsOneWidget);
});

InMemorySharedPreferencesAsync ships with shared_preferences_platform_interface, so no plugin channel is needed in tests.

Wrap-up

The runtime language switch is about 60 lines: a ChangeNotifier holding a nullable Locale, SharedPreferencesAsync for persistence, and ListenableBuilder around MaterialApp. Keep locale: null as the System option so the device setting still wins, read strings only inside build, and regenerate whenever an ARB file is added.

The part that keeps breaking in real projects is not the switch, it is the ARB files behind it: a missing locale file, a misnamed script variant, a dropped plural case. Try FlutterLocalisation free to manage your app_<locale>.arb files in an editor with ICU plural validation, and keep every locale in the picker actually translated.