master #81

Closed
malo wants to merge 322 commits from master into feat/bloc_layout/new-package
22 changed files with 510 additions and 429 deletions
Showing only changes of commit 6c7e561fde - Show all commits

View File

@ -23,28 +23,131 @@
<img src="https://img.shields.io/badge/SDK-Flutter-blue?style=flat-square" alt="SDK: Flutter" /> <img src="https://img.shields.io/badge/SDK-Flutter-blue?style=flat-square" alt="SDK: Flutter" />
</p> </p>
A package by wyatt studio This package aims to facilitate and improve the internationalization of your applications. It allows, among other things, to load translation files on the fly during the execution of the application.
## Features ## Features
TODO: List what your package can do. Maybe include images, gifs, or videos. * Load translation files
+ [x] Load translation files from assets
+ [x] Load translation files from the network
+ [ ] Load translation files from the file system
+ [ ] Load translation files from multiple sources
## Getting started * Supports multiple formats
+ [x] Supports JSON format
+ [x] Supports YAML format
+ [x] Supports ARB formats
+ [ ] Supports CSV format
+ [x] Supports custom formats parsers (see Parser class)
TODO: List prerequisites and provide or point to information on how to * Usage
start using the package. + [x] Detects the current locale
+ [x] Act as a LocalizationDelegate
+ [x] Act as a DataSource (in the sense of the Wyatt Architecture)
* Other
+ [ ] Generate translation constants from fallback translation files
## Usage ## Usage
TODO: Include short and useful examples for package users. Add longer examples You can use this package as a LocalizationDelegate or as a DataSource.
to `/example` folder.
### As a LocalizationDelegate
It is recommended to use this package as a LocalizationDelegate. This allows you to use the `context.i18n` method to translate your strings. It follows the standard of the `flutter_localizations` package and you can use it in the MaterialApp widget as follows:
```dart ```dart
const like = 'sample'; class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
localizationsDelegates: [
I18nDelegate(
dataSource: NetworkI18nDataSourceImpl(
baseUri: 'https://i18n.wyatt-studio.fr/apps/flutter_demo',
),
localeTransformer: (locale) => locale.languageCode,
),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate
],
supportedLocales: [
Locale('en'),
Locale('fr'),
],
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
``` ```
## Additional information And in your widgets:
TODO: Tell users more about the package: where to find more information, how to ```dart
contribute to the package, how to file issues, what response they can expect Text(context.i18n('youHavePushed', {'count': 42})),
from the package authors, and more. // => 'You have pushed the button this many times: 42' in English
// => 'Vous avez appuyé sur le bouton ce nombre de fois: 42' in French
```
### As a DataSource
This gives you more control over the internationalization of your application. You can handle the loading of the translation files yourself.
For example, if you want to create a Repository that will handle the loading of the translation files, you can do it.
Provide DataSource with GetIt:
```dart
// Initialize i18n
final I18nDataSource i18nDataSource =
await AssetsI18nDataSourceImpl.withSystemLocale(
basePath: 'l10n',
baseName: 'intl',
localeTransformer: (locale) => locale.languageCode,
);
// Initialize real sources/services
GetIt.I.registerLazySingleton<I18nDataSource>(
() => i18nDataSource,
);
```
Create a Repository:
```dart
class I18nRepository {
I18nRepository({
required this.dataSource,
});
final I18nDataSource dataSource;
Future<I18n> load() async {
final i18n = await dataSource.load();
return i18n;
}
}
```
And use it in your cubit:
```dart
class MyCubit extends Cubit<MyState> {
MyCubit({
required this.i18nRepository,
}) : super(MyState());
final I18nRepository i18nRepository;
Future<void> loadI18n() async {
final i18n = await i18nRepository.load();
emit(state.copyWith(i18n: i18n));
}
}
```
> Note: you should create a cache system to avoid reloading the translation files every time.

View File

@ -29,36 +29,32 @@ class App extends StatelessWidget {
static const String title = 'Wyatt i18n Example'; static const String title = 'Wyatt i18n Example';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => MaterialApp(
final I18nDataSource dataSource = NetworkDataSourceImpl( title: title,
baseUri: Uri.parse( theme: ThemeData(
'https://git.wyatt-studio.fr/Wyatt-FOSS/wyatt-packages/raw/commit/75f561a19e0484e67e511dbf29601ec5f58544aa/packages/wyatt_i18n/example/assets/', primarySwatch: Colors.blue,
),
);
final I18nRepository repository =
I18nRepositoryImpl(dataSource: dataSource);
return MaterialApp(
title: title,
theme: ThemeData(
primarySwatch: Colors.blue,
),
localizationsDelegates: [
I18nDelegate(repository: repository),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate
],
home: Scaffold(
appBar: AppBar(
title: const Text(title),
), ),
body: Builder( localizationsDelegates: [
builder: (ctx) => Center( I18nDelegate(
child: Text(ctx.i18n('youHavePushed', {'count': 654})), dataSource: NetworkI18nDataSourceImpl(
baseUri: Uri.parse(
'https://git.wyatt-studio.fr/Wyatt-FOSS/wyatt-packages/raw/commit/75f561a19e0484e67e511dbf29601ec5f58544aa/packages/wyatt_i18n/example/assets/',
),
localeTransformer: (locale) => locale.languageCode,
),
),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate
],
home: Scaffold(
appBar: AppBar(
title: const Text(title),
),
body: Builder(
builder: (ctx) => Center(
child: Text(ctx.i18n('youHavePushed', {'count': 654})),
),
), ),
), ),
), );
);
}
} }

View File

@ -14,11 +14,10 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:wyatt_i18n/src/core/utils/arb_parser.dart';
import 'package:wyatt_i18n/src/core/utils/json_parser.dart';
import 'package:wyatt_i18n/src/core/utils/parser.dart'; import 'package:wyatt_i18n/src/core/utils/parser.dart';
import 'package:wyatt_i18n/src/core/utils/parsers/arb_parser.dart';
import 'package:wyatt_i18n/src/core/utils/yaml_parser.dart'; import 'package:wyatt_i18n/src/core/utils/parsers/json_parser.dart';
import 'package:wyatt_i18n/src/core/utils/parsers/yaml_parser.dart';
/// Enum for i18n file formats and extensions. /// Enum for i18n file formats and extensions.
/// ///

View File

@ -0,0 +1,24 @@
// Copyright (C) 2023 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:intl/intl_standalone.dart'
if (dart.library.html) 'package:intl/intl_browser.dart';
/// Utility class for internationalization.
abstract class IntlUtils {
/// Returns system locale.
static Future<String> getSystemLocale() => findSystemLocale();
}

View File

@ -14,8 +14,8 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:wyatt_i18n/src/core/utils/json_parser.dart';
import 'package:wyatt_i18n/src/core/utils/parser.dart'; import 'package:wyatt_i18n/src/core/utils/parser.dart';
import 'package:wyatt_i18n/src/core/utils/parsers/json_parser.dart';
/// {@template arb_parser} /// {@template arb_parser}
/// A class that parses a given input of type [String] into a given output /// A class that parses a given input of type [String] into a given output

View File

@ -20,8 +20,8 @@ import 'package:wyatt_i18n/src/domain/entities/tokens.dart';
import 'package:wyatt_i18n/wyatt_i18n.dart'; import 'package:wyatt_i18n/wyatt_i18n.dart';
/// {@template i18n_file_parser} /// {@template i18n_file_parser}
/// This class is responsible for parsing the [I18nFile] and returning the /// This class is responsible for parsing the [I18n] and returning the
/// translated string. /// translated string using the [arguments] provided and the [IcuParser].
/// {@endtemplate} /// {@endtemplate}
class I18nFileParser extends Parser<String, String> { class I18nFileParser extends Parser<String, String> {
/// {@macro i18n_file_parser} /// {@macro i18n_file_parser}
@ -30,8 +30,8 @@ class I18nFileParser extends Parser<String, String> {
this.arguments = const {}, this.arguments = const {},
}) : super(); }) : super();
/// The [I18nFile] to be parsed. /// The [I18n] to be parsed.
final I18nFile i18n; final I18n i18n;
/// The arguments to be used in the translation. /// The arguments to be used in the translation.
final Map<String, dynamic> arguments; final Map<String, dynamic> arguments;

View File

@ -17,6 +17,7 @@
// ignore_for_file: avoid_dynamic_calls, inference_failure_on_untyped_parameter // ignore_for_file: avoid_dynamic_calls, inference_failure_on_untyped_parameter
import 'package:petitparser/petitparser.dart' hide Token; import 'package:petitparser/petitparser.dart' hide Token;
import 'package:wyatt_i18n/src/core/utils/parser.dart' as wyatt;
import 'package:wyatt_i18n/src/domain/entities/tokens.dart'; import 'package:wyatt_i18n/src/domain/entities/tokens.dart';
/// {@template icu_parser} /// {@template icu_parser}
@ -24,7 +25,7 @@ import 'package:wyatt_i18n/src/domain/entities/tokens.dart';
/// See https://unicode-org.github.io/icu/userguide/format_parse/messages/ /// See https://unicode-org.github.io/icu/userguide/format_parse/messages/
/// for the syntax. /// for the syntax.
/// {@endtemplate} /// {@endtemplate}
class IcuParser { class IcuParser extends wyatt.Parser<String, List<Token>?> {
/// {@macro icu_parser} /// {@macro icu_parser}
IcuParser() { IcuParser() {
// There is a cycle here, so we need the explicit // There is a cycle here, so we need the explicit
@ -167,12 +168,13 @@ class IcuParser {
Parser get parameter => (openCurly & id & closeCurly) Parser get parameter => (openCurly & id & closeCurly)
.map((result) => Argument(result[1] as String)); .map((result) => Argument(result[1] as String));
List<Token>? parse(String message) { @override
List<Token>? parse(String input) {
final parsed = (compound | pluralOrGenderOrSelect | simpleText | empty) final parsed = (compound | pluralOrGenderOrSelect | simpleText | empty)
.map( .map(
(result) => List<Token>.from(result is List ? result : [result]), (result) => List<Token>.from(result is List ? result : [result]),
) )
.parse(message); .parse(input);
return parsed.isSuccess ? parsed.value : null; return parsed.isSuccess ? parsed.value : null;
} }

View File

@ -0,0 +1,50 @@
// Copyright (C) 2023 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/widgets.dart';
import 'package:wyatt_i18n/wyatt_i18n.dart';
/// {@template locale_parser}
/// A parser that parses a [String] into a [Locale].
/// Example:
/// ```dart
/// final parser = LocaleParser();
/// final locale = parser.parse('en-US');
/// // locale.languageCode == 'en'
/// // locale.countryCode == 'US'
/// ```
/// {@endtemplate}
class LocaleParser extends Parser<String, Locale> {
/// {@macro locale_parser}
const LocaleParser() : super();
@override
Locale parse(String input) {
final languageParts = RegExp('([a-z]*)[_-]?([a-z|A-Z]*)').firstMatch(input);
final languageCode = languageParts?.group(1);
final countryCode = languageParts?.group(2);
if (languageCode == null) {
throw ParserException("Can't parse locale tag '$input'", null);
}
return Locale(languageCode, countryCode);
}
/// Serializes the given [locale] into a [String].
String serialize(Locale locale) => locale.countryCode == null
? locale.languageCode
: '${locale.languageCode}_${locale.countryCode}';
}

View File

@ -14,9 +14,9 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
export 'arb_parser.dart';
export 'i18n_file_parser.dart';
export 'icu_parser.dart';
export 'json_parser.dart';
export 'parser.dart'; export 'parser.dart';
export 'yaml_parser.dart'; export 'parsers/arb_parser.dart';
export 'parsers/i18n_file_parser.dart';
export 'parsers/icu_parser.dart';
export 'parsers/json_parser.dart';
export 'parsers/yaml_parser.dart';

View File

@ -14,6 +14,6 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
export 'data_sources/assets_file_data_source_impl.dart'; export '../domain/entities/i18n.dart';
export 'data_sources/network_data_source_impl.dart'; export 'data_sources/assets_i18n_data_source_impl.dart';
export 'repositories/i18n_repository_impl.dart'; export 'data_sources/network_i18n_data_source_impl.dart';

View File

@ -15,31 +15,61 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/services.dart' show AssetBundle, rootBundle; import 'package:flutter/services.dart' show AssetBundle, rootBundle;
import 'package:wyatt_i18n/src/core/enums/format.dart'; import 'package:flutter/widgets.dart';
import 'package:wyatt_i18n/src/core/exceptions/exceptions.dart';
import 'package:wyatt_i18n/src/core/utils/assets_utils.dart'; import 'package:wyatt_i18n/src/core/utils/assets_utils.dart';
import 'package:wyatt_i18n/src/domain/data_sources/i18n_data_source.dart'; import 'package:wyatt_i18n/src/core/utils/intl_utils.dart';
import 'package:wyatt_i18n/src/core/utils/parsers/locale_parser.dart';
import 'package:wyatt_i18n/wyatt_i18n.dart';
/// {@template assets_file_data_source_impl} /// {@template assets_i18n_data_source_impl}
/// Implementation of [I18nDataSource] that loads i18n files from the assets. /// Implementation of [I18nDataSource] that loads i18n files from the assets.
/// ///
/// The [basePath] is the folder where the i18n files are located. /// The [basePath] is the folder where the i18n files are located.
/// The [baseName] is the name of the i18n files without the extension. /// The [baseName] is the name of the i18n files without the extension.
/// The [format] is the format of the i18n files. /// The [format] is the format of the i18n files.
/// The [defaultLocale] is the default locale to use when the locale is not
/// specified.
/// The [separator] is the separator to use when the locale is specified.
/// ///
/// For example, if the i18n files are located in the `assets/l10n/` and are /// For example, if the i18n files are located in the `assets/l10n/` and are
/// named `i18n.en.arb`, `i18n.fr.arb`, etc., then the [basePath] /// named `i18n.en.arb`, `i18n.fr.arb`, etc., then the [basePath]
/// is `l10n` and the [baseName] is `i18n` and the [format] is [Format.arb]. /// is `l10n` and the [baseName] is `i18n` and the [format] is [Format.arb].
/// {@endtemplate} /// {@endtemplate}
class AssetsFileDataSourceImpl extends I18nDataSource { class AssetsI18nDataSourceImpl extends I18nDataSource {
/// {@macro assets_file_data_source_impl} /// {@macro assets_i18n_data_source_impl}
AssetsFileDataSourceImpl({ AssetsI18nDataSourceImpl({
this.basePath = '', this.basePath = '',
this.baseName = 'i18n', this.baseName = 'i18n',
super.format = Format.arb, super.format = Format.arb,
super.defaultLocale = 'en', super.defaultLocale = const Locale('en'),
super.separator = '.',
super.localeTransformer,
}) : super(); }) : super();
/// Creates a new instance of [AssetsI18nDataSourceImpl] with the system
/// locale as the default locale.
/// So, the [defaultLocale] is the system locale and you don't need to
/// specify it when you load the i18n file.
// ignore: long-parameter-list
static Future<AssetsI18nDataSourceImpl> withSystemLocale({
String basePath = '',
String baseName = 'i18n',
Format format = Format.arb,
String separator = '.',
LocaleTransformer? localeTransformer,
}) async {
final locale = await IntlUtils.getSystemLocale();
return AssetsI18nDataSourceImpl(
basePath: basePath,
baseName: baseName,
format: format,
defaultLocale: const LocaleParser().parse(locale),
separator: separator,
localeTransformer: localeTransformer,
);
}
/// The folder where the i18n files are located. /// The folder where the i18n files are located.
/// ///
/// Note: The path is relative to the `assets` folder. So, `assets/l10n/` /// Note: The path is relative to the `assets` folder. So, `assets/l10n/`
@ -53,15 +83,20 @@ class AssetsFileDataSourceImpl extends I18nDataSource {
final AssetBundle assetBundle = rootBundle; final AssetBundle assetBundle = rootBundle;
/// Tries to load the i18n file from the given [locale]. /// Tries to load the i18n file from the given [locale].
Future<String> _tryLoad(String? locale) async { Future<I18n> _tryLoad(Locale? locale) async {
String? content; String? content;
final ext = format.name; final ext = format.name;
final path = AssetsUtils.cleanPath( final defaultLocaleString = localeTransformer?.call(defaultLocale) ??
locale == null const LocaleParser().serialize(defaultLocale);
? '$basePath/$baseName.$defaultLocale.$ext'
: '$basePath/$baseName.$locale.$ext', final localeString = locale == null
); ? defaultLocaleString
: localeTransformer?.call(locale) ??
const LocaleParser().serialize(locale);
final path =
AssetsUtils.cleanPath('$basePath/$baseName.$localeString.$ext');
try { try {
if (await AssetsUtils.assetExists(path)) { if (await AssetsUtils.assetExists(path)) {
@ -76,7 +111,7 @@ class AssetsFileDataSourceImpl extends I18nDataSource {
if (content == null && locale != null) { if (content == null && locale != null) {
try { try {
final fallbackPath = AssetsUtils.cleanPath( final fallbackPath = AssetsUtils.cleanPath(
'$basePath/$baseName.$defaultLocale.$ext', '$basePath/$baseName.$defaultLocaleString.$ext',
); );
content = await assetBundle.loadString(fallbackPath); content = await assetBundle.loadString(fallbackPath);
} catch (_) { } catch (_) {
@ -90,12 +125,19 @@ class AssetsFileDataSourceImpl extends I18nDataSource {
throw SourceNotFoundException(path, format: format); throw SourceNotFoundException(path, format: format);
} }
return content; /// Parse the i18n file.
final parsedData = format.parser.parse(content);
return I18n(
unparsedData: content,
data: parsedData,
locale: locale,
);
} }
/// Tries to load the i18n file from a given [uri]. /// Tries to load the i18n file from a given [uri].
/// This method is used when the [uri] is not null. /// This method is used when the [uri] is not null.
Future<String> _tryLoadUri(Uri uri) async { Future<I18n> _tryLoadUri(Uri uri) async {
String? content; String? content;
try { try {
content = await assetBundle.loadString(uri.toString()); content = await assetBundle.loadString(uri.toString());
@ -103,7 +145,13 @@ class AssetsFileDataSourceImpl extends I18nDataSource {
throw SourceNotFoundException(uri.toString(), format: format); throw SourceNotFoundException(uri.toString(), format: format);
} }
return content; /// Parse the i18n file.
final parsedData = format.parser.parse(content);
return I18n(
unparsedData: content,
data: parsedData,
);
} }
/// Loads the i18n file from Assets folder. /// Loads the i18n file from Assets folder.
@ -111,7 +159,8 @@ class AssetsFileDataSourceImpl extends I18nDataSource {
/// The i18n file must be in [basePath], named [baseName] + /// The i18n file must be in [basePath], named [baseName] +
/// `.<locale>.<extension>` and must be specified in the `pubspec.yaml` file. /// `.<locale>.<extension>` and must be specified in the `pubspec.yaml` file.
@override @override
Future<String> load({required String? locale}) async => _tryLoad(locale); Future<I18n> load({Locale? locale}) async =>
super.currentI18nFile = await _tryLoad(locale);
/// Loads the i18n file from Assets folder. /// Loads the i18n file from Assets folder.
/// ///
@ -119,5 +168,6 @@ class AssetsFileDataSourceImpl extends I18nDataSource {
/// from the given [uri]. In this case, the [basePath] and the /// from the given [uri]. In this case, the [basePath] and the
/// [baseName] are ignored. And there is no fallback. /// [baseName] are ignored. And there is no fallback.
@override @override
Future<String> loadFrom(Uri uri) async => _tryLoadUri(uri); Future<I18n> loadFrom(Uri uri) async =>
super.currentI18nFile = await _tryLoadUri(uri);
} }

View File

@ -15,19 +15,26 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:wyatt_i18n/src/core/enums/format.dart'; import 'package:wyatt_i18n/src/core/enums/format.dart';
import 'package:wyatt_i18n/src/core/exceptions/exceptions.dart'; import 'package:wyatt_i18n/src/core/exceptions/exceptions.dart';
import 'package:wyatt_i18n/src/core/utils/assets_utils.dart'; import 'package:wyatt_i18n/src/core/utils/assets_utils.dart';
import 'package:wyatt_i18n/src/core/utils/intl_utils.dart';
import 'package:wyatt_i18n/src/core/utils/parsers/locale_parser.dart';
import 'package:wyatt_i18n/src/domain/data_sources/i18n_data_source.dart'; import 'package:wyatt_i18n/src/domain/data_sources/i18n_data_source.dart';
import 'package:wyatt_i18n/src/domain/entities/i18n.dart';
/// {@template network_data_source_impl} /// {@template network_i18n_data_source_impl}
/// Implementation of [I18nDataSource] that loads i18n files from the network. /// Implementation of [I18nDataSource] that loads i18n files from the network.
/// ///
/// The [baseUri] is the base uri where the i18n files are located. /// The [baseUri] is the base uri where the i18n files are located.
/// The [baseName] is the name of the i18n files without the extension. /// The [baseName] is the name of the i18n files without the extension.
/// The [fallbackAssetPath] is the path to the fallback i18n files. /// The [fallbackAssetPath] is the path to the fallback i18n files.
/// The [format] is the format of the i18n files. /// The [format] is the format of the i18n files.
/// The [defaultLocale] is the default locale to use when the locale is not
/// specified.
/// The [separator] is the separator to use when the locale is specified.
/// ///
/// For example, if the i18n files are located at `https://example.com/i18n/` /// For example, if the i18n files are located at `https://example.com/i18n/`
/// and are named `i18n.en.arb`, `i18n.fr.arb`, etc., then the [baseUri] is /// and are named `i18n.en.arb`, `i18n.fr.arb`, etc., then the [baseUri] is
@ -42,16 +49,42 @@ import 'package:wyatt_i18n/src/domain/data_sources/i18n_data_source.dart';
/// and are named `i18n.arb`, `i18n.en.arb`, `i18n.fr.arb`, etc., then the /// and are named `i18n.arb`, `i18n.en.arb`, `i18n.fr.arb`, etc., then the
/// [fallbackAssetPath] is `l10n`. /// [fallbackAssetPath] is `l10n`.
/// {@endtemplate} /// {@endtemplate}
class NetworkDataSourceImpl extends I18nDataSource { class NetworkI18nDataSourceImpl extends I18nDataSource {
/// {@macro network_data_source_impl} /// {@macro network_i18n_data_source_impl}
const NetworkDataSourceImpl({ NetworkI18nDataSourceImpl({
required this.baseUri, required this.baseUri,
this.baseName = 'i18n', this.baseName = 'i18n',
this.fallbackAssetPath = '', this.fallbackAssetPath = '',
super.format = Format.arb, super.format = Format.arb,
super.defaultLocale = 'en', super.defaultLocale = const Locale('en'),
super.separator = '.',
super.localeTransformer,
}) : super(); }) : super();
/// Creates a new instance of [NetworkI18nDataSourceImpl] with the system
/// locale as the default locale.
/// So, the [defaultLocale] is the system locale and you don't need to
/// specify it when you load the i18n file.
// ignore: long-parameter-list
static Future<NetworkI18nDataSourceImpl> withSystemLocale({
required Uri baseUri,
String baseName = 'i18n',
Format format = Format.arb,
String separator = '.',
LocaleTransformer? localeTransformer,
}) async {
final locale = await IntlUtils.getSystemLocale();
return NetworkI18nDataSourceImpl(
baseUri: baseUri,
baseName: baseName,
format: format,
defaultLocale: const LocaleParser().parse(locale),
separator: separator,
localeTransformer: localeTransformer,
);
}
/// The base uri where the i18n files are located. /// The base uri where the i18n files are located.
final Uri baseUri; final Uri baseUri;
@ -68,18 +101,26 @@ class NetworkDataSourceImpl extends I18nDataSource {
final String fallbackAssetPath; final String fallbackAssetPath;
/// Tries to load the i18n file from the given [locale]. /// Tries to load the i18n file from the given [locale].
Future<String> _tryLoad(String? locale, {Uri? overrideUri}) async { Future<I18n> _tryLoad(Locale? locale, {Uri? overrideUri}) async {
String? content; String? content;
final ext = format.name; final ext = format.name;
final defaultLocaleString = localeTransformer?.call(defaultLocale) ??
const LocaleParser().serialize(defaultLocale);
final localeString = locale == null
? defaultLocaleString
: localeTransformer?.call(locale) ??
const LocaleParser().serialize(locale);
/// If the locale is null, then we try to load the default i18n file from /// If the locale is null, then we try to load the default i18n file from
/// the fallback asset path. /// the fallback asset path.
/// Otherwise, we try to load the i18n file for the given locale from the /// Otherwise, we try to load the i18n file for the given locale from the
/// base uri. /// base uri.
final path = AssetsUtils.cleanPath( final path = AssetsUtils.cleanPath(
locale == null locale == null
? '$fallbackAssetPath/$baseName.$defaultLocale.$ext' ? '$fallbackAssetPath/$baseName.$defaultLocaleString.$ext'
: overrideUri?.toString() ?? '$baseUri/$baseName.$locale.$ext', : overrideUri?.toString() ?? '$baseUri/$baseName.$localeString.$ext',
); );
if (locale == null) { if (locale == null) {
@ -108,7 +149,7 @@ class NetworkDataSourceImpl extends I18nDataSource {
if (content == null && locale != null) { if (content == null && locale != null) {
try { try {
final fallbackPath = AssetsUtils.cleanPath( final fallbackPath = AssetsUtils.cleanPath(
'$fallbackAssetPath/$baseName.$defaultLocale.$ext', '$fallbackAssetPath/$baseName.$defaultLocaleString.$ext',
); );
content = await rootBundle.loadString(fallbackPath); content = await rootBundle.loadString(fallbackPath);
} catch (_) { } catch (_) {
@ -122,7 +163,14 @@ class NetworkDataSourceImpl extends I18nDataSource {
throw SourceNotFoundException(path, format: format); throw SourceNotFoundException(path, format: format);
} }
return content; /// Parse the i18n file.
final parsedData = format.parser.parse(content);
return I18n(
unparsedData: content,
data: parsedData,
locale: locale,
);
} }
/// Loads the i18n file for the given [locale]. /// Loads the i18n file for the given [locale].
@ -130,12 +178,14 @@ class NetworkDataSourceImpl extends I18nDataSource {
/// If the [locale] is null, then the default i18n file is loaded from the /// If the [locale] is null, then the default i18n file is loaded from the
/// fallback asset path. /// fallback asset path.
@override @override
Future<String> load({required String? locale}) async => _tryLoad(locale); Future<I18n> load({Locale? locale}) async =>
super.currentI18nFile = await _tryLoad(locale);
/// Loads the i18n file from the given [uri]. /// Loads the i18n file from the given [uri].
/// ///
/// If the fetch fails, then the fallback i18n files are loaded from the /// If the fetch fails, then the fallback i18n files are loaded from the
/// [fallbackAssetPath] in the root bundle. /// [fallbackAssetPath] in the root bundle.
@override @override
Future<String> loadFrom(Uri uri) async => _tryLoad(null, overrideUri: uri); Future<I18n> loadFrom(Uri uri) async =>
super.currentI18nFile = await _tryLoad(null, overrideUri: uri);
} }

View File

@ -1,157 +0,0 @@
// Copyright (C) 2023 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:wyatt_architecture/wyatt_architecture.dart';
import 'package:wyatt_i18n/wyatt_i18n.dart';
import 'package:wyatt_type_utils/wyatt_type_utils.dart' hide Option;
/// {@template i18n_repository_impl}
/// The default implementation of [I18nRepository].
/// {@endtemplate}
class I18nRepositoryImpl extends I18nRepository {
/// {@macro i18n_repository_impl}
I18nRepositoryImpl({
required this.dataSource,
}) : super();
/// The data source used to load the i18n file.
final I18nDataSource dataSource;
/// The current i18n instance.
I18nFile _i18n = const I18nFile.empty();
@override
I18nFile get i18n => _i18n;
Future<I18nFile> _parse(
String content,
Parser<String, Map<String, dynamic>> parser, {
String? locale,
bool strict = false,
}) async {
final parsed = parser.parse(content);
String parsedLocale;
/// Checks if the locale is present in the parsed data.
/// If not, throws an exception.
/// If yes, sets the locale to the parsed locale.
if (parsed.containsKey('@@locale')) {
if (strict) {
/// Checks if the parsed locale is a string.
if (parsed['@@locale'] is! String) {
throw NoLocaleException();
}
/// Checks if the parsed locale is the same as the given locale.
if (locale != null && parsed['@@locale'] as String != locale) {
throw InvalidLocaleException(locale, parsed['@@locale'] as String);
}
}
parsedLocale = parsed['@@locale'] as String;
} else {
if (strict) {
/// Throws an exception if the locale is not present in the parsed data.
throw NoLocaleException();
} else {
/// Sets the locale to the given locale.
/// If the given locale is null, sets the locale to 'null'.
/// This is done to prevent the locale from being null.
/// It should never be null.
parsedLocale = locale ?? 'null';
}
}
return I18nFile(
locale: parsedLocale,
unparsedData: content,
data: parsed,
);
}
@override
FutureOrResult<I18nFile> load({
required String? locale,
bool strict = false,
Parser<String, Map<String, dynamic>>? parser,
}) async =>
await Result.tryCatchAsync<I18nFile, AppException, AppException>(
() async {
final content = await dataSource.load(locale: locale);
return _i18n = await _parse(
content,
parser ?? dataSource.format.parser,
locale: locale,
strict: strict,
);
},
(error) => error,
);
@override
FutureOrResult<I18nFile> loadFrom(
Uri uri, {
Parser<String, Map<String, dynamic>>? parser,
}) async =>
await Result.tryCatchAsync<I18nFile, AppException, AppException>(
() async {
final content = await dataSource.loadFrom(uri);
/// Strict is always true when loading from a uri. Because
/// the locale is not given and can't be inferred.
return _i18n = await _parse(
content,
parser ?? dataSource.format.parser,
strict: true,
);
},
(error) => error,
);
@override
Result<String, AppException> get(
String key, [
Map<String, dynamic> arguments = const {},
]) {
final I18nFileParser parser =
I18nFileParser(i18n: _i18n, arguments: arguments);
if (_i18n.containsKey(key)) {
return Result.tryCatch<String, AppException, AppException>(
() => parser.parse(_i18n[key] as String, key: key),
(error) => error,
);
} else {
throw KeyNotFoundException(key, arguments);
}
}
@override
Result<I18nFile, AppException> getI18n() =>
Result.conditional(!_i18n.isEmpty, _i18n, NotLoadedException());
@override
Result<String, AppException> getLocale() =>
Result.conditional(!_i18n.isEmpty, _i18n.locale, NotLoadedException());
@override
Result<void, AppException> setI18n(I18nFile i18n) {
_i18n = i18n;
return const Ok(null);
}
}

View File

@ -14,8 +14,11 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/widgets.dart';
import 'package:wyatt_architecture/wyatt_architecture.dart'; import 'package:wyatt_architecture/wyatt_architecture.dart';
import 'package:wyatt_i18n/src/core/enums/format.dart'; import 'package:wyatt_i18n/wyatt_i18n.dart';
typedef LocaleTransformer = String Function(Locale);
/// {@template i18n_data_source} /// {@template i18n_data_source}
/// Base class for i18n data sources. /// Base class for i18n data sources.
@ -26,24 +29,62 @@ import 'package:wyatt_i18n/src/core/enums/format.dart';
/// {@endtemplate} /// {@endtemplate}
abstract class I18nDataSource extends BaseDataSource { abstract class I18nDataSource extends BaseDataSource {
/// {@macro i18n_data_source} /// {@macro i18n_data_source}
const I18nDataSource({ I18nDataSource({
this.format = Format.arb, this.format = Format.arb,
this.defaultLocale = 'en', this.defaultLocale = const Locale('en'),
this.separator = '.',
this.localeTransformer,
}); });
/// The format of the i18n file. /// The format of the i18n file.
final Format format; final Format format;
/// The default locale. /// The default locale.
final String defaultLocale; /// This is used when the locale is `null` in the [load] method.
Locale defaultLocale;
/// The separator used in file name.
/// This is used to separate the baseName and the locale.
///
/// For example, if the baseName is `i18n` and the locale is `en`, if the
/// separator is `_`, the path will be `i18n_en`.
final String separator;
/// The list of parsers used to parse the i18n file.
/// This is used to parse the i18n file into a [Map].
// final List<Parser<String, Map<String, dynamic>>> decoders;
/// The current i18n file loaded from the source.
/// This is used to avoid loading the same file twice.
I18n? currentI18nFile;
/// Function used to transform the [Locale] into a string.
/// This is used to get the file name from the [Locale].
///
/// For example, if the [Locale] is `en_US`, the file name will be `en_us`.
///
/// But maybe you want to use `en-us` instead. In this case, you can use
/// ```dart
/// localeTransformer: (locale) => locale.toString().replaceAll('_', '-'),
/// ```
/// to replace the `_` with `-`.
///
/// If you want to use `en` instead of `en_US`, you can use
/// ```dart
/// localeTransformer: (locale) => locale.languageCode,
/// ```
///
/// If this is `null`, the default transformer will be used.
/// The default transformer will use the LocaleParser serializer method.
LocaleTransformer? localeTransformer;
/// Loads the i18n file from the source. /// Loads the i18n file from the source.
/// If [locale] is not `null`, it will load the file with the given [locale]. /// If [locale] is not `null`, it will load the file with the given [locale].
/// Otherwise, it will load the file from the default location. /// Otherwise, it will load the file from the default location.
Future<String> load({required String? locale}); Future<I18n> load({Locale? locale});
/// Loads the i18n file from the source. /// Loads the i18n file from the source.
/// ///
/// This method is used to load the i18n file from the given [Uri]. /// This method is used to load the i18n file from the given [Uri].
Future<String> loadFrom(Uri uri); Future<I18n> loadFrom(Uri uri);
} }

View File

@ -16,5 +16,3 @@
export 'data_sources/i18n_data_source.dart'; export 'data_sources/i18n_data_source.dart';
export 'entities/i18n.dart'; export 'entities/i18n.dart';
export 'entities/i18n_file.dart';
export 'repositories/i18n_repository.dart';

View File

@ -14,47 +14,104 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart';
import 'package:wyatt_architecture/wyatt_architecture.dart'; import 'package:wyatt_architecture/wyatt_architecture.dart';
import 'package:wyatt_i18n/wyatt_i18n.dart'; import 'package:wyatt_i18n/src/core/exceptions/exceptions.dart';
import 'package:wyatt_i18n/src/core/utils/parsers/i18n_file_parser.dart';
import 'package:wyatt_i18n/src/core/utils/parsers/locale_parser.dart';
/// {@template i18n} /// {@template i18n_file}
/// This class is used to store the translations of the application. /// Data structure for i18n files.
/// This entity is used by the I18nDelegate and Flutter's Localizations
/// widget to provide the translations to the application.
/// {@endtemplate} /// {@endtemplate}
class I18n extends Entity { class I18n extends Entity with EquatableMixin {
/// {@macro i18n} /// {@macro i18n_file}
I18n({ const I18n({
required this.i18nRepository, required this.unparsedData,
required this.data,
this.locale,
}) : super(); }) : super();
final I18nRepository i18nRepository; /// Creates an empty i18n file.
const I18n.empty() : this(unparsedData: '', data: const {});
/// Get the translation of the given [key]. /// The locale of the i18n file.
/// If the [key] is not found, the [key] itself is returned. final Locale? locale;
/// If the [key] is found, the translation is returned.
/// If [args] is not null, the translation is formatted with the
/// given arguments.
String get(String key, [Map<String, dynamic>? args]) {
final result = i18nRepository.get(key, args ?? const <String, dynamic>{});
return result.fold( /// The unparsed data of the i18n file.
(value) => value, final String unparsedData;
(error) => key,
); /// The data of the i18n file.
final Map<String, dynamic> data;
/// Gets the locale of the i18n file.
/// If [strict] is true, it will throw an exception if the locale is not
/// present in the i18n file.
/// If [strict] is false, it will return the locale of the i18n file if it is
/// present, otherwise it will return the locale passed to the constructor.
Locale getLocale({bool strict = false}) {
if (strict) {
if (data.containsKey('@@locale')) {
if (data['@@locale'] is! String) {
throw NoLocaleException();
}
return const LocaleParser().parse(data['@@locale'] as String);
} else {
throw NoLocaleException();
}
} else {
if (data.containsKey('@@locale')) {
if (data['@@locale'] is! String) {
if (locale != null) {
return locale!;
} else {
throw NoLocaleException();
}
}
return const LocaleParser().parse(data['@@locale'] as String);
} else {
if (locale != null) {
return locale!;
} else {
throw NoLocaleException();
}
}
}
} }
/// Get the translation of the given [key]. /// Gets the value of a key.
/// /// If the key is not present in the i18n file, it will throw a
/// Note: arguments are not supported. /// [KeyNotFoundException].
String operator [](String key) => get(key); /// If the key is present in the i18n file, it will return the value of the
/// key parsed with ICU message format.
String get(String key, [Map<String, dynamic>? args]) {
final arguments = args ?? const <String, dynamic>{};
/// The parser used to parse the i18n file with ICU message format.
final I18nFileParser parser =
I18nFileParser(i18n: this, arguments: arguments);
if (containsKey(key)) {
return parser.parse(this[key] as String, key: key);
} else {
throw KeyNotFoundException(key, arguments);
}
}
/// Get the translation of the given [key]. /// Get the translation of the given [key].
String call(String key, [Map<String, dynamic>? args]) => get(key, args); String call(String key, [Map<String, dynamic>? args]) => get(key, args);
/// Load the translations from the given [locale]. /// Checks if the i18n file contains a key.
/// If the [locale] is not found, the default locale is loaded. bool containsKey(String key) => data.containsKey(key);
Future<void> load(String locale) async {
await i18nRepository.load(locale: locale); /// Gets the value of a key.
} dynamic operator [](String key) => data[key];
/// Checks if the i18n file is empty.
bool get isEmpty => data.isEmpty && unparsedData.isEmpty && locale == null;
@override
List<Object?> get props => [locale, unparsedData, data];
} }

View File

@ -1,54 +0,0 @@
// Copyright (C) 2023 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:equatable/equatable.dart';
import 'package:wyatt_architecture/wyatt_architecture.dart';
/// {@template i18n_file}
/// Data structure for i18n files.
/// {@endtemplate}
class I18nFile extends Entity with EquatableMixin {
/// {@macro i18n_file}
const I18nFile({
required this.locale,
required this.unparsedData,
required this.data,
}) : super();
/// Creates an empty i18n file.
const I18nFile.empty() : this(locale: '', unparsedData: '', data: const {});
/// The locale of the i18n file.
final String locale;
/// The unparsed data of the i18n file.
final String unparsedData;
/// The data of the i18n file.
final Map<String, dynamic> data;
/// Checks if the i18n file contains a key.
bool containsKey(String key) => data.containsKey(key);
/// Gets the value of a key.
dynamic operator [](String key) => data[key];
/// Checks if the i18n file is empty.
bool get isEmpty => data.isEmpty && unparsedData.isEmpty && locale.isEmpty;
@override
List<Object?> get props => [locale, unparsedData, data];
}

View File

@ -1,77 +0,0 @@
// Copyright (C) 2023 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:wyatt_architecture/wyatt_architecture.dart';
import 'package:wyatt_i18n/src/core/utils/parser.dart';
import 'package:wyatt_i18n/src/domain/entities/i18n_file.dart';
import 'package:wyatt_type_utils/wyatt_type_utils.dart';
/// {@template i18n_repository}
/// Base class for i18n repositories.
///
/// This class is used to manage i18n files.
/// {@endtemplate}
abstract class I18nRepository extends BaseRepository {
/// {@macro i18n_repository}
const I18nRepository() : super();
/// The current i18n file.
I18nFile get i18n;
/// Loads the i18n file from the source.
/// If [strict] is `true`, it will throw an NoLocaleException if the
/// `@@locale` key is not found in the i18n file, otherwise it will
/// set the locale to the given [locale].
/// If [parser] is not `null`, it will use the given parser to parse
/// the i18n file. Otherwise, it will use the default parser for the format.
FutureOrResult<I18nFile> load({
required String? locale,
bool strict = false,
Parser<String, Map<String, dynamic>>? parser,
});
/// Loads the i18n file from the given [uri].
/// If [parser] is not `null`, it will use the given parser to parse
/// the i18n file. Otherwise, it will use the default parser for the format.
FutureOrResult<I18nFile> loadFrom(
Uri uri, {
Parser<String, Map<String, dynamic>>? parser,
});
/// Gets the translation for the given [key].
///
/// If [arguments] is not `null`, it will replace the placeholders in the
/// translation with the given arguments.
Result<String, AppException> get(
String key, [
Map<String, dynamic> arguments = const {},
]);
/// Sets the current i18n instance.
///
/// This method is used to set the current i18n instance.
Result<void, AppException> setI18n(I18nFile i18n);
/// Gets the current i18n instance.
///
/// This method is used to get the current i18n instance.
Result<I18nFile, AppException> getI18n();
/// Gets the current locale.
///
/// This method is used to get the current locale.
Result<String, AppException> getLocale();
}

View File

@ -18,21 +18,21 @@ import 'package:flutter/cupertino.dart';
import 'package:wyatt_i18n/wyatt_i18n.dart'; import 'package:wyatt_i18n/wyatt_i18n.dart';
/// {@template i18n_delegate} /// {@template i18n_delegate}
/// A [LocalizationsDelegate] that loads the [I18n] instance. /// A [LocalizationsDelegate] that loads the [I18n] instance from
/// the [I18nDataSource].
/// {@endtemplate} /// {@endtemplate}
class I18nDelegate extends LocalizationsDelegate<I18n> { class I18nDelegate extends LocalizationsDelegate<I18n> {
/// {@macro i18n_delegate} /// {@macro i18n_delegate}
I18nDelegate({ I18nDelegate({
required this.repository, required this.dataSource,
}); });
/// The [I18nRepository] that will be used to load the i18n file. final I18nDataSource dataSource;
final I18nRepository repository;
/// The current locale. /// The current locale.
Locale? currentLocale; Locale? currentLocale;
/// Since we are using the [I18nRepository] to load the i18n file, /// Since we are using the [I18nDataSource] to load the i18n file,
/// we don't need to check if the locale is supported. /// we don't need to check if the locale is supported.
/// We can just return `true`. /// We can just return `true`.
@override @override
@ -40,9 +40,10 @@ class I18nDelegate extends LocalizationsDelegate<I18n> {
@override @override
Future<I18n> load(Locale locale) async { Future<I18n> load(Locale locale) async {
await repository.load(locale: locale.languageCode); currentLocale = locale;
final i18n = await dataSource.load(locale: locale);
return I18n(i18nRepository: repository); return i18n;
} }
@override @override

View File

@ -18,6 +18,4 @@
/// And update any widgets that are listening to the i18n map. /// And update any widgets that are listening to the i18n map.
/// ///
/// A top level BLoC is used to provide the i18n map to the app. /// A top level BLoC is used to provide the i18n map to the app.
void main() { void main() {}
}