Files
immich/mobile/lib/widgets/settings/language_settings.dart
shenlong ad65e9011a
Some checks are pending
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Docker / pre-job (push) Waiting to run
Docker / Re-Tag ML () (push) Blocked by required conditions
Docker / Re-Tag ML (-armnn) (push) Blocked by required conditions
Docker / Re-Tag ML (-cuda) (push) Blocked by required conditions
Docker / Re-Tag ML (-openvino) (push) Blocked by required conditions
Docker / Re-Tag ML (-rknn) (push) Blocked by required conditions
Docker / Re-Tag ML (-rocm) (push) Blocked by required conditions
Docker / Re-Tag Server () (push) Blocked by required conditions
Docker / Build and Push ML (armnn, linux/arm64, -armnn) (push) Blocked by required conditions
Docker / Build and Push ML (cpu, ) (push) Blocked by required conditions
Docker / Build and Push ML (cuda, linux/amd64, -cuda) (push) Blocked by required conditions
Docker / Build and Push ML (openvino, linux/amd64, -openvino) (push) Blocked by required conditions
Docker / Build and Push ML (rknn, linux/arm64, -rknn) (push) Blocked by required conditions
Docker / Build and Push ML (rocm, linux/amd64, {"linux/amd64": "mich"}, -rocm) (push) Blocked by required conditions
Docker / Build and Push Server (push) Blocked by required conditions
Docker / Docker Build & Push Server Success (push) Blocked by required conditions
Docker / Docker Build & Push ML Success (push) Blocked by required conditions
Docs build / pre-job (push) Waiting to run
Docs build / Docs Build (push) Blocked by required conditions
Static Code Analysis / pre-job (push) Waiting to run
Static Code Analysis / Run Dart Code Analysis (push) Blocked by required conditions
Static Code Analysis / zizmor (push) Waiting to run
Test / pre-job (push) Waiting to run
Test / Test & Lint Server (push) Blocked by required conditions
Test / Unit Test CLI (push) Blocked by required conditions
Test / Unit Test CLI (Windows) (push) Blocked by required conditions
Test / Lint Web (push) Blocked by required conditions
Test / Test Web (push) Blocked by required conditions
Test / Test i18n (push) Blocked by required conditions
Test / End-to-End Lint (push) Blocked by required conditions
Test / Medium Tests (Server) (push) Blocked by required conditions
Test / End-to-End Tests (Server & CLI) (ubuntu-24.04-arm) (push) Blocked by required conditions
Test / End-to-End Tests (Server & CLI) (ubuntu-latest) (push) Blocked by required conditions
Test / End-to-End Tests (Web) (ubuntu-24.04-arm) (push) Blocked by required conditions
Test / End-to-End Tests (Web) (ubuntu-latest) (push) Blocked by required conditions
Test / End-to-End Tests Success (push) Blocked by required conditions
Test / Unit Test Mobile (push) Blocked by required conditions
Test / Unit Test ML (push) Blocked by required conditions
Test / .github Files Formatting (push) Blocked by required conditions
Test / ShellCheck (push) Waiting to run
Test / OpenAPI Clients (push) Waiting to run
Test / SQL Schema Checks (push) Waiting to run
chore: bump line length to 120 (#20191)
2025-07-25 02:37:22 +00:00

320 lines
9.8 KiB
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/translate_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<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
Widget build(BuildContext context, WidgetRef ref) {
final localeEntries = useMemoized(() => locales.entries.toList(), const []);
final currentLocale = context.locale;
final filteredLocaleEntries = useState<List<MapEntry<String, Locale>>>(localeEntries);
final selectedLocale = useState<Locale>(currentLocale);
final isLoading = useState<bool>(false);
final isButtonDisabled = selectedLocale.value == currentLocale || isLoading.value;
final searchController = useTextEditingController();
final searchFocusNode = useFocusNode();
final debounceTimer = useRef<Timer?>(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,
cacheExtent: 100,
itemBuilder: (context, index) {
final countryName = filteredLocaleEntries.value[index].key;
final localeValue = filteredLocaleEntries.value[index].value;
final bool isSelected = selectedLocale.value == localeValue;
return _LanguageItem(
key: ValueKey(localeValue.toString()),
countryName: countryName,
localeValue: localeValue,
isSelected: isSelected,
onTap: () {
selectedLocale.value = localeValue;
},
);
},
),
),
if (filteredLocaleEntries.value.isNotEmpty)
_LanguageApplyButton(
isDisabled: isButtonDisabled,
isLoading: isLoading.value,
onPressed: () => _applyLanguageChange(
context,
selectedLocale,
isLoading,
),
),
],
),
);
}
}
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<String> 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'.t(context: context),
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'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
color: context.colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
'language_no_results_subtitle'.t(context: context),
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'.t(context: context),
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16.0,
),
),
),
),
),
);
}
}
class _LanguageItem extends StatelessWidget {
const _LanguageItem({
super.key,
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,
),
),
),
);
}
}