diff --git a/mobile/lib/extensions/translation_extensions.dart b/mobile/lib/extensions/translation_extensions.dart new file mode 100644 index 0000000000..9ef3741e75 --- /dev/null +++ b/mobile/lib/extensions/translation_extensions.dart @@ -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? args]) { + return _translationService.translate(this, args); + } +} + +extension BuildContextTranslation on BuildContext { + String t(String key, [Map? args]) { + return _translationService.translate(key, args); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 32bb025916..19aba77cee 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -11,6 +11,7 @@ import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/locales.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/providers/app_life_cycle.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/services/background.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/theme_data.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; @@ -158,12 +160,12 @@ class ImmichAppState extends ConsumerState void _configureFileDownloaderNotifications() { FileDownloader().configureNotification( running: TaskNotification( - 'downloading_media'.tr(), - '${'file_name'.tr()}: {filename}', + 'downloading_media'.t(), + '${'file_name'.t()}: {filename}', ), complete: TaskNotification( - 'download_finished'.tr(), - '${'file_name'.tr()}: {filename}', + 'download_finished'.t(), + '${'file_name'.t()}: {filename}', ), progressBar: true, ); @@ -205,34 +207,34 @@ class ImmichAppState extends ConsumerState overrides: [ localeProvider.overrideWithValue(context.locale), ], - child: MaterialApp( + child: MaterialApp.router( + title: 'Immich', + debugShowCheckedModeBanner: true, localizationsDelegates: context.localizationDelegates, supportedLocales: context.supportedLocales, locale: context.locale, - debugShowCheckedModeBanner: true, - home: MaterialApp.router( - title: 'Immich', - debugShowCheckedModeBanner: false, - 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)], - ), + 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)], + ), + builder: (context, child) { + EasyLocalizationService.setContext(context); + return child!; + }, ), ); } } -// ignore: prefer-single-widget-per-file class MainWidget extends StatelessWidget { const MainWidget({super.key}); diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 335f71acab..1b59b7a563 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -6,7 +6,6 @@ import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.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/http_ssl_options.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; final backgroundServiceProvider = Provider((ref) => BackgroundService()); @@ -76,8 +76,7 @@ class BackgroundService { Future enableService({bool immediate = false}) async { try { final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!; - final String title = - "backup_background_service_default_notification".tr(); + final String title = 'backup_background_service_default_notification'.t(); final bool ok = await _foregroundChannel .invokeMethod('enable', [callback.toRawHandle(), title, immediate]); return ok; @@ -325,7 +324,8 @@ class BackgroundService { return false; } - final translationsOk = await loadTranslations(); + final translationsOk = + await EasyLocalizationService().loadTranslations(); if (!translationsOk) { debugPrint("[_callHandler] could not load translations"); } @@ -452,8 +452,8 @@ class BackgroundService { toUpload = await backupService.removeAlreadyUploadedAssets(toUpload); } catch (e) { _showErrorNotification( - title: "backup_background_service_error_title".tr(), - content: "backup_background_service_connection_failed_message".tr(), + title: 'backup_background_service_error_title'.t(), + content: 'backup_background_service_connection_failed_message'.t(), ); return false; } @@ -468,7 +468,7 @@ class BackgroundService { _assetsToUploadCount = toUpload.length; _uploadedAssetsCount = 0; _updateNotification( - title: "backup_background_service_in_progress_notification".tr(), + title: 'backup_background_service_in_progress_notification'.t(), content: notifyTotalProgress ? formatAssetBackupProgress( _uploadedAssetsCount, @@ -502,8 +502,8 @@ class BackgroundService { if (!ok && !_cancellationToken!.isCancelled) { _showErrorNotification( - title: "backup_background_service_error_title".tr(), - content: "backup_background_service_backup_failed_message".tr(), + title: 'backup_background_service_error_title'.t(), + content: 'backup_background_service_backup_failed_message'.t(), ); } @@ -561,8 +561,8 @@ class BackgroundService { void _onBackupError(ErrorUploadAsset errorAssetInfo) { _showErrorNotification( - title: "backup_background_service_upload_failure_notification" - .tr(namedArgs: {'filename': errorAssetInfo.fileName}), + title: 'backup_background_service_upload_failure_notification' + .t({'filename': errorAssetInfo.fileName}), individualTag: errorAssetInfo.id, ); } @@ -576,8 +576,8 @@ class BackgroundService { } _throttledDetailNotify.title = - "backup_background_service_current_upload_notification" - .tr(namedArgs: {'filename': currentUploadAsset.fileName}); + 'backup_background_service_current_upload_notification' + .t({'filename': currentUploadAsset.fileName}); _throttledDetailNotify.progress = 0; _throttledDetailNotify.total = 0; } diff --git a/mobile/lib/services/localization.service.dart b/mobile/lib/services/localization.service.dart index 8bee710544..af3fb746d4 100644 --- a/mobile/lib/services/localization.service.dart +++ b/mobile/lib/services/localization.service.dart @@ -1,31 +1,128 @@ -// ignore_for_file: implementation_imports - -import 'package:easy_localization/src/easy_localization_controller.dart'; -import 'package:easy_localization/src/localization.dart'; -import 'package:flutter/foundation.dart'; -import 'package:immich_mobile/constants/locales.dart'; -import 'package:immich_mobile/generated/codegen_loader.g.dart'; - -/// Workaround to manually load translations in another Isolate -Future loadTranslations() async { - await EasyLocalizationController.initEasyLocation(); - - final 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(); - - return Localization.load( - controller.locale, - translations: controller.translations, - fallbackTranslations: controller.fallbackTranslations, - ); -} +// 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/localization.dart'; + +import 'package:intl/message_format.dart'; +import 'package:immich_mobile/constants/locales.dart'; +import 'package:immich_mobile/generated/codegen_loader.g.dart'; + +abstract class ILocalizationService { + String translate(String key, [Map? args]); + + Locale get currentLocale; + + bool get isInitialized; + + Future loadTranslations(); + + Future changeLocale(Locale localeCode); +} + +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? 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 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 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; + } + } +} diff --git a/mobile/lib/widgets/settings/language_settings.dart b/mobile/lib/widgets/settings/language_settings.dart index 7dc7f89ea1..f3e4d7c25b 100644 --- a/mobile/lib/widgets/settings/language_settings.dart +++ b/mobile/lib/widgets/settings/language_settings.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.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/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/widgets/common/search_field.dart'; @@ -12,15 +12,14 @@ class LanguageSettings extends HookConsumerWidget { const LanguageSettings({super.key}); Future _applyLanguageChange( - BuildContext context, ValueNotifier selectedLocale, ValueNotifier isLoading, ) async { isLoading.value = true; await Future.delayed(const Duration(milliseconds: 500)); try { - await context.setLocale(selectedLocale.value); - await loadTranslations(); + await EasyLocalizationService().changeLocale(selectedLocale.value); + await EasyLocalizationService().loadTranslations(); } finally { isLoading.value = false; } @@ -29,7 +28,7 @@ class LanguageSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final localeEntries = useMemoized(() => locales.entries.toList(), const []); - final currentLocale = context.locale; + final currentLocale = EasyLocalizationService().currentLocale; final filteredLocaleEntries = useState>>(localeEntries); final selectedLocale = useState(currentLocale); @@ -115,7 +114,6 @@ class LanguageSettings extends HookConsumerWidget { isDisabled: isButtonDisabled, isLoading: isLoading.value, onPressed: () => _applyLanguageChange( - context, selectedLocale, isLoading, ), @@ -162,7 +160,7 @@ class _LanguageSearchBar extends StatelessWidget { child: SearchField( autofocus: false, contentPadding: const EdgeInsets.all(12), - hintText: 'language_search_hint'.tr(), + hintText: 'language_search_hint'.t(), prefixIcon: const Icon(Icons.search_rounded), suffixIcon: controller.text.isNotEmpty ? IconButton( @@ -196,14 +194,14 @@ class _LanguageNotFound extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'language_no_results_title'.tr(), + 'language_no_results_title'.t(), style: context.textTheme.titleMedium?.copyWith( color: context.colorScheme.onSurface, ), ), const SizedBox(height: 4), Text( - 'language_no_results_subtitle'.tr(), + 'language_no_results_subtitle'.t(), style: context.textTheme.bodyMedium?.copyWith( color: context.colorScheme.onSurface.withValues(alpha: 0.8), ), @@ -246,7 +244,7 @@ class _LanguageApplyButton extends StatelessWidget { ), ) : Text( - 'setting_languages_apply'.tr(), + 'setting_languages_apply'.t(), style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 16.0,