LocaleChain for Flutter

Smart locale fallback chains for Flutter — because pt-BR users deserve pt-PT, not English.

The Problem

Flutter's i18n ecosystem has zero support for configurable locale fallback chains. Every major package — easy_localization, gen-l10n, slang, flutter_i18n — only supports a single fallback locale.

When pt-BR translations are missing a key, the system jumps straight to English. Your users see English when a perfectly good Portuguese translation exists in pt-PT.

The Solution

One line change. Zero migration. Your existing context.tr('key') calls just work.

LocaleChain intercepts at the asset-loading layer and deep-merges translations from a configurable fallback chain. When a key is missing in pt-BR, it checks pt-PT, then pt, then en — before giving up.

Installation

dependencies:
  locale_chain: ^0.1.0

Quick Start

import 'package:locale_chain/locale_chain.dart';
import 'package:locale_chain/easy_localization.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await EasyLocalization.ensureInitialized();
  LocaleChain.configure();  // 1. Configure chains

  runApp(
    EasyLocalization(
      supportedLocales: [Locale('en'), Locale('pt', 'BR'), Locale('pt', 'PT')],
      path: 'assets/translations',
      assetLoader: LocaleChainAssetLoader(  // 2. Swap asset loader
        baseLoader: RootBundleAssetLoader(),
      ),
      fallbackLocale: Locale('en'),
      child: MyApp(),
    ),
  );
}

// In widgets — unchanged
Text(context.tr('greeting'))

That's it. A pt-BR user will now see pt-PT translations when pt-BR is missing a key.

Custom Configuration

Default (zero config)

LocaleChain.configure();

Uses all built-in fallback chains. Covers Portuguese, Spanish, French, German, Italian, Dutch, Norwegian, and Malay regional variants.

With overrides (merge with defaults)

LocaleChain.configure(
  fallbacks: {'pt-BR': ['pt-PT', 'pt']},
);

Your overrides replace matching keys in the default map. All other defaults remain.

Full custom (replace defaults)

LocaleChain.configure(
  fallbacks: {
    'pt-BR': ['pt-PT', 'pt'],
    'es-MX': ['es-419', 'es'],
  },
  mergeDefaults: false,
);

Only the chains you specify will be active. No defaults.

Standalone Usage

Don't use easy_localization? Use mergedMessages() directly with any Map-based i18n system:

LocaleChain.configure();

final messages = await LocaleChain.mergedMessages(
  locale: Locale('pt', 'BR'),
  loader: (locale) async {
    final tag = localeToTag(locale);
    final json = await rootBundle.loadString('assets/i18n/$tag.json');
    return jsonDecode(json);
  },
);

Default Fallback Map

Portuguese

Locale Fallback Chain
pt-BR pt-PT -> pt -> (default)
pt-PT pt -> (default)

Spanish

Locale Fallback Chain
es-419 es -> (default)
es-MX es-419 -> es -> (default)
es-AR es-419 -> es -> (default)
es-CO es-419 -> es -> (default)
es-CL es-419 -> es -> (default)
es-PE es-419 -> es -> (default)
es-VE es-419 -> es -> (default)
es-EC es-419 -> es -> (default)
es-GT es-419 -> es -> (default)
es-CU es-419 -> es -> (default)
es-BO es-419 -> es -> (default)
es-DO es-419 -> es -> (default)
es-HN es-419 -> es -> (default)
es-PY es-419 -> es -> (default)
es-SV es-419 -> es -> (default)
es-NI es-419 -> es -> (default)
es-CR es-419 -> es -> (default)
es-PA es-419 -> es -> (default)
es-UY es-419 -> es -> (default)
es-PR es-419 -> es -> (default)

French

Locale Fallback Chain
fr-CA fr -> (default)
fr-BE fr -> (default)
fr-CH fr -> (default)
fr-LU fr -> (default)
fr-MC fr -> (default)
fr-SN fr -> (default)
fr-CI fr -> (default)
fr-ML fr -> (default)
fr-CM fr -> (default)
fr-MG fr -> (default)
fr-CD fr -> (default)

German

Locale Fallback Chain
de-AT de -> (default)
de-CH de -> (default)
de-LU de -> (default)
de-LI de -> (default)

Italian

Locale Fallback Chain
it-CH it -> (default)

Dutch

Locale Fallback Chain
nl-BE nl -> (default)

Norwegian

Locale Fallback Chain
nb no -> (default)
nn nb -> no -> (default)

Malay

Locale Fallback Chain
ms-MY ms -> (default)
ms-SG ms -> (default)
ms-BN ms -> (default)

How It Works

  1. configure() stores the fallback chain configuration in memory.
  2. LocaleChainAssetLoader wraps your existing AssetLoader (e.g., RootBundleAssetLoader).
  3. When easy_localization requests translations for a locale, the adapter loads translations for every locale in the chain.
  4. Translations are deep-merged from least specific (default) to most specific (current locale). Missing keys are filled from the next locale in the chain.
  5. easy_localization receives a complete Map with no missing keys — chain fallback is invisible to the rest of your app.

FAQ

Is this safe for production? Yes. The adapter wraps easy_localization's AssetLoader interface — a stable, public API. No monkey-patching or reflection.

Performance impact? Negligible. Merged translations are cached per locale. The chain loading only happens once per locale (on first access or locale change).

Does it work with nested translation keys? Yes. The deep merge is recursive — nested Maps are merged at every level.

Can I use a non-English default locale? Yes. Pass defaultLocale to configure():

LocaleChain.configure(defaultLocale: 'es');

Can I deactivate it? Yes. Call LocaleChain.reset() to clear configuration. The LocaleChainAssetLoader will pass through to the base loader without chain merging.

Minimum Flutter version? Flutter 3.10+ (Dart 3.0+).

Contributing

  • Open issues for bugs or feature requests.
  • PRs welcome, especially for adding new locale fallback chains.
  • Run flutter test for unit tests.
  • Run flutter analyze for static analysis.

License

MIT License - see LICENSE file.

Built by i18nagent.ai