Tutorial · 14 min · intermediate

Localization

Show your app's text in the user's language: ARB files, the generated S class, switching language at runtime, and remembering the choice.

Localizing an app means showing its text in the user’s own language. In DartNative you do it the same way as in Flutter. You keep your strings in one file per language, run a generator that turns those files into a Dart class, and call that class from your widgets instead of writing text directly.

The files are ARB files, which are JSON. The generator is the dartnative_intl plugin. The class it writes is called S by default, so a heading in your app becomes S().app_title and the right translation comes out.

There is a second part, and it is where most of the work goes: deciding which language the app opens in, changing it while the app runs, and remembering the choice for next time.

In this tutorial you will build both parts. You end up with one screen in four languages, a picker that switches the app without restarting it, and a choice that survives a force-quit. Gee ships in nine languages using this same setup.

What you need

dependencies:
  dartnative_shared_preferences: ^1.0.0   # remembers the chosen language
  intl: ^0.20.2                           # formatting, and the catalogue

dev_dependencies:
  dartnative_intl: ^1.0.0                 # the ARB to Dart generator

dartnative_intl only runs at build time, so it goes in dev_dependencies. If you have used intl_utils before, this is the same thing: same config block, same generated API. The only difference is that the code it writes imports dartnative instead of Flutter.

1. The ARB files

Every string your app shows lives in an ARB file, one per language, in an l10n/ folder. ARB is JSON with a convention: each key is a message, and a key prefixed with @ describes the one above it.

Start with English, which will be the source of truth:

{
  "@@locale": "en",
  "app_title": "Localization",
  "greeting": "Hello, {name}!",
  "@greeting": {
    "placeholders": { "name": {} }
  },
  "messages_count": "{count, plural, =0{No messages} =1{One message} other{{count} messages}}",
  "@messages_count": {
    "placeholders": { "count": {} }
  },
  "today_is": "Today is {date}.",
  "@today_is": {
    "placeholders": {
      "date": { "type": "DateTime", "format": "yMMMMd" }
    }
  }
}

That small file already shows the four kinds of message you will write:

  • A plain string. app_title becomes S().app_title.
  • A placeholder. greeting becomes S().greeting('Ada'). The @greeting block is what tells the generator that name is a parameter.
  • A plural. messages_count picks a form based on the number you pass. S().messages_count(0) gives “No messages”, S().messages_count(5) gives “5 messages”. Each language sets its own cases. English needs two, Arabic needs six, and every translation declares what it needs.
  • A formatted value. today_is takes a DateTime and formats it the way the current language writes dates, so the same code produces “September 18, 2026” and “18 settembre 2026”.

The translations are the same file with the values replaced. Only the English file carries the @ blocks; the others inherit them:

{
  "@@locale": "it",
  "app_title": "Localizzazione",
  "greeting": "Ciao, {name}!",
  "messages_count": "{count, plural, =0{Nessun messaggio} =1{Un messaggio} other{{count} messaggi}}",
  "today_is": "Oggi è {date}."
}

The finished code ships English, Italian, Spanish and Arabic.

2. Generate the Dart

Tell the generator where your ARB files are and what to call the class it writes. Add this to pubspec.yaml, at the top level:

flutter_intl:
  enabled: true
  arb_dir: l10n
  output_dir: lib/generated
  class_name: S
  main_locale: en

Then:

dn run-tool dartnative_intl:generate

This writes lib/generated/l10n.dart and one message file per language. Do not edit them. They are rewritten every time you run the command, which you should do after every change to an ARB file.

Use dn run-tool, not dart run. Plain pub looks for packages on pub.dev, and the DartNative packages are not there.

You get one method per key, typed from the placeholders you declared:

S().app_title                 // String
S().greeting('Ada')           // String, one placeholder
S().messages_count(5)         // String, plural form chosen by the number
S().today_is(DateTime.now())  // String, date formatted for the language

3. Decide which language to open in

On a first launch there is no saved choice, so the app should follow the device. On every launch after that, the user’s own choice wins, even if the device is set to something else. And whichever it is, the messages have to be loaded before the first screen builds, or the app paints once in the wrong language.

A small provider holds all of that:

class LanguageProvider {
  final Signal<AppLanguage> _language = signal(_fallback);

  Signal<AppLanguage> get languageSignal => _language;

  Locale get locale =>
      Locale(_language.value.languageCode, _language.value.countryCode);

  Future<void> initialize(SharedPreferences prefs) async {
    final saved = prefs.getString(kPrefKeyLanguage);
    _language.value = _resolve(saved);
    if (saved == null || saved.isEmpty) {
      await _persist(prefs, _language.value);
    }
    await _load(locale);
  }

  /// The messages, and the date and number symbols that go with them.
  Future<void> _load(Locale locale) async {
    await initializeDateFormatting(locale.languageCode);
    await S.load(locale);
  }
}

initializeDateFormatting is the easy one to miss. S().today_is(...) builds a DateFormat underneath, and that throws unless the locale’s symbols have been loaded first:

LocaleDataException: Locale data has not been initialized,
call initializeDateFormatting(<locale>).

It comes from package:intl/date_symbol_data_local.dart, it is safe to call more than once, and loading it beside the messages means the two can never disagree about which language is current.

_resolve is the three-step fallback: the saved language if you still ship it, else the device’s language if you translated it, else English.

AppLanguage _resolve(String? saved) {
  if (saved != null && saved.isNotEmpty) {
    final stored = AppLanguage.fromJson(jsonDecode(saved) as Map<String, dynamic>);
    final match = supportedAppLanguages
        .where((l) => l.languageCode == stored.languageCode);
    if (match.isNotEmpty) return match.first;
  }
  final deviceCode = Intl.systemLocale.split(RegExp('[_-]')).first;
  final device = supportedAppLanguages.where((l) => l.languageCode == deviceCode);
  return device.isNotEmpty ? device.first : _fallback;
}

Intl.systemLocale gives the device’s locale as the platform reports it, like en_US or ar. Only the language part is used, so a phone set to Brazilian Portuguese still finds your pt file.

Check the saved language against supportedAppLanguages before using it. If you drop a language later, anyone who had chosen it would otherwise be stuck on a code you no longer ship.

4. Wire it at startup

Everything above comes together in main, and the order matters:

Future<void> main() async {
  DartNativePluginRegistrant.registerAll();

  // 1. Preferences first: the saved language lives here.
  prefs = await SharedPreferences.getInstance();

  // 2. Restore the language and load its messages.
  await languageProvider.initialize(prefs);

  // 3. Point the generated code at the loaded messages.
  Localizations.setResolver((_, __) => S.current);

  runApp(const LocalizationDemo());
}

Do not call S.load again after this. A second call overrides the language you just restored, on every launch. The symptom is easy to misread: the app always opens in English, but switching languages inside the app works fine. Gee shipped this bug once, and the warning comment is still in its main.dart.

5. Read the strings

In a widget, watch the signal and call S():

@override
Widget build(BuildContext context) {
  final current = languageProvider.languageSignal.watch(context);

  return Scaffold(
    appBar: AppBar(title: Text(S().app_title)),
    body: ListView(
      children: [
        Text(S().greeting('Ada')),
        Text(S().language_current(current.name)),
        Text(S().messages_count(_messages)),
        Text(S().today_is(DateTime.now())),
      ],
    ),
  );
}

Watching the signal is what makes the screen re-read every S() call when the language changes. Without it the text is fetched once and stays.

6. Switch language without restarting

The picker is an ordinary list of rows. The interesting part is the two lines that run when one is tapped, and the order they run in:

Future<void> changeLanguage(
  SharedPreferences prefs,
  AppLanguage language,
) async {
  if (language == _language.value) return;
  await _load(Locale(language.languageCode, language.countryCode));
  _language.value = language;
  await _persist(prefs, language);
}

Load the messages before setting the signal. If you set the signal first, every watching screen rebuilds while the old messages are still loaded, so they show the previous language until something else triggers another rebuild. It looks like a caching bug, but it is just the wrong order.

Saving comes last. That is what makes the choice survive a force-quit: reopen the app and step 3 finds the saved language before anything paints.

What about right-to-left?

Arabic is in the finished code and its text renders correctly, but the layout is a separate question. DartNative reads the layout direction from the platform once, at startup: the app’s own interface layout direction on iOS, the configuration’s on Android. Picking a right-to-left language inside your app therefore translates the text without mirroring the layout.

For the layout to mirror, the operating system has to consider the app right-to-left. On Android that means a per-app language change, on iOS a device language change. Native apps work the same way, which is why apps that ship Arabic usually let the system pick the language instead of offering their own picker.

Adding a language later

Three steps. Only the first takes any real time:

  1. Copy l10n/intl_en.arb to l10n/intl_<code>.arb and translate the values.
  2. Add an AppLanguage entry to supportedAppLanguages.
  3. Run dn run-tool dartnative_intl:generate.

The generator finds the new file on its own, and supportedLocales grows with it.

What you built

A screen in four languages, a picker that switches between them, and a choice that survives a restart. It rests on three things: ARB files hold the strings, a generated class is the only way widgets read them, and one provider owns which language is current.

The full code is in tutorials/localization.

Open the finished code →