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
- A project from Your first DartNative app
- These dependencies:
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_titlebecomesS().app_title. - A placeholder.
greetingbecomesS().greeting('Ada'). The@greetingblock is what tells the generator thatnameis a parameter. - A plural.
messages_countpicks 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_istakes aDateTimeand 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:
- Copy
l10n/intl_en.arbtol10n/intl_<code>.arband translate the values. - Add an
AppLanguageentry tosupportedAppLanguages. - 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.