1
0
mirror of https://github.com/immich-app/immich.git synced 2025-08-08 23:07:06 +02:00

re-write localization service and add translation extension

This commit is contained in:
dvbthien
2025-06-05 16:03:32 +07:00
parent 86f64fd0bf
commit fdd7386020
5 changed files with 190 additions and 77 deletions

View File

@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/services/localization.service.dart';
final _translationService = EasyLocalizationService();
extension StringTranslation on String {
String t([Map<String, Object>? args]) {
return _translationService.translate(this, args);
}
}
extension BuildContextTranslation on BuildContext {
String t(String key, [Map<String, Object>? args]) {
return _translationService.translate(key, args);
}
}

View File

@ -11,6 +11,7 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translation_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
@ -22,6 +23,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/routing/app_navigation_observer.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart';
import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
@ -158,12 +160,12 @@ class ImmichAppState extends ConsumerState<ImmichApp>
void _configureFileDownloaderNotifications() { void _configureFileDownloaderNotifications() {
FileDownloader().configureNotification( FileDownloader().configureNotification(
running: TaskNotification( running: TaskNotification(
'downloading_media'.tr(), 'downloading_media'.t(),
'${'file_name'.tr()}: {filename}', '${'file_name'.t()}: {filename}',
), ),
complete: TaskNotification( complete: TaskNotification(
'download_finished'.tr(), 'download_finished'.t(),
'${'file_name'.tr()}: {filename}', '${'file_name'.t()}: {filename}',
), ),
progressBar: true, progressBar: true,
); );
@ -205,34 +207,34 @@ class ImmichAppState extends ConsumerState<ImmichApp>
overrides: [ overrides: [
localeProvider.overrideWithValue(context.locale), localeProvider.overrideWithValue(context.locale),
], ],
child: MaterialApp( child: MaterialApp.router(
title: 'Immich',
debugShowCheckedModeBanner: true,
localizationsDelegates: context.localizationDelegates, localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales, supportedLocales: context.supportedLocales,
locale: context.locale, locale: context.locale,
debugShowCheckedModeBanner: true, themeMode: ref.watch(immichThemeModeProvider),
home: MaterialApp.router( darkTheme: getThemeData(
title: 'Immich', colorScheme: immichTheme.dark,
debugShowCheckedModeBanner: false, locale: context.locale,
themeMode: ref.watch(immichThemeModeProvider),
darkTheme: getThemeData(
colorScheme: immichTheme.dark,
locale: context.locale,
),
theme: getThemeData(
colorScheme: immichTheme.light,
locale: context.locale,
),
routeInformationParser: router.defaultRouteParser(),
routerDelegate: router.delegate(
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
),
), ),
theme: getThemeData(
colorScheme: immichTheme.light,
locale: context.locale,
),
routeInformationParser: router.defaultRouteParser(),
routerDelegate: router.delegate(
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
),
builder: (context, child) {
EasyLocalizationService.setContext(context);
return child!;
},
), ),
); );
} }
} }
// ignore: prefer-single-widget-per-file
class MainWidget extends StatelessWidget { class MainWidget extends StatelessWidget {
const MainWidget({super.key}); const MainWidget({super.key});

View File

@ -6,7 +6,6 @@ import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -34,6 +33,7 @@ import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart'; import 'package:path_provider_foundation/path_provider_foundation.dart';
import 'package:immich_mobile/extensions/translation_extensions.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final backgroundServiceProvider = Provider((ref) => BackgroundService()); final backgroundServiceProvider = Provider((ref) => BackgroundService());
@ -76,8 +76,7 @@ class BackgroundService {
Future<bool> enableService({bool immediate = false}) async { Future<bool> enableService({bool immediate = false}) async {
try { try {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!; final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
final String title = final String title = 'backup_background_service_default_notification'.t();
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel final bool ok = await _foregroundChannel
.invokeMethod('enable', [callback.toRawHandle(), title, immediate]); .invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
return ok; return ok;
@ -325,7 +324,8 @@ class BackgroundService {
return false; return false;
} }
final translationsOk = await loadTranslations(); final translationsOk =
await EasyLocalizationService().loadTranslations();
if (!translationsOk) { if (!translationsOk) {
debugPrint("[_callHandler] could not load translations"); debugPrint("[_callHandler] could not load translations");
} }
@ -452,8 +452,8 @@ class BackgroundService {
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload); toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
} catch (e) { } catch (e) {
_showErrorNotification( _showErrorNotification(
title: "backup_background_service_error_title".tr(), title: 'backup_background_service_error_title'.t(),
content: "backup_background_service_connection_failed_message".tr(), content: 'backup_background_service_connection_failed_message'.t(),
); );
return false; return false;
} }
@ -468,7 +468,7 @@ class BackgroundService {
_assetsToUploadCount = toUpload.length; _assetsToUploadCount = toUpload.length;
_uploadedAssetsCount = 0; _uploadedAssetsCount = 0;
_updateNotification( _updateNotification(
title: "backup_background_service_in_progress_notification".tr(), title: 'backup_background_service_in_progress_notification'.t(),
content: notifyTotalProgress content: notifyTotalProgress
? formatAssetBackupProgress( ? formatAssetBackupProgress(
_uploadedAssetsCount, _uploadedAssetsCount,
@ -502,8 +502,8 @@ class BackgroundService {
if (!ok && !_cancellationToken!.isCancelled) { if (!ok && !_cancellationToken!.isCancelled) {
_showErrorNotification( _showErrorNotification(
title: "backup_background_service_error_title".tr(), title: 'backup_background_service_error_title'.t(),
content: "backup_background_service_backup_failed_message".tr(), content: 'backup_background_service_backup_failed_message'.t(),
); );
} }
@ -561,8 +561,8 @@ class BackgroundService {
void _onBackupError(ErrorUploadAsset errorAssetInfo) { void _onBackupError(ErrorUploadAsset errorAssetInfo) {
_showErrorNotification( _showErrorNotification(
title: "backup_background_service_upload_failure_notification" title: 'backup_background_service_upload_failure_notification'
.tr(namedArgs: {'filename': errorAssetInfo.fileName}), .t({'filename': errorAssetInfo.fileName}),
individualTag: errorAssetInfo.id, individualTag: errorAssetInfo.id,
); );
} }
@ -576,8 +576,8 @@ class BackgroundService {
} }
_throttledDetailNotify.title = _throttledDetailNotify.title =
"backup_background_service_current_upload_notification" 'backup_background_service_current_upload_notification'
.tr(namedArgs: {'filename': currentUploadAsset.fileName}); .t({'filename': currentUploadAsset.fileName});
_throttledDetailNotify.progress = 0; _throttledDetailNotify.progress = 0;
_throttledDetailNotify.total = 0; _throttledDetailNotify.total = 0;
} }

View File

@ -1,31 +1,128 @@
// ignore_for_file: implementation_imports // ignore_for_file: implementation_imports
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:easy_localization/src/easy_localization_controller.dart'; import 'package:easy_localization/src/easy_localization_controller.dart';
import 'package:easy_localization/src/localization.dart'; import 'package:easy_localization/src/localization.dart';
import 'package:flutter/foundation.dart';
import 'package:intl/message_format.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart';
/// Workaround to manually load translations in another Isolate abstract class ILocalizationService {
Future<bool> loadTranslations() async { String translate(String key, [Map<String, Object>? args]);
await EasyLocalizationController.initEasyLocation();
final controller = EasyLocalizationController( Locale get currentLocale;
supportedLocales: locales.values.toList(),
useFallbackTranslations: true,
saveLocale: true,
assetLoader: const CodegenLoader(),
path: translationsPath,
useOnlyLangCode: false,
onLoadError: (e) => debugPrint(e.toString()),
fallbackLocale: locales.values.first,
);
await controller.loadTranslations(); bool get isInitialized;
return Localization.load( Future<bool> loadTranslations();
controller.locale,
translations: controller.translations, Future<void> changeLocale(Locale localeCode);
fallbackTranslations: controller.fallbackTranslations, }
);
class EasyLocalizationService implements ILocalizationService {
EasyLocalizationService._internal();
static EasyLocalizationService? _instance;
static EasyLocalizationService get instance {
_instance ??= EasyLocalizationService._internal();
return _instance!;
}
factory EasyLocalizationService() => instance;
static BuildContext? _context;
static EasyLocalizationController? _controller;
static bool _isInitialized = false;
static void setContext(BuildContext context) {
if (_context != context) {
debugPrint('🔄 Updating EasyLocalization context');
_context = context;
}
}
static BuildContext? get context => _context;
@override
String translate(String key, [Map<String, Object>? args]) {
if (_context != null) {
try {
String message = _context!.tr(key);
if (args != null) {
return MessageFormat(message, locale: Intl.defaultLocale ?? 'en')
.format(args);
}
return message;
} catch (e) {
debugPrint('❌ Translation error for key "$key": $e');
return key;
}
}
return key;
}
@override
Locale get currentLocale {
if (_controller != null) {
return _controller!.locale;
}
if (_context != null) {
return _context!.locale;
}
return Intl.defaultLocale != null
? Locale(Intl.defaultLocale!)
: locales.values.first;
}
@override
bool get isInitialized => _isInitialized;
@override
Future<void> changeLocale(Locale localeCode) async {
try {
if (_context != null) {
await _context!.setLocale(localeCode);
}
if (_controller != null) {
await _controller!.setLocale(localeCode);
}
debugPrint('✅ Locale changed to: $localeCode');
} catch (e) {
debugPrint('❌ Failed to change locale to $localeCode: $e');
}
}
@override
Future<bool> loadTranslations() async {
if (_isInitialized) {
debugPrint('✅ Translations already loaded');
return true;
}
try {
await EasyLocalizationController.initEasyLocation();
_controller = EasyLocalizationController(
supportedLocales: locales.values.toList(),
useFallbackTranslations: true,
saveLocale: true,
assetLoader: const CodegenLoader(),
path: translationsPath,
useOnlyLangCode: false,
onLoadError: (e) => debugPrint(e.toString()),
fallbackLocale: locales.values.first,
);
await _controller!.loadTranslations();
final bool result = Localization.load(
_controller!.locale,
translations: _controller!.translations,
fallbackTranslations: _controller!.fallbackTranslations,
);
_isInitialized = result;
debugPrint('✅ Translations loaded: $result');
return result;
} catch (e) {
debugPrint('❌ Error loading translations: $e');
return false;
}
}
} }

View File

@ -2,9 +2,9 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/extensions/translation_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/common/search_field.dart'; import 'package:immich_mobile/widgets/common/search_field.dart';
@ -12,15 +12,14 @@ class LanguageSettings extends HookConsumerWidget {
const LanguageSettings({super.key}); const LanguageSettings({super.key});
Future<void> _applyLanguageChange( Future<void> _applyLanguageChange(
BuildContext context,
ValueNotifier<Locale> selectedLocale, ValueNotifier<Locale> selectedLocale,
ValueNotifier<bool> isLoading, ValueNotifier<bool> isLoading,
) async { ) async {
isLoading.value = true; isLoading.value = true;
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
try { try {
await context.setLocale(selectedLocale.value); await EasyLocalizationService().changeLocale(selectedLocale.value);
await loadTranslations(); await EasyLocalizationService().loadTranslations();
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
@ -29,7 +28,7 @@ class LanguageSettings extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final localeEntries = useMemoized(() => locales.entries.toList(), const []); final localeEntries = useMemoized(() => locales.entries.toList(), const []);
final currentLocale = context.locale; final currentLocale = EasyLocalizationService().currentLocale;
final filteredLocaleEntries = final filteredLocaleEntries =
useState<List<MapEntry<String, Locale>>>(localeEntries); useState<List<MapEntry<String, Locale>>>(localeEntries);
final selectedLocale = useState<Locale>(currentLocale); final selectedLocale = useState<Locale>(currentLocale);
@ -115,7 +114,6 @@ class LanguageSettings extends HookConsumerWidget {
isDisabled: isButtonDisabled, isDisabled: isButtonDisabled,
isLoading: isLoading.value, isLoading: isLoading.value,
onPressed: () => _applyLanguageChange( onPressed: () => _applyLanguageChange(
context,
selectedLocale, selectedLocale,
isLoading, isLoading,
), ),
@ -162,7 +160,7 @@ class _LanguageSearchBar extends StatelessWidget {
child: SearchField( child: SearchField(
autofocus: false, autofocus: false,
contentPadding: const EdgeInsets.all(12), contentPadding: const EdgeInsets.all(12),
hintText: 'language_search_hint'.tr(), hintText: 'language_search_hint'.t(),
prefixIcon: const Icon(Icons.search_rounded), prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: controller.text.isNotEmpty suffixIcon: controller.text.isNotEmpty
? IconButton( ? IconButton(
@ -196,14 +194,14 @@ class _LanguageNotFound extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'language_no_results_title'.tr(), 'language_no_results_title'.t(),
style: context.textTheme.titleMedium?.copyWith( style: context.textTheme.titleMedium?.copyWith(
color: context.colorScheme.onSurface, color: context.colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'language_no_results_subtitle'.tr(), 'language_no_results_subtitle'.t(),
style: context.textTheme.bodyMedium?.copyWith( style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.8), color: context.colorScheme.onSurface.withValues(alpha: 0.8),
), ),
@ -246,7 +244,7 @@ class _LanguageApplyButton extends StatelessWidget {
), ),
) )
: Text( : Text(
'setting_languages_apply'.tr(), 'setting_languages_apply'.t(),
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 16.0, fontSize: 16.0,