diff --git a/i18n/en.json b/i18n/en.json index 98ca467c51..a25d62e94c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1097,6 +1097,9 @@ "kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}", "keyboard_shortcuts": "Keyboard shortcuts", "language": "Language", + "language_no_results_subtitle": "Try adjusting your search term", + "language_no_results_title": "No languages found", + "language_search_hint": "Search languages...", "language_setting_description": "Select your preferred language", "last_seen": "Last seen", "latest_version": "Latest Version", @@ -1628,7 +1631,6 @@ "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", "setting_languages_subtitle": "Change the app's language", - "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {duration}", "setting_notifications_notify_hours": "{count} hours", "setting_notifications_notify_immediately": "immediately", diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index dc186720f3..05c7606970 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -30,7 +30,7 @@ enum SettingSection { "backup_setting_subtitle", ), languages( - 'setting_languages_title', + 'language', Icons.language, "setting_languages_subtitle", ), diff --git a/mobile/lib/widgets/settings/language_settings.dart b/mobile/lib/widgets/settings/language_settings.dart index 990dcfdfe8..7dc7f89ea1 100644 --- a/mobile/lib/widgets/settings/language_settings.dart +++ b/mobile/lib/widgets/settings/language_settings.dart @@ -1,79 +1,324 @@ -import 'package:easy_localization/easy_localization.dart'; +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/extensions/build_context_extensions.dart'; import 'package:immich_mobile/services/localization.service.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; 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(); + } finally { + isLoading.value = false; + } + } + @override Widget build(BuildContext context, WidgetRef ref) { + final localeEntries = useMemoized(() => locales.entries.toList(), const []); final currentLocale = context.locale; - final textController = useTextEditingController( - text: locales.keys.firstWhere( - (countryName) => locales[countryName] == currentLocale, - ), - ); - + final filteredLocaleEntries = + useState>>(localeEntries); final selectedLocale = useState(currentLocale); - return ListView( - padding: const EdgeInsets.all(16), - children: [ - LayoutBuilder( - builder: (context, constraints) { - return DropdownMenu( - width: constraints.maxWidth, - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - contentPadding: const EdgeInsets.only(left: 16), - ), - menuStyle: MenuStyle( - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15), + final isLoading = useState(false); + final isButtonDisabled = + selectedLocale.value == currentLocale || isLoading.value; + + final searchController = useTextEditingController(); + final searchFocusNode = useFocusNode(); + final debounceTimer = useRef(null); + + void onSearch(String searchTerm) { + debounceTimer.value?.cancel(); + debounceTimer.value = Timer(const Duration(milliseconds: 500), () { + if (searchTerm.isEmpty) { + filteredLocaleEntries.value = localeEntries; + } else { + filteredLocaleEntries.value = localeEntries + .where( + (entry) => + entry.key.toLowerCase().contains(searchTerm.toLowerCase()), + ) + .toList(); + } + }); + } + + void clearSearch() { + searchController.clear(); + onSearch(''); + } + + useEffect( + () { + void searchListener() => onSearch(searchController.text); + searchController.addListener(searchListener); + return () { + searchController.removeListener(searchListener); + debounceTimer.value?.cancel(); + }; + }, + [searchController], + ); + + return SafeArea( + child: Column( + children: [ + _LanguageSearchBar( + controller: searchController, + focusNode: searchFocusNode, + onClear: clearSearch, + onChanged: (_) => onSearch(searchController.text), + ), + Expanded( + child: filteredLocaleEntries.value.isEmpty + ? const _LanguageNotFound() + : ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: filteredLocaleEntries.value.length, + itemExtent: 64.0, + itemBuilder: (context, index) { + final countryName = + filteredLocaleEntries.value[index].key; + final localeValue = + filteredLocaleEntries.value[index].value; + final bool isSelected = + selectedLocale.value == localeValue; + + return _LanguageItem( + countryName: countryName, + localeValue: localeValue, + isSelected: isSelected, + onTap: () { + selectedLocale.value = localeValue; + }, + ); + }, ), - ), - backgroundColor: WidgetStatePropertyAll( - context.colorScheme.surfaceContainer, - ), + ), + if (filteredLocaleEntries.value.isNotEmpty) + _LanguageApplyButton( + isDisabled: isButtonDisabled, + isLoading: isLoading.value, + onPressed: () => _applyLanguageChange( + context, + selectedLocale, + isLoading, ), - menuHeight: context.height * 0.5, - hintText: "Languages", - label: const Text('Languages'), - dropdownMenuEntries: locales.keys - .map( - (countryName) => DropdownMenuEntry( - value: locales[countryName], - label: countryName, - ), - ) - .toList(), - controller: textController, - onSelected: (value) { - if (value != null) { - selectedLocale.value = value; - } - }, - ); - }, - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: selectedLocale.value == currentLocale - ? null - : () { - context.setLocale(selectedLocale.value); - loadTranslations(); - }, - child: const Text('setting_languages_apply').tr(), - ), - ], + ), + ], + ), + ); + } +} + +class _LanguageSearchBar extends StatelessWidget { + const _LanguageSearchBar({ + required this.controller, + required this.focusNode, + required this.onClear, + required this.onChanged, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final VoidCallback onClear; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(top: 16, bottom: 8, left: 50, right: 50), + decoration: BoxDecoration( + color: context.colorScheme.surface, + ), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(24)), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withValues(alpha: 0.075), + context.colorScheme.primary.withValues(alpha: 0.09), + context.colorScheme.primary.withValues(alpha: 0.075), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: SearchField( + autofocus: false, + contentPadding: const EdgeInsets.all(12), + hintText: 'language_search_hint'.tr(), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear_rounded), + onPressed: onClear, + ) + : null, + controller: controller, + onChanged: onChanged, + focusNode: focusNode, + onTapOutside: (_) => focusNode.unfocus(), + ), + ), + ); + } +} + +class _LanguageNotFound extends StatelessWidget { + const _LanguageNotFound(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off_rounded, + size: 64, + color: context.colorScheme.onSurface.withValues(alpha: 0.4), + ), + const SizedBox(height: 8), + Text( + 'language_no_results_title'.tr(), + style: context.textTheme.titleMedium?.copyWith( + color: context.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + 'language_no_results_subtitle'.tr(), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.8), + ), + ), + ], + ), + ); + } +} + +class _LanguageApplyButton extends StatelessWidget { + const _LanguageApplyButton({ + required this.isDisabled, + required this.isLoading, + required this.onPressed, + }); + + final bool isDisabled; + final bool isLoading; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: context.colorScheme.surface, + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: isDisabled ? null : onPressed, + child: isLoading + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text( + 'setting_languages_apply'.tr(), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16.0, + ), + ), + ), + ), + ), + ); + } +} + +class _LanguageItem extends StatelessWidget { + const _LanguageItem({ + required this.countryName, + required this.localeValue, + required this.isSelected, + required this.onTap, + }); + + final String countryName; + final Locale localeValue; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: + context.colorScheme.surfaceContainerLowest.withValues(alpha: .6), + borderRadius: const BorderRadius.all( + Radius.circular(16.0), + ), + border: Border.all( + color: context.colorScheme.outlineVariant.withValues(alpha: .4), + width: 1.0, + ), + ), + child: ListTile( + title: Text( + countryName, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? context.colorScheme.primary + : context.colorScheme.onSurfaceVariant, + ), + ), + trailing: isSelected + ? Icon( + Icons.check, + color: context.colorScheme.primary, + size: 20, + ) + : null, + onTap: onTap, + selected: isSelected, + selectedTileColor: context.colorScheme.primary.withValues(alpha: .15), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16.0)), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + ), + ), ); } }