diff --git a/slang/README.md b/slang/README.md index b6c97d0c..cc9d4358 100644 --- a/slang/README.md +++ b/slang/README.md @@ -956,6 +956,27 @@ Or adjust built-in formats: } ``` +To avoid repetition, you can define custom types via `@@types`. +Please note that the types are locale-specific. If you use [namespaces](#-namespaces), all definitions are merged. + +```json +{ + "@@types": { + "price": "currency(symbol: 'USD')", + "dateOnly": "DateFormat('MM/dd/yyyy')" + }, + "account": "You have {amount: price} in your account", + "today": "Today is {today: dateOnly}", + "tomorrow": "Tomorrow is {tomorrow: dateOnly}" +} +``` + +```dart +String a = t.account(amount: 1234.56); // You have $1,234.56 in your account +String b = t.today(today: DateTime(2023, 3, 2)); // Today is 03/02/2023 +String c = t.tomorrow(tomorrow: DateTime(2023, 3, 5)); // Tomorrow is 03/05/2023 +``` + ### ➤ Interfaces Often, multiple objects have the same attributes. You can create a common super class for that. diff --git a/slang/lib/node.dart b/slang/lib/node.dart index 596db32b..0dd6a9a4 100644 --- a/slang/lib/node.dart +++ b/slang/lib/node.dart @@ -1 +1,2 @@ +export 'package:slang/src/api/formatter.dart'; export 'package:slang/src/builder/model/node.dart'; diff --git a/slang/lib/src/api/formatter.dart b/slang/lib/src/api/formatter.dart new file mode 100644 index 00000000..fd98e84b --- /dev/null +++ b/slang/lib/src/api/formatter.dart @@ -0,0 +1,26 @@ +import 'package:intl/intl.dart'; + +class ValueFormatter { + /// Is either [NumberFormat] or [DateFormat]. + /// Unfortunately, there is no super class for both. + final Object Function() _formatter; + + /// The actual formatter. + /// We delay the initialization to ensure that intl is already initialized + /// by Flutter before we create the formatter. + late final Object formatter = _formatter(); + + ValueFormatter(this._formatter); + + /// Formats the given [value] with the formatter. + String format(Object value) { + switch (formatter) { + case NumberFormat formatter: + return formatter.format(value as num); + case DateFormat formatter: + return formatter.format(value as DateTime); + default: + throw Exception('Unknown formatter: $formatter'); + } + } +} diff --git a/slang/lib/src/api/locale.dart b/slang/lib/src/api/locale.dart index ae4f42c2..e23ff8ea 100644 --- a/slang/lib/src/api/locale.dart +++ b/slang/lib/src/api/locale.dart @@ -1,3 +1,4 @@ +import 'package:slang/src/api/formatter.dart'; import 'package:slang/src/api/pluralization.dart'; import 'package:slang/src/builder/model/node.dart'; @@ -17,6 +18,7 @@ class TranslationMetadata, final Map overrides; final PluralResolver? cardinalResolver; final PluralResolver? ordinalResolver; + final Map types; /// The secret. /// Used to decrypt obfuscated translation strings. @@ -29,6 +31,7 @@ class TranslationMetadata, required this.overrides, required this.cardinalResolver, required this.ordinalResolver, + this.types = const {}, this.s = 0, }); diff --git a/slang/lib/src/builder/builder/translation_model_builder.dart b/slang/lib/src/builder/builder/translation_model_builder.dart index 8ebf48f0..010e1f91 100644 --- a/slang/lib/src/builder/builder/translation_model_builder.dart +++ b/slang/lib/src/builder/builder/translation_model_builder.dart @@ -1,6 +1,7 @@ import 'dart:collection'; import 'package:collection/collection.dart'; +import 'package:slang/src/builder/builder/text/l10n_parser.dart'; import 'package:slang/src/builder/model/build_model_config.dart'; import 'package:slang/src/builder/model/context_type.dart'; import 'package:slang/src/builder/model/enums.dart'; @@ -16,11 +17,13 @@ class BuildModelResult { final ObjectNode root; // the actual strings final List interfaces; // detected interfaces final List contexts; // detected context types + final Map types; // detected types, values are rendered as is BuildModelResult({ required this.root, required this.interfaces, required this.contexts, + required this.types, }); } @@ -71,6 +74,28 @@ class TranslationModelBuilder { context.enumName: context.toPending(), }; + final types = {}; + final typesNode = map['@@types']; + if (typesNode != null && typesNode is Map) { + for (final entry in typesNode.entries) { + final key = entry.key; + final value = entry.value; + if (value is String) { + final typeInfo = parseL10n( + locale: locale, + paramName: 'value', + type: value, + ); + if (typeInfo != null) { + types[key] = FormatTypeInfo( + paramType: typeInfo.paramType, + implementation: typeInfo.format, + ); + } + } + } + } + // 1st iteration: Build nodes according to given map // // Linked Translations: @@ -79,6 +104,7 @@ class TranslationModelBuilder { // Reason: Not all TextNodes are built, so final parameters are unknown final resultNodeTree = _parseMapNode( locale: locale, + types: types, parentPath: '', parentRawPath: '', curr: map, @@ -214,6 +240,10 @@ class TranslationModelBuilder { generateEnum: c.generateEnum, )) .toList(), + types: { + for (final entry in types.entries) + entry.key: entry.value.implementation, + }, ); } } @@ -222,6 +252,7 @@ class TranslationModelBuilder { /// and returns the node model. Map _parseMapNode({ required I18nLocale locale, + required Map types, required String parentPath, required String parentRawPath, required Map curr, @@ -267,6 +298,7 @@ Map _parseMapNode({ rawPath: currRawPath, modifiers: modifiers, locale: locale, + types: types, raw: value.toString(), comment: comment, shouldEscape: shouldEscapeText, @@ -278,6 +310,7 @@ Map _parseMapNode({ rawPath: currRawPath, modifiers: modifiers, locale: locale, + types: types, raw: value.toString(), comment: comment, shouldEscape: shouldEscapeText, @@ -297,6 +330,7 @@ Map _parseMapNode({ }; children = _parseMapNode( locale: locale, + types: types, parentPath: currPath, parentRawPath: currRawPath, curr: listAsMap, @@ -323,6 +357,7 @@ Map _parseMapNode({ // key: { ...value } children = _parseMapNode( locale: locale, + types: types, parentPath: currPath, parentRawPath: currRawPath, curr: value, @@ -379,6 +414,7 @@ Map _parseMapNode({ // rebuild children as RichText digestedMap = _parseMapNode( locale: locale, + types: types, parentPath: currPath, parentRawPath: currRawPath, curr: { @@ -921,3 +957,13 @@ extension on BuildModelConfig { ); } } + +class FormatTypeInfo { + final String paramType; // num or DateTime + final String implementation; // raw string that will be rendered as is + + FormatTypeInfo({ + required this.paramType, + required this.implementation, + }); +} diff --git a/slang/lib/src/builder/builder/translation_model_list_builder.dart b/slang/lib/src/builder/builder/translation_model_list_builder.dart index 899d8c0e..11ed719a 100644 --- a/slang/lib/src/builder/builder/translation_model_list_builder.dart +++ b/slang/lib/src/builder/builder/translation_model_list_builder.dart @@ -42,6 +42,7 @@ class TranslationModelListBuilder { root: baseResult.root, contexts: baseResult.contexts, interfaces: baseResult.interfaces, + types: baseResult.types, ); } else { final result = TranslationModelBuilder.build( @@ -57,6 +58,7 @@ class TranslationModelListBuilder { root: result.root, contexts: result.contexts, interfaces: result.interfaces, + types: result.types, ); } }).toList() diff --git a/slang/lib/src/builder/generator/generate_translations.dart b/slang/lib/src/builder/generator/generate_translations.dart index 8c4e5eed..33f8954f 100644 --- a/slang/lib/src/builder/generator/generate_translations.dart +++ b/slang/lib/src/builder/generator/generate_translations.dart @@ -203,6 +203,15 @@ void _generateClass( buffer.writeln('\t\t overrides: overrides ?? {},'); buffer.writeln('\t\t cardinalResolver: cardinalResolver,'); buffer.writeln('\t\t ordinalResolver: ordinalResolver,'); + if (localeData.types.isNotEmpty) { + buffer.writeln('\t\t types: {'); + for (final entry in localeData.types.entries) { + buffer.writeln( + '\t\t \'${entry.key}\': ValueFormatter(() => ${entry.value.substring(0, entry.value.length - 14)}),', + ); + } + buffer.writeln('\t\t },'); + } if (config.obfuscation.enabled) { final String method; final List parts; diff --git a/slang/lib/src/builder/model/i18n_data.dart b/slang/lib/src/builder/model/i18n_data.dart index c43e05bb..c4433e8e 100644 --- a/slang/lib/src/builder/model/i18n_data.dart +++ b/slang/lib/src/builder/model/i18n_data.dart @@ -12,6 +12,7 @@ class I18nData { final ObjectNode root; // the actual strings final List contexts; // detected context types final List interfaces; // detected interfaces + final Map types; // detected types, values are rendered as is I18nData({ required this.base, @@ -19,6 +20,7 @@ class I18nData { required this.root, required this.contexts, required this.interfaces, + required this.types, }); /// sorts base locale first, then alphabetically diff --git a/slang/lib/src/builder/model/node.dart b/slang/lib/src/builder/model/node.dart index 55288eaf..6182b536 100644 --- a/slang/lib/src/builder/model/node.dart +++ b/slang/lib/src/builder/model/node.dart @@ -1,5 +1,6 @@ import 'package:slang/src/builder/builder/text/l10n_parser.dart'; import 'package:slang/src/builder/builder/text/param_parser.dart'; +import 'package:slang/src/builder/builder/translation_model_builder.dart'; import 'package:slang/src/builder/model/context_type.dart'; import 'package:slang/src/builder/model/enums.dart'; import 'package:slang/src/builder/model/i18n_locale.dart'; @@ -218,6 +219,9 @@ abstract class TextNode extends Node implements LeafNode { /// The locale of the text node final I18nLocale locale; + /// User-defined types for the locale + final Map types; + /// The original string final String raw; @@ -251,6 +255,7 @@ abstract class TextNode extends Node implements LeafNode { required super.modifiers, required super.comment, required this.locale, + required this.types, required this.raw, required this.shouldEscape, required this.interpolation, @@ -297,6 +302,7 @@ class StringTextNode extends TextNode { required super.rawPath, required super.modifiers, required super.locale, + required super.types, required super.raw, required super.comment, required super.shouldEscape, @@ -306,6 +312,7 @@ class StringTextNode extends TextNode { }) { final parsedResult = _parseInterpolation( locale: locale, + types: types, raw: shouldEscape ? _escapeContent(raw, interpolation) : raw, interpolation: interpolation, defaultType: 'Object', @@ -341,6 +348,7 @@ class StringTextNode extends TextNode { rawPath: rawPath, modifiers: modifiers, locale: locale, + types: types, raw: raw, comment: comment, shouldEscape: shouldEscape, @@ -392,6 +400,7 @@ class RichTextNode extends TextNode { required super.rawPath, required super.modifiers, required super.locale, + required super.types, required super.raw, required super.comment, required super.shouldEscape, @@ -401,6 +410,7 @@ class RichTextNode extends TextNode { }) { final rawParsedResult = _parseInterpolation( locale: locale, + types: types, raw: shouldEscape ? _escapeContent(raw, interpolation) : raw, interpolation: interpolation, defaultType: 'ignored', @@ -477,6 +487,7 @@ class RichTextNode extends TextNode { rawPath: rawPath, modifiers: modifiers, locale: locale, + types: types, raw: raw, comment: comment, shouldEscape: shouldEscape, @@ -542,6 +553,7 @@ class _ParseInterpolationResult { _ParseInterpolationResult _parseInterpolation({ required I18nLocale locale, + required Map types, required String raw, required StringInterpolation interpolation, required String defaultType, @@ -558,6 +570,12 @@ _ParseInterpolationResult _parseInterpolation({ caseStyle: paramCase, ); + final existingType = types[parsedParam.paramType]; + if (existingType != null) { + params[parsedParam.paramName] = existingType.paramType; + return '\${_root.\$meta.types[\'${parsedParam.paramType}\']!.format(${parsedParam.paramName})}'; + } + final parsedL10n = parseL10n( locale: locale, paramName: parsedParam.paramName, diff --git a/slang/lib/src/builder/model/translation_map.dart b/slang/lib/src/builder/model/translation_map.dart index 22a6170a..de111881 100644 --- a/slang/lib/src/builder/model/translation_map.dart +++ b/slang/lib/src/builder/model/translation_map.dart @@ -25,6 +25,16 @@ class TranslationMap { } _internalMap[locale]![namespace] = translations; + + // Copy types of each namespace to the global types map, + // merging them with existing types. + final typesMap = translations['@@types'] as Map?; + if (typesMap != null) { + _internalMap[locale]!['@@types'] = { + ...?_internalMap[locale]!['@@types'], + ...typesMap, + }; + } } /// Return all locales specified in this map diff --git a/slang/test/unit/model/i18n_data_test.dart b/slang/test/unit/model/i18n_data_test.dart index 29e5de8b..ac3e2f0f 100644 --- a/slang/test/unit/model/i18n_data_test.dart +++ b/slang/test/unit/model/i18n_data_test.dart @@ -17,6 +17,7 @@ I18nData _i18n(String locale, [bool base = false]) { ), contexts: [], interfaces: [], + types: {}, ); } diff --git a/slang/test/util/text_node_builder.dart b/slang/test/util/text_node_builder.dart index 3fd38aa8..603abc73 100644 --- a/slang/test/util/text_node_builder.dart +++ b/slang/test/util/text_node_builder.dart @@ -15,6 +15,7 @@ StringTextNode textNode( rawPath: '', modifiers: {}, locale: _locale, + types: {}, raw: raw, comment: null, interpolation: interpolation, @@ -36,6 +37,7 @@ RichTextNode richTextNode( modifiers: {}, comment: null, locale: _locale, + types: {}, raw: raw, interpolation: interpolation, paramCase: paramCase,