diff --git a/mobile/bin/generate_keys.dart b/mobile/bin/generate_keys.dart index 8353b1c6f4..3c5c284c3e 100644 --- a/mobile/bin/generate_keys.dart +++ b/mobile/bin/generate_keys.dart @@ -3,7 +3,99 @@ import 'dart:convert'; import 'dart:io'; -const _kReservedWords = ['continue']; +const _kReservedWords = [ + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'Function', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', +]; + +const _kIntParamNames = [ + 'count', + 'number', + 'amount', + 'total', + 'index', + 'size', + 'length', + 'width', + 'height', + 'year', + 'month', + 'day', + 'hour', + 'minute', + 'second', + 'page', + 'limit', + 'offset', + 'max', + 'min', + 'id', + 'num', + 'quantity', +]; void main() async { final sourceFile = File('../i18n/en.json'); @@ -15,49 +107,258 @@ void main() async { final outputDir = Directory('lib/generated'); await outputDir.create(recursive: true); - final outputFile = File('lib/generated/intl_keys.g.dart'); - await _generate(sourceFile, outputFile); + final content = await sourceFile.readAsString(); + final translations = json.decode(content) as Map; + + final outputFile = File('lib/generated/translations.g.dart'); + await _generateTranslations(translations, outputFile); print('Generated ${outputFile.path}'); } -Future _generate(File source, File output) async { - final content = await source.readAsString(); - final translations = json.decode(content) as Map; +class TranslationNode { + final String key; + final String? value; + final Map children; + final List params; + + const TranslationNode({ + required this.key, + this.value, + Map? children, + List? params, + }) : children = children ?? const {}, + params = params ?? const []; + + bool get isLeaf => value != null; + bool get hasParams => params.isNotEmpty; +} + +class TranslationParam { + final String name; + final String type; + + const TranslationParam(this.name, this.type); +} + +Future _generateTranslations(Map translations, File output) async { + final root = _buildTranslationTree('', translations); final buffer = StringBuffer(''' // DO NOT EDIT. This is code generated via generate_keys.dart -abstract class IntlKeys { -'''); +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/widgets.dart'; +import 'package:intl/message_format.dart'; - _writeKeys(buffer, translations); - buffer.writeln('}'); - - await output.writeAsString(buffer.toString()); +extension TranslationsExtension on BuildContext { + Translations get t => Translations.of(this); } -void _writeKeys( - StringBuffer buffer, - Map map, [ - String prefix = '', -]) { - for (final entry in map.entries) { - final key = entry.key; - final value = entry.value; +class StaticTranslations { + StaticTranslations._(); + static final instance = Translations._(null); +} - if (value is Map) { - _writeKeys(buffer, value, prefix.isEmpty ? key : '${prefix}_$key'); - } else { - final name = _cleanName(prefix.isEmpty ? key : '${prefix}_$key'); - final path = prefix.isEmpty ? key : '$prefix.$key'.replaceAll('_', '.'); - buffer.writeln(' static const $name = \'$path\';'); +abstract class _BaseTranslations { + BuildContext? get _context; + + String _t(String key, [Map? args]) { + if (key.isEmpty) return ''; + try { + final translated = key.tr(context: _context); + return args != null + ? MessageFormat(translated, locale: Intl.defaultLocale ?? 'en').format(args) + : translated; + } catch (e) { + return key; } } } -String _cleanName(String name) { - name = name.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_'); - if (RegExp(r'^[0-9]').hasMatch(name)) name = 'k_$name'; - if (_kReservedWords.contains(name)) name = '${name}_'; +class Translations extends _BaseTranslations { + @override + final BuildContext? _context; + Translations._(this._context); + + static Translations of(BuildContext context) { + context.locale; + return Translations._(context); + } + +'''); + + _generateClassMembers(buffer, root, ' '); + buffer.writeln('}'); + _generateNestedClasses(buffer, root); + + await output.writeAsString(buffer.toString()); +} + +TranslationNode _buildTranslationTree(String key, dynamic value) { + if (value is Map) { + final children = {}; + for (final entry in value.entries) { + children[entry.key] = _buildTranslationTree(entry.key, entry.value); + } + return TranslationNode(key: key, children: children); + } else { + final stringValue = value.toString(); + final params = _extractParams(stringValue); + return TranslationNode(key: key, value: stringValue, params: params); + } +} + +List _extractParams(String value) { + final params = {}; + + final icuRegex = RegExp(r'\{(\w+),\s*(plural|select|number|date|time)([^}]*(?:\{[^}]*\}[^}]*)*)\}'); + for (final match in icuRegex.allMatches(value)) { + final name = match.group(1)!; + final icuType = match.group(2)!; + final icuContent = match.group(3) ?? ''; + + if (params.containsKey(name)) continue; + + String type; + if (icuType == 'plural' || icuType == 'number') { + type = 'int'; + } else if (icuType == 'select') { + final hasTrueFalse = RegExp(r',\s*(true|false)\s*\{').hasMatch(icuContent); + type = hasTrueFalse ? 'bool' : 'String'; + } else { + type = 'String'; + } + + params[name] = TranslationParam(name, type); + } + + var cleanedValue = value; + var depth = 0; + var icuStart = -1; + + for (var i = 0; i < value.length; i++) { + if (value[i] == '{') { + if (depth == 0) icuStart = i; + depth++; + } else if (value[i] == '}') { + depth--; + if (depth == 0 && icuStart >= 0) { + final block = value.substring(icuStart, i + 1); + if (RegExp(r'^\{\w+,').hasMatch(block)) { + cleanedValue = cleanedValue.replaceFirst(block, ''); + } + icuStart = -1; + } + } + } + + final simpleRegex = RegExp(r'\{(\w+)\}'); + for (final match in simpleRegex.allMatches(cleanedValue)) { + final name = match.group(1)!; + + if (params.containsKey(name)) continue; + + String type; + if (_kIntParamNames.contains(name.toLowerCase())) { + type = 'int'; + } else { + type = 'Object'; + } + + params[name] = TranslationParam(name, type); + } + + return params.values.toList(); +} + +void _generateClassMembers(StringBuffer buffer, TranslationNode node, String indent, [String keyPrefix = '']) { + final sortedKeys = node.children.keys.toList()..sort(); + + for (final childKey in sortedKeys) { + final child = node.children[childKey]!; + final dartName = _escapeName(childKey); + final fullKey = keyPrefix.isEmpty ? childKey : '$keyPrefix.$childKey'; + + if (child.isLeaf) { + if (child.hasParams) { + _generateMethod(buffer, dartName, fullKey, child.params, indent); + } else { + _generateGetter(buffer, dartName, fullKey, indent); + } + } else { + final className = _toNestedClassName(keyPrefix, childKey); + buffer.writeln('${indent}late final $dartName = $className._(_context);'); + } + } +} + +void _generateGetter(StringBuffer buffer, String dartName, String translationKey, String indent) { + buffer.writeln('${indent}String get $dartName => _t(\'$translationKey\');'); +} + +void _generateMethod( + StringBuffer buffer, + String dartName, + String translationKey, + List params, + String indent, +) { + final paramList = params.map((p) => 'required ${p.type} ${_escapeName(p.name)}').join(', '); + final argsMap = params.map((p) => '\'${p.name}\': ${_escapeName(p.name)}').join(', '); + buffer.writeln('${indent}String $dartName({$paramList}) => _t(\'$translationKey\', {$argsMap});'); +} + +void _generateNestedClasses(StringBuffer buffer, TranslationNode node, [String keyPrefix = '']) { + final sortedKeys = node.children.keys.toList()..sort(); + + for (final childKey in sortedKeys) { + final child = node.children[childKey]!; + final fullKey = keyPrefix.isEmpty ? childKey : '$keyPrefix.$childKey'; + + if (!child.isLeaf && child.children.isNotEmpty) { + final className = _toNestedClassName(keyPrefix, childKey); + buffer.writeln(); + buffer.writeln('class $className extends _BaseTranslations {'); + buffer.writeln(' @override'); + buffer.writeln(' final BuildContext? _context;'); + buffer.writeln(' $className._(this._context);'); + _generateClassMembers(buffer, child, ' ', fullKey); + buffer.writeln('}'); + _generateNestedClasses(buffer, child, fullKey); + } + } +} + +String _toNestedClassName(String prefix, String key) { + final parts = []; + if (prefix.isNotEmpty) { + parts.addAll(prefix.split('.')); + } + parts.add(key); + + final result = StringBuffer('_'); + for (final part in parts) { + final words = part.split('_'); + for (final word in words) { + if (word.isNotEmpty) { + result.write(word[0].toUpperCase()); + if (word.length > 1) { + result.write(word.substring(1).toLowerCase()); + } + } + } + } + result.write('Translations'); + + return result.toString(); +} + +String _escapeName(String name) { + if (_kReservedWords.contains(name)) { + return '$name\$'; + } + if (RegExp(r'^[0-9]').hasMatch(name)) { + return 'k$name'; + } return name; } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c3804d97f6..eb2da9b5c1 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -16,9 +16,8 @@ import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/domain/services/background_worker.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; -import 'package:immich_mobile/generated/intl_keys.g.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; @@ -217,8 +216,8 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve ref .read(backgroundWorkerFgServiceProvider) .saveNotificationMessage( - IntlKeys.uploading_media.t(), - IntlKeys.backup_background_service_default_notification.t(), + StaticTranslations.instance.uploading_media, + StaticTranslations.instance.backup_background_service_default_notification, ); } } else { diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 47052ea436..b32a3bf843 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/generated/intl_keys.g.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; @@ -153,7 +153,7 @@ class _DriftBackupPageState extends ConsumerState { Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1), const SizedBox(width: 8), Text( - IntlKeys.backup_error_sync_failed.t(), + context.t.backup_error_sync_failed, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error), textAlign: TextAlign.center, ), diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index 1cfab355d6..c7c34b9cd2 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -7,7 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/generated/intl_keys.g.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class SettingsHeader { String key = ""; @@ -61,7 +61,7 @@ class HeaderSettingsPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( - title: const Text(IntlKeys.headers_settings_tile_title).tr(), + title: Text(context.t.headers_settings_tile_title), centerTitle: false, actions: [ IconButton( diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index 483427d2de..8319915ad9 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/generated/intl_keys.g.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/partner.provider.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; @@ -41,13 +41,13 @@ class LibraryPage extends ConsumerWidget { ActionButton( onPressed: () => context.pushRoute(const FavoritesRoute()), icon: Icons.favorite_outline_rounded, - label: IntlKeys.favorites.tr(), + label: context.t.favorites, ), const SizedBox(width: 8), ActionButton( onPressed: () => context.pushRoute(const ArchiveRoute()), icon: Icons.archive_outlined, - label: IntlKeys.archived.tr(), + label: context.t.archived, ), ], ), @@ -58,14 +58,14 @@ class LibraryPage extends ConsumerWidget { ActionButton( onPressed: () => context.pushRoute(const SharedLinkRoute()), icon: Icons.link_outlined, - label: IntlKeys.shared_links.tr(), + label: context.t.shared_links, ), SizedBox(width: trashEnabled ? 8 : 0), trashEnabled ? ActionButton( onPressed: () => context.pushRoute(const TrashRoute()), icon: Icons.delete_outline_rounded, - label: IntlKeys.trash.tr(), + label: context.t.trash, ) : const SizedBox.shrink(), ], @@ -120,26 +120,20 @@ class QuickAccessButtons extends ConsumerWidget { ), ), leading: const Icon(Icons.folder_outlined, size: 26), - title: Text( - IntlKeys.folders.tr(), - style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), - ), + title: Text(context.t.folders, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)), onTap: () => context.pushRoute(FolderRoute()), ), ListTile( leading: const Icon(Icons.lock_outline_rounded, size: 26), title: Text( - IntlKeys.locked_folder.tr(), + context.t.locked_folder, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), ), onTap: () => context.pushRoute(const LockedRoute()), ), ListTile( leading: const Icon(Icons.group_outlined, size: 26), - title: Text( - IntlKeys.partners.tr(), - style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), - ), + title: Text(context.t.partners, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)), onTap: () => context.pushRoute(const PartnerRoute()), ), PartnerList(partners: partners), @@ -235,7 +229,7 @@ class PeopleCollectionCard extends ConsumerWidget { Padding( padding: const EdgeInsets.all(8.0), child: Text( - IntlKeys.people.tr(), + context.t.people, style: context.textTheme.titleSmall?.copyWith( color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, @@ -295,7 +289,7 @@ class LocalAlbumsCollectionCard extends HookConsumerWidget { Padding( padding: const EdgeInsets.all(8.0), child: Text( - IntlKeys.on_this_device.tr(), + context.t.on_this_device, style: context.textTheme.titleSmall?.copyWith( color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, @@ -346,7 +340,7 @@ class PlacesCollectionCard extends StatelessWidget { Padding( padding: const EdgeInsets.all(8.0), child: Text( - IntlKeys.places.tr(), + context.t.places, style: context.textTheme.titleSmall?.copyWith( color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, diff --git a/mobile/lib/presentation/pages/drift_trash.page.dart b/mobile/lib/presentation/pages/drift_trash.page.dart index 8713166027..a85f69a75e 100644 --- a/mobile/lib/presentation/pages/drift_trash.page.dart +++ b/mobile/lib/presentation/pages/drift_trash.page.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -43,9 +44,7 @@ class DriftTrashPage extends StatelessWidget { return SliverPadding( padding: const EdgeInsets.all(16.0), - sliver: SliverToBoxAdapter( - child: const Text("trash_page_info").t(context: context, args: {"days": "$trashDays"}), - ), + sliver: SliverToBoxAdapter(child: Text(context.t.trash_page_info(days: trashDays))), ); }, ), diff --git a/mobile/lib/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart b/mobile/lib/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart index c3bb64faf6..d7e547054e 100644 --- a/mobile/lib/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart +++ b/mobile/lib/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart @@ -1,9 +1,8 @@ import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/generated/intl_keys.g.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/routing/router.dart'; class CustomProxyHeaderSettings extends StatelessWidget { @@ -15,11 +14,11 @@ class CustomProxyHeaderSettings extends StatelessWidget { contentPadding: const EdgeInsets.symmetric(horizontal: 20), dense: true, title: Text( - IntlKeys.advanced_settings_proxy_headers_title.tr(), + context.t.advanced_settings_proxy_headers_title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), ), subtitle: Text( - IntlKeys.advanced_settings_proxy_headers_subtitle.tr(), + context.t.advanced_settings_proxy_headers_subtitle, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), onTap: () => context.pushRoute(const HeaderSettingsRoute()), diff --git a/mobile/makefile b/mobile/makefile index b90e95c902..ef134a1466 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -37,7 +37,7 @@ translation: dart run easy_localization:generate -S ../i18n dart run bin/generate_keys.dart dart format lib/generated/codegen_loader.g.dart - dart format lib/generated/intl_keys.g.dart + dart format lib/generated/translations.g.dart analyze: dart analyze --fatal-infos diff --git a/mobile/mise.toml b/mobile/mise.toml index cdafd1cc18..6767836aa3 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -32,13 +32,7 @@ depends = [ [tasks."codegen:translation"] alias = "translation" description = "Generate translations from i18n JSONs" -run = [ - { task = "//i18n:format-fix" }, - { tasks = [ - "i18n:loader", - "i18n:keys", - ] }, -] +run = [{ task = "//i18n:format-fix" }, { tasks = ["i18n:loader", "i18n:keys"] }] [tasks."codegen:app-icon"] description = "Generate app icons" @@ -158,10 +152,10 @@ run = [ description = "Generate i18n keys" hide = true sources = ["i18n/en.json"] -outputs = "lib/generated/intl_keys.g.dart" +outputs = "lib/generated/translations.g.dart" run = [ "dart run bin/generate_keys.dart", - "dart format lib/generated/intl_keys.g.dart", + "dart format lib/generated/translations.g.dart", ] [tasks."analyze:dart"]