Compare commits

...

3 Commits

9 changed files with 240 additions and 38 deletions

View File

@ -58,7 +58,13 @@ class MalformedValueException extends ClientException {
: super('Key `$key` references a malformed value. ($value)');
}
/// Exception thrown when the parser fails.
class ParserException extends ClientException {
ParserException(String message, StackTrace? stackTrace)
: super('$message\n\n$stackTrace');
}
/// Exception thrown when the i18n is not loaded.
class NotLoadedException extends ClientException {
NotLoadedException() : super('I18n not loaded.');
}

View File

@ -0,0 +1,40 @@
// 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 'dart:convert';
import 'package:flutter/services.dart';
class AssetsUtils {
/// Checks if the given [assetPath] is a local asset.
///
/// In fact, this method loads the asset and checks if it is null.
static Future<bool> assetExists(String assetPath) async {
final encoded = utf8.encoder.convert(
Uri(path: Uri.encodeFull(assetPath)).path,
);
final asset = await ServicesBinding.instance.defaultBinaryMessenger
.send('flutter/assets', encoded.buffer.asByteData());
return asset != null;
}
/// Cleans the given [path] by removing all the consecutive slashes.
/// For example, `///a///b///c///` becomes `/a/b/c/`.
/// It also removes the first slash if it exists.
static String cleanPath(String path) =>
path.replaceAll(RegExp(r'\/+'), '/').replaceFirst(RegExp(r'^\/'), '');
}

View File

@ -14,11 +14,10 @@
// 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 'dart:convert';
import 'package:flutter/services.dart' show ServicesBinding, rootBundle;
import 'package:flutter/services.dart' show AssetBundle, rootBundle;
import 'package:wyatt_i18n/src/core/enums/format.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/domain/data_sources/i18n_data_source.dart';
/// {@template assets_file_data_source_impl}
@ -34,10 +33,11 @@ import 'package:wyatt_i18n/src/domain/data_sources/i18n_data_source.dart';
/// {@endtemplate}
class AssetsFileDataSourceImpl extends I18nDataSource {
/// {@macro assets_file_data_source_impl}
const AssetsFileDataSourceImpl({
AssetsFileDataSourceImpl({
this.basePath = 'assets',
this.baseName = 'i18n',
super.format = Format.arb,
super.defaultLocale = 'en',
}) : super();
/// The folder where the i18n files are located.
@ -46,29 +46,23 @@ class AssetsFileDataSourceImpl extends I18nDataSource {
/// The name of the i18n files without the extension.
final String baseName;
/// Checks if the given [assetPath] is a local asset.
///
/// In fact, this method loads the asset and checks if it is null.
Future<bool> assetExists(String assetPath) async {
final encoded = utf8.encoder.convert(
Uri(path: Uri.encodeFull(assetPath)).path,
);
final asset = await ServicesBinding.instance.defaultBinaryMessenger
.send('flutter/assets', encoded.buffer.asByteData());
return asset != null;
}
/// The asset bundle used to load the i18n files.
final AssetBundle assetBundle = rootBundle;
/// Tries to load the i18n file from the given [locale].
Future<String> _tryLoad(String? locale) async {
String? content;
final ext = format.name;
final path = locale == null
? '$basePath/$baseName.$ext'
: '$basePath/$baseName.$locale.$ext';
final path = AssetsUtils.cleanPath(
locale == null
? '$basePath/$baseName.$defaultLocale.$ext'
: '$basePath/$baseName.$locale.$ext',
);
try {
if (await assetExists(path)) {
content = await rootBundle.loadString(path);
if (await AssetsUtils.assetExists(path)) {
content = await assetBundle.loadString(path);
}
} catch (_) {
content = null;
@ -78,7 +72,10 @@ class AssetsFileDataSourceImpl extends I18nDataSource {
/// default i18n file.
if (content == null && locale != null) {
try {
content = await rootBundle.loadString('$basePath/$baseName.$ext');
final fallbackPath = AssetsUtils.cleanPath(
'$basePath/$baseName.$defaultLocale.$ext',
);
content = await assetBundle.loadString(fallbackPath);
} catch (_) {
throw SourceNotFoundException(path, format: format);
}
@ -98,7 +95,7 @@ class AssetsFileDataSourceImpl extends I18nDataSource {
Future<String> _tryLoadUri(Uri uri) async {
String? content;
try {
content = await rootBundle.loadString(uri.toString());
content = await assetBundle.loadString(uri.toString());
} catch (_) {
throw SourceNotFoundException(uri.toString(), format: format);
}

View File

@ -14,21 +14,117 @@
// 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/services.dart';
import 'package:http/http.dart' as http;
import 'package:wyatt_i18n/src/core/enums/format.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/domain/data_sources/i18n_data_source.dart';
/// {@template network_data_source_impl}
/// Implementation of [I18nDataSource] that loads i18n files from the network.
///
/// 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 [fallbackAssetPath] is the path to the fallback i18n files.
/// The [format] is the format of the i18n files.
///
/// 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
/// `https://example.com/i18n/` and the [baseName] is `i18n` and the
/// [format] is [Format.arb].
///
/// If the i18n file for the given locale is not found or if the fetch fails,
/// then the fallback i18n files are loaded from the [fallbackAssetPath] in
/// the root bundle.
///
/// For example, if the fallback i18n files are located in the `assets` and are
/// named `i18n.arb`, `i18n.en.arb`, `i18n.fr.arb`, etc., then the
/// [fallbackAssetPath] is `assets`.
/// {@endtemplate}
class NetworkDataSourceImpl extends I18nDataSource {
const NetworkDataSourceImpl({super.format = Format.arb}) : super();
@override
Future<String> load({required String? locale}) {
// TODO(wyatt): implement load
throw UnimplementedError();
/// {@macro network_data_source_impl}
const NetworkDataSourceImpl({
required this.baseUri,
this.baseName = 'i18n',
this.fallbackAssetPath = 'assets',
super.format = Format.arb,
super.defaultLocale = 'en',
}) : super();
/// The base uri where the i18n files are located.
final Uri baseUri;
/// The name of the i18n files without the extension.
final String baseName;
/// The path to the fallback i18n files.
///
/// This is used when the i18n file for the given locale is not found or
/// if the fetch fails.
final String fallbackAssetPath;
/// Tries to load the i18n file from the given [locale].
Future<String> _tryLoad(String? locale, {Uri? overrideUri}) async {
String? content;
final ext = format.name;
/// If the locale is null, then we try to load the default i18n file from
/// the fallback asset path.
/// Otherwise, we try to load the i18n file for the given locale from the
/// base uri.
final path = AssetsUtils.cleanPath(
locale == null
? '$fallbackAssetPath/$baseName.$defaultLocale.$ext'
: overrideUri?.toString() ?? '$baseUri/$baseName.$locale.$ext',
);
if (locale == null) {
/// Load the default i18n file from the fallback asset path.
try {
if (await AssetsUtils.assetExists(path)) {
content = await rootBundle.loadString(path);
}
} catch (_) {
content = null;
}
} else {
/// Load the i18n file for the given locale from the base uri.
try {
final response = await http.get(Uri.parse(path));
if (response.statusCode >= 200 && response.statusCode < 300) {
content = response.body;
}
} catch (_) {
content = null;
}
}
/// If the i18n file is not found, then we try to load the
/// default i18n file.
if (content == null && locale != null) {
try {
final fallbackPath = AssetsUtils.cleanPath(
'$fallbackAssetPath/$baseName.$defaultLocale.$ext',
);
content = await rootBundle.loadString(fallbackPath);
} catch (_) {
throw SourceNotFoundException(path, format: format);
}
}
/// If the default i18n file is not found, then we throw an exception.
/// This case should happen only if the locale is null.
if (content == null) {
throw SourceNotFoundException(path, format: format);
}
return content;
}
@override
Future<String> loadFrom(Uri uri) {
// TODO(wyatt): implement loadFrom
throw UnimplementedError();
}
Future<String> load({required String? locale}) async => _tryLoad(locale);
@override
Future<String> loadFrom(Uri uri) async => _tryLoad(null, overrideUri: uri);
}

View File

@ -15,18 +15,27 @@
// 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/i18n_parser.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.
I18n _i18n = const I18n.empty();
@override
I18n get i18n => _i18n;
Future<I18n> _parse(
String content,
Parser<String, Map<String, dynamic>> parser, {
@ -129,4 +138,19 @@ class I18nRepositoryImpl extends I18nRepository {
throw KeyNotFoundException(key, arguments);
}
}
@override
Result<I18n, 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(I18n i18n) {
_i18n = i18n;
return const Ok(null);
}
}

View File

@ -17,23 +17,33 @@
import 'package:wyatt_architecture/wyatt_architecture.dart';
import 'package:wyatt_i18n/src/core/enums/format.dart';
/// {@template i18n_data_source}
/// Base class for i18n data sources.
///
///
/// This class is used to load i18n files from a source.
/// It returns a [Future] that resolves to a [String] containing the i18n file
/// contents.
/// {@endtemplate}
abstract class I18nDataSource extends BaseDataSource {
const I18nDataSource({this.format = Format.arb});
/// {@macro i18n_data_source}
const I18nDataSource({
this.format = Format.arb,
this.defaultLocale = 'en',
});
/// The format of the i18n file.
final Format format;
/// The default locale.
final String defaultLocale;
/// Loads the i18n file from the source.
/// If [locale] is not `null`, it will load the file with the given [locale].
/// Otherwise, it will load the file from the default location.
Future<String> load({required String? locale});
/// Loads the i18n file from the source.
///
///
/// This method is used to load the i18n file from the given [Uri].
Future<String> loadFrom(Uri uri);
}

View File

@ -39,4 +39,6 @@ class I18n extends Entity {
bool containsKey(String key) => data.containsKey(key);
dynamic operator [](String key) => data[key];
bool get isEmpty => data.isEmpty && unparsedData.isEmpty && locale.isEmpty;
}

View File

@ -19,10 +19,18 @@ import 'package:wyatt_i18n/src/core/utils/parser.dart';
import 'package:wyatt_i18n/src/domain/entities/i18n.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.
I18n 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
@ -44,8 +52,26 @@ abstract class I18nRepository extends BaseRepository {
});
/// 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(I18n i18n);
/// Gets the current i18n instance.
///
/// This method is used to get the current i18n instance.
Result<I18n, AppException> getI18n();
/// Gets the current locale.
///
/// This method is used to get the current locale.
Result<String, AppException> getLocale();
}

View File

@ -9,6 +9,7 @@ environment:
dependencies:
collection: ^1.17.0
flutter: {sdk: flutter}
http: ^0.13.5
intl: ^0.18.0
path: ^1.8.0
petitparser: ^5.1.0