Localization
Magic provides a Laravel-style localisation system that lets you retrieve and switch translation strings at runtime without needing BuildContext.
On this page
Introduction#
Magic provides a Laravel-style localisation system that allows you to retrieve strings in various languages without needing BuildContext.
// Anywhere in your code, no context needed
Text(trans('welcome', {'name': 'Magic'})) // "Welcome, Magic!"
Text(trans('auth.failed')) // "Authentication failed."
Configuration#
Enabling Localization#
Add LocalizationServiceProvider to your providers in config/app.dart:
'providers': [
(app) => LocalizationServiceProvider(app),
// ... other providers
],
Localization Configuration#
Create lib/config/localization.dart:
Map<String, dynamic> get localizationConfig => {
'localization': {
'locale': 'en',
'fallback_locale': 'en',
'supported_locales': ['en', 'tr', 'es', 'de'],
'auto_detect_locale': true,
'path': 'assets/lang',
},
};
Configuration Options#
| Key | Default | Description |
|---|---|---|
locale |
'en' |
Default locale |
fallback_locale |
'en' |
Fallback when translation is missing |
supported_locales |
['en'] |
Supported locale codes |
auto_detect_locale |
false |
Auto-detect from device on boot |
path |
'assets/lang' |
Translation JSON files path |
Register Assets#
Add translation files to your pubspec.yaml:
flutter:
assets:
- assets/lang/en.json
- assets/lang/tr.json
- assets/lang/es.json
Defining Translation Strings#
Translation strings are stored as JSON files in assets/lang/:
{
"welcome": "Welcome, :name!",
"auth": {
"login": "Login",
"register": "Register",
"logout": "Logout",
"failed": "These credentials do not match our records.",
"throttle": "Too many login attempts. Please try again in :seconds seconds."
},
"validation": {
"required": "The :attribute field is required.",
"email": "The :attribute must be a valid email address.",
"min": {
"string": "The :attribute must be at least :min characters."
}
},
"attributes": {
"email": "email address",
"password": "password",
"password_confirmation": "password confirmation"
}
}
Nested Keys#
Use dot notation to access nested translations:
trans('auth.failed') // "These credentials do not match..."
trans('validation.required') // "The :attribute field is required."
Retrieving Translations#
Basic Usage#
// Simple translation
WText(trans('auth.login'))
// With parameters
WText(trans('welcome', {'name': user.name}))
// Output: "Welcome, John!"
// Nested keys
WText(trans('auth.throttle', {'seconds': '60'}))
// Output: "Too many login attempts. Please try again in 60 seconds."
The trans() Helper#
The trans() function is a globally available helper that delegates to Lang.get:
String trans(String key, [Map<String, dynamic>? replace])
- Returns the translation for the current locale
- Falls back to
fallback_localeper key, so a key missing from the current locale's catalogue is served from the fallback's rather than rendering raw - Returns the key itself if neither catalogue defines it
- Replaces
:paramplaceholders with provided values
The fallback catalogue is loaded once, alongside the locale's own, and the locale's strings are layered over it. A fallback that will not load is a logged warning rather than a failure: the locale's own strings still render.
You can also call Lang.get directly, which is equivalent:
final message = Lang.get('welcome', {'name': 'Magic'});
Pluralization#
A sentence carrying a number needs more than one wording, and trans() cannot give it one: it is a lookup plus a replacement, so the same line renders at every count. Use transChoice(), Laravel's trans_choice, which takes the count and picks a segment from a pipe-separated line.
String transChoice(String key, int number, [Map<String, dynamic>? replace])
{
"apples": "There is one apple|There are :count apples"
}
transChoice('apples', 1); // "There is one apple"
transChoice('apples', 4); // "There are 4 apples"
:count is substituted from the number you pass, so you never pass it twice. An explicit count in replace still wins, which is how you render a spelled-out number.
Inline conditions
Segments may carry an explicit count or range, and the first that matches wins. A range accepts * on either bound.
{
"inbox": "{0} Nothing here|[1,19] :count messages|[20,*] Lots of messages"
}
transChoice('inbox', 0); // "Nothing here"
transChoice('inbox', 7); // "7 messages"
transChoice('inbox', 99); // "Lots of messages"
Write a line that is all conditions or none. A half-conditional line shifts its own positional segments, because a condition that matched nothing is stripped and what remains is read by index.
The plural rules are per language
This is the part that surprises people, and it is why the count is not simply compared to one.
| Language | Forms | Rule |
|---|---|---|
tr, ja, ko, zh, id, vi and others |
1 | every count takes segment 0 |
en, de, es, nl, pt, sv and others |
2 | n == 1 takes segment 0 |
fr, hi, fil, am and others |
2 | n == 0 || n == 1 takes segment 0 |
ru, uk, hr, sr, bs, be |
3 | by the last digit |
ar |
6 | zero, one, two, few, many, other |
Other languages (for example cs, pl and ro) carry rules of their own.
Turkish has no plural agreement after a number, so a Turkish line's second segment is never reached. If your app's first locale is Turkish, Japanese or Chinese, a two-segment English line will look correct throughout development and be wrong the first time the second locale renders.
An unlisted language answers segment 0, which is Laravel's own default: it reads as "this language does not agree with its number" rather than as English. The full table lives in MessageSelector, ported from Laravel's getPluralIndex.
Lang.choice is the facade form and is equivalent:
Lang.choice('apples', 4);
Checking Key Existence#
Use Lang.has to check whether a translation key exists for the current locale before retrieving it:
if (Lang.has('errors.payment_declined')) {
return trans('errors.payment_declined');
}
Locale State#
Current Locale#
Lang.current returns the active Locale:
final locale = Lang.current; // Locale('tr')
print(locale.languageCode); // "tr"
Loaded State#
Lang.isLoaded returns true once translations have been loaded for the current locale. Use this to guard against calling trans() before the localisation service has finished booting:
if (Lang.isLoaded) {
print(trans('welcome'));
}
Supported Locales#
Lang.supportedLocales returns the list of Locale objects your app supports, as configured in localization.supported_locales:
final locales = Lang.supportedLocales;
// [Locale('en'), Locale('tr'), Locale('es')]
Changing Locale#
Setting the Locale#
Call Lang.setLocale to switch the active locale at runtime. By default it also triggers Magic.reload() so all widgets rebuild with the new strings:
// Change locale and rebuild the app
await Lang.setLocale(Locale('tr'));
// Load translations without triggering app reload
await Lang.setLocale(Locale('tr'), reload: false);
Language Picker Example#
WFormSelect<String>(
value: Lang.current.languageCode,
options: [
SelectOption(value: 'en', label: 'English'),
SelectOption(value: 'tr', label: 'Türkçe'),
SelectOption(value: 'es', label: 'Español'),
],
onChange: (code) async {
await Lang.setLocale(Locale(code!));
Magic.toast(trans('common.language_changed'));
},
label: trans('settings.language'),
)
Auto-Detecting Locale#
Lang.detectLocale returns the best matching Locale from the device or browser without changing the current locale. It compares device preferences against supported_locales and returns the closest match:
final detected = Lang.detectLocale();
print(detected); // e.g., Locale('tr')
Lang.detectAndSetLocale combines detection and activation in one call. It returns the locale that was set:
// On app start, pick the user's device language automatically
final locale = await Lang.detectAndSetLocale();
print(locale); // e.g., Locale('tr')
You can also use auto_detect_locale: true in the localisation config to have LocalizationServiceProvider call this automatically during boot.
Setting Supported Locales at Runtime#
If you need to change the supported locale list after boot (for example, after loading user preferences from the server), use Lang.setSupportedLocales:
Lang.setSupportedLocales([Locale('en'), Locale('fr'), Locale('de')]);
Reacting to Locale Changes#
The Translator underlying Lang extends Flutter's ChangeNotifier. Use Lang.addListener and Lang.removeListener to subscribe to locale changes without a BuildContext:
class LocaleAwareService {
LocaleAwareService() {
Lang.addListener(_onLocaleChanged);
}
void _onLocaleChanged() {
// Re-format any locale-sensitive values
print('Locale changed to: ${Lang.current}');
}
void dispose() {
Lang.removeListener(_onLocaleChanged);
}
}
Listeners fire after every successful setLocale or detectAndSetLocale call, so they are a reliable hook for updating any state that depends on the current locale outside the widget tree.
Flutter Integration (Delegate)#
When you use a plain MaterialApp from package:material_ui/material_ui.dart (rather than MagicApp), you must pass Lang.delegate to localizationsDelegates so Flutter's localisation lifecycle triggers translation reloads when the device locale changes. GlobalMaterialLocalizations.delegates already carries the Material, Cupertino and Widgets delegates:
MaterialApp(
localizationsDelegates: [
Lang.delegate,
...GlobalMaterialLocalizations.delegates,
],
supportedLocales: Lang.supportedLocales,
home: const HomePage(),
)
Lang.delegate is a const LocalizationsDelegate<Translator>. It implements isSupported by checking against localization.supported_locales from config and delegates load to the Translator singleton.
Automatic HTTP Headers#
When LocalizationServiceProvider is registered, Magic automatically attaches two headers to every outgoing HTTP request via LocalizationInterceptor. No manual setup is required.
| Header | Value | Example |
|---|---|---|
Accept-Language |
Current locale language code | en, tr, es |
X-Timezone |
Current IANA timezone identifier | Europe/Istanbul, America/New_York |
Both values are resolved at request-time, so they always reflect the locale and timezone that are active when the request is dispatched.
// When the current locale is 'tr' and timezone is 'Europe/Istanbul',
// every Http.get/post/put/delete call automatically includes:
//
// Accept-Language: tr
// X-Timezone: Europe/Istanbul
//
// This works transparently, no configuration needed.
final response = await Http.get('/api/profile');
Timezone Detection#
Set auto_detect_timezone to true in config/localization.dart (it defaults to
false) and Magic resolves the device's real IANA timezone identifier on boot,
reading it from the platform's native APIs via the flutter_timezone package. The
resolved value is sent as the X-Timezone header on every outgoing request, so the
backend can interpret the client's timestamps.
// config/localization.dart
'auto_detect_timezone': true,
'timezone': 'UTC', // the fallback when detection cannot resolve a zone
You do not need any timezone detection code of your own. When detection cannot
resolve a valid identifier, the configured timezone stays in place; Magic does
not guess a zone from the device's UTC offset, because an offset does not identify
a zone (many zones share an offset and differ in DST rules).
CLI Commands#
Create Translation File#
dart run magic:dowel make:lang fr
dart run magic:dowel make:lang de
dart run magic:dowel make:lang tr
This command creates assets/lang/<code>.json. When assets/lang/en.json exists, the new file gets a copy of its key tree with every value copied verbatim, ready for a translator; otherwise the file is an empty {}. Use --from=<code> to copy another catalogue and --force to overwrite an existing file:
dart run magic:dowel make:lang tr --from=en
The command does not edit pubspec.yaml: add the new file under flutter: assets: yourself (see Register Assets under Configuration).
Development: Hot Restart#
During development, Magic attempts to bypass Flutter's asset bundle cache so that translation JSON changes can be picked up on hot restart (Shift+R) without a full rebuild.
| Platform | Mechanism | Reliability |
|---|---|---|
| Web (Chrome) | Fetches JSON via HTTP with cache-busting query parameter | Verified |
| macOS / Linux / Windows | Reads JSON from disk via dart:io |
Best-effort (works when flutter run sets the working directory to the project root) |
| iOS / Android | Attempts disk read via dart:io, falls back to rootBundle |
Limited (asset files are typically not on disk) |
This behaviour is debug-mode only (kDebugMode). Release builds use Flutter's standard rootBundle with full caching for optimal performance.