Skip to content

Localization

View raw .md

Magic provides a Laravel-style localization system that lets you retrieve and switch translation strings at runtime without needing BuildContext.

On this page

Introduction#

Magic provides a Laravel-style localization system that allows you to easily retrieve strings in various languages without needing BuildContext. This is a game-changer for Flutter developers who are tired of passing context everywhere.

dart
// 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:

dart
'providers': [
  (app) => LocalizationServiceProvider(app),
  // ... other providers
],

Localization Configuration#

Create lib/config/localization.dart:

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:

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/:

json
{
  "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:

dart
trans('auth.failed')           // "These credentials do not match..."
trans('validation.required')   // "The :attribute field is required."

Retrieving Translations#

Basic Usage#

dart
// 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:

dart
String trans(String key, [Map<String, dynamic>? replace])
  • Returns the translation for the current locale
  • Falls back to fallback_locale per 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 :param placeholders 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:

dart
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.

dart
String transChoice(String key, int number, [Map<String, dynamic>? replace])
json
{
  "apples": "There is one apple|There are :count apples"
}
dart
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.

json
{
  "inbox": "{0} Nothing here|[1,19] :count messages|[20,*] Lots of messages"
}
dart
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 nine more 1 every count takes segment 0
en, de, es, nl, pt, sv and 43 more 2 n == 1 takes segment 0
fr, hi, fil, am and nine more 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

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:

dart
Lang.choice('apples', 4);

Checking Key Existence#

Use Lang.has to check whether a translation key exists for the current locale before retrieving it:

dart
if (Lang.has('errors.payment_declined')) {
  return trans('errors.payment_declined');
}

Locale State#

Current Locale#

Lang.current returns the active Locale:

dart
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 localization service has finished booting:

dart
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:

dart
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:

dart
// 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#

dart
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:

dart
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:

dart
// 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 localization 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:

dart
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:

dart
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 (rather than MagicApp), you must pass Lang.delegate to localizationsDelegates so Flutter's localization lifecycle triggers translation reloads when the device locale changes:

dart
MaterialApp(
  localizationsDelegates: [
    Lang.delegate,
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
    GlobalCupertinoLocalizations.delegate,
  ],
  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.

dart
// 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.

dart
// 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#

bash
dart run magic:artisan make:lang fr
dart run magic:artisan make:lang de
dart run magic:artisan make:lang tr

This command:

  1. Creates assets/lang/<code>.json with a starter template
  2. Automatically registers the asset in pubspec.yaml

Starter Template#

The generated file includes common translation keys:

json
{
  "welcome": "Welcome, :name!",
  "common": {
    "save": "Save",
    "cancel": "Cancel",
    "delete": "Delete",
    "success": "Success",
    "error": "Error"
  },
  "auth": {
    "login": "Login",
    "register": "Register",
    "logout": "Logout",
    "failed": "Authentication failed.",
    "throttle": "Too many attempts. Please try again in :seconds seconds."
  },
  "validation": {
    "required": "The :attribute field is required.",
    "email": "The :attribute must be a valid email address."
  },
  "attributes": {
    "email": "email",
    "password": "password"
  }
}

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 behavior is debug-mode only (kDebugMode). Release builds use Flutter's standard rootBundle with full caching for optimal performance.

esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.