From 0567d907844ea682f39aa3fbcbaa03933c0a757b Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Fri, 27 Feb 2026 23:34:51 +0200 Subject: [PATCH] feat: implement client-side localization infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add languageProvider (StateProvider, default 'ru') with supportedLanguages map matching backend locale.Supported - Wire Accept-Language header into AuthInterceptor via languageGetter callback; all API requests now carry the current language - Sync language from user profile preferences into languageProvider on every ProfileNotifier load/update - Add language field to UpdateProfileRequest, serialized as preferences.language in PUT /profile - Profile screen: НАСТРОЙКИ section displays current language; edit sheet adds DropdownButtonFormField for language selection Co-Authored-By: Claude Sonnet 4.6 --- client/lib/core/api/api_client.dart | 8 +++-- client/lib/core/api/auth_interceptor.dart | 13 ++++++-- client/lib/core/auth/auth_provider.dart | 4 ++- client/lib/core/locale/language_provider.dart | 23 ++++++++++++++ .../features/profile/profile_provider.dart | 20 ++++++++++-- .../lib/features/profile/profile_screen.dart | 31 +++++++++++++++++++ .../lib/features/profile/profile_service.dart | 3 ++ 7 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 client/lib/core/locale/language_provider.dart diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart index 0799fcf..f265c08 100644 --- a/client/lib/core/api/api_client.dart +++ b/client/lib/core/api/api_client.dart @@ -5,7 +5,11 @@ import 'auth_interceptor.dart'; class ApiClient { late final Dio _dio; - ApiClient({required String baseUrl, required SecureStorageService storage}) { + ApiClient({ + required String baseUrl, + required SecureStorageService storage, + required String Function() languageGetter, + }) { _dio = Dio(BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 60), @@ -14,7 +18,7 @@ class ApiClient { )); _dio.interceptors.addAll([ - AuthInterceptor(storage: storage, dio: _dio), + AuthInterceptor(storage: storage, dio: _dio, languageGetter: languageGetter), LogInterceptor(requestBody: true, responseBody: true), ]); } diff --git a/client/lib/core/api/auth_interceptor.dart b/client/lib/core/api/auth_interceptor.dart index 4cd43c7..c1e2058 100644 --- a/client/lib/core/api/auth_interceptor.dart +++ b/client/lib/core/api/auth_interceptor.dart @@ -4,21 +4,28 @@ import '../auth/secure_storage.dart'; class AuthInterceptor extends Interceptor { final SecureStorageService _storage; final Dio _dio; + final String Function() _languageGetter; // Prevents multiple simultaneous token refresh requests bool _isRefreshing = false; final List<({RequestOptions options, ErrorInterceptorHandler handler})> _pendingRequests = []; - AuthInterceptor({required SecureStorageService storage, required Dio dio}) - : _storage = storage, - _dio = dio; + AuthInterceptor({ + required SecureStorageService storage, + required Dio dio, + required String Function() languageGetter, + }) : _storage = storage, + _dio = dio, + _languageGetter = languageGetter; @override Future onRequest( RequestOptions options, RequestInterceptorHandler handler, ) async { + options.headers['Accept-Language'] = _languageGetter(); + if (options.path.startsWith('/auth/')) { return handler.next(options); } diff --git a/client/lib/core/auth/auth_provider.dart b/client/lib/core/auth/auth_provider.dart index 3cd2548..4192219 100644 --- a/client/lib/core/auth/auth_provider.dart +++ b/client/lib/core/auth/auth_provider.dart @@ -1,12 +1,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:firebase_auth/firebase_auth.dart' as fb; import '../../shared/models/user.dart'; import '../api/api_client.dart'; import '../config/app_config.dart'; +import '../locale/language_provider.dart'; import 'auth_service.dart'; import 'secure_storage.dart'; -import 'package:firebase_auth/firebase_auth.dart' as fb; enum AuthStatus { unknown, authenticated, unauthenticated } @@ -144,6 +145,7 @@ final apiClientProvider = Provider((ref) { return ApiClient( baseUrl: config.apiBaseUrl, storage: storage, + languageGetter: () => ref.read(languageProvider), ); }); diff --git a/client/lib/core/locale/language_provider.dart b/client/lib/core/locale/language_provider.dart new file mode 100644 index 0000000..5d4d757 --- /dev/null +++ b/client/lib/core/locale/language_provider.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Supported ISO 639-1 language codes with their native names. +/// Must match the backend locale.Supported map. +const supportedLanguages = { + 'en': 'English', + 'ru': 'Русский', + 'es': 'Español', + 'de': 'Deutsch', + 'fr': 'Français', + 'it': 'Italiano', + 'pt': 'Português', + 'zh': '中文', + 'ja': '日本語', + 'ko': '한국어', + 'ar': 'العربية', + 'hi': 'हिन्दी', +}; + +/// Current app language (ISO 639-1 code). +/// Synced from user.preferences['language'] after the profile loads or is updated. +/// Defaults to 'ru'. +final languageProvider = StateProvider((_) => 'ru'); diff --git a/client/lib/features/profile/profile_provider.dart b/client/lib/features/profile/profile_provider.dart index e9164fd..1d60a77 100644 --- a/client/lib/features/profile/profile_provider.dart +++ b/client/lib/features/profile/profile_provider.dart @@ -1,32 +1,48 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/locale/language_provider.dart'; import '../../shared/models/user.dart'; import 'profile_service.dart'; class ProfileNotifier extends StateNotifier> { final ProfileService _service; + final void Function(String) _setLanguage; - ProfileNotifier(this._service) : super(const AsyncValue.loading()) { + ProfileNotifier(this._service, this._setLanguage) + : super(const AsyncValue.loading()) { load(); } Future load() async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() => _service.getProfile()); + _syncLanguage(); } Future update(UpdateProfileRequest req) async { try { final updated = await _service.updateProfile(req); state = AsyncValue.data(updated); + _syncLanguage(); return true; } catch (_) { return false; } } + + // Propagates the user's preferred language to the global languageProvider. + void _syncLanguage() { + final lang = state.valueOrNull?.preferences['language'] as String?; + if (lang != null && supportedLanguages.containsKey(lang)) { + _setLanguage(lang); + } + } } final profileProvider = StateNotifierProvider>( - (ref) => ProfileNotifier(ref.read(profileServiceProvider)), + (ref) => ProfileNotifier( + ref.read(profileServiceProvider), + (lang) => ref.read(languageProvider.notifier).state = lang, + ), ); diff --git a/client/lib/features/profile/profile_screen.dart b/client/lib/features/profile/profile_screen.dart index 238348c..c6f5e91 100644 --- a/client/lib/features/profile/profile_screen.dart +++ b/client/lib/features/profile/profile_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/auth/auth_provider.dart'; +import '../../core/locale/language_provider.dart'; import '../../core/theme/app_colors.dart'; import '../../shared/models/user.dart'; import 'profile_provider.dart'; @@ -114,6 +115,18 @@ class _ProfileBody extends StatelessWidget { ), ), ], + const SizedBox(height: 16), + + // Settings + _SectionLabel('НАСТРОЙКИ'), + const SizedBox(height: 6), + _InfoCard(children: [ + _InfoRow( + 'Язык', + supportedLanguages[user.preferences['language'] as String? ?? 'ru'] ?? + 'Русский', + ), + ]), const SizedBox(height: 32), _LogoutButton(), @@ -327,6 +340,7 @@ class _EditProfileSheetState extends ConsumerState { String? _gender; String? _goal; String? _activity; + String? _language; bool _saving = false; @override @@ -341,6 +355,7 @@ class _EditProfileSheetState extends ConsumerState { _gender = u.gender; _goal = u.goal; _activity = u.activity; + _language = u.preferences['language'] as String? ?? 'ru'; } @override @@ -367,6 +382,7 @@ class _EditProfileSheetState extends ConsumerState { gender: _gender, goal: _goal, activity: _activity, + language: _language, ); final ok = await ref.read(profileProvider.notifier).update(req); @@ -555,6 +571,21 @@ class _EditProfileSheetState extends ConsumerState { onSelectionChanged: (s) => setState( () => _activity = s.isEmpty ? null : s.first), ), + const SizedBox(height: 20), + + // Language + DropdownButtonFormField( + value: _language, + decoration: + const InputDecoration(labelText: 'Язык интерфейса'), + items: supportedLanguages.entries + .map((e) => DropdownMenuItem( + value: e.key, + child: Text(e.value), + )) + .toList(), + onChanged: (v) => setState(() => _language = v), + ), const SizedBox(height: 32), // Save diff --git a/client/lib/features/profile/profile_service.dart b/client/lib/features/profile/profile_service.dart index 09902fa..9daacb2 100644 --- a/client/lib/features/profile/profile_service.dart +++ b/client/lib/features/profile/profile_service.dart @@ -16,6 +16,7 @@ class UpdateProfileRequest { final String? gender; final String? activity; final String? goal; + final String? language; const UpdateProfileRequest({ this.name, @@ -25,6 +26,7 @@ class UpdateProfileRequest { this.gender, this.activity, this.goal, + this.language, }); Map toJson() { @@ -36,6 +38,7 @@ class UpdateProfileRequest { if (gender != null) map['gender'] = gender; if (activity != null) map['activity'] = activity; if (goal != null) map['goal'] = goal; + if (language != null) map['preferences'] = {'language': language}; return map; } }