feat: typed translation generator (#24693)

* feat: typed translation generator

* replace legacy key usage with new generated keys

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong
2025-12-20 21:03:19 -06:00
committed by GitHub
parent e1f5f3b939
commit a887595a40
9 changed files with 358 additions and 72 deletions

View File

@@ -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<String, dynamic>;
final outputFile = File('lib/generated/translations.g.dart');
await _generateTranslations(translations, outputFile);
print('Generated ${outputFile.path}');
}
Future<void> _generate(File source, File output) async {
final content = await source.readAsString();
final translations = json.decode(content) as Map<String, dynamic>;
class TranslationNode {
final String key;
final String? value;
final Map<String, TranslationNode> children;
final List<TranslationParam> params;
const TranslationNode({
required this.key,
this.value,
Map<String, TranslationNode>? children,
List<TranslationParam>? 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<void> _generateTranslations(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>) {
_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<String, Object>? 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<String, dynamic>) {
final children = <String, TranslationNode>{};
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<TranslationParam> _extractParams(String value) {
final params = <String, TranslationParam>{};
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<TranslationParam> 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 = <String>[];
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;
}

View File

@@ -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<ImmichApp> 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 {

View File

@@ -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<DriftBackupPage> {
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,
),

View File

@@ -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(

View File

@@ -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,

View File

@@ -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))),
);
},
),

View File

@@ -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()),

View File

@@ -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

View File

@@ -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"]