Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# [next version]

- 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.
- `AssetBundleLocalizationDataSource` now also tries to load files concurrently.

# [0.8.0]

- Bump support to flutter 3.38.x [PR](https://github.com/williamhjcho/i18next/pull/24)
Expand Down
10 changes: 10 additions & 0 deletions example/lib/localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
8 changes: 7 additions & 1 deletion example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ class _MyAppState extends State<MyApp> {
bundlePath: 'localizations',
),
// extra formatting options can be added here
options: I18NextOptions(formats: formatters()),
options: I18NextOptions(
formats: formatters(),
fallbackLocales: [Locale('dev')],
),
),
],
home: MyHomePage(
Expand Down Expand Up @@ -106,6 +109,7 @@ class _MyHomePageState extends State<MyHomePage> {
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)),
Expand Down Expand Up @@ -150,6 +154,8 @@ class _MyHomePageState extends State<MyHomePage> {
onPressed: resetCounter,
child: Text(counterL10n.resetCounter),
),
const Divider(),
Text(devL10n.key, style: theme.textTheme.labelSmall),
],
),
),
Expand Down
3 changes: 3 additions & 0 deletions example/localizations/dev/all.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"key": "This is a development localization string."
}
1 change: 1 addition & 0 deletions example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ flutter:
# for now, we'll have to add them manually
- localizations/en-US/
- localizations/pt-BR/
- localizations/dev/
77 changes: 42 additions & 35 deletions lib/src/data_sources/asset_bundle_data_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,72 +10,79 @@ 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
/// [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<Map<String, dynamic>> load(Locale locale) async {
Future<Map<Locale, Map<String, dynamic>>> load(List<Locale> 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 = <Locale, Map<String, dynamic>>{};
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<Map<String, dynamic>> loadFromFiles(Iterable<String> files) async {
// TODO: make it case insensitive?
final namespaces = HashMap<String, dynamic>();
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;
}

Expand Down
2 changes: 1 addition & 1 deletion lib/src/data_sources/localization_data_source.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'dart:ui';

abstract class LocalizationDataSource {
Future<Map<String, dynamic>> load(Locale locale);
Future<Map<Locale, Map<String, dynamic>>> load(List<Locale> locales);
}
11 changes: 5 additions & 6 deletions lib/src/i18next_localization_delegate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ class I18NextLocalizationDelegate extends LocalizationsDelegate<I18Next> {
required this.dataSource,
ResourceStore? resourceStore,
this.options,
}) : resourceStore = resourceStore ?? ResourceStore(),
super();
}) : resourceStore = resourceStore ?? ResourceStore();

/// The list of supported locales by this delegate.
///
Expand Down Expand Up @@ -77,11 +76,11 @@ class I18NextLocalizationDelegate extends LocalizationsDelegate<I18Next> {
@override
Future<I18Next> load(Locale locale) {
locale = normalizeLocale(locale);

return dataSource.load(locale).then((namespaces) {
final allLocales = [locale, ...?options?.fallbackLocales];
return dataSource.load(allLocales).then((loaded) {
// TODO: should delete previous locales/namespaces from resource store?
for (final entry in namespaces.entries) {
resourceStore.addNamespace(locale, entry.key, entry.value);
for (final entry in loaded.entries) {
resourceStore.addLocale(entry.key, entry.value);
}
return I18Next(locale, resourceStore, options: options);
});
Expand Down
21 changes: 21 additions & 0 deletions lib/src/options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ typedef EscapeHandler = String Function(String input);
class I18NextOptions with Diagnosticable {
const I18NextOptions({
this.fallbackNamespaces,
this.fallbackLocales,
this.namespaceSeparator,
this.contextSeparator,
this.pluralSeparator,
Expand All @@ -69,6 +70,7 @@ class I18NextOptions with Diagnosticable {

static const I18NextOptions base = I18NextOptions(
fallbackNamespaces: null,
fallbackLocales: null,
namespaceSeparator: ':',
contextSeparator: '_',
pluralSeparator: '_',
Expand Down Expand Up @@ -100,6 +102,18 @@ class I18NextOptions with Diagnosticable {
/// Defaults to null.
final List<String>? fallbackNamespaces;

/// 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 locale.
/// [missingKeyHandler] is called only after all locales have been evaluated.
///
/// obs: the fallback locales must be loaded with the primary locale.
///
/// Defaults to null.
final List<Locale>? fallbackLocales;

/// The separator used when splitting the key.
///
/// Defaults to ':'.
Expand Down Expand Up @@ -237,6 +251,7 @@ class I18NextOptions with Diagnosticable {
if (other == null) return this;
return copyWith(
fallbackNamespaces: other.fallbackNamespaces ?? fallbackNamespaces,
fallbackLocales: other.fallbackLocales ?? fallbackLocales,
namespaceSeparator: other.namespaceSeparator ?? namespaceSeparator,
contextSeparator: other.contextSeparator ?? contextSeparator,
pluralSeparator: other.pluralSeparator ?? pluralSeparator,
Expand Down Expand Up @@ -274,6 +289,7 @@ class I18NextOptions with Diagnosticable {
/// properties that aren't null.
I18NextOptions copyWith({
List<String>? fallbackNamespaces,
List<Locale>? fallbackLocales,
String? namespaceSeparator,
String? contextSeparator,
String? pluralSeparator,
Expand All @@ -299,6 +315,7 @@ class I18NextOptions with Diagnosticable {
}) {
return I18NextOptions(
fallbackNamespaces: fallbackNamespaces ?? this.fallbackNamespaces,
fallbackLocales: fallbackLocales ?? this.fallbackLocales,
namespaceSeparator: namespaceSeparator ?? this.namespaceSeparator,
contextSeparator: contextSeparator ?? this.contextSeparator,
pluralSeparator: pluralSeparator ?? this.pluralSeparator,
Expand Down Expand Up @@ -330,6 +347,8 @@ class I18NextOptions with Diagnosticable {

@override
int get hashCode => Object.hashAll([
fallbackNamespaces,
fallbackLocales,
namespaceSeparator,
contextSeparator,
pluralSeparator,
Expand Down Expand Up @@ -360,6 +379,7 @@ class I18NextOptions with Diagnosticable {
return other.runtimeType == runtimeType &&
other is I18NextOptions &&
other.fallbackNamespaces == fallbackNamespaces &&
other.fallbackLocales == fallbackLocales &&
other.namespaceSeparator == namespaceSeparator &&
other.contextSeparator == contextSeparator &&
other.pluralSeparator == pluralSeparator &&
Expand Down Expand Up @@ -387,6 +407,7 @@ class I18NextOptions with Diagnosticable {
super.debugFillProperties(properties);
properties
..add(IterableProperty('fallbackNamespaces', fallbackNamespaces))
..add(IterableProperty('fallbackLocales', fallbackLocales))
..add(StringProperty('namespaceSeparator', namespaceSeparator))
..add(StringProperty('contextSeparator', contextSeparator))
..add(StringProperty('pluralSeparator', pluralSeparator))
Expand Down
5 changes: 5 additions & 0 deletions lib/src/resource_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ class ResourceStore {
_data[locale]?.remove(namespace);
}

void addLocale(Locale locale, Map<String, dynamic> namespaces) {
_data[locale] ??= {};
_data[locale]?.addAll(namespaces);
}

/// Unregisters the [locale] from the store and from the [cache].
Future<void> removeLocale(Locale locale) async {
_data.remove(locale);
Expand Down
50 changes: 25 additions & 25 deletions lib/src/translator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,32 +73,32 @@ class Translator {
keys.add(tempKey += pluralSuffix);
}

final namespaces = <String>[
namespace,
if (options.fallbackNamespaces != null) ...options.fallbackNamespaces!,
];
final namespaces = <String>[namespace, ...?options.fallbackNamespaces];
final locales = <Locale>[locale, ...?options.fallbackLocales];

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,
);
}
}
}
}
Expand Down
Loading