From 4ded6b14079ac54724eca3757a9d6ef05ea1ab89 Mon Sep 17 00:00:00 2001 From: Nicolas Javed Date: Tue, 19 Aug 2025 07:51:36 +0200 Subject: [PATCH 01/10] feat: Added a key ':/' to load liked translations files --- lib/src/asset_loader.dart | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/src/asset_loader.dart b/lib/src/asset_loader.dart index 03cef967..9f91e311 100644 --- a/lib/src/asset_loader.dart +++ b/lib/src/asset_loader.dart @@ -30,10 +30,32 @@ class RootBundleAssetLoader extends AssetLoader { return '$basePath/${locale.toStringWithSeparator(separator: "-")}.json'; } + Future> _getLinkedTranslationFileDataFromBaseJson(Map baseJson) async { + Map fullJson = {}; + + for (var entry in baseJson.entries) { + var key = entry.key; + var value = entry.value; + + if (value.startsWith(':/')) { + String filePath = value.substring(2); + baseJson[key] = json.decode(await rootBundle.loadString(filePath)); + fullJson.addAll(baseJson[key]); + continue; + } + + fullJson[key] = baseJson[key]; + } + + return fullJson; + } + @override Future?> load(String path, Locale locale) async { var localePath = getLocalePath(path, locale); EasyLocalization.logger.debug('Load asset from $path'); - return json.decode(await rootBundle.loadString(localePath)); + + Map baseJson = json.decode(await rootBundle.loadString(localePath)); + return _getLinkedTranslationFileDataFromBaseJson(baseJson); } } From 839433dd7f2a9219bb5edb5ffe640616540a548c Mon Sep 17 00:00:00 2001 From: Nicolas Javed Date: Tue, 19 Aug 2025 08:02:02 +0200 Subject: [PATCH 02/10] feat: using base path and local for linked translation file loading --- lib/src/asset_loader.dart | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/src/asset_loader.dart b/lib/src/asset_loader.dart index 9f91e311..ce6b3996 100644 --- a/lib/src/asset_loader.dart +++ b/lib/src/asset_loader.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:developer'; import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; @@ -30,17 +31,23 @@ class RootBundleAssetLoader extends AssetLoader { return '$basePath/${locale.toStringWithSeparator(separator: "-")}.json'; } - Future> _getLinkedTranslationFileDataFromBaseJson(Map baseJson) async { + String _getLinkedLocalePath(String basePath, String filePath, Locale locale) { + return '$basePath/${locale.toStringWithSeparator(separator: "-")}/$filePath'; + } + + Future> _getLinkedTranslationFileDataFromBaseJson( + String basePath, Locale locale, Map baseJson) async { Map fullJson = {}; for (var entry in baseJson.entries) { var key = entry.key; var value = entry.value; - if (value.startsWith(':/')) { + if (value is String && value.startsWith(':/')) { String filePath = value.substring(2); - baseJson[key] = json.decode(await rootBundle.loadString(filePath)); - fullJson.addAll(baseJson[key]); + Map linkedJson = + json.decode(await rootBundle.loadString(_getLinkedLocalePath(basePath, filePath, locale))); + fullJson.addAll({key: linkedJson}); continue; } @@ -56,6 +63,6 @@ class RootBundleAssetLoader extends AssetLoader { EasyLocalization.logger.debug('Load asset from $path'); Map baseJson = json.decode(await rootBundle.loadString(localePath)); - return _getLinkedTranslationFileDataFromBaseJson(baseJson); + return _getLinkedTranslationFileDataFromBaseJson(path, locale, baseJson); } } From 7eadfaa3fa2f5698b84816e6a9dd97f258278e7f Mon Sep 17 00:00:00 2001 From: Nicolas Javed Date: Tue, 19 Aug 2025 08:41:44 +0200 Subject: [PATCH 03/10] chore: readme --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 365930a2..7ba2ffc8 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ flutter: - assets/translations/ ``` + + ### 🔌 Loading translations from other resources You can use JSON,CSV,HTTP,XML,Yaml files, etc. @@ -407,6 +409,36 @@ Output: print('example.emptyNameError'.tr()); //Output: Please fill in your full name ``` +### 🔥 Linked files: + +> ⚠ This is only available for the default asset loader (on Json Files). + +You can split translations for a single locale into multiple files by using linked files. This helps keep your JSON clean and maintainable. + +To link an external file, set the key’s value to a path prefixed with `:/`, relative to your translations directory. For example, with default path `assets/translations` and locale `en-US`: + +```json +{ + "errors": ":/errors.json", + "validation": ":/validation.json", + "notifications": ":/notifications.json" +} +``` + +At runtime, Easy Localization will load: +``` +assets +└── translations + └── en-US + ├── errors.json + ├── validation.json + └── notifications.json +``` + +Each linked file must contain a valid JSON object of translation keys. + +Don't forget to add your linked files (or linked files folder, here assets/translations/en-US/), to your pubspec.yaml : [See installation](#-installation). + ### 🔥 Reset locale `resetLocale()` Reset locale to device locale From 8b2ba903f898e6778753763960d285eaf90d9527 Mon Sep 17 00:00:00 2001 From: Nicolas Javed Date: Tue, 19 Aug 2025 10:08:30 +0200 Subject: [PATCH 04/10] chore: fetching file in all value Getting file link to all key not just first level Getting file link in data from files datas --- lib/src/asset_loader.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/src/asset_loader.dart b/lib/src/asset_loader.dart index ce6b3996..aae2d28d 100644 --- a/lib/src/asset_loader.dart +++ b/lib/src/asset_loader.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:developer'; import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; @@ -45,13 +44,15 @@ class RootBundleAssetLoader extends AssetLoader { if (value is String && value.startsWith(':/')) { String filePath = value.substring(2); - Map linkedJson = - json.decode(await rootBundle.loadString(_getLinkedLocalePath(basePath, filePath, locale))); - fullJson.addAll({key: linkedJson}); + value = json.decode(await rootBundle.loadString(_getLinkedLocalePath(basePath, filePath, locale))); + } + + if (value is Map) { + fullJson[key] = await _getLinkedTranslationFileDataFromBaseJson(basePath, locale, value); continue; } - fullJson[key] = baseJson[key]; + fullJson[key] = value; } return fullJson; From abd38f82f1a3fdf03d959407a22613b81e268986 Mon Sep 17 00:00:00 2001 From: Nicolas Javed Date: Tue, 19 Aug 2025 10:44:26 +0200 Subject: [PATCH 05/10] feat: Added duplicate file loading detection --- lib/src/asset_loader.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/src/asset_loader.dart b/lib/src/asset_loader.dart index aae2d28d..64bd2e7f 100644 --- a/lib/src/asset_loader.dart +++ b/lib/src/asset_loader.dart @@ -35,7 +35,8 @@ class RootBundleAssetLoader extends AssetLoader { } Future> _getLinkedTranslationFileDataFromBaseJson( - String basePath, Locale locale, Map baseJson) async { + String basePath, Locale locale, Map baseJson, + {List fileLoaded = const []}) async { Map fullJson = {}; for (var entry in baseJson.entries) { @@ -44,11 +45,18 @@ class RootBundleAssetLoader extends AssetLoader { if (value is String && value.startsWith(':/')) { String filePath = value.substring(2); + + if (fileLoaded.contains(filePath)) { + throw Exception('Circular reference detected: $filePath is loaded multiple times'); + } + + fileLoaded.add(filePath); value = json.decode(await rootBundle.loadString(_getLinkedLocalePath(basePath, filePath, locale))); } if (value is Map) { - fullJson[key] = await _getLinkedTranslationFileDataFromBaseJson(basePath, locale, value); + fullJson[key] = + await _getLinkedTranslationFileDataFromBaseJson(basePath, locale, value, fileLoaded: fileLoaded); continue; } From a7e5a95c49c7932e3e42cbadf5cbc9b4d880eae3 Mon Sep 17 00:00:00 2001 From: Nicolas Javed Date: Tue, 19 Aug 2025 11:15:34 +0200 Subject: [PATCH 06/10] feat: restored WidgetTester.idle() in test Was needed for test to pass, everything is working as intended. iddle is probably need be cause de loading is slightly longer --- lib/src/asset_loader.dart | 7 +- test/easy_localization_context_test.dart | 38 ++---- test/easy_localization_widget_test.dart | 162 +++++++++-------------- 3 files changed, 71 insertions(+), 136 deletions(-) diff --git a/lib/src/asset_loader.dart b/lib/src/asset_loader.dart index 64bd2e7f..4bb45c4e 100644 --- a/lib/src/asset_loader.dart +++ b/lib/src/asset_loader.dart @@ -37,7 +37,7 @@ class RootBundleAssetLoader extends AssetLoader { Future> _getLinkedTranslationFileDataFromBaseJson( String basePath, Locale locale, Map baseJson, {List fileLoaded = const []}) async { - Map fullJson = {}; + Map fullJson = Map.from(baseJson); for (var entry in baseJson.entries) { var key = entry.key; @@ -57,10 +57,7 @@ class RootBundleAssetLoader extends AssetLoader { if (value is Map) { fullJson[key] = await _getLinkedTranslationFileDataFromBaseJson(basePath, locale, value, fileLoaded: fileLoaded); - continue; } - - fullJson[key] = value; } return fullJson; @@ -72,6 +69,6 @@ class RootBundleAssetLoader extends AssetLoader { EasyLocalization.logger.debug('Load asset from $path'); Map baseJson = json.decode(await rootBundle.loadString(localePath)); - return _getLinkedTranslationFileDataFromBaseJson(path, locale, baseJson); + return await _getLinkedTranslationFileDataFromBaseJson(path, locale, baseJson); } } diff --git a/test/easy_localization_context_test.dart b/test/easy_localization_context_test.dart index c0348a97..ac42d9d0 100644 --- a/test/easy_localization_context_test.dart +++ b/test/easy_localization_context_test.dart @@ -87,9 +87,7 @@ void main() async { saveLocale: false, useOnlyLangCode: true, // fallbackLocale:Locale('en') , - supportedLocales: const [ - Locale('ar') - ], // Locale('en', 'US'), Locale('ar','DZ') + supportedLocales: const [Locale('ar')], // Locale('en', 'US'), Locale('ar','DZ') child: const MyApp(), )); // await tester.idle(); @@ -117,10 +115,7 @@ void main() async { await tester.pumpWidget(EasyLocalization( path: '../../i18n', // fallbackLocale:Locale('en') , - supportedLocales: const [ - Locale('en', 'US'), - Locale('ar', 'DZ') - ], // Locale('en', 'US'), Locale('ar','DZ') + supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], // Locale('en', 'US'), Locale('ar','DZ') child: const MyApp(), )); // await tester.idle(); @@ -140,13 +135,10 @@ void main() async { await tester.pumpWidget(EasyLocalization( path: '../../i18n', // fallbackLocale:Locale('en') , - supportedLocales: const [ - Locale('en', 'US'), - Locale('ar', 'DZ') - ], // Locale('en', 'US'), Locale('ar','DZ') + supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], // Locale('en', 'US'), Locale('ar','DZ') child: const MyApp(), )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); @@ -161,13 +153,10 @@ void main() async { await tester.runAsync(() async { await tester.pumpWidget(EasyLocalization( path: '../../i18n', - supportedLocales: const [ - Locale('en', 'US'), - Locale('ar', 'DZ') - ], // Locale('en', 'US'), Locale('ar','DZ') + supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], // Locale('en', 'US'), Locale('ar','DZ') child: const MyApp(), )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); @@ -182,10 +171,7 @@ void main() async { await tester.runAsync(() async { await tester.pumpWidget(EasyLocalization( path: '../../i18n', - supportedLocales: const [ - Locale('en', 'US'), - Locale('ar', 'DZ') - ], // Locale('en', 'US'), Locale('ar','DZ') + supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], // Locale('en', 'US'), Locale('ar','DZ') startLocale: const Locale('ar', 'DZ'), child: const MyApp(), )); @@ -208,10 +194,7 @@ void main() async { await tester.runAsync(() async { await tester.pumpWidget(EasyLocalization( path: '../../i18n', - supportedLocales: const [ - Locale('en', 'US'), - Locale('ar', 'DZ') - ], // Locale('en', 'US'), Locale('ar','DZ') + supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], // Locale('en', 'US'), Locale('ar','DZ') child: const MyApp(), )); await tester.idle(); @@ -229,10 +212,7 @@ void main() async { await tester.runAsync(() async { await tester.pumpWidget(EasyLocalization( path: '../../i18n', - supportedLocales: const [ - Locale('en', 'US'), - Locale('ar', 'DZ') - ], // Locale('en', 'US'), Locale('ar','DZ') + supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], // Locale('en', 'US'), Locale('ar','DZ') startLocale: const Locale('ar', 'DZ'), child: const MyApp(), )); diff --git a/test/easy_localization_widget_test.dart b/test/easy_localization_widget_test.dart index b7963029..542cb335 100644 --- a/test/easy_localization_widget_test.dart +++ b/test/easy_localization_widget_test.dart @@ -88,15 +88,14 @@ void main() async { assetLoader: const JsonAssetLoader(), child: const MyApp(), )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); expect(Localization.of(_context), isInstanceOf()); expect(Localization.instance, isInstanceOf()); expect(Localization.instance, Localization.of(_context)); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en', 'US')]); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en', 'US')]); expect(EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); final trFinder = find.text('test'); @@ -121,12 +120,11 @@ void main() async { supportedLocales: const [Locale('en', 'US')], child: const MyApp(), )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en', 'US')]); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en', 'US')]); expect(EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); final trFinder = find.text('test_en-US'); @@ -147,12 +145,11 @@ void main() async { supportedLocales: const [Locale('en', 'US')], child: const MyApp(), )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en', 'US')]); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en', 'US')]); expect(EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); final trFinder = find.text('test_en-US'); @@ -173,11 +170,10 @@ void main() async { supportedLocales: const [Locale('en', 'US')], child: const MyApp(), )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - final trFinder = - find.byWidgetPredicate((widget) => widget is ErrorWidget); + final trFinder = find.byWidgetPredicate((widget) => widget is ErrorWidget); expect(trFinder, findsOneWidget); await tester.pump(); }); @@ -192,12 +188,11 @@ void main() async { supportedLocales: const [Locale('en', 'US')], child: const MyApp(), )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en', 'US')]); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en', 'US')]); expect(EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); var l = const Locale('en', 'US'); @@ -232,13 +227,12 @@ void main() async { supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], child: const MyApp(), )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); expect(Localization.of(_context), isInstanceOf()); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en', 'US'), const Locale('ar', 'DZ')]); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en', 'US'), const Locale('ar', 'DZ')]); expect(EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); var trFinder = find.text('test_en-US'); @@ -255,23 +249,22 @@ void main() async { l = const Locale('ar', 'DZ'); await EasyLocalization.of(_context)!.setLocale(l); - // await tester.idle(); + await tester.idle(); await tester.pump(); expect(EasyLocalization.of(_context)!.locale, l); l = const Locale('en', 'US'); await EasyLocalization.of(_context)!.setLocale(l); - // await tester.idle(); + await tester.idle(); await tester.pump(); expect(EasyLocalization.of(_context)!.locale, l); l = const Locale('en', 'UK'); - expect(() async => {await EasyLocalization.of(_context)!.setLocale(l)}, - throwsAssertionError); + expect(() async => {await EasyLocalization.of(_context)!.setLocale(l)}, throwsAssertionError); l = const Locale('ar', 'DZ'); await EasyLocalization.of(_context)!.setLocale(l); - // await tester.idle(); + await tester.idle(); await tester.pump(); expect(EasyLocalization.of(_context)!.locale, l); }); @@ -289,12 +282,11 @@ void main() async { child: const MyApp(), )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - await EasyLocalization.of(_context)! - .setLocale(const Locale('ar', 'DZ')); + await EasyLocalization.of(_context)!.setLocale(const Locale('ar', 'DZ')); await tester.pump(); @@ -333,12 +325,11 @@ void main() async { supportedLocales: const [Locale('en'), Locale('ar')], child: const MyApp(), // Locale('en', 'US'), Locale('ar','DZ') )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en'), const Locale('ar')]); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en'), const Locale('ar')]); expect(EasyLocalization.of(_context)!.locale, const Locale('en')); var l = const Locale('en'); @@ -359,12 +350,11 @@ void main() async { supportedLocales: const [Locale('en'), Locale('ar')], child: const MyApp(), // Locale('en', 'US'), Locale('ar','DZ') )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en'), const Locale('ar')]); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en'), const Locale('ar')]); expect(EasyLocalization.of(_context)!.locale, const Locale('en')); var l = const Locale('en'); @@ -386,15 +376,13 @@ void main() async { fallbackLocale: const Locale('ar'), child: const MyApp(), )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('ar')]); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('ar')]); expect(EasyLocalization.of(_context)!.locale, const Locale('ar')); - expect( - EasyLocalization.of(_context)!.fallbackLocale, const Locale('ar')); + expect(EasyLocalization.of(_context)!.fallbackLocale, const Locale('ar')); }); }, ); @@ -411,12 +399,11 @@ void main() async { supportedLocales: const [Locale('ar')], child: const MyApp(), // Locale('en', 'US'), Locale('ar','DZ') )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('ar')]); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('ar')]); expect(EasyLocalization.of(_context)!.locale, const Locale('ar')); expect(EasyLocalization.of(_context)!.fallbackLocale, null); }); @@ -440,13 +427,12 @@ void main() async { supportedLocales: const [Locale('en'), Locale('ar')], child: const MyApp(), // )); - // await tester.idle(); + await tester.idle(); await tester.pump(const Duration(seconds: 2)); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en'), const Locale('ar')]); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en'), const Locale('ar')]); expect(EasyLocalization.of(_context)!.locale, const Locale('en')); expect(EasyLocalization.of(_context)!.fallbackLocale, null); }); @@ -462,15 +448,13 @@ void main() async { supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], child: const MyApp(), // )); - // await tester.idle(); + await tester.idle(); await tester.pump(const Duration(seconds: 2)); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en', 'US'), const Locale('ar', 'DZ')]); - expect( - EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en', 'US'), const Locale('ar', 'DZ')]); + expect(EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); expect(EasyLocalization.of(_context)!.fallbackLocale, null); }); }, @@ -486,15 +470,13 @@ void main() async { supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], child: const MyApp(), // )); - // await tester.idle(); + await tester.idle(); await tester.pump(const Duration(seconds: 2)); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en', 'US'), const Locale('ar', 'DZ')]); - expect( - EasyLocalization.of(_context)!.locale, const Locale('ar', 'DZ')); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en', 'US'), const Locale('ar', 'DZ')]); + expect(EasyLocalization.of(_context)!.locale, const Locale('ar', 'DZ')); expect(EasyLocalization.of(_context)!.fallbackLocale, null); }); }, @@ -521,12 +503,11 @@ void main() async { supportedLocales: const [Locale('en'), Locale('ar')], child: const MyApp(), // Locale('en', 'US'), Locale('ar','DZ') )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en'), const Locale('ar')]); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en'), const Locale('ar')]); expect(EasyLocalization.of(_context)!.locale, const Locale('ar')); expect(EasyLocalization.of(_context)!.fallbackLocale, null); }); @@ -553,14 +534,12 @@ void main() async { supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], child: const MyApp(), // Locale('en', 'US'), Locale('ar','DZ') )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en', 'US'), const Locale('ar', 'DZ')]); - expect( - EasyLocalization.of(_context)!.locale, const Locale('ar', 'DZ')); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en', 'US'), const Locale('ar', 'DZ')]); + expect(EasyLocalization.of(_context)!.locale, const Locale('ar', 'DZ')); expect(EasyLocalization.of(_context)!.fallbackLocale, null); }); }, @@ -577,17 +556,14 @@ void main() async { supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], child: const MyApp(), // Locale('en', 'US'), Locale('ar','DZ') )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.supportedLocales, - [const Locale('en', 'US'), const Locale('ar', 'DZ')]); - expect( - EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); + expect(EasyLocalization.of(_context)!.supportedLocales, [const Locale('en', 'US'), const Locale('ar', 'DZ')]); + expect(EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); - await EasyLocalization.of(_context)! - .setLocale(const Locale('en', 'US')); + await EasyLocalization.of(_context)!.setLocale(const Locale('en', 'US')); }); }, ); @@ -609,12 +585,11 @@ void main() async { supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], child: const MyApp(), // Locale('en', 'US'), Locale('ar','DZ') )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect( - EasyLocalization.of(_context)!.locale, const Locale('ar', 'DZ')); + expect(EasyLocalization.of(_context)!.locale, const Locale('ar', 'DZ')); await EasyLocalization.of(_context)!.deleteSaveLocale(); }); }, @@ -630,12 +605,11 @@ void main() async { supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], child: const MyApp(), // Locale('en', 'US'), Locale('ar','DZ') )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect( - EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); + expect(EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); }); }, ); @@ -649,12 +623,11 @@ void main() async { supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], child: const MyApp(), // Locale('en', 'US'), Locale('ar','DZ') )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect(EasyLocalization.of(_context)!.deviceLocale.toString(), - Platform.localeName); + expect(EasyLocalization.of(_context)!.deviceLocale.toString(), Platform.localeName); }); }, ); @@ -665,24 +638,19 @@ void main() async { await tester.runAsync(() async { await tester.pumpWidget(EasyLocalization( path: '../../i18n', - supportedLocales: const [ - Locale('en', 'US'), - Locale('ar', 'DZ') - ], // Locale('en', 'US'), Locale('ar','DZ') + supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], // Locale('en', 'US'), Locale('ar','DZ') startLocale: const Locale('ar', 'DZ'), child: const MyApp(), )); - // await tester.idle(); + await tester.idle(); // The async delegator load will require build on the next frame. Thus, pump await tester.pump(); - expect( - EasyLocalization.of(_context)!.locale, const Locale('ar', 'DZ')); + expect(EasyLocalization.of(_context)!.locale, const Locale('ar', 'DZ')); // reset to device locale await _context.resetLocale(); await tester.pump(); - expect( - EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); + expect(EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); }); }, ); @@ -700,8 +668,7 @@ void main() async { // The async delegator load will require build on the next frame. Thus, pump await tester.pumpAndSettle(); - expect(EasyLocalization.of(_context)!.deviceLocale.toString(), - Platform.localeName); + expect(EasyLocalization.of(_context)!.deviceLocale.toString(), Platform.localeName); }); }, ); @@ -712,10 +679,7 @@ void main() async { await tester.runAsync(() async { await tester.pumpWidget(EasyLocalization( path: '../../i18n', - supportedLocales: const [ - Locale('en', 'US'), - Locale('ar', 'DZ') - ], // Locale('en', 'US'), Locale('ar','DZ') + supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], // Locale('en', 'US'), Locale('ar','DZ') startLocale: const Locale('ar', 'DZ'), child: const MyApp(), )); @@ -723,13 +687,11 @@ void main() async { // The async delegator load will require build on the next frame. Thus, pump await tester.pumpAndSettle(); - expect( - EasyLocalization.of(_context)!.locale, const Locale('ar', 'DZ')); + expect(EasyLocalization.of(_context)!.locale, const Locale('ar', 'DZ')); // reset to device locale await _context.resetLocale(); await tester.pumpAndSettle(); - expect( - EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); + expect(EasyLocalization.of(_context)!.locale, const Locale('en', 'US')); }); }, ); @@ -738,10 +700,7 @@ void main() async { group('Context extensions tests', () { final testWidget = EasyLocalization( path: '../../i18n', - supportedLocales: const [ - Locale('en', 'US'), - Locale('ar', 'DZ') - ], // Locale('en', 'US'), Locale('ar','DZ') + supportedLocales: const [Locale('en', 'US'), Locale('ar', 'DZ')], // Locale('en', 'US'), Locale('ar','DZ') startLocale: const Locale('en', 'US'), child: const MyApp( child: MyLocalizedWidget(), @@ -821,8 +780,7 @@ void main() async { true, ); expect( - initialPluralValue != _contextPluralValue && - _contextPluralValue == expectedArPluralTextWidgetValue, + initialPluralValue != _contextPluralValue && _contextPluralValue == expectedArPluralTextWidgetValue, true, ); }); From ed4d755c9ecc7956530062b42f534c00cfff3762 Mon Sep 17 00:00:00 2001 From: Nicolas Javed Date: Tue, 19 Aug 2025 12:04:40 +0200 Subject: [PATCH 07/10] chore: max linked depth --- lib/src/asset_loader.dart | 71 ++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/lib/src/asset_loader.dart b/lib/src/asset_loader.dart index 4bb45c4e..24223a12 100644 --- a/lib/src/asset_loader.dart +++ b/lib/src/asset_loader.dart @@ -24,6 +24,9 @@ abstract class AssetLoader { /// default used is RootBundleAssetLoader which uses flutter's assetloader /// class RootBundleAssetLoader extends AssetLoader { + // Place inside class RootBundleAssetLoader + static const int _maxLinkedDepth = 32; + const RootBundleAssetLoader(); String getLocalePath(String basePath, Locale locale) { @@ -35,28 +38,64 @@ class RootBundleAssetLoader extends AssetLoader { } Future> _getLinkedTranslationFileDataFromBaseJson( - String basePath, Locale locale, Map baseJson, - {List fileLoaded = const []}) async { - Map fullJson = Map.from(baseJson); + String basePath, + Locale locale, + Map baseJson, { + required Set visited, + required Map> cache, + int depth = 0, + }) async { + if (depth > _maxLinkedDepth) { + throw StateError('Maximum linked files depth ($_maxLinkedDepth) exceeded for $locale at $basePath.'); + } + + final Map fullJson = Map.from(baseJson); - for (var entry in baseJson.entries) { - var key = entry.key; + for (final entry in baseJson.entries) { + final key = entry.key; var value = entry.value; if (value is String && value.startsWith(':/')) { - String filePath = value.substring(2); + final rawPath = value.substring(2).trim(); + // Normalize and reject traversal + final normalizedPath = rawPath.replaceAll(RegExp(r'^[\\/]+'), ''); + if (normalizedPath.contains('..')) { + throw FormatException('Invalid linked file path "$rawPath" for key "$key".'); + } + final linkedAssetPath = _getLinkedLocalePath(basePath, normalizedPath, locale); - if (fileLoaded.contains(filePath)) { - throw Exception('Circular reference detected: $filePath is loaded multiple times'); + if (visited.contains(linkedAssetPath)) { + throw StateError('Cyclic linked files detected at "$linkedAssetPath" (key: "$key").'); } - fileLoaded.add(filePath); - value = json.decode(await rootBundle.loadString(_getLinkedLocalePath(basePath, filePath, locale))); + final Map linkedJson = cache[linkedAssetPath] ?? + (cache[linkedAssetPath] = + (json.decode(await rootBundle.loadString(linkedAssetPath)) as Map)); + + visited.add(linkedAssetPath); + try { + value = await _getLinkedTranslationFileDataFromBaseJson( + basePath, + locale, + linkedJson, + visited: visited, + cache: cache, + depth: depth + 1, + ); + } finally { + visited.remove(linkedAssetPath); + } } if (value is Map) { - fullJson[key] = - await _getLinkedTranslationFileDataFromBaseJson(basePath, locale, value, fileLoaded: fileLoaded); + fullJson[key] = await _getLinkedTranslationFileDataFromBaseJson( + basePath, + locale, + value, + visited: visited, + cache: cache, + depth: depth + 1, + ); } } @@ -69,6 +108,12 @@ class RootBundleAssetLoader extends AssetLoader { EasyLocalization.logger.debug('Load asset from $path'); Map baseJson = json.decode(await rootBundle.loadString(localePath)); - return await _getLinkedTranslationFileDataFromBaseJson(path, locale, baseJson); + return await _getLinkedTranslationFileDataFromBaseJson( + path, + locale, + baseJson, + visited: {}, + cache: >{}, + ); } } From 255057d8d591586a8a6bc51b09714c8943f224ef Mon Sep 17 00:00:00 2001 From: Nicolas Javed Date: Wed, 20 Aug 2025 10:49:03 +0200 Subject: [PATCH 08/10] test: Added test for linked files Adds tests for linked files, including error handling for cyclic dependencies and missing files. Includes a fix to consider the `useOnlyLangCode` flag. --- i18n/en-cyclic.json | 4 + i18n/en-cyclic/cycle_file1.json | 4 + i18n/en-cyclic/cycle_file2.json | 4 + i18n/en-linked.json | 19 ++ i18n/en-linked/deep/level1.json | 4 + i18n/en-linked/deep/level2.json | 4 + i18n/en-linked/errors.json | 5 + i18n/en-linked/multi_errors.json | 5 + i18n/en-linked/multi_validation.json | 6 + i18n/en-linked/nested/messages.json | 5 + i18n/en-linked/validation.json | 6 + i18n/en-missing.json | 4 + i18n/en.json | 9 +- i18n/en/hats.json | 9 + test/asset_loader_linked_files_test.dart | 219 +++++++++++++++++++++++ 15 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 i18n/en-cyclic.json create mode 100644 i18n/en-cyclic/cycle_file1.json create mode 100644 i18n/en-cyclic/cycle_file2.json create mode 100644 i18n/en-linked.json create mode 100644 i18n/en-linked/deep/level1.json create mode 100644 i18n/en-linked/deep/level2.json create mode 100644 i18n/en-linked/errors.json create mode 100644 i18n/en-linked/multi_errors.json create mode 100644 i18n/en-linked/multi_validation.json create mode 100644 i18n/en-linked/nested/messages.json create mode 100644 i18n/en-linked/validation.json create mode 100644 i18n/en-missing.json create mode 100644 i18n/en/hats.json create mode 100644 test/asset_loader_linked_files_test.dart diff --git a/i18n/en-cyclic.json b/i18n/en-cyclic.json new file mode 100644 index 00000000..61918b72 --- /dev/null +++ b/i18n/en-cyclic.json @@ -0,0 +1,4 @@ +{ + "test": "cyclic_test", + "cycle_start": ":/cycle_file1.json" +} diff --git a/i18n/en-cyclic/cycle_file1.json b/i18n/en-cyclic/cycle_file1.json new file mode 100644 index 00000000..e8cca1e3 --- /dev/null +++ b/i18n/en-cyclic/cycle_file1.json @@ -0,0 +1,4 @@ +{ + "file1_value": "This is file 1", + "link_to_file2": ":/cycle_file2.json" +} diff --git a/i18n/en-cyclic/cycle_file2.json b/i18n/en-cyclic/cycle_file2.json new file mode 100644 index 00000000..4209fbdc --- /dev/null +++ b/i18n/en-cyclic/cycle_file2.json @@ -0,0 +1,4 @@ +{ + "file2_value": "This is file 2", + "link_back_to_file1": ":/cycle_file1.json" +} diff --git a/i18n/en-linked.json b/i18n/en-linked.json new file mode 100644 index 00000000..d2e655c1 --- /dev/null +++ b/i18n/en-linked.json @@ -0,0 +1,19 @@ +{ + "test": "test_linked_en", + "hello": "Hello", + "app": { + "name": "Test App", + "errors": ":/errors.json" + }, + "validation": ":/validation.json", + "nested": { + "module": { + "messages": ":/nested/messages.json" + } + }, + "multiple": { + "errors": ":/multi_errors.json", + "validation": ":/multi_validation.json" + }, + "deep_nested": ":/deep/level1.json" +} diff --git a/i18n/en-linked/deep/level1.json b/i18n/en-linked/deep/level1.json new file mode 100644 index 00000000..89536e2f --- /dev/null +++ b/i18n/en-linked/deep/level1.json @@ -0,0 +1,4 @@ +{ + "level1_value": "This is level 1", + "level2": ":/deep/level2.json" +} diff --git a/i18n/en-linked/deep/level2.json b/i18n/en-linked/deep/level2.json new file mode 100644 index 00000000..3a7fb86c --- /dev/null +++ b/i18n/en-linked/deep/level2.json @@ -0,0 +1,4 @@ +{ + "level2_value": "This is level 2", + "final_message": "Deep nesting works!" +} diff --git a/i18n/en-linked/errors.json b/i18n/en-linked/errors.json new file mode 100644 index 00000000..ac43fb9a --- /dev/null +++ b/i18n/en-linked/errors.json @@ -0,0 +1,5 @@ +{ + "not_found": "Resource not found", + "server_error": "Internal server error", + "invalid_input": "Invalid input provided" +} diff --git a/i18n/en-linked/multi_errors.json b/i18n/en-linked/multi_errors.json new file mode 100644 index 00000000..d8041c66 --- /dev/null +++ b/i18n/en-linked/multi_errors.json @@ -0,0 +1,5 @@ +{ + "not_found": "Multiple resource not found", + "server_error": "Multiple internal server error", + "invalid_input": "Multiple invalid input provided" +} diff --git a/i18n/en-linked/multi_validation.json b/i18n/en-linked/multi_validation.json new file mode 100644 index 00000000..025252de --- /dev/null +++ b/i18n/en-linked/multi_validation.json @@ -0,0 +1,6 @@ +{ + "required": "Multiple field is required", + "email": "Multiple valid email address required", + "min_length": "Multiple minimum length is {min} characters", + "max_length": "Multiple maximum length is {max} characters" +} diff --git a/i18n/en-linked/nested/messages.json b/i18n/en-linked/nested/messages.json new file mode 100644 index 00000000..fc0b0800 --- /dev/null +++ b/i18n/en-linked/nested/messages.json @@ -0,0 +1,5 @@ +{ + "welcome": "Welcome to our app", + "goodbye": "Thank you for using our app", + "info": "This is a nested message file" +} diff --git a/i18n/en-linked/validation.json b/i18n/en-linked/validation.json new file mode 100644 index 00000000..b08589bf --- /dev/null +++ b/i18n/en-linked/validation.json @@ -0,0 +1,6 @@ +{ + "required": "This field is required", + "email": "Please enter a valid email address", + "min_length": "Minimum length is {min} characters", + "max_length": "Maximum length is {max} characters" +} diff --git a/i18n/en-missing.json b/i18n/en-missing.json new file mode 100644 index 00000000..5ba1d1ee --- /dev/null +++ b/i18n/en-missing.json @@ -0,0 +1,4 @@ +{ + "test": "missing_file_test", + "missing": ":/nonexistent.json" +} diff --git a/i18n/en.json b/i18n/en.json index a52297a8..cee70471 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -8,14 +8,7 @@ "many": "{} many days", "other": "{} other days" }, - "hat": { - "zero": "no hats", - "one": "one hat", - "two": "two hats", - "few": "few hats", - "many": "many hats", - "other": "other hats" - }, + "hats": ":/hats.json", "hat_other": { "other": "other hats" } diff --git a/i18n/en/hats.json b/i18n/en/hats.json new file mode 100644 index 00000000..6b7a4177 --- /dev/null +++ b/i18n/en/hats.json @@ -0,0 +1,9 @@ +{ + "zero": "no hats", + "one": "one hat", + "two": "two hats", + "few": "few hats", + "many": "many hats", + "other": "other hats" + +} \ No newline at end of file diff --git a/test/asset_loader_linked_files_test.dart b/test/asset_loader_linked_files_test.dart new file mode 100644 index 00000000..7fa766f7 --- /dev/null +++ b/test/asset_loader_linked_files_test.dart @@ -0,0 +1,219 @@ +import 'dart:developer'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:easy_localization/src/easy_localization_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() async { + // Initialize the test environment + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + + group('Asset Loader - Linked Translation Files', () { + group('RootBundleAssetLoader with linked files', () { + test('should load single linked file', () async { + final controller = EasyLocalizationController( + forceLocale: const Locale('en', 'linked'), + path: '../../i18n', + supportedLocales: const [Locale('en', 'linked')], + useOnlyLangCode: false, + useFallbackTranslations: false, + saveLocale: false, + onLoadError: (FlutterError e) { + log(e.toString()); + }, + assetLoader: const RootBundleAssetLoader(), + ); + + await controller.loadTranslations(); + final result = await controller.loadTranslationData(const Locale('en', 'linked')); + + expect(result['hello'], 'Hello'); + expect(result['app']['name'], 'Test App'); + expect(result['app']['errors']['not_found'], 'Resource not found'); + expect(result['app']['errors']['server_error'], 'Internal server error'); + expect(result['app']['errors']['invalid_input'], 'Invalid input provided'); + }); + + test('should load multiple linked files', () async { + final controller = EasyLocalizationController( + forceLocale: const Locale('en', 'linked'), + path: '../../i18n', + supportedLocales: const [Locale('en', 'linked')], + useOnlyLangCode: false, + useFallbackTranslations: false, + saveLocale: false, + onLoadError: (FlutterError e) { + log(e.toString()); + }, + assetLoader: const RootBundleAssetLoader(), + ); + + await controller.loadTranslations(); + final result = await controller.loadTranslationData(const Locale('en', 'linked')); + + // Check validation linked file + expect(result['validation']['required'], 'This field is required'); + expect(result['validation']['email'], 'Please enter a valid email address'); + expect(result['validation']['min_length'], 'Minimum length is {min} characters'); + + // Check multiple references to same file work + expect(result['multiple']['errors']['not_found'], 'Multiple resource not found'); + expect(result['multiple']['validation']['required'], 'Multiple field is required'); + }); + + test('should load nested linked files', () async { + final controller = EasyLocalizationController( + forceLocale: const Locale('en', 'linked'), + path: '../../i18n', + supportedLocales: const [Locale('en', 'linked')], + useOnlyLangCode: false, + useFallbackTranslations: false, + saveLocale: false, + onLoadError: (FlutterError e) { + log(e.toString()); + }, + assetLoader: const RootBundleAssetLoader(), + ); + + await controller.loadTranslations(); + final result = await controller.loadTranslationData(const Locale('en', 'linked')); + + // Check nested folder structure + expect(result['nested']['module']['messages']['welcome'], 'Welcome to our app'); + expect(result['nested']['module']['messages']['goodbye'], 'Thank you for using our app'); + expect(result['nested']['module']['messages']['info'], 'This is a nested message file'); + }); + + test('should load deeply nested linked files', () async { + final controller = EasyLocalizationController( + forceLocale: const Locale('en', 'linked'), + path: '../../i18n', + supportedLocales: const [Locale('en', 'linked')], + useOnlyLangCode: false, + useFallbackTranslations: false, + saveLocale: false, + onLoadError: (FlutterError e) { + log(e.toString()); + }, + assetLoader: const RootBundleAssetLoader(), + ); + + await controller.loadTranslations(); + final result = await controller.loadTranslationData(const Locale('en', 'linked')); + + // Check deep nesting (file linking to another file) + expect(result['deep_nested']['level1_value'], 'This is level 1'); + expect(result['deep_nested']['level2']['level2_value'], 'This is level 2'); + expect(result['deep_nested']['level2']['final_message'], 'Deep nesting works!'); + }); + + test('should preserve original structure with linked files', () async { + final controller = EasyLocalizationController( + forceLocale: const Locale('en', 'linked'), + path: '../../i18n', + supportedLocales: const [Locale('en', 'linked')], + useOnlyLangCode: false, + useFallbackTranslations: false, + saveLocale: false, + onLoadError: (FlutterError e) { + log(e.toString()); + }, + assetLoader: const RootBundleAssetLoader(), + ); + + await controller.loadTranslations(); + final result = await controller.loadTranslationData(const Locale('en', 'linked')); + + // Verify that the original structure is preserved + expect(result['test'], 'test_linked_en'); + expect(result['hello'], 'Hello'); + expect(result.containsKey('app'), true); + expect(result['app'].containsKey('name'), true); + expect(result['app'].containsKey('errors'), true); + + // The linked reference should be replaced with actual content + expect(result['app']['errors'] is Map, true); + expect(result['app']['errors'] is String, false); + }); + }); + + group('Error handling for linked files', () { + test('should throw error for cyclic linked files', () async { + final controller = EasyLocalizationController( + forceLocale: const Locale('en', 'cyclic'), + path: '../../i18n', + supportedLocales: const [Locale('en', 'cyclic')], + useOnlyLangCode: false, + useFallbackTranslations: false, + saveLocale: false, + onLoadError: (FlutterError e) { + // Don't just log, rethrow the error so we can catch it in tests + throw e; + }, + assetLoader: const RootBundleAssetLoader(), + ); + + try { + await controller.loadTranslations(); + fail('Expected StateError to be thrown'); + } catch (e) { + expect(e, isA()); + expect(e.toString(), contains('Cyclic linked files detected')); + } + }); + + test('should throw error for missing linked file', () async { + final controller = EasyLocalizationController( + forceLocale: const Locale('en', 'missing'), + path: '../../i18n', + supportedLocales: const [Locale('en', 'missing')], + useOnlyLangCode: false, + useFallbackTranslations: false, + saveLocale: false, + onLoadError: (FlutterError e) { + // Don't just log, rethrow the error so we can catch it in tests + throw e; + }, + assetLoader: const RootBundleAssetLoader(), + ); + + try { + await controller.loadTranslations(); + fail('Expected FlutterError to be thrown'); + } catch (e) { + expect(e, isA()); + } + }); + }); + + group('Edge cases for linked files', () { + test('should work with useOnlyLangCode setting', () async { + // Test with a simple locale using useOnlyLangCode + final controller = EasyLocalizationController( + forceLocale: const Locale('en'), + path: '../../i18n', + supportedLocales: const [Locale('en')], + useOnlyLangCode: true, + useFallbackTranslations: false, + saveLocale: false, + onLoadError: (FlutterError e) { + log(e.toString()); + }, + assetLoader: const RootBundleAssetLoader(), + ); + + await controller.loadTranslations(); + final result = await controller.loadTranslationData(const Locale('en')); + + // Should successfully load the en.json file + expect(result['test'], 'test_en'); + expect(result.containsKey('hats'), true); + expect((result['hats'] as Map).containsKey('zero'), true); + }); + }); + }); +} From 02aac2353a64bf41ccefe21ede3753f7d0e18910 Mon Sep 17 00:00:00 2001 From: Nicolas Javed Date: Mon, 25 Aug 2025 10:22:45 +0200 Subject: [PATCH 09/10] chore: Added LinkedFileResolver to asbtract linked file parsing Fixed: audit being broken after link file changes --- README.md | 4 +- bin/audit/audit_command.dart | 28 ++- debug_linked.dart | 0 example/lib/generated/codegen_loader.g.dart | 63 ++---- lib/easy_localization.dart | 2 + lib/src/asset_loader.dart | 94 ++------- lib/src/easy_localization_app.dart | 18 +- lib/src/file_loaders/file_loader.dart | 5 + lib/src/file_loaders/io_file_loader.dart | 17 ++ .../file_loaders/root_bundle_file_loader.dart | 12 ++ lib/src/linked_file_resolver.dart | 98 +++++++++ test/asset_loader_linked_files_test.dart | 32 +-- test/easy_localization_test.dart | 197 ++++++------------ test/utils/test_asset_loaders.dart | 59 +++--- test_audit.dart | 0 15 files changed, 304 insertions(+), 325 deletions(-) create mode 100644 debug_linked.dart create mode 100644 lib/src/file_loaders/file_loader.dart create mode 100644 lib/src/file_loaders/io_file_loader.dart create mode 100644 lib/src/file_loaders/root_bundle_file_loader.dart create mode 100644 lib/src/linked_file_resolver.dart create mode 100644 test_audit.dart diff --git a/README.md b/README.md index 7ba2ffc8..90f59686 100644 --- a/README.md +++ b/README.md @@ -411,8 +411,6 @@ print('example.emptyNameError'.tr()); //Output: Please fill in your full name ### 🔥 Linked files: -> ⚠ This is only available for the default asset loader (on Json Files). - You can split translations for a single locale into multiple files by using linked files. This helps keep your JSON clean and maintainable. To link an external file, set the key’s value to a path prefixed with `:/`, relative to your translations directory. For example, with default path `assets/translations` and locale `en-US`: @@ -435,7 +433,7 @@ assets └── notifications.json ``` -Each linked file must contain a valid JSON object of translation keys. +Each linked file must contain a valid object of translation keys (of the file type you are using [Other file types](#-loading-translations-from-other-resources)). Don't forget to add your linked files (or linked files folder, here assets/translations/en-US/), to your pubspec.yaml : [See installation](#-installation). diff --git a/bin/audit/audit_command.dart b/bin/audit/audit_command.dart index 0f5af8db..e6548fe7 100644 --- a/bin/audit/audit_command.dart +++ b/bin/audit/audit_command.dart @@ -1,10 +1,11 @@ import 'dart:convert'; import 'dart:io'; - +import 'package:easy_localization/src/linked_file_resolver.dart'; import 'package:path/path.dart'; +import '../../lib/src/file_loaders/io_file_loader.dart'; class AuditCommand { - void run({required String transDir, required String srcDir}) { + void run({required String transDir, required String srcDir}) async { try { final translationDir = Directory(transDir); final sourceDir = Directory(srcDir); @@ -19,7 +20,7 @@ class AuditCommand { return; } - final allTranslations = _loadTranslations(translationDir); + final allTranslations = await _loadTranslations(translationDir); final usedKeys = _scanSourceForKeys(sourceDir); _report(allTranslations, usedKeys); @@ -31,15 +32,30 @@ class AuditCommand { /// Walks [translationsDir], reads every `.json`, flattens nested maps /// into dot‑separated keys, and returns a map: /// { 'en': {'home.title', 'home.subtitle', …}, 'fr': { … } } - Map> _loadTranslations(Directory translationsDir) { + /// Also handles linked translation files (those containing ':/file.json' references) + Future>> _loadTranslations(Directory translationsDir) async { final result = >{}; + const IOFileLoader fileLoader = IOFileLoader(); + const LinkedFileResolver linkedFileResolver = JsonLinkedFileResolver(fileLoader: fileLoader); + for (var file in translationsDir.listSync().whereType()) { if (!file.path.endsWith('.json')) continue; try { - final langCode = basenameWithoutExtension(file.path); + final local = basenameWithoutExtension(file.path); + final langCode = local.split('-').first; + final hasCountryCode = local.split('-').length > 1; + final countryCode = hasCountryCode ? local.split('-').last : null; final jsonMap = json.decode(file.readAsStringSync()) as Map; - result[langCode] = _flatten(jsonMap); + + // Process linked files if present using the shared resolver + final resolvedJson = await linkedFileResolver.resolveLinkedFiles( + basePath: translationsDir.path, + languageCode: langCode, + baseJson: jsonMap, + countryCode: countryCode, + ); + result[local] = _flatten(resolvedJson); } catch (e) { stderr.writeln('Error reading ${file.path}: $e'); } diff --git a/debug_linked.dart b/debug_linked.dart new file mode 100644 index 00000000..e69de29b diff --git a/example/lib/generated/codegen_loader.g.dart b/example/lib/generated/codegen_loader.g.dart index 0a52c173..16559d42 100644 --- a/example/lib/generated/codegen_loader.g.dart +++ b/example/lib/generated/codegen_loader.g.dart @@ -4,10 +4,11 @@ import 'dart:ui'; -import 'package:easy_localization/easy_localization.dart' show AssetLoader; +import 'package:easy_localization/easy_localization.dart' + show AssetLoader, JsonLinkedFileResolver, RootBundleFileLoader; class CodegenLoader extends AssetLoader { - const CodegenLoader(); + const CodegenLoader() : super(linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader())); @override Future> load(String fullPath, Locale locale) { @@ -20,11 +21,7 @@ class CodegenLoader extends AssetLoader { "msg_named": "{} مكتوبة باللغة {lang}", "clickMe": "إضغط هنا", "profile": { - "reset_password": { - "label": "اعادة تعين كلمة السر", - "username": "المستخدم", - "password": "كلمة السر" - } + "reset_password": {"label": "اعادة تعين كلمة السر", "username": "المستخدم", "password": "كلمة السر"} }, "clicked": { "zero": "لم تنقر بعد!", @@ -55,11 +52,7 @@ class CodegenLoader extends AssetLoader { "msg_named": "{} مكتوبة باللغة {lang}", "clickMe": "إضغط هنا", "profile": { - "reset_password": { - "label": "اعادة تعين كلمة السر", - "username": "المستخدم", - "password": "كلمة السر" - } + "reset_password": {"label": "اعادة تعين كلمة السر", "username": "المستخدم", "password": "كلمة السر"} }, "clicked": { "zero": "لم تنقر بعد!", @@ -90,11 +83,7 @@ class CodegenLoader extends AssetLoader { "msg_named": "{} ist in {lang} geschrieben", "clickMe": "Click mich", "profile": { - "reset_password": { - "label": "Password zurücksetzten", - "username": "Name", - "password": "Password" - } + "reset_password": {"label": "Password zurücksetzten", "username": "Name", "password": "Password"} }, "clicked": { "zero": "Du hast {} mal geklickt", @@ -125,11 +114,7 @@ class CodegenLoader extends AssetLoader { "msg_named": "{} ist in {lang} geschrieben", "clickMe": "Click mich", "profile": { - "reset_password": { - "label": "Password zurücksetzten", - "username": "Name", - "password": "Password" - } + "reset_password": {"label": "Password zurücksetzten", "username": "Name", "password": "Password"} }, "clicked": { "zero": "Du hast {} mal geklickt", @@ -160,11 +145,7 @@ class CodegenLoader extends AssetLoader { "msg_named": "{} are written in the {lang} language", "clickMe": "Click me", "profile": { - "reset_password": { - "label": "Reset Password", - "username": "Username", - "password": "password" - } + "reset_password": {"label": "Reset Password", "username": "Username", "password": "password"} }, "clicked": { "zero": "You clicked {} times!", @@ -195,11 +176,7 @@ class CodegenLoader extends AssetLoader { "msg_named": "{} are written in the {lang} language", "clickMe": "Click me", "profile": { - "reset_password": { - "label": "Reset Password", - "username": "Username", - "password": "password" - } + "reset_password": {"label": "Reset Password", "username": "Username", "password": "password"} }, "clicked": { "zero": "You clicked {} times!", @@ -230,11 +207,7 @@ class CodegenLoader extends AssetLoader { "msg_named": "{} написан на языке {lang}", "clickMe": "Нажми на меня", "profile": { - "reset_password": { - "label": "Сбросить пароль", - "username": "Логин", - "password": "Пароль" - } + "reset_password": {"label": "Сбросить пароль", "username": "Логин", "password": "Пароль"} }, "clicked": { "zero": "Ты кликнул {} раз!", @@ -255,10 +228,7 @@ class CodegenLoader extends AssetLoader { "gender": { "male": "Привет мужык ;) ", "female": "Привет девчуля :)", - "with_arg": { - "male": "Привет мужык ;) {}", - "female": "Привет девчуля :) {}" - } + "with_arg": {"male": "Привет мужык ;) {}", "female": "Привет девчуля :) {}"} }, "reset_locale": "Сбросить язык" }; @@ -268,11 +238,7 @@ class CodegenLoader extends AssetLoader { "msg_named": "{} написан на языке {lang}", "clickMe": "Нажми на меня", "profile": { - "reset_password": { - "label": "Сбросить пароль", - "username": "Логин", - "password": "Пароль" - } + "reset_password": {"label": "Сбросить пароль", "username": "Логин", "password": "Пароль"} }, "clicked": { "zero": "Ты кликнул {} раз!", @@ -293,10 +259,7 @@ class CodegenLoader extends AssetLoader { "gender": { "male": "Привет мужык ;) ", "female": "Привет девчуля :)", - "with_arg": { - "male": "Привет мужык ;) {}", - "female": "Привет девчуля :) {}" - } + "with_arg": {"male": "Привет мужык ;) {}", "female": "Привет девчуля :) {}"} }, "reset_locale": "Сбросить язык" }; diff --git a/lib/easy_localization.dart b/lib/easy_localization.dart index 5a5b458b..8c5836cc 100644 --- a/lib/easy_localization.dart +++ b/lib/easy_localization.dart @@ -4,4 +4,6 @@ export 'package:easy_localization/src/easy_localization_app.dart'; export 'package:easy_localization/src/asset_loader.dart'; export 'package:easy_localization/src/public.dart'; export 'package:easy_localization/src/public_ext.dart'; +export 'package:easy_localization/src/linked_file_resolver.dart'; +export 'package:easy_localization/src/file_loaders/root_bundle_file_loader.dart'; export 'package:intl/intl.dart'; diff --git a/lib/src/asset_loader.dart b/lib/src/asset_loader.dart index e235b968..bc7ef4f9 100644 --- a/lib/src/asset_loader.dart +++ b/lib/src/asset_loader.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:ui'; - import 'package:easy_localization/easy_localization.dart'; +import 'package:easy_localization/src/file_loaders/io_file_loader.dart'; import 'package:flutter/services.dart'; /// abstract class used to building your Custom AssetLoader @@ -16,7 +16,11 @@ import 'package:flutter/services.dart'; ///} /// ``` abstract class AssetLoader { - const AssetLoader(); + // Place inside class RootBundleAssetLoader + final LinkedFileResolver linkedFileResolver; + + const AssetLoader({required this.linkedFileResolver}); + Future?> load(String path, Locale locale); } @@ -24,89 +28,31 @@ abstract class AssetLoader { /// default used is RootBundleAssetLoader which uses flutter's assetloader /// class RootBundleAssetLoader extends AssetLoader { - // Place inside class RootBundleAssetLoader - static const int _maxLinkedDepth = 32; + const RootBundleAssetLoader({LinkedFileResolver? linkedFileResolver}) + : super( + linkedFileResolver: linkedFileResolver ?? const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader())); - const RootBundleAssetLoader(); + factory RootBundleAssetLoader.fromIOFile() { + return const RootBundleAssetLoader( + linkedFileResolver: JsonLinkedFileResolver(fileLoader: IOFileLoader()), + ); + } String getLocalePath(String basePath, Locale locale) { return '$basePath/${locale.toStringWithSeparator(separator: "-")}.json'; } - String _getLinkedLocalePath(String basePath, String filePath, Locale locale) { - return '$basePath/${locale.toStringWithSeparator(separator: "-")}/$filePath'; - } - - Future> _getLinkedTranslationFileDataFromBaseJson( - String basePath, - Locale locale, - Map baseJson, { - required Set visited, - int depth = 0, - }) async { - if (depth > _maxLinkedDepth) { - throw StateError('Maximum linked files depth ($_maxLinkedDepth) exceeded for $locale at $basePath.'); - } - - final Map fullJson = Map.from(baseJson); - - for (final entry in baseJson.entries) { - final key = entry.key; - var value = entry.value; - - if (value is String && value.startsWith(':/')) { - final rawPath = value.substring(2).trim(); - final linkedAssetPath = _getLinkedLocalePath(basePath, rawPath, locale); - - if (visited.contains(linkedAssetPath)) { - throw StateError('Cyclic linked files detected at "$linkedAssetPath" (key: "$key").'); - } - - final Map linkedJson = - json.decode(await rootBundle.loadString(linkedAssetPath)) as Map; - - visited.add(linkedAssetPath); - try { - final resolved = await _getLinkedTranslationFileDataFromBaseJson( - basePath, - locale, - linkedJson, - visited: visited, - depth: depth + 1, - ); - fullJson[key] = resolved; - } catch (e) { - throw StateError( - 'Error resolving linked file "$linkedAssetPath" for key "$key": $e', - ); - } - } - - if (value is Map) { - fullJson[key] = await _getLinkedTranslationFileDataFromBaseJson( - basePath, - locale, - value, - visited: visited, - depth: depth + 1, - ); - } - } - - return fullJson; - } - @override Future?> load(String path, Locale locale) async { var localePath = getLocalePath(path, locale); EasyLocalization.logger.debug('Load asset from $path'); - Map baseJson = json.decode(await rootBundle.loadString(localePath)); - return await _getLinkedTranslationFileDataFromBaseJson( - path, - locale, - baseJson, - visited: {}, + Map baseJson = json.decode(await linkedFileResolver.fileLoader.loadString(localePath)); + return await linkedFileResolver.resolveLinkedFiles( + basePath: path, + languageCode: locale.languageCode, + countryCode: locale.countryCode, + baseJson: baseJson, ); } } diff --git a/lib/src/easy_localization_app.dart b/lib/src/easy_localization_app.dart index f3858b63..a9ba1316 100644 --- a/lib/src/easy_localization_app.dart +++ b/lib/src/easy_localization_app.dart @@ -152,14 +152,12 @@ class EasyLocalization extends StatefulWidget { _EasyLocalizationState createState() => _EasyLocalizationState(); // ignore: library_private_types_in_public_api - static _EasyLocalizationProvider? of(BuildContext context) => - _EasyLocalizationProvider.of(context); + static _EasyLocalizationProvider? of(BuildContext context) => _EasyLocalizationProvider.of(context); /// ensureInitialized needs to be called in main /// so that savedLocale is loaded and used from the /// start. - static Future ensureInitialized() async => - await EasyLocalizationController.initEasyLocation(); + static Future ensureInitialized() async => await EasyLocalizationController.initEasyLocation(); /// Customizable logger static EasyLogger logger = EasyLogger(name: '🌎 Easy Localization'); @@ -216,8 +214,7 @@ class _EasyLocalizationState extends State { delegate: _EasyLocalizationDelegate( localizationController: localizationController, supportedLocales: widget.supportedLocales, - useFallbackTranslationsForEmptyResources: - widget.useFallbackTranslationsForEmptyResources, + useFallbackTranslationsForEmptyResources: widget.useFallbackTranslationsForEmptyResources, ignorePluralRules: widget.ignorePluralRules, ), ); @@ -253,8 +250,7 @@ class _EasyLocalizationProvider extends InheritedWidget { // _EasyLocalizationDelegate get delegate => parent.delegate; - _EasyLocalizationProvider(this.parent, this._localeState, - {Key? key, required this.delegate}) + _EasyLocalizationProvider(this.parent, this._localeState, {Key? key, required this.delegate}) : currentLocale = _localeState.locale, _translationsLoaded = _localeState.translations != null, super(key: key, child: parent.child) { @@ -292,8 +288,7 @@ class _EasyLocalizationProvider extends InheritedWidget { @override bool updateShouldNotify(_EasyLocalizationProvider oldWidget) { - return oldWidget.currentLocale != locale - || oldWidget._translationsLoaded != _translationsLoaded; + return oldWidget.currentLocale != locale || oldWidget._translationsLoaded != _translationsLoaded; } static _EasyLocalizationProvider? of(BuildContext context) => @@ -332,8 +327,7 @@ class _EasyLocalizationDelegate extends LocalizationsDelegate { value, translations: localizationController!.translations, fallbackTranslations: localizationController!.fallbackTranslations, - useFallbackTranslationsForEmptyResources: - useFallbackTranslationsForEmptyResources, + useFallbackTranslationsForEmptyResources: useFallbackTranslationsForEmptyResources, ignorePluralRules: ignorePluralRules, ); return Future.value(Localization.instance); diff --git a/lib/src/file_loaders/file_loader.dart b/lib/src/file_loaders/file_loader.dart new file mode 100644 index 00000000..822b1623 --- /dev/null +++ b/lib/src/file_loaders/file_loader.dart @@ -0,0 +1,5 @@ +/// Abstract file loader interface to allow different implementations +/// for Flutter runtime (using rootBundle) and CLI (using dart:io) +abstract class FileLoader { + Future loadString(String path); +} diff --git a/lib/src/file_loaders/io_file_loader.dart b/lib/src/file_loaders/io_file_loader.dart new file mode 100644 index 00000000..62275994 --- /dev/null +++ b/lib/src/file_loaders/io_file_loader.dart @@ -0,0 +1,17 @@ +import 'dart:io'; + +import 'package:easy_localization/src/file_loaders/file_loader.dart'; + +/// File loader implementation for Dart CLI applications using dart:io +class IOFileLoader implements FileLoader { + const IOFileLoader(); + + @override + Future loadString(String path) async { + final file = File(path); + if (!file.existsSync()) { + throw FileSystemException('File not found', path); + } + return file.readAsString(); + } +} diff --git a/lib/src/file_loaders/root_bundle_file_loader.dart b/lib/src/file_loaders/root_bundle_file_loader.dart new file mode 100644 index 00000000..31e418c7 --- /dev/null +++ b/lib/src/file_loaders/root_bundle_file_loader.dart @@ -0,0 +1,12 @@ +import 'package:easy_localization/src/file_loaders/file_loader.dart'; +import 'package:flutter/services.dart'; + +/// File loader implementation for Flutter applications using rootBundle +class RootBundleFileLoader implements FileLoader { + const RootBundleFileLoader(); + + @override + Future loadString(String path) async { + return rootBundle.loadString(path); + } +} diff --git a/lib/src/linked_file_resolver.dart b/lib/src/linked_file_resolver.dart new file mode 100644 index 00000000..302a05c2 --- /dev/null +++ b/lib/src/linked_file_resolver.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:easy_localization/src/file_loaders/file_loader.dart'; + +/// Resolves linked translation files by loading referenced files and merging them +/// into the base JSON structure. Handles the ':/filename.json' syntax used in linked files. + +abstract class LinkedFileResolver { + final int maxLinkedDepth = 32; + final FileLoader fileLoader; + + const LinkedFileResolver({required this.fileLoader}); + + Future> resolveLinkedFiles({ + required String basePath, + required String languageCode, + required Map baseJson, + Set? visited, + int depth = 0, + String? countryCode, + }); + + String getLinkedLocalePath(String basePath, String filePath, String languageCode, {String? countryCode}) { + if (countryCode != null) { + return '$basePath/$languageCode-$countryCode/$filePath'; + } + + return '$basePath/$languageCode/$filePath'; + } +} + +class JsonLinkedFileResolver extends LinkedFileResolver { + const JsonLinkedFileResolver({required FileLoader fileLoader}) : super(fileLoader: fileLoader); + + @override + Future> resolveLinkedFiles({ + required String basePath, + required String languageCode, + required Map baseJson, + Set? visited, + int depth = 0, + String? countryCode, + }) async { + visited ??= {}; + + if (depth > maxLinkedDepth) { + throw StateError('Maximum linked files depth ($maxLinkedDepth) exceeded for $languageCode at $basePath.'); + } + + final Map fullJson = Map.from(baseJson); + + for (final entry in baseJson.entries) { + final key = entry.key; + var value = entry.value; + + if (value is String && value.startsWith(':/')) { + final rawPath = value.substring(2).trim(); + final linkedAssetPath = getLinkedLocalePath(basePath, rawPath, languageCode, countryCode: countryCode); + + if (visited.contains(linkedAssetPath)) { + throw StateError('Cyclic linked files detected at "$linkedAssetPath" (key: "$key").'); + } + + try { + final linkedContent = await fileLoader.loadString(linkedAssetPath); + final Map linkedJson = json.decode(linkedContent) as Map; + + visited.add(linkedAssetPath); + + final resolved = await resolveLinkedFiles( + basePath: basePath, + languageCode: languageCode, + baseJson: linkedJson, + visited: visited, + depth: depth + 1, + countryCode: countryCode, + ); + fullJson[key] = resolved; + } catch (e) { + throw StateError( + 'Error resolving linked file "$linkedAssetPath" for key "$key": $e', + ); + } + } else if (value is Map) { + fullJson[key] = await resolveLinkedFiles( + basePath: basePath, + languageCode: languageCode, + baseJson: value, + visited: visited, + depth: depth + 1, + countryCode: countryCode, + ); + } + } + + return fullJson; + } +} diff --git a/test/asset_loader_linked_files_test.dart b/test/asset_loader_linked_files_test.dart index 7fa766f7..fb98dfcd 100644 --- a/test/asset_loader_linked_files_test.dart +++ b/test/asset_loader_linked_files_test.dart @@ -17,7 +17,7 @@ void main() async { test('should load single linked file', () async { final controller = EasyLocalizationController( forceLocale: const Locale('en', 'linked'), - path: '../../i18n', + path: 'i18n', supportedLocales: const [Locale('en', 'linked')], useOnlyLangCode: false, useFallbackTranslations: false, @@ -25,7 +25,7 @@ void main() async { onLoadError: (FlutterError e) { log(e.toString()); }, - assetLoader: const RootBundleAssetLoader(), + assetLoader: RootBundleAssetLoader.fromIOFile(), ); await controller.loadTranslations(); @@ -41,7 +41,7 @@ void main() async { test('should load multiple linked files', () async { final controller = EasyLocalizationController( forceLocale: const Locale('en', 'linked'), - path: '../../i18n', + path: 'i18n', supportedLocales: const [Locale('en', 'linked')], useOnlyLangCode: false, useFallbackTranslations: false, @@ -49,7 +49,7 @@ void main() async { onLoadError: (FlutterError e) { log(e.toString()); }, - assetLoader: const RootBundleAssetLoader(), + assetLoader: RootBundleAssetLoader.fromIOFile(), ); await controller.loadTranslations(); @@ -68,7 +68,7 @@ void main() async { test('should load nested linked files', () async { final controller = EasyLocalizationController( forceLocale: const Locale('en', 'linked'), - path: '../../i18n', + path: 'i18n', supportedLocales: const [Locale('en', 'linked')], useOnlyLangCode: false, useFallbackTranslations: false, @@ -76,7 +76,7 @@ void main() async { onLoadError: (FlutterError e) { log(e.toString()); }, - assetLoader: const RootBundleAssetLoader(), + assetLoader: RootBundleAssetLoader.fromIOFile(), ); await controller.loadTranslations(); @@ -91,7 +91,7 @@ void main() async { test('should load deeply nested linked files', () async { final controller = EasyLocalizationController( forceLocale: const Locale('en', 'linked'), - path: '../../i18n', + path: 'i18n', supportedLocales: const [Locale('en', 'linked')], useOnlyLangCode: false, useFallbackTranslations: false, @@ -99,7 +99,7 @@ void main() async { onLoadError: (FlutterError e) { log(e.toString()); }, - assetLoader: const RootBundleAssetLoader(), + assetLoader: RootBundleAssetLoader.fromIOFile(), ); await controller.loadTranslations(); @@ -114,7 +114,7 @@ void main() async { test('should preserve original structure with linked files', () async { final controller = EasyLocalizationController( forceLocale: const Locale('en', 'linked'), - path: '../../i18n', + path: 'i18n', supportedLocales: const [Locale('en', 'linked')], useOnlyLangCode: false, useFallbackTranslations: false, @@ -122,7 +122,7 @@ void main() async { onLoadError: (FlutterError e) { log(e.toString()); }, - assetLoader: const RootBundleAssetLoader(), + assetLoader: RootBundleAssetLoader.fromIOFile(), ); await controller.loadTranslations(); @@ -145,7 +145,7 @@ void main() async { test('should throw error for cyclic linked files', () async { final controller = EasyLocalizationController( forceLocale: const Locale('en', 'cyclic'), - path: '../../i18n', + path: 'i18n', supportedLocales: const [Locale('en', 'cyclic')], useOnlyLangCode: false, useFallbackTranslations: false, @@ -154,7 +154,7 @@ void main() async { // Don't just log, rethrow the error so we can catch it in tests throw e; }, - assetLoader: const RootBundleAssetLoader(), + assetLoader: RootBundleAssetLoader.fromIOFile(), ); try { @@ -169,7 +169,7 @@ void main() async { test('should throw error for missing linked file', () async { final controller = EasyLocalizationController( forceLocale: const Locale('en', 'missing'), - path: '../../i18n', + path: 'i18n', supportedLocales: const [Locale('en', 'missing')], useOnlyLangCode: false, useFallbackTranslations: false, @@ -178,7 +178,7 @@ void main() async { // Don't just log, rethrow the error so we can catch it in tests throw e; }, - assetLoader: const RootBundleAssetLoader(), + assetLoader: RootBundleAssetLoader.fromIOFile(), ); try { @@ -195,7 +195,7 @@ void main() async { // Test with a simple locale using useOnlyLangCode final controller = EasyLocalizationController( forceLocale: const Locale('en'), - path: '../../i18n', + path: 'i18n', supportedLocales: const [Locale('en')], useOnlyLangCode: true, useFallbackTranslations: false, @@ -203,7 +203,7 @@ void main() async { onLoadError: (FlutterError e) { log(e.toString()); }, - assetLoader: const RootBundleAssetLoader(), + assetLoader: RootBundleAssetLoader.fromIOFile(), ); await controller.loadTranslations(); diff --git a/test/easy_localization_test.dart b/test/easy_localization_test.dart index 3cf311fa..c4522585 100644 --- a/test/easy_localization_test.dart +++ b/test/easy_localization_test.dart @@ -57,28 +57,19 @@ void main() { }); test('load() succeeds', () async { - expect( - Localization.load(const Locale('en'), translations: r1.translations), - true); + expect(Localization.load(const Locale('en'), translations: r1.translations), true); }); test('load() with fallback succeeds', () async { expect( - Localization.load(const Locale('en'), - translations: r1.translations, - fallbackTranslations: r2.translations), + Localization.load(const Locale('en'), translations: r1.translations, fallbackTranslations: r2.translations), true); }); - test('merge fallbackLocale with locale without country code succeeds', - () async { + test('merge fallbackLocale with locale without country code succeeds', () async { await EasyLocalizationController( forceLocale: const Locale('es', 'AR'), - supportedLocales: const [ - Locale('en'), - Locale('es'), - Locale('es', 'AR') - ], + supportedLocales: const [Locale('en'), Locale('es'), Locale('es', 'AR')], path: 'path/en-us.json', useOnlyLangCode: false, useFallbackTranslations: true, @@ -94,12 +85,9 @@ void main() { test('localeFromString() succeeds', () async { expect(const Locale('ar'), 'ar'.toLocale()); expect(const Locale('ar', 'DZ'), 'ar_DZ'.toLocale()); - expect(const Locale.fromSubtags(languageCode: 'ar', scriptCode: 'Arab'), - 'ar_Arab'.toLocale()); + expect(const Locale.fromSubtags(languageCode: 'ar', scriptCode: 'Arab'), 'ar_Arab'.toLocale()); expect( - const Locale.fromSubtags( - languageCode: 'ar', scriptCode: 'Arab', countryCode: 'DZ'), - 'ar_Arab_DZ'.toLocale()); + const Locale.fromSubtags(languageCode: 'ar', scriptCode: 'Arab', countryCode: 'DZ'), 'ar_Arab_DZ'.toLocale()); }); test('load() Failed assertion', () async { @@ -112,22 +100,15 @@ void main() { }); test('load() correctly sets locale path', () async { - expect( - Localization.load(const Locale('en'), translations: r1.translations), - true); + expect(Localization.load(const Locale('en'), translations: r1.translations), true); expect(Localization.instance.tr('path'), 'path/en.json'); }); test('load() respects useOnlyLangCode', () async { - expect( - Localization.load(const Locale('en'), translations: r1.translations), - true); + expect(Localization.load(const Locale('en'), translations: r1.translations), true); expect(Localization.instance.tr('path'), 'path/en.json'); - expect( - Localization.load(const Locale('en', 'us'), - translations: r2.translations), - true); + expect(Localization.load(const Locale('en', 'us'), translations: r2.translations), true); expect(Localization.instance.tr('path'), 'path/en-us.json'); }); @@ -154,8 +135,7 @@ void main() { }); /// E.g. if user saved a locale that was removed in a later version - test('controller loads fallback if saved locale is not supported', - () async { + test('controller loads fallback if saved locale is not supported', () async { SharedPreferences.setMockInitialValues({ 'locale': 'de', }); @@ -191,12 +171,9 @@ void main() { const zh = Locale('zh', ''); const zh2 = Locale('zh', ''); const zhCN = Locale('zh', 'CN'); - const zhHans = - Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'); - const zhHant = - Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); - const zhHansCN = Locale.fromSubtags( - languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'); + const zhHans = Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'); + const zhHant = Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); + const zhHansCN = Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'); expect(zh.supports(zhHansCN), isTrue); expect(zh2.supports(zhHansCN), isTrue); expect(zhCN.supports(zhHansCN), isTrue); @@ -208,25 +185,22 @@ void main() { test('select locale from device locale', () { const en = Locale('en', ''); const zh = Locale('zh', ''); - const zhHans = - Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'); - const zhHant = - Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); - const zhHansCN = Locale.fromSubtags( - languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'); + const zhHans = Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'); + const zhHant = Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); + const zhHansCN = Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'); expect( EasyLocalizationController.selectLocaleFrom([en, zh], zhHansCN), zh, ); expect( - EasyLocalizationController.selectLocaleFrom( - [zhHant, zhHans], zhHansCN), + EasyLocalizationController.selectLocaleFrom([zhHant, zhHans], zhHansCN), zhHans, ); }); - test('select best lenguage match if no perfect match exists', () { // #674 + test('select best lenguage match if no perfect match exists', () { + // #674 const userDeviceLocale = Locale('en', 'FR'); const supportedLocale1 = Locale('en', 'US'); const supportedLocale2 = Locale('zh', 'CN'); @@ -241,7 +215,8 @@ void main() { ); }); - test('select perfect match if exists', () { // #674 + test('select perfect match if exists', () { + // #674 const userDeviceLocale = Locale('en', 'GB'); const supportedLocale1 = Locale('en', 'US'); const supportedLocale2 = userDeviceLocale; @@ -274,8 +249,7 @@ void main() { setUpAll(() async { await r.loadTranslations(); Localization.load(const Locale('en'), - translations: r.translations, - fallbackTranslations: r.fallbackTranslations); + translations: r.translations, fallbackTranslations: r.fallbackTranslations); }); test('finds and returns resource', () { expect(Localization.instance.tr('test'), 'test'); @@ -313,34 +287,25 @@ void main() { }); test('can resolve linked locale messages and apply modifiers', () { - expect(Localization.instance.tr('linkAndModify'), - 'this is linked and MODIFIED'); + expect(Localization.instance.tr('linkAndModify'), 'this is linked and MODIFIED'); }); - test('can resolve multiple linked locale messages and apply modifiers', - () { + test('can resolve multiple linked locale messages and apply modifiers', () { expect(Localization.instance.tr('linkMany'), 'many Locale messages'); }); test('can resolve linked locale messages with brackets', () { - expect(Localization.instance.tr('linkedWithBrackets'), - 'linked with brackets.'); + expect(Localization.instance.tr('linkedWithBrackets'), 'linked with brackets.'); }); test('can resolve any number of nested arguments', () { - expect( - Localization.instance - .tr('nestedArguments', args: ['a', 'argument', '!']), - 'this is a nested argument!'); + expect(Localization.instance.tr('nestedArguments', args: ['a', 'argument', '!']), 'this is a nested argument!'); }); test('can resolve nested named arguments', () { expect( - Localization.instance.tr('nestedNamedArguments', namedArgs: { - 'firstArg': 'this', - 'secondArg': 'named argument', - 'thirdArg': '!' - }), + Localization.instance.tr('nestedNamedArguments', + namedArgs: {'firstArg': 'this', 'secondArg': 'named argument', 'thirdArg': '!'}), 'this is a nested named argument!'); }); @@ -353,11 +318,9 @@ void main() { expect(Localization.instance.tr('test_missing'), 'test_missing'); final logIterator = printLog.iterator; logIterator.moveNext(); - expect(logIterator.current, - contains('Localization key [test_missing] not found')); + expect(logIterator.current, contains('Localization key [test_missing] not found')); logIterator.moveNext(); - expect(logIterator.current, - contains('Fallback localization key [test_missing] not found')); + expect(logIterator.current, contains('Fallback localization key [test_missing] not found')); })); test('uses fallback translations', overridePrint(() { @@ -368,8 +331,7 @@ void main() { test('reports missing resource with fallback', overridePrint(() { printLog = []; expect(Localization.instance.tr('test_missing_fallback'), 'fallback!'); - expect(printLog.first, - contains('Localization key [test_missing_fallback] not found')); + expect(printLog.first, contains('Localization key [test_missing_fallback] not found')); })); test('uses empty translation, not using fallback', overridePrint(() { @@ -386,8 +348,7 @@ void main() { test('returns resource and replaces argument in any nest level', () { expect( - Localization.instance - .tr('nested.super.duper.nested_with_arg', args: ['what a nest']), + Localization.instance.tr('nested.super.duper.nested_with_arg', args: ['what a nest']), 'nested.super.duper.nested_with_arg what a nest', ); }); @@ -399,25 +360,20 @@ void main() { ); }); - test( - 'should raise exception if provided arguments length is different from the count of {} in the resource', - () { + test('should raise exception if provided arguments length is different from the count of {} in the resource', () { // @TODO }); test('return resource and replaces named argument', () { expect( - Localization.instance.tr('test_replace_named', - namedArgs: {'arg1': 'one', 'arg2': 'two'}), + Localization.instance.tr('test_replace_named', namedArgs: {'arg1': 'one', 'arg2': 'two'}), 'test named replace one two', ); }); - test('returns resource and replaces named argument in any nest level', - () { + test('returns resource and replaces named argument in any nest level', () { expect( - Localization.instance.tr('nested.super.duper.nested_with_named_arg', - namedArgs: {'arg': 'what a nest'}), + Localization.instance.tr('nested.super.duper.nested_with_named_arg', namedArgs: {'arg': 'what a nest'}), 'nested.super.duper.nested_with_named_arg what a nest', ); }); @@ -435,13 +391,11 @@ void main() { test('gender returns the correct resource and replaces args', () { expect( - Localization.instance - .tr('gender_and_replace', gender: 'male', args: ['one']), + Localization.instance.tr('gender_and_replace', gender: 'male', args: ['one']), 'Hi one man ;)', ); expect( - Localization.instance - .tr('gender_and_replace', gender: 'female', args: ['one']), + Localization.instance.tr('gender_and_replace', gender: 'female', args: ['one']), 'Hello one girl :)', ); }); @@ -479,8 +433,7 @@ void main() { test('reports empty resource with fallback', overridePrint(() { printLog = []; expect(Localization.instance.tr('test_empty_fallback'), 'fallback!'); - expect(printLog.first, - contains('Localization key [test_empty_fallback] not found')); + expect(printLog.first, contains('Localization key [test_empty_fallback] not found')); })); test('reports empty resource', overridePrint(() { @@ -488,11 +441,9 @@ void main() { expect(Localization.instance.tr('test_empty'), 'test_empty'); final logIterator = printLog.iterator; logIterator.moveNext(); - expect(logIterator.current, - contains('Localization key [test_empty] not found')); + expect(logIterator.current, contains('Localization key [test_empty] not found')); logIterator.moveNext(); - expect(logIterator.current, - contains('Fallback localization key [test_empty] not found')); + expect(logIterator.current, contains('Fallback localization key [test_empty] not found')); })); }); @@ -513,8 +464,7 @@ void main() { setUpAll(() async { await r.loadTranslations(); Localization.load(const Locale('fb'), - translations: r.translations, - fallbackTranslations: r.fallbackTranslations); + translations: r.translations, fallbackTranslations: r.fallbackTranslations); }); test('zero', () { @@ -544,8 +494,7 @@ void main() { expect(Localization.instance.plural('hat_other', 1), 'other hats'); }); - test('two as fallback and fallback translations priority', - overridePrint(() { + test('two as fallback and fallback translations priority', overridePrint(() { printLog = []; expect( Localization.instance.plural('test_fallback_plurals', 2), @@ -554,66 +503,55 @@ void main() { expect(printLog, isEmpty); })); - test('two as fallback and fallback translations priority', - overridePrint(() { - printLog = []; - expect( - Localization.instance.plural('test_empty_fallback_plurals', 2), - '', - ); - expect(printLog, isEmpty); - })); + test('two as fallback and fallback translations priority', overridePrint(() { + printLog = []; + expect( + Localization.instance.plural('test_empty_fallback_plurals', 2), + '', + ); + expect(printLog, isEmpty); + })); test('with number format', () { - expect( - Localization.instance - .plural('day', 3, format: NumberFormat.currency()), - 'USD3.00 other days'); + expect(Localization.instance.plural('day', 3, format: NumberFormat.currency()), 'USD3.00 other days'); }); test('zero with args', () { - expect(Localization.instance.plural('money', 0, args: ['John', '0']), - 'John has no money'); + expect(Localization.instance.plural('money', 0, args: ['John', '0']), 'John has no money'); }); test('one with args', () { - expect(Localization.instance.plural('money', 1, args: ['John', '1']), - 'John has 1 dollar'); + expect(Localization.instance.plural('money', 1, args: ['John', '1']), 'John has 1 dollar'); }); test('other with args', () { - expect(Localization.instance.plural('money', 3, args: ['John', '3']), - 'John has 3 dollars'); + expect(Localization.instance.plural('money', 3, args: ['John', '3']), 'John has 3 dollars'); }); test('zero with named args', () { expect( - Localization.instance.plural('money_named_args', 0, - namedArgs: {'name': 'John', 'money': '0'}), + Localization.instance.plural('money_named_args', 0, namedArgs: {'name': 'John', 'money': '0'}), 'John has no money', ); }); test('one with named args', () { expect( - Localization.instance.plural('money_named_args', 1, - namedArgs: {'name': 'John', 'money': '1'}), + Localization.instance.plural('money_named_args', 1, namedArgs: {'name': 'John', 'money': '1'}), 'John has 1 dollar', ); }); test('other with named args', () { expect( - Localization.instance.plural('money_named_args', 3, - namedArgs: {'name': 'John', 'money': '3'}), + Localization.instance.plural('money_named_args', 3, namedArgs: {'name': 'John', 'money': '3'}), 'John has 3 dollars', ); }); test('named args and value name', () { expect( - Localization.instance.plural('money_named_args', 3, - namedArgs: {'name': 'John'}, name: 'money'), + Localization.instance.plural('money_named_args', 3, namedArgs: {'name': 'John'}, name: 'money'), 'John has 3 dollars', ); }); @@ -643,8 +581,7 @@ void main() { ); }); - test('two as fallback for empty resource and fallback translations priority', - overridePrint(() { + test('two as fallback for empty resource and fallback translations priority', overridePrint(() { printLog = []; expect( Localization.instance.plural('test_empty_fallback_plurals', 2), @@ -653,17 +590,13 @@ void main() { expect(printLog, isEmpty); })); - test('reports empty plural resource with fallback', - overridePrint(() { + test('reports empty plural resource with fallback', overridePrint(() { printLog = []; expect( Localization.instance.plural('test_empty_fallback_plurals', -1), 'fallback other', ); - expect( - printLog.first, - contains( - 'Localization key [test_empty_fallback_plurals.other] not found')); + expect(printLog.first, contains('Localization key [test_empty_fallback_plurals.other] not found')); })); test('reports empty plural resource', overridePrint(() { @@ -674,11 +607,9 @@ void main() { ); final logIterator = printLog.iterator; logIterator.moveNext(); - expect(logIterator.current, - contains('Localization key [test_empty_plurals.other] not found')); + expect(logIterator.current, contains('Localization key [test_empty_plurals.other] not found')); logIterator.moveNext(); - expect(logIterator.current, - contains('Fallback localization key [test_empty_plurals.other] not found')); + expect(logIterator.current, contains('Fallback localization key [test_empty_plurals.other] not found')); })); }); diff --git a/test/utils/test_asset_loaders.dart b/test/utils/test_asset_loaders.dart index d6c3e2d3..ed77ff7b 100644 --- a/test/utils/test_asset_loaders.dart +++ b/test/utils/test_asset_loaders.dart @@ -1,9 +1,11 @@ import 'dart:ui'; -import 'package:easy_localization/src/asset_loader.dart'; +import 'package:easy_localization/easy_localization.dart' + show AssetLoader, JsonLinkedFileResolver, RootBundleFileLoader; class ImmutableJsonAssetLoader extends AssetLoader { - const ImmutableJsonAssetLoader(); + const ImmutableJsonAssetLoader() + : super(linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader())); @override Future> load(String fullPath, Locale locale) { @@ -14,7 +16,7 @@ class ImmutableJsonAssetLoader extends AssetLoader { } class JsonAssetLoader extends AssetLoader { - const JsonAssetLoader(); + const JsonAssetLoader() : super(linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader())); @override Future> load(String fullPath, Locale locale) { @@ -25,10 +27,7 @@ class JsonAssetLoader extends AssetLoader { 'test_replace_two': 'test replace {} {}', 'test_replace_named': 'test named replace {arg1} {arg2}', 'gender': {'male': 'Hi man ;)', 'female': 'Hello girl :)'}, - 'gender_and_replace': { - 'male': 'Hi {} man ;)', - 'female': 'Hello {} girl :)' - }, + 'gender_and_replace': {'male': 'Hi {} man ;)', 'female': 'Hello {} girl :)'}, 'day': { 'zero': '{} days', 'one': '{} day', @@ -80,14 +79,12 @@ class JsonAssetLoader extends AssetLoader { 'duper': { 'nested': 'nested.super.duper.nested', 'nested_with_arg': 'nested.super.duper.nested_with_arg {}', - 'nested_with_named_arg': - 'nested.super.duper.nested_with_named_arg {arg}' + 'nested_with_named_arg': 'nested.super.duper.nested_with_named_arg {arg}' } } }, 'path': fullPath, - 'test_missing_fallback': - (locale.languageCode == 'fb' ? 'fallback!' : null), + 'test_missing_fallback': (locale.languageCode == 'fb' ? 'fallback!' : null), 'test_empty_fallback': (locale.languageCode == 'fb' ? 'fallback!' : ''), 'test_fallback_plurals': (locale.languageCode == 'fb' ? { @@ -121,31 +118,31 @@ class JsonAssetLoader extends AssetLoader { }), 'test_empty_plurals': (locale.languageCode == 'fb' ? { - 'zero': '', - 'one': '', - 'two': '', - 'few': '', - 'many': '', - 'other': '', - } + 'zero': '', + 'one': '', + 'two': '', + 'few': '', + 'many': '', + 'other': '', + } : { - 'zero': '', - 'one': '', - 'two': '', - 'few': '', - 'many': '', - 'other': '', - }) + 'zero': '', + 'one': '', + 'two': '', + 'few': '', + 'many': '', + 'other': '', + }) }); } } class ExternalAssetLoader extends AssetLoader { - const ExternalAssetLoader(); + const ExternalAssetLoader() + : super(linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader())); @override - Future> load(String fullPath, Locale locale) => - Future.value(const { + Future> load(String fullPath, Locale locale) => Future.value(const { 'package_value_01': 'package_value_01', 'package_value_02': 'package_value_02', 'package_value_03': 'package_value_03', @@ -153,11 +150,11 @@ class ExternalAssetLoader extends AssetLoader { } class NestedAssetLoader extends AssetLoader { - const NestedAssetLoader(); + const NestedAssetLoader() + : super(linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader())); @override - Future> load(String fullPath, Locale locale) => - Future.value({ + Future> load(String fullPath, Locale locale) => Future.value({ 'nested': { 'super': { 'duper': {'nested': 'nested.super.duper.nested'} diff --git a/test_audit.dart b/test_audit.dart new file mode 100644 index 00000000..e69de29b From 0cd09b1784d87bc29765ce5d1c8b6c9ff74afbc7 Mon Sep 17 00:00:00 2001 From: Nicolas Javed Date: Mon, 25 Aug 2025 10:45:47 +0200 Subject: [PATCH 10/10] chore: added file loader to asset bundle --- bin/audit/audit_command.dart | 2 +- example/lib/generated/codegen_loader.g.dart | 5 +++- lib/src/asset_loader.dart | 20 +++++++++++----- lib/src/easy_localization_app.dart | 5 +++- lib/src/linked_file_resolver.dart | 2 +- ...localization_extra_asset_loaders_test.dart | 24 +++++++------------ test/easy_localization_widget_test.dart | 2 +- test/utils/test_asset_loaders.dart | 21 +++++++++++----- 8 files changed, 48 insertions(+), 33 deletions(-) diff --git a/bin/audit/audit_command.dart b/bin/audit/audit_command.dart index 0830dd04..a7833154 100644 --- a/bin/audit/audit_command.dart +++ b/bin/audit/audit_command.dart @@ -5,7 +5,7 @@ import 'package:path/path.dart'; import 'package:easy_localization/src/file_loaders/io_file_loader.dart'; class AuditCommand { - void run({required String transDir, required String srcDir}) async { + Future run({required String transDir, required String srcDir}) async { try { final translationDir = Directory(transDir); final sourceDir = Directory(srcDir); diff --git a/example/lib/generated/codegen_loader.g.dart b/example/lib/generated/codegen_loader.g.dart index 16559d42..e89bfd27 100644 --- a/example/lib/generated/codegen_loader.g.dart +++ b/example/lib/generated/codegen_loader.g.dart @@ -8,7 +8,10 @@ import 'package:easy_localization/easy_localization.dart' show AssetLoader, JsonLinkedFileResolver, RootBundleFileLoader; class CodegenLoader extends AssetLoader { - const CodegenLoader() : super(linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader())); + const CodegenLoader() + : super( + linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader()), + fileLoader: const RootBundleFileLoader()); @override Future> load(String fullPath, Locale locale) { diff --git a/lib/src/asset_loader.dart b/lib/src/asset_loader.dart index bc7ef4f9..f92a1a54 100644 --- a/lib/src/asset_loader.dart +++ b/lib/src/asset_loader.dart @@ -1,8 +1,8 @@ import 'dart:convert'; import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; +import 'package:easy_localization/src/file_loaders/file_loader.dart'; import 'package:easy_localization/src/file_loaders/io_file_loader.dart'; -import 'package:flutter/services.dart'; /// abstract class used to building your Custom AssetLoader /// Example: @@ -17,9 +17,10 @@ import 'package:flutter/services.dart'; /// ``` abstract class AssetLoader { // Place inside class RootBundleAssetLoader + final FileLoader fileLoader; final LinkedFileResolver linkedFileResolver; - const AssetLoader({required this.linkedFileResolver}); + const AssetLoader({required this.linkedFileResolver, required this.fileLoader}); Future?> load(String path, Locale locale); } @@ -28,13 +29,20 @@ abstract class AssetLoader { /// default used is RootBundleAssetLoader which uses flutter's assetloader /// class RootBundleAssetLoader extends AssetLoader { - const RootBundleAssetLoader({LinkedFileResolver? linkedFileResolver}) - : super( - linkedFileResolver: linkedFileResolver ?? const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader())); + const RootBundleAssetLoader({required LinkedFileResolver linkedFileResolver, required FileLoader fileLoader}) + : super(linkedFileResolver: linkedFileResolver, fileLoader: fileLoader); + + factory RootBundleAssetLoader.fromRootBundle() { + return const RootBundleAssetLoader( + linkedFileResolver: JsonLinkedFileResolver(fileLoader: RootBundleFileLoader()), + fileLoader: RootBundleFileLoader(), + ); + } factory RootBundleAssetLoader.fromIOFile() { return const RootBundleAssetLoader( linkedFileResolver: JsonLinkedFileResolver(fileLoader: IOFileLoader()), + fileLoader: IOFileLoader(), ); } @@ -47,7 +55,7 @@ class RootBundleAssetLoader extends AssetLoader { var localePath = getLocalePath(path, locale); EasyLocalization.logger.debug('Load asset from $path'); - Map baseJson = json.decode(await linkedFileResolver.fileLoader.loadString(localePath)); + Map baseJson = json.decode(await fileLoader.loadString(localePath)); return await linkedFileResolver.resolveLinkedFiles( basePath: path, languageCode: locale.languageCode, diff --git a/lib/src/easy_localization_app.dart b/lib/src/easy_localization_app.dart index a9ba1316..bb3e583e 100644 --- a/lib/src/easy_localization_app.dart +++ b/lib/src/easy_localization_app.dart @@ -137,7 +137,10 @@ class EasyLocalization extends StatefulWidget { this.useFallbackTranslations = false, this.useFallbackTranslationsForEmptyResources = false, this.ignorePluralRules = true, - this.assetLoader = const RootBundleAssetLoader(), + this.assetLoader = const RootBundleAssetLoader( + fileLoader: RootBundleFileLoader(), + linkedFileResolver: JsonLinkedFileResolver(fileLoader: RootBundleFileLoader()), + ), this.extraAssetLoaders, this.saveLocale = true, this.errorWidget, diff --git a/lib/src/linked_file_resolver.dart b/lib/src/linked_file_resolver.dart index 302a05c2..c657519e 100644 --- a/lib/src/linked_file_resolver.dart +++ b/lib/src/linked_file_resolver.dart @@ -87,7 +87,7 @@ class JsonLinkedFileResolver extends LinkedFileResolver { languageCode: languageCode, baseJson: value, visited: visited, - depth: depth + 1, + depth: depth, countryCode: countryCode, ); } diff --git a/test/easy_localization_extra_asset_loaders_test.dart b/test/easy_localization_extra_asset_loaders_test.dart index 31fddcd9..1ca021eb 100644 --- a/test/easy_localization_extra_asset_loaders_test.dart +++ b/test/easy_localization_extra_asset_loaders_test.dart @@ -29,8 +29,7 @@ void main() { expect(result.entries.length, 1); }); - test('load assets from external loader and merge with asset loader', - () async { + test('load assets from external loader and merge with asset loader', () async { final EasyLocalizationController controller = EasyLocalizationController( forceLocale: const Locale('en'), path: 'path/en.json', @@ -42,11 +41,10 @@ void main() { log(e.toString()); }, assetLoader: const ImmutableJsonAssetLoader(), - extraAssetLoaders: [const ExternalAssetLoader()], + extraAssetLoaders: [ExternalAssetLoader()], ); - final Map result = - await controller.loadTranslationData(const Locale('en')); + final Map result = await controller.loadTranslationData(const Locale('en')); expect(result, { 'test': 'test', @@ -57,9 +55,7 @@ void main() { expect(result.entries.length, 4); }); - test( - 'load assets from external loader with nested translations and merge with asset loader', - () async { + test('load assets from external loader with nested translations and merge with asset loader', () async { final EasyLocalizationController controller = EasyLocalizationController( forceLocale: const Locale('en'), path: 'path/en.json', @@ -71,11 +67,10 @@ void main() { log(e.toString()); }, assetLoader: const ImmutableJsonAssetLoader(), - extraAssetLoaders: [const NestedAssetLoader()], + extraAssetLoaders: [NestedAssetLoader()], ); - final Map result = - await controller.loadTranslationData(const Locale('en')); + final Map result = await controller.loadTranslationData(const Locale('en')); expect(result, { 'test': 'test', @@ -90,9 +85,7 @@ void main() { expect(result.entries.length, 2); }); - test( - 'load assets from external loader and merge duplicates with asset loader', - () async { + test('load assets from external loader and merge duplicates with asset loader', () async { final EasyLocalizationController controller = EasyLocalizationController( forceLocale: const Locale('en'), path: 'path/en.json', @@ -107,8 +100,7 @@ void main() { extraAssetLoaders: [const ImmutableJsonAssetLoader()], ); - final Map result = - await controller.loadTranslationData(const Locale('en')); + final Map result = await controller.loadTranslationData(const Locale('en')); expect(result, {'test': 'test'}); expect(result.entries.length, 1); diff --git a/test/easy_localization_widget_test.dart b/test/easy_localization_widget_test.dart index 542cb335..d71996a6 100644 --- a/test/easy_localization_widget_test.dart +++ b/test/easy_localization_widget_test.dart @@ -116,7 +116,7 @@ void main() async { await tester.runAsync(() async { await tester.pumpWidget(EasyLocalization( path: '../../i18n', - assetLoader: const RootBundleAssetLoader(), + assetLoader: RootBundleAssetLoader.fromIOFile(), supportedLocales: const [Locale('en', 'US')], child: const MyApp(), )); diff --git a/test/utils/test_asset_loaders.dart b/test/utils/test_asset_loaders.dart index ed77ff7b..3523911d 100644 --- a/test/utils/test_asset_loaders.dart +++ b/test/utils/test_asset_loaders.dart @@ -5,7 +5,9 @@ import 'package:easy_localization/easy_localization.dart' class ImmutableJsonAssetLoader extends AssetLoader { const ImmutableJsonAssetLoader() - : super(linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader())); + : super( + linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader()), + fileLoader: const RootBundleFileLoader()); @override Future> load(String fullPath, Locale locale) { @@ -16,7 +18,10 @@ class ImmutableJsonAssetLoader extends AssetLoader { } class JsonAssetLoader extends AssetLoader { - const JsonAssetLoader() : super(linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader())); + const JsonAssetLoader() + : super( + linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader()), + fileLoader: const RootBundleFileLoader()); @override Future> load(String fullPath, Locale locale) { @@ -138,8 +143,10 @@ class JsonAssetLoader extends AssetLoader { } class ExternalAssetLoader extends AssetLoader { - const ExternalAssetLoader() - : super(linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader())); + ExternalAssetLoader() + : super( + linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader()), + fileLoader: const RootBundleFileLoader()); @override Future> load(String fullPath, Locale locale) => Future.value(const { @@ -150,8 +157,10 @@ class ExternalAssetLoader extends AssetLoader { } class NestedAssetLoader extends AssetLoader { - const NestedAssetLoader() - : super(linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader())); + NestedAssetLoader() + : super( + linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader()), + fileLoader: const RootBundleFileLoader()); @override Future> load(String fullPath, Locale locale) => Future.value({