mirror of
https://github.com/immich-app/immich.git
synced 2025-07-07 18:24:10 -04:00
feat(mobile): Improve language setting UI (#18854)
* improve language ui * fix lint * add search language, add safe area, fix button in dark * hide apply button when search not found --------- Co-authored-by: dvbthien <dvbthien@gmail.com>
This commit is contained in:
parent
393e8d50b2
commit
e506c7fb19
@ -1097,6 +1097,9 @@
|
|||||||
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
|
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
|
||||||
"keyboard_shortcuts": "Keyboard shortcuts",
|
"keyboard_shortcuts": "Keyboard shortcuts",
|
||||||
"language": "Language",
|
"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",
|
"language_setting_description": "Select your preferred language",
|
||||||
"last_seen": "Last seen",
|
"last_seen": "Last seen",
|
||||||
"latest_version": "Latest Version",
|
"latest_version": "Latest Version",
|
||||||
@ -1628,7 +1631,6 @@
|
|||||||
"setting_image_viewer_title": "Images",
|
"setting_image_viewer_title": "Images",
|
||||||
"setting_languages_apply": "Apply",
|
"setting_languages_apply": "Apply",
|
||||||
"setting_languages_subtitle": "Change the app's language",
|
"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_failures_grace_period": "Notify background backup failures: {duration}",
|
||||||
"setting_notifications_notify_hours": "{count} hours",
|
"setting_notifications_notify_hours": "{count} hours",
|
||||||
"setting_notifications_notify_immediately": "immediately",
|
"setting_notifications_notify_immediately": "immediately",
|
||||||
|
@ -30,7 +30,7 @@ enum SettingSection {
|
|||||||
"backup_setting_subtitle",
|
"backup_setting_subtitle",
|
||||||
),
|
),
|
||||||
languages(
|
languages(
|
||||||
'setting_languages_title',
|
'language',
|
||||||
Icons.language,
|
Icons.language,
|
||||||
"setting_languages_subtitle",
|
"setting_languages_subtitle",
|
||||||
),
|
),
|
||||||
|
@ -1,79 +1,324 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
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/extensions/build_context_extensions.dart';
|
|
||||||
import 'package:immich_mobile/services/localization.service.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 {
|
class LanguageSettings extends HookConsumerWidget {
|
||||||
const LanguageSettings({super.key});
|
const LanguageSettings({super.key});
|
||||||
|
|
||||||
|
Future<void> _applyLanguageChange(
|
||||||
|
BuildContext context,
|
||||||
|
ValueNotifier<Locale> selectedLocale,
|
||||||
|
ValueNotifier<bool> 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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final localeEntries = useMemoized(() => locales.entries.toList(), const []);
|
||||||
final currentLocale = context.locale;
|
final currentLocale = context.locale;
|
||||||
final textController = useTextEditingController(
|
final filteredLocaleEntries =
|
||||||
text: locales.keys.firstWhere(
|
useState<List<MapEntry<String, Locale>>>(localeEntries);
|
||||||
(countryName) => locales[countryName] == currentLocale,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final selectedLocale = useState<Locale>(currentLocale);
|
final selectedLocale = useState<Locale>(currentLocale);
|
||||||
|
|
||||||
return ListView(
|
final isLoading = useState<bool>(false);
|
||||||
padding: const EdgeInsets.all(16),
|
final isButtonDisabled =
|
||||||
children: [
|
selectedLocale.value == currentLocale || isLoading.value;
|
||||||
LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
final searchController = useTextEditingController();
|
||||||
return DropdownMenu(
|
final searchFocusNode = useFocusNode();
|
||||||
width: constraints.maxWidth,
|
final debounceTimer = useRef<Timer?>(null);
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
|
||||||
border: OutlineInputBorder(
|
void onSearch(String searchTerm) {
|
||||||
borderRadius: BorderRadius.circular(20),
|
debounceTimer.value?.cancel();
|
||||||
),
|
debounceTimer.value = Timer(const Duration(milliseconds: 500), () {
|
||||||
contentPadding: const EdgeInsets.only(left: 16),
|
if (searchTerm.isEmpty) {
|
||||||
),
|
filteredLocaleEntries.value = localeEntries;
|
||||||
menuStyle: MenuStyle(
|
} else {
|
||||||
shape: WidgetStatePropertyAll<OutlinedBorder>(
|
filteredLocaleEntries.value = localeEntries
|
||||||
RoundedRectangleBorder(
|
.where(
|
||||||
borderRadius: BorderRadius.circular(15),
|
(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<Color>(
|
if (filteredLocaleEntries.value.isNotEmpty)
|
||||||
context.colorScheme.surfaceContainer,
|
_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,
|
class _LanguageSearchBar extends StatelessWidget {
|
||||||
),
|
const _LanguageSearchBar({
|
||||||
)
|
required this.controller,
|
||||||
.toList(),
|
required this.focusNode,
|
||||||
controller: textController,
|
required this.onClear,
|
||||||
onSelected: (value) {
|
required this.onChanged,
|
||||||
if (value != null) {
|
});
|
||||||
selectedLocale.value = value;
|
|
||||||
}
|
final TextEditingController controller;
|
||||||
},
|
final FocusNode focusNode;
|
||||||
);
|
final VoidCallback onClear;
|
||||||
},
|
final ValueChanged<String> onChanged;
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
@override
|
||||||
ElevatedButton(
|
Widget build(BuildContext context) {
|
||||||
onPressed: selectedLocale.value == currentLocale
|
return Container(
|
||||||
? null
|
padding: const EdgeInsets.only(top: 16, bottom: 8, left: 50, right: 50),
|
||||||
: () {
|
decoration: BoxDecoration(
|
||||||
context.setLocale(selectedLocale.value);
|
color: context.colorScheme.surface,
|
||||||
loadTranslations();
|
),
|
||||||
},
|
child: DecoratedBox(
|
||||||
child: const Text('setting_languages_apply').tr(),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user