From 28f83bad92d5636af00585e80c2888b4d9a3daf9 Mon Sep 17 00:00:00 2001 From: William Cho Date: Mon, 5 Jan 2026 17:38:59 -0300 Subject: [PATCH 1/9] feat: adds fallbackLanguages option --- lib/src/options.dart | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/src/options.dart b/lib/src/options.dart index b1c8b1c..c081f73 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -43,6 +43,7 @@ typedef EscapeHandler = String Function(String input); class I18NextOptions with Diagnosticable { const I18NextOptions({ this.fallbackNamespaces, + this.fallbackLanguages, this.namespaceSeparator, this.contextSeparator, this.pluralSeparator, @@ -69,6 +70,7 @@ class I18NextOptions with Diagnosticable { static const I18NextOptions base = I18NextOptions( fallbackNamespaces: null, + fallbackLanguages: null, namespaceSeparator: ':', contextSeparator: '_', pluralSeparator: '_', @@ -100,6 +102,18 @@ class I18NextOptions with Diagnosticable { /// Defaults to null. final List? fallbackNamespaces; + /// The languages that will be used to fallback when no key matches were found + /// in the current language. + /// These languages are evaluated in the order they are put in the list. + /// + /// [fallbackNamespaces] will take priority over language. + /// [missingKeyHandler] is called only after all languages have been evaluated. + /// + /// The fallback languages must be loaded with the primary language. + /// + /// Defaults to null. + final List? fallbackLanguages; + /// The separator used when splitting the key. /// /// Defaults to ':'. @@ -237,6 +251,7 @@ class I18NextOptions with Diagnosticable { if (other == null) return this; return copyWith( fallbackNamespaces: other.fallbackNamespaces ?? fallbackNamespaces, + fallbackLanguages: other.fallbackLanguages ?? fallbackLanguages, namespaceSeparator: other.namespaceSeparator ?? namespaceSeparator, contextSeparator: other.contextSeparator ?? contextSeparator, pluralSeparator: other.pluralSeparator ?? pluralSeparator, @@ -274,6 +289,7 @@ class I18NextOptions with Diagnosticable { /// properties that aren't null. I18NextOptions copyWith({ List? fallbackNamespaces, + List? fallbackLanguages, String? namespaceSeparator, String? contextSeparator, String? pluralSeparator, @@ -299,6 +315,7 @@ class I18NextOptions with Diagnosticable { }) { return I18NextOptions( fallbackNamespaces: fallbackNamespaces ?? this.fallbackNamespaces, + fallbackLanguages: fallbackLanguages ?? this.fallbackLanguages, namespaceSeparator: namespaceSeparator ?? this.namespaceSeparator, contextSeparator: contextSeparator ?? this.contextSeparator, pluralSeparator: pluralSeparator ?? this.pluralSeparator, @@ -330,6 +347,8 @@ class I18NextOptions with Diagnosticable { @override int get hashCode => Object.hashAll([ + fallbackNamespaces, + fallbackLanguages, namespaceSeparator, contextSeparator, pluralSeparator, @@ -360,6 +379,7 @@ class I18NextOptions with Diagnosticable { return other.runtimeType == runtimeType && other is I18NextOptions && other.fallbackNamespaces == fallbackNamespaces && + other.fallbackLanguages == fallbackLanguages && other.namespaceSeparator == namespaceSeparator && other.contextSeparator == contextSeparator && other.pluralSeparator == pluralSeparator && @@ -387,6 +407,7 @@ class I18NextOptions with Diagnosticable { super.debugFillProperties(properties); properties ..add(IterableProperty('fallbackNamespaces', fallbackNamespaces)) + ..add(IterableProperty('fallbackLanguages', fallbackLanguages)) ..add(StringProperty('namespaceSeparator', namespaceSeparator)) ..add(StringProperty('contextSeparator', contextSeparator)) ..add(StringProperty('pluralSeparator', pluralSeparator)) From ee100b06dad609196ed5435fb9ff3ebdcffe2728 Mon Sep 17 00:00:00 2001 From: William Cho Date: Mon, 5 Jan 2026 17:39:12 -0300 Subject: [PATCH 2/9] feat: translator fallbacks through languages --- lib/src/translator.dart | 50 +++++++++---------- test/i18next_test.dart | 107 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 29 deletions(-) diff --git a/lib/src/translator.dart b/lib/src/translator.dart index 2fa644c..7b3d703 100644 --- a/lib/src/translator.dart +++ b/lib/src/translator.dart @@ -73,32 +73,32 @@ class Translator { keys.add(tempKey += pluralSuffix); } - final namespaces = [ - namespace, - if (options.fallbackNamespaces != null) ...options.fallbackNamespaces!, - ]; + final namespaces = [namespace, ...?options.fallbackNamespaces]; + final locales = [locale, ...?options.fallbackLanguages]; - for (final currentNamespace in namespaces) { - for (final currentKey in keys.reversed) { - // TODO: translation context object - try { - final found = find( - locale, - currentNamespace, - currentKey, - variables, - options, - ); - if (found != null) return found; - } catch (error) { - return options.translationFailedHandler?.call( - locale, - currentNamespace, - currentKey, - variables, - options, - error, - ); + for (final locale in locales) { + for (final currentNamespace in namespaces) { + for (final currentKey in keys.reversed) { + // TODO: translation context object + try { + final found = find( + locale, + currentNamespace, + currentKey, + variables, + options, + ); + if (found != null) return found; + } catch (error) { + return options.translationFailedHandler?.call( + locale, + currentNamespace, + currentKey, + variables, + options, + error, + ); + } } } } diff --git a/test/i18next_test.dart b/test/i18next_test.dart index 6691031..8e1c99d 100644 --- a/test/i18next_test.dart +++ b/test/i18next_test.dart @@ -217,8 +217,11 @@ void main() { }); group('fallback', () { + const french = Locale('fr'), german = Locale('de'); + const fallbackNamespace1 = 'fallback_namespace_1'; + const fallbackNamespace2 = 'fallback_namespace_2'; + test('given a global fallback key substitution', () { - const fallbackNamespace1 = 'fallback_namespace_1'; i18next = I18Next( locale, resourceStore, @@ -233,9 +236,6 @@ void main() { }); group('given 2 global fallback keys subsitution', () { - const fallbackNamespace1 = 'fallback_namespace_1'; - const fallbackNamespace2 = 'fallback_namespace_2'; - setUp(() { i18next = I18Next( locale, @@ -263,6 +263,105 @@ void main() { expect(i18next.t('$namespace:key'), 'value'); }); }); + + test('given fallback language', () { + i18next = I18Next( + locale, + resourceStore, + options: const I18NextOptions(fallbackLanguages: [french]), + ); + + mockKey('key', 'original value'); + mockKey('key', 'french value', locale: french); + mockKey('other.key', 'french value', locale: french); + + expect(i18next.t('$namespace:key'), 'original value'); + expect(i18next.t('$namespace:other.key'), 'french value'); + }); + + test('given fallback languages', () { + i18next = I18Next( + locale, + resourceStore, + options: const I18NextOptions(fallbackLanguages: [french, german]), + ); + + mockKey('french.key', 'french value', locale: french); + mockKey('german.key', 'german value', locale: german); + + expect(i18next.t('$namespace:french.key'), 'french value'); + expect(i18next.t('$namespace:german.key'), 'german value'); + }); + + test('given fallback language but key is still missing', () { + i18next = I18Next( + locale, + resourceStore, + options: const I18NextOptions(fallbackLanguages: [french, german]), + ); + + expect(i18next.t('$namespace:unknown.key'), '$namespace:unknown.key'); + }); + + test('given fallback language and namespace', () { + i18next = I18Next( + locale, + resourceStore, + options: I18NextOptions( + fallbackNamespaces: [fallbackNamespace1, fallbackNamespace2], + fallbackLanguages: [french, german], + missingKeyHandler: expectAsync4( + (locale, key, variables, options) => fail('Should not be called'), + count: 0, + ), + ), + ); + + mockKey('key', 'original value', ns: namespace); + mockKey('key', 'fallbackValue1', ns: fallbackNamespace1); + mockKey('key', 'fallbackValue2', ns: fallbackNamespace2); + mockKey('french.key', 'french value', ns: namespace, locale: french); + mockKey( + 'french.key', + 'french fallback 1', + ns: fallbackNamespace1, + locale: french, + ); + mockKey('german.key', 'german value', ns: namespace, locale: german); + mockKey( + 'german.key', + 'german fallback 2', + ns: fallbackNamespace2, + locale: german, + ); + + expect(i18next.t('$namespace:key'), 'original value'); + expect(i18next.t('$fallbackNamespace1:key'), 'fallbackValue1'); + expect(i18next.t('$fallbackNamespace2:key'), 'fallbackValue2'); + + expect(i18next.t('$namespace:french.key'), 'french value'); + expect(i18next.t('$fallbackNamespace1:french.key'), 'french fallback 1'); + + expect(i18next.t('$namespace:german.key'), 'german value'); + expect(i18next.t('$fallbackNamespace2:german.key'), 'german fallback 2'); + }); + + test('when no fallback finds a key', () { + i18next = I18Next( + locale, + resourceStore, + options: I18NextOptions( + fallbackNamespaces: [fallbackNamespace1, fallbackNamespace2], + fallbackLanguages: [french, german], + missingKeyHandler: expectAsync4( + (locale, key, variables, options) => 'final fallback', + count: 1, + ), + ), + ); + + expect(i18next.t('$namespace:key'), 'final fallback'); + }); }); group('pluralization', () { From 41ee9c31d2d84cb3cce66d062b3530ac58cfdf69 Mon Sep 17 00:00:00 2001 From: William Cho Date: Mon, 5 Jan 2026 18:04:33 -0300 Subject: [PATCH 3/9] feat: localization delegate loads fallback locales --- lib/src/i18next_localization_delegate.dart | 26 ++++++++++++++++---- lib/src/resource_store.dart | 5 ++++ test/i18next_localization_delegate_test.dart | 7 +++--- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/lib/src/i18next_localization_delegate.dart b/lib/src/i18next_localization_delegate.dart index 5be40ac..08c87c7 100644 --- a/lib/src/i18next_localization_delegate.dart +++ b/lib/src/i18next_localization_delegate.dart @@ -16,8 +16,7 @@ class I18NextLocalizationDelegate extends LocalizationsDelegate { required this.dataSource, ResourceStore? resourceStore, this.options, - }) : resourceStore = resourceStore ?? ResourceStore(), - super(); + }) : resourceStore = resourceStore ?? ResourceStore(); /// The list of supported locales by this delegate. /// @@ -78,10 +77,27 @@ class I18NextLocalizationDelegate extends LocalizationsDelegate { Future load(Locale locale) { locale = normalizeLocale(locale); - return dataSource.load(locale).then((namespaces) { + final fallbackLanguages = options?.fallbackLanguages; + final Future)>> futures; + if (fallbackLanguages != null && fallbackLanguages.isNotEmpty) { + futures = Future.wait( + [locale, ...fallbackLanguages].map( + (locale) => dataSource + .load(locale) + .then((namespaces) => (locale, namespaces)), + ), + ); + } else { + // keep future chain sync if data source is also sync + futures = dataSource + .load(locale) + .then((namespaces) => [(locale, namespaces)]); + } + + return futures.then((results) { // TODO: should delete previous locales/namespaces from resource store? - for (final entry in namespaces.entries) { - resourceStore.addNamespace(locale, entry.key, entry.value); + for (final (locale, namespaces) in results) { + resourceStore.addLocale(locale, namespaces); } return I18Next(locale, resourceStore, options: options); }); diff --git a/lib/src/resource_store.dart b/lib/src/resource_store.dart index 499a302..6ea1ce4 100644 --- a/lib/src/resource_store.dart +++ b/lib/src/resource_store.dart @@ -36,6 +36,11 @@ class ResourceStore { _data[locale]?.remove(namespace); } + void addLocale(Locale locale, Map namespaces) { + _data[locale] ??= {}; + _data[locale]?.addAll(namespaces); + } + /// Unregisters the [locale] from the store and from the [cache]. Future removeLocale(Locale locale) async { _data.remove(locale); diff --git a/test/i18next_localization_delegate_test.dart b/test/i18next_localization_delegate_test.dart index eb6229f..69fcb07 100644 --- a/test/i18next_localization_delegate_test.dart +++ b/test/i18next_localization_delegate_test.dart @@ -102,8 +102,9 @@ void main() { final i18next = await localizationDelegate.load(en); expect(i18next.locale, en); - verify(() => resourceStore.addNamespace(en, 'ns1', data1)).called(1); - verify(() => resourceStore.addNamespace(en, 'ns2', data2)).called(1); + verify( + () => resourceStore.addLocale(en, {'ns1': data1, 'ns2': data2}), + ).called(1); }); test('when dataSource is synchronous', () { @@ -117,7 +118,7 @@ void main() { localizationDelegate.load(en).then((value) => result = value); expect(result, isNotNull); expect(result!.locale, en); - verify(() => resourceStore.addNamespace(en, 'ns1', data1)).called(1); + verify(() => resourceStore.addLocale(en, {'ns1': data1})).called(1); }); }); } From 47a932e999fd6e775d8458f0afd62ec7463776b5 Mon Sep 17 00:00:00 2001 From: William Cho Date: Mon, 5 Jan 2026 18:16:06 -0300 Subject: [PATCH 4/9] feat: adds fallback dev locale in example --- example/lib/localizations.dart | 10 ++++++++++ example/lib/main.dart | 8 +++++++- example/localizations/dev/all.json | 3 +++ example/pubspec.yaml | 1 + 4 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 example/localizations/dev/all.json diff --git a/example/lib/localizations.dart b/example/lib/localizations.dart index b055b94..dd251aa 100644 --- a/example/lib/localizations.dart +++ b/example/lib/localizations.dart @@ -37,3 +37,13 @@ class CounterL10n { String get resetCounter => i18next.t('counter:resetCounter'); } + +class DevL10n { + const DevL10n(this.i18next); + + final I18Next i18next; + + static DevL10n of(BuildContext context) => DevL10n(I18Next.of(context)!); + + String get key => i18next.t('all:key'); +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 23519b2..2e4e1df 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -51,7 +51,10 @@ class _MyAppState extends State { bundlePath: 'localizations', ), // extra formatting options can be added here - options: I18NextOptions(formats: formatters()), + options: I18NextOptions( + formats: formatters(), + fallbackLanguages: [Locale('dev')], + ), ), ], home: MyHomePage( @@ -106,6 +109,7 @@ class _MyHomePageState extends State { final theme = Theme.of(context); final homepageL10n = HomePageL10n.of(context); final counterL10n = CounterL10n.of(context); + final devL10n = DevL10n.of(context); return Scaffold( appBar: AppBar(title: Text(homepageL10n.title)), @@ -150,6 +154,8 @@ class _MyHomePageState extends State { onPressed: resetCounter, child: Text(counterL10n.resetCounter), ), + const Divider(), + Text(devL10n.key, style: theme.textTheme.labelSmall), ], ), ), diff --git a/example/localizations/dev/all.json b/example/localizations/dev/all.json new file mode 100644 index 0000000..2132dfd --- /dev/null +++ b/example/localizations/dev/all.json @@ -0,0 +1,3 @@ +{ + "key": "This is a development localization string." +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 92849d1..4b180be 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -30,3 +30,4 @@ flutter: # for now, we'll have to add them manually - localizations/en-US/ - localizations/pt-BR/ + - localizations/dev/ From 48485a53b857fb428449dd20ff6f014b598a2f6a Mon Sep 17 00:00:00 2001 From: William Cho Date: Mon, 5 Jan 2026 18:18:03 -0300 Subject: [PATCH 5/9] chore: update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d2e4f8..7b3ca89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# [next version] + +- Adds optional `fallbackLanguages` to options to allow sequential language fallbacks. + - due to how asset loading works, all languages that were declared as fallback need to be loaded beforehand. + # [0.8.0] - Bump support to flutter 3.38.x [PR](https://github.com/williamhjcho/i18next/pull/24) From 53bb30ca9c6dea6a730e2e894e19ff15e63b93db Mon Sep 17 00:00:00 2001 From: William Cho Date: Tue, 6 Jan 2026 07:43:00 -0300 Subject: [PATCH 6/9] breaking: change asset bundle data source bundle path to required --- CHANGELOG.md | 2 ++ lib/src/data_sources/asset_bundle_data_source.dart | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3ca89..6f8ff4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ - Adds optional `fallbackLanguages` to options to allow sequential language fallbacks. - due to how asset loading works, all languages that were declared as fallback need to be loaded beforehand. +- BREAKING: `AssetBundleLocalizationDataSource.bundlePath` is now required. + - if you were using the default, just pass `localizations` as the argument. # [0.8.0] diff --git a/lib/src/data_sources/asset_bundle_data_source.dart b/lib/src/data_sources/asset_bundle_data_source.dart index 43b24ee..4274709 100644 --- a/lib/src/data_sources/asset_bundle_data_source.dart +++ b/lib/src/data_sources/asset_bundle_data_source.dart @@ -10,21 +10,24 @@ import 'localization_data_source.dart'; /// A [LocalizationDataSource] that retrieves assets from an [AssetBundle]. class AssetBundleLocalizationDataSource implements LocalizationDataSource { AssetBundleLocalizationDataSource({ - this.bundlePath = 'localizations', + required this.bundlePath, AssetBundle? bundle, this.cache = true, }) : bundle = bundle ?? rootBundle; /// The path prefixed to the asset when retrieving from the [bundle]. /// - /// Defaults to 'localizations'. + /// e.g. `l10n` if your assets are located in `l10n/en-US/feature.json`. final String bundlePath; /// The [AssetBundle] where it retrieves the assets from. /// - /// Defaults no [rootBundle]. + /// Defaults to [rootBundle]. final AssetBundle bundle; + /// Whether to cache the loaded assets from the [bundle]. + /// + /// Defaults to `true`. final bool cache; /// Loads all '.json' localization files declared in [manifest] with From 2c8828c0c3a2bbeb30fe7387389de60bf4d6449f Mon Sep 17 00:00:00 2001 From: William Cho Date: Tue, 6 Jan 2026 07:51:20 -0300 Subject: [PATCH 7/9] breaking: loads locales in batches --- CHANGELOG.md | 2 + .../asset_bundle_data_source.dart | 68 ++++++++-------- .../localization_data_source.dart | 2 +- lib/src/i18next_localization_delegate.dart | 25 +----- .../asset_bundle_data_source_test.dart | 77 +++++++++++++++---- test/i18next_localization_delegate_test.dart | 34 ++++++-- 6 files changed, 129 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8ff4f..7ebf738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - due to how asset loading works, all languages that were declared as fallback need to be loaded beforehand. - BREAKING: `AssetBundleLocalizationDataSource.bundlePath` is now required. - if you were using the default, just pass `localizations` as the argument. +- BREAKING: `LocalizationDataSource.load` now receives a list of locales, instead of a single locale. + - `AssetBundleLocalizationDataSource` now also tries to load files concurrently. # [0.8.0] diff --git a/lib/src/data_sources/asset_bundle_data_source.dart b/lib/src/data_sources/asset_bundle_data_source.dart index 4274709..c29eaca 100644 --- a/lib/src/data_sources/asset_bundle_data_source.dart +++ b/lib/src/data_sources/asset_bundle_data_source.dart @@ -30,55 +30,59 @@ class AssetBundleLocalizationDataSource implements LocalizationDataSource { /// Defaults to `true`. final bool cache; - /// Loads all '.json' localization files declared in [manifest] with - /// [bundlePath] given a [locale]. The assets themselves must have been - /// previously declared in `pubspec.yaml`. + /// Loads all '.json' localization files declared in the bundle's asset + /// manfiest with [bundlePath] given the [locales]. + /// The assets themselves must have been previously declared in `pubspec.yaml`. /// /// For example, if your project structure is as follows: /// /// ``` - /// /app - /// - l10n - /// - en-US/localizations.json - /// - pt-BR/localizations.json + /// /app/ + /// - l10n/ + /// - en-US/ + /// - common.json + /// - feature_a.json + /// - pt-BR/ + /// - common.json + /// - feature_a.json /// ``` /// /// Then the desired [bundlePath] should be `l10n`. /// - /// - [manifest] determines from where the namespaced files will be loaded - /// from. This file should contain a [Map] where the keys represent the - /// asset's path. Defaults to 'AssetManifest.json'. - /// /// The end result is a [Map] that contains all the namespaces which are /// the file names themselves (case sensitive). @override - Future> load(Locale locale) async { + Future>> load(List locales) async { final assetManifest = await AssetManifest.loadFromAssetBundle(bundle); - final assetFiles = assetManifest.listAssets(); - - /// On every platform you never should try to get the `path.separator`, - /// because Flutter is fetching all assets in `/` style. - /// `path.separator` should only be used to handle OS files. - final bundleLocalePath = '$bundlePath/${locale.toLanguageTag()}'; - - final files = assetFiles - // trailing slash is to guarantee the whole dir matches, otherwise - // it might allow undesired files - .where((key) => key.contains(bundleLocalePath)) - .where((key) => path.extension(key) == '.json'); + final assetFiles = assetManifest + .listAssets() + .where((key) => path.extension(key) == '.json') + .toList(); - return await loadFromFiles(files); + final loadedLocales = >{}; + await Future.wait( + locales.map((locale) async { + /// On every platform you never should try to get the `path.separator`, + /// because Flutter is fetching all assets in `/` style. + /// `path.separator` should only be used to handle OS files. + final assetPath = '$bundlePath/${locale.toLanguageTag()}'; + final localeAssetFiles = assetFiles.where((f) => f.contains(assetPath)); + final namespaces = await loadFromFiles(localeAssetFiles); + loadedLocales[locale] = namespaces; + }), + ); + return loadedLocales; } Future> loadFromFiles(Iterable files) async { - // TODO: make it case insensitive? final namespaces = HashMap(); - for (final file in files) { - // TODO: make this a lazy eval and let loading be handed concurrently? - final namespace = path.basenameWithoutExtension(file); - final string = await bundle.loadString(file, cache: cache); - namespaces[namespace] = jsonDecode(string); - } + await Future.wait( + files.map((file) async { + final namespace = path.basenameWithoutExtension(file); + final string = await bundle.loadString(file, cache: cache); + namespaces[namespace] = jsonDecode(string); + }), + ); return namespaces; } diff --git a/lib/src/data_sources/localization_data_source.dart b/lib/src/data_sources/localization_data_source.dart index e4b86f2..1047207 100644 --- a/lib/src/data_sources/localization_data_source.dart +++ b/lib/src/data_sources/localization_data_source.dart @@ -1,5 +1,5 @@ import 'dart:ui'; abstract class LocalizationDataSource { - Future> load(Locale locale); + Future>> load(List locales); } diff --git a/lib/src/i18next_localization_delegate.dart b/lib/src/i18next_localization_delegate.dart index 08c87c7..fb172ce 100644 --- a/lib/src/i18next_localization_delegate.dart +++ b/lib/src/i18next_localization_delegate.dart @@ -76,28 +76,11 @@ class I18NextLocalizationDelegate extends LocalizationsDelegate { @override Future load(Locale locale) { locale = normalizeLocale(locale); - - final fallbackLanguages = options?.fallbackLanguages; - final Future)>> futures; - if (fallbackLanguages != null && fallbackLanguages.isNotEmpty) { - futures = Future.wait( - [locale, ...fallbackLanguages].map( - (locale) => dataSource - .load(locale) - .then((namespaces) => (locale, namespaces)), - ), - ); - } else { - // keep future chain sync if data source is also sync - futures = dataSource - .load(locale) - .then((namespaces) => [(locale, namespaces)]); - } - - return futures.then((results) { + final languages = [locale, ...?options?.fallbackLanguages]; + return dataSource.load(languages).then((loadedLanguages) { // TODO: should delete previous locales/namespaces from resource store? - for (final (locale, namespaces) in results) { - resourceStore.addLocale(locale, namespaces); + for (final entry in loadedLanguages.entries) { + resourceStore.addLocale(entry.key, entry.value); } return I18Next(locale, resourceStore, options: options); }); diff --git a/test/data_sources/asset_bundle_data_source_test.dart b/test/data_sources/asset_bundle_data_source_test.dart index be2f82f..54b5aa1 100644 --- a/test/data_sources/asset_bundle_data_source_test.dart +++ b/test/data_sources/asset_bundle_data_source_test.dart @@ -34,6 +34,8 @@ class MockAssetManifest implements AssetManifest { void main() { const bundlePath = 'bundle/path'; + const anyLocale = Locale('any'); + const enUS = Locale('en', 'US'), pt = Locale('pt'); late MockAssetBundle bundle; late AssetBundleLocalizationDataSource dataSource; @@ -59,11 +61,12 @@ void main() { }); test('given any locale', () async { - await expectLater(dataSource.load(const Locale('any')), completes); + await expectLater(dataSource.load([anyLocale]), completes); }); test('given an unregistered locale', () { - expect(dataSource.load(const Locale('ar')), completion(isEmpty)); + const locale = Locale('ar'); + expect(dataSource.load([locale]), completion({locale: {}})); }); test('given a supported full locale', () async { @@ -72,9 +75,11 @@ void main() { ).thenAnswer((_) async => '{}'); await expectLater( - dataSource.load(const Locale('en', 'US')), + dataSource.load([enUS]), completion( - equals(>{'file1': {}, 'file2': {}}), + equals({ + enUS: {'file1': {}, 'file2': {}}, + }), ), ); @@ -83,10 +88,8 @@ void main() { }); test('given an unsupported long locale', () async { - await expectLater( - dataSource.load(const Locale('pt-BR')), - completion(isEmpty), - ); + const ptBR = Locale('pt-BR'); + await expectLater(dataSource.load([ptBR]), completion({ptBR: {}})); verifyNever( () => bundle.loadString(any(that: contains('$bundlePath/pt/'))), @@ -103,11 +106,12 @@ void main() { when( () => bundle.loadString(any(that: contains('$bundlePath/'))), ).thenAnswer((_) async => '{}'); - await expectLater( - dataSource.load(const Locale('pt')), + dataSource.load([pt]), completion( - equals(>{'file1': {}, 'file2': {}}), + equals({ + pt: {'file1': {}, 'file2': {}}, + }), ), ); @@ -119,10 +123,8 @@ void main() { }); test('given an unsupported short locale', () async { - await expectLater( - dataSource.load(const Locale('ar')), - completion(isEmpty), - ); + const locale = Locale('ar'); + await expectLater(dataSource.load([locale]), completion({locale: {}})); verifyNever( () => bundle.loadString(any(that: contains('$bundlePath/ar/'))), @@ -135,15 +137,56 @@ void main() { ); }); + test('given supported multiple locales', () async { + when( + () => bundle.loadString(any(that: contains('$bundlePath/'))), + ).thenAnswer((_) async => '{}'); + await expectLater( + dataSource.load([enUS, pt]), + completion( + equals({ + enUS: {'file1': {}, 'file2': {}}, + pt: {'file1': {}, 'file2': {}}, + }), + ), + ); + + verify(() => bundle.loadString('$bundlePath/en-US/file1.json')).called(1); + verify(() => bundle.loadString('$bundlePath/en-US/file2.json')).called(1); + verify(() => bundle.loadString('$bundlePath/pt/file1.json')).called(1); + verify(() => bundle.loadString('$bundlePath/pt/file2.json')).called(1); + }); + + test('given supported and unsupported multiple locales', () async { + when( + () => bundle.loadString(any(that: contains('$bundlePath/'))), + ).thenAnswer((_) async => '{}'); + await expectLater( + dataSource.load([enUS, pt, anyLocale]), + completion( + equals({ + enUS: {'file1': {}, 'file2': {}}, + pt: {'file1': {}, 'file2': {}}, + anyLocale: {}, + }), + ), + ); + + verify(() => bundle.loadString('$bundlePath/en-US/file1.json')).called(1); + verify(() => bundle.loadString('$bundlePath/en-US/file2.json')).called(1); + verify(() => bundle.loadString('$bundlePath/pt/file1.json')).called(1); + verify(() => bundle.loadString('$bundlePath/pt/file2.json')).called(1); + }); + test('when bundle errors', () async { const error = 'Some error'; when(() => bundle.loadMock(any())).thenAnswer((_) async => throw error); - await expectLater(dataSource.load(const Locale('any')), throwsA(error)); + await expectLater(dataSource.load([anyLocale]), throwsA(error)); }); test('given incorrect source-path to any bundle asset', () async { - await expectLater(dataSource.load(const Locale('any')), completes); + await expectLater(dataSource.load([anyLocale]), completes); verifyNever(() => bundle.loadString(any(that: contains('bundle\\path')))); }); diff --git a/test/i18next_localization_delegate_test.dart b/test/i18next_localization_delegate_test.dart index 69fcb07..dbfcdb9 100644 --- a/test/i18next_localization_delegate_test.dart +++ b/test/i18next_localization_delegate_test.dart @@ -69,14 +69,14 @@ void main() { when(() => dataSource.load(any())).thenAnswer((_) async => {}); await expectLater(localizationDelegate.load(en), completes); - verify(() => dataSource.load(en)).called(1); + verify(() => dataSource.load([en])).called(1); }); test('given a language code matching locale', () async { when(() => dataSource.load(any())).thenAnswer((_) async => {}); await expectLater(localizationDelegate.load(enUS), completes); - verify(() => dataSource.load(en)).called(1); + verify(() => dataSource.load([en])).called(1); }); test('given a non matching language code locale', () async { @@ -86,6 +86,20 @@ void main() { verifyNever(() => dataSource.load(any())); }); + test('given fallback locales', () async { + localizationDelegate = I18NextLocalizationDelegate( + locales: [en], + dataSource: dataSource, + resourceStore: resourceStore, + options: I18NextOptions(fallbackLocales: [ptBR]), + ); + when(() => dataSource.load(any())).thenAnswer((_) async => {}); + + await expectLater(localizationDelegate.load(enUS), completes); + // loads locales that aren't marked in supported locales as well. + verify(() => dataSource.load([en, ptBR])).called(1); + }); + test('when dataSource errors', () async { const error = 'Some error'; when(() => dataSource.load(any())).thenAnswer((_) async => throw error); @@ -96,9 +110,11 @@ void main() { test('when dataSource succeeds', () async { const data1 = {'key': 'ns1'}; const data2 = {'key': 'ns1'}; - when( - () => dataSource.load(any()), - ).thenAnswer((_) async => {'ns1': data1, 'ns2': data2}); + when(() => dataSource.load(any())).thenAnswer( + (_) async => { + en: {'ns1': data1, 'ns2': data2}, + }, + ); final i18next = await localizationDelegate.load(en); expect(i18next.locale, en); @@ -109,9 +125,11 @@ void main() { test('when dataSource is synchronous', () { const data1 = {'key': 'ns1'}; - when( - () => dataSource.load(any()), - ).thenAnswer((_) => SynchronousFuture({'ns1': data1})); + when(() => dataSource.load(any())).thenAnswer( + (_) => SynchronousFuture({ + en: {'ns1': data1}, + }), + ); // checking if this is being called sync I18Next? result; From 9407eea4d754c882392cdb3d0a40d09fe58c7ea1 Mon Sep 17 00:00:00 2001 From: William Cho Date: Tue, 6 Jan 2026 09:29:01 -0300 Subject: [PATCH 8/9] chore: rename languages->locales --- CHANGELOG.md | 4 +-- example/lib/main.dart | 2 +- lib/src/i18next_localization_delegate.dart | 6 ++--- lib/src/options.dart | 30 +++++++++++----------- lib/src/translator.dart | 2 +- test/i18next_test.dart | 18 ++++++------- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ebf738..439c3c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # [next version] -- Adds optional `fallbackLanguages` to options to allow sequential language fallbacks. - - due to how asset loading works, all languages that were declared as fallback need to be loaded beforehand. +- Adds optional `fallbackLocales` to options to allow sequential locale fallbacks. + - due to how asset loading works, all locales that were declared as fallback need to be loaded beforehand. - BREAKING: `AssetBundleLocalizationDataSource.bundlePath` is now required. - if you were using the default, just pass `localizations` as the argument. - BREAKING: `LocalizationDataSource.load` now receives a list of locales, instead of a single locale. diff --git a/example/lib/main.dart b/example/lib/main.dart index 2e4e1df..a6e0d98 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -53,7 +53,7 @@ class _MyAppState extends State { // extra formatting options can be added here options: I18NextOptions( formats: formatters(), - fallbackLanguages: [Locale('dev')], + fallbackLocales: [Locale('dev')], ), ), ], diff --git a/lib/src/i18next_localization_delegate.dart b/lib/src/i18next_localization_delegate.dart index fb172ce..99a7184 100644 --- a/lib/src/i18next_localization_delegate.dart +++ b/lib/src/i18next_localization_delegate.dart @@ -76,10 +76,10 @@ class I18NextLocalizationDelegate extends LocalizationsDelegate { @override Future load(Locale locale) { locale = normalizeLocale(locale); - final languages = [locale, ...?options?.fallbackLanguages]; - return dataSource.load(languages).then((loadedLanguages) { + final allLocales = [locale, ...?options?.fallbackLocales]; + return dataSource.load(allLocales).then((loaded) { // TODO: should delete previous locales/namespaces from resource store? - for (final entry in loadedLanguages.entries) { + for (final entry in loaded.entries) { resourceStore.addLocale(entry.key, entry.value); } return I18Next(locale, resourceStore, options: options); diff --git a/lib/src/options.dart b/lib/src/options.dart index c081f73..4298fcb 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -43,7 +43,7 @@ typedef EscapeHandler = String Function(String input); class I18NextOptions with Diagnosticable { const I18NextOptions({ this.fallbackNamespaces, - this.fallbackLanguages, + this.fallbackLocales, this.namespaceSeparator, this.contextSeparator, this.pluralSeparator, @@ -70,7 +70,7 @@ class I18NextOptions with Diagnosticable { static const I18NextOptions base = I18NextOptions( fallbackNamespaces: null, - fallbackLanguages: null, + fallbackLocales: null, namespaceSeparator: ':', contextSeparator: '_', pluralSeparator: '_', @@ -102,17 +102,17 @@ class I18NextOptions with Diagnosticable { /// Defaults to null. final List? fallbackNamespaces; - /// The languages that will be used to fallback when no key matches were found - /// in the current language. - /// These languages are evaluated in the order they are put in the list. + /// The locales that will be used to fallback when no key matches were found + /// in the current locale. + /// These locales are evaluated in the order they are put in the list. /// - /// [fallbackNamespaces] will take priority over language. - /// [missingKeyHandler] is called only after all languages have been evaluated. + /// [fallbackNamespaces] will take priority over locale. + /// [missingKeyHandler] is called only after all locales have been evaluated. /// - /// The fallback languages must be loaded with the primary language. + /// obs: the fallback locales must be loaded with the primary locale. /// /// Defaults to null. - final List? fallbackLanguages; + final List? fallbackLocales; /// The separator used when splitting the key. /// @@ -251,7 +251,7 @@ class I18NextOptions with Diagnosticable { if (other == null) return this; return copyWith( fallbackNamespaces: other.fallbackNamespaces ?? fallbackNamespaces, - fallbackLanguages: other.fallbackLanguages ?? fallbackLanguages, + fallbackLocales: other.fallbackLocales ?? fallbackLocales, namespaceSeparator: other.namespaceSeparator ?? namespaceSeparator, contextSeparator: other.contextSeparator ?? contextSeparator, pluralSeparator: other.pluralSeparator ?? pluralSeparator, @@ -289,7 +289,7 @@ class I18NextOptions with Diagnosticable { /// properties that aren't null. I18NextOptions copyWith({ List? fallbackNamespaces, - List? fallbackLanguages, + List? fallbackLocales, String? namespaceSeparator, String? contextSeparator, String? pluralSeparator, @@ -315,7 +315,7 @@ class I18NextOptions with Diagnosticable { }) { return I18NextOptions( fallbackNamespaces: fallbackNamespaces ?? this.fallbackNamespaces, - fallbackLanguages: fallbackLanguages ?? this.fallbackLanguages, + fallbackLocales: fallbackLocales ?? this.fallbackLocales, namespaceSeparator: namespaceSeparator ?? this.namespaceSeparator, contextSeparator: contextSeparator ?? this.contextSeparator, pluralSeparator: pluralSeparator ?? this.pluralSeparator, @@ -348,7 +348,7 @@ class I18NextOptions with Diagnosticable { @override int get hashCode => Object.hashAll([ fallbackNamespaces, - fallbackLanguages, + fallbackLocales, namespaceSeparator, contextSeparator, pluralSeparator, @@ -379,7 +379,7 @@ class I18NextOptions with Diagnosticable { return other.runtimeType == runtimeType && other is I18NextOptions && other.fallbackNamespaces == fallbackNamespaces && - other.fallbackLanguages == fallbackLanguages && + other.fallbackLocales == fallbackLocales && other.namespaceSeparator == namespaceSeparator && other.contextSeparator == contextSeparator && other.pluralSeparator == pluralSeparator && @@ -407,7 +407,7 @@ class I18NextOptions with Diagnosticable { super.debugFillProperties(properties); properties ..add(IterableProperty('fallbackNamespaces', fallbackNamespaces)) - ..add(IterableProperty('fallbackLanguages', fallbackLanguages)) + ..add(IterableProperty('fallbackLocales', fallbackLocales)) ..add(StringProperty('namespaceSeparator', namespaceSeparator)) ..add(StringProperty('contextSeparator', contextSeparator)) ..add(StringProperty('pluralSeparator', pluralSeparator)) diff --git a/lib/src/translator.dart b/lib/src/translator.dart index 7b3d703..835cffe 100644 --- a/lib/src/translator.dart +++ b/lib/src/translator.dart @@ -74,7 +74,7 @@ class Translator { } final namespaces = [namespace, ...?options.fallbackNamespaces]; - final locales = [locale, ...?options.fallbackLanguages]; + final locales = [locale, ...?options.fallbackLocales]; for (final locale in locales) { for (final currentNamespace in namespaces) { diff --git a/test/i18next_test.dart b/test/i18next_test.dart index 8e1c99d..8a410f3 100644 --- a/test/i18next_test.dart +++ b/test/i18next_test.dart @@ -264,11 +264,11 @@ void main() { }); }); - test('given fallback language', () { + test('given fallback locales', () { i18next = I18Next( locale, resourceStore, - options: const I18NextOptions(fallbackLanguages: [french]), + options: const I18NextOptions(fallbackLocales: [french]), ); mockKey('key', 'original value'); @@ -279,11 +279,11 @@ void main() { expect(i18next.t('$namespace:other.key'), 'french value'); }); - test('given fallback languages', () { + test('given fallback localess', () { i18next = I18Next( locale, resourceStore, - options: const I18NextOptions(fallbackLanguages: [french, german]), + options: const I18NextOptions(fallbackLocales: [french, german]), ); mockKey('french.key', 'french value', locale: french); @@ -293,23 +293,23 @@ void main() { expect(i18next.t('$namespace:german.key'), 'german value'); }); - test('given fallback language but key is still missing', () { + test('given fallback locales but key is still missing', () { i18next = I18Next( locale, resourceStore, - options: const I18NextOptions(fallbackLanguages: [french, german]), + options: const I18NextOptions(fallbackLocales: [french, german]), ); expect(i18next.t('$namespace:unknown.key'), '$namespace:unknown.key'); }); - test('given fallback language and namespace', () { + test('given fallback locales and namespace', () { i18next = I18Next( locale, resourceStore, options: I18NextOptions( fallbackNamespaces: [fallbackNamespace1, fallbackNamespace2], - fallbackLanguages: [french, german], + fallbackLocales: [french, german], missingKeyHandler: expectAsync4( (locale, key, variables, options) => fail('Should not be called'), count: 0, @@ -352,7 +352,7 @@ void main() { resourceStore, options: I18NextOptions( fallbackNamespaces: [fallbackNamespace1, fallbackNamespace2], - fallbackLanguages: [french, german], + fallbackLocales: [french, german], missingKeyHandler: expectAsync4( (locale, key, variables, options) => 'final fallback', count: 1, From 39b3b5610f6f1b27b92ee3a18ebd994e5e15c650 Mon Sep 17 00:00:00 2001 From: William Cho Date: Tue, 6 Jan 2026 09:29:06 -0300 Subject: [PATCH 9/9] chore: adds mise --- .github/workflows/build.yaml | 12 +++++++----- mise.toml | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 mise.toml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1c44901..bff7619 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -13,14 +13,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: subosito/flutter-action@v2 + - uses: jdx/mise-action@v2 + with: + version: 2025.10.21 - name: Install Dependencies - run: flutter pub get + run: mise run setup - name: Format - run: dart format --set-exit-if-changed lib test + run: mise run format - name: Analyze - run: flutter analyze lib test + run: mise run lint - name: Run tests - run: flutter test --no-pub --coverage --test-randomize-ordering-seed random + run: mise run test --coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v2 diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..a5569c5 --- /dev/null +++ b/mise.toml @@ -0,0 +1,17 @@ +[settings] +lockfile = true + +[tools] +flutter = "3.38.5" + +[tasks.setup] +run = 'flutter pub get' + +[tasks.format] +run = 'dart format --set-exit-if-changed .' + +[tasks.lint] +run = "flutter analyze --no-pub ." + +[tasks.test] +run = 'flutter test --no-pub --test-randomize-ordering-seed=random'