feat(locale): 实现 App 启动时语言和时区自动设置
- 新增系统语言/时区读取工具函数 - SessionStore 扩展支持时区存储 - 启动流程自动检测并保存系统语言/时区 - 注册时传递语言/时区到后端 - 登录后从服务器同步语言/时区
This commit is contained in:
+36
-9
@@ -4,7 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
import '../core/auth/session_store.dart';
|
||||
import '../core/localization/system_locale.dart';
|
||||
import '../core/logging/logger.dart';
|
||||
import '../core/timezone/system_timezone.dart';
|
||||
import '../data/network/api_client.dart';
|
||||
import '../data/storage/local_kv_store.dart';
|
||||
import '../features/auth/data/apis/auth_api.dart';
|
||||
@@ -42,6 +44,7 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
late final NotificationRepository _notificationRepository;
|
||||
late final NotificationBloc _notificationBloc;
|
||||
Locale _locale = const Locale('zh');
|
||||
String _timezone = 'Asia/Shanghai';
|
||||
ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale(
|
||||
const Locale('zh'),
|
||||
);
|
||||
@@ -300,11 +303,14 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final serverLanguage = profile.preferences.interfaceLanguage;
|
||||
final serverLanguage = profile.preferences.language;
|
||||
final serverTimezone = profile.preferences.timezone;
|
||||
final serverLocale = localeFromLanguageTag(serverLanguage);
|
||||
await _sessionStore.saveLocaleTag(serverLanguage);
|
||||
await _sessionStore.saveTimezone(serverTimezone);
|
||||
setState(() {
|
||||
_locale = serverLocale;
|
||||
_timezone = serverTimezone;
|
||||
_profileSettings = profile;
|
||||
_loadedProfileUserEmail = userEmail;
|
||||
});
|
||||
@@ -352,8 +358,8 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
|
||||
Future<void> _saveProfileSettings(ProfileSettingsV1 next) async {
|
||||
try {
|
||||
final oldLanguage = _profileSettings.preferences.interfaceLanguage;
|
||||
final newLanguage = next.preferences.interfaceLanguage;
|
||||
final oldLanguage = _profileSettings.preferences.language;
|
||||
final newLanguage = next.preferences.language;
|
||||
final saved = await _profileApi.updateSettings(next);
|
||||
if (!mounted) {
|
||||
return;
|
||||
@@ -383,13 +389,29 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
final localeTag = await _sessionStore.getLocaleTag();
|
||||
final locale = localeTag != null
|
||||
? localeFromLanguageTag(localeTag)
|
||||
: const Locale('zh');
|
||||
final savedLocaleTag = await _sessionStore.getLocaleTag();
|
||||
final Locale locale;
|
||||
if (savedLocaleTag != null) {
|
||||
locale = localeFromLanguageTag(savedLocaleTag);
|
||||
} else {
|
||||
final systemLocale = getSystemLocale();
|
||||
locale = resolveSystemLocale(systemLocale) ?? const Locale('zh');
|
||||
await _sessionStore.saveLocaleTag(languageTagFromLocale(locale));
|
||||
}
|
||||
|
||||
final savedTimezone = await _sessionStore.getTimezone();
|
||||
final String timezone;
|
||||
if (savedTimezone != null) {
|
||||
timezone = savedTimezone;
|
||||
} else {
|
||||
timezone = await getSystemTimezone();
|
||||
await _sessionStore.saveTimezone(timezone);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_locale = locale;
|
||||
_timezone = timezone;
|
||||
_profileSettings = ProfileSettingsV1.defaultsForLocale(locale);
|
||||
});
|
||||
}
|
||||
@@ -406,7 +428,7 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
_locale = locale;
|
||||
_profileSettings = _profileSettings.copyWith(
|
||||
preferences: _profileSettings.preferences.copyWith(
|
||||
interfaceLanguage: languageTag,
|
||||
language: languageTag,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -486,7 +508,12 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
onLocaleChanged: (_) {},
|
||||
onRequestOtp: _authBloc.sendOtp,
|
||||
onLoginWithOtp: (email, otp) {
|
||||
return _authBloc.loginWithOtp(email: email, otp: otp);
|
||||
return _authBloc.loginWithOtp(
|
||||
email: email,
|
||||
otp: otp,
|
||||
language: languageTagFromLocale(_locale),
|
||||
timezone: _timezone,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class SessionStore {
|
||||
static const String _emailKey = 'saved_email';
|
||||
static const String _welcomeReadKey = 'has_seen_welcome_dialog';
|
||||
static const String _localeKey = 'selected_locale';
|
||||
static const String _timezoneKey = 'selected_timezone';
|
||||
|
||||
Future<void> saveToken(String token) async {
|
||||
await _secureStorage.write(key: _tokenKey, value: token);
|
||||
@@ -66,4 +67,12 @@ class SessionStore {
|
||||
Future<String?> getLocaleTag() async {
|
||||
return _kvStore.getString(_localeKey);
|
||||
}
|
||||
|
||||
Future<void> saveTimezone(String timezone) async {
|
||||
await _kvStore.setString(_timezoneKey, timezone);
|
||||
}
|
||||
|
||||
Future<String?> getTimezone() async {
|
||||
return _kvStore.getString(_timezoneKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'dart:ui';
|
||||
|
||||
Locale? resolveSystemLocale(Locale systemLocale) {
|
||||
final lang = systemLocale.languageCode.toLowerCase();
|
||||
final script = systemLocale.scriptCode;
|
||||
final country = systemLocale.countryCode;
|
||||
|
||||
if (lang == 'en') {
|
||||
return const Locale('en');
|
||||
}
|
||||
|
||||
if (lang == 'zh') {
|
||||
if (script == 'Hant' ||
|
||||
country == 'TW' ||
|
||||
country == 'HK' ||
|
||||
country == 'MO') {
|
||||
return const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant');
|
||||
}
|
||||
return const Locale('zh');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Locale getSystemLocale() {
|
||||
return PlatformDispatcher.instance.locale;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
|
||||
Future<String> getSystemTimezone() async {
|
||||
return FlutterTimezone.getLocalTimezone();
|
||||
}
|
||||
@@ -19,10 +19,18 @@ class AuthApi {
|
||||
Future<SessionResponse> createEmailSession({
|
||||
required String email,
|
||||
required String token,
|
||||
String? language,
|
||||
String? timezone,
|
||||
}) async {
|
||||
final data = <String, dynamic>{
|
||||
'email': email,
|
||||
'token': token,
|
||||
};
|
||||
if (language != null) data['language'] = language;
|
||||
if (timezone != null) data['timezone'] = timezone;
|
||||
final json = await _apiClient.postJson(
|
||||
'/api/v1/auth/email-session',
|
||||
data: {'email': email, 'token': token},
|
||||
data: data,
|
||||
);
|
||||
return SessionResponse.fromJson(json);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ abstract class AuthRepository {
|
||||
Future<AuthUser> loginWithEmailOtp({
|
||||
required String email,
|
||||
required String otp,
|
||||
String? language,
|
||||
String? timezone,
|
||||
});
|
||||
|
||||
Future<AuthUser?> recoverSession();
|
||||
@@ -37,8 +39,15 @@ class AuthRepositoryImpl implements AuthRepository {
|
||||
Future<AuthUser> loginWithEmailOtp({
|
||||
required String email,
|
||||
required String otp,
|
||||
String? language,
|
||||
String? timezone,
|
||||
}) async {
|
||||
final session = await _authApi.createEmailSession(email: email, token: otp);
|
||||
final session = await _authApi.createEmailSession(
|
||||
email: email,
|
||||
token: otp,
|
||||
language: language,
|
||||
timezone: timezone,
|
||||
);
|
||||
await _sessionStore.saveToken(session.accessToken);
|
||||
await _sessionStore.saveRefreshToken(session.refreshToken);
|
||||
await _sessionStore.saveEmail(email);
|
||||
|
||||
@@ -50,8 +50,15 @@ class AuthBloc extends ChangeNotifier {
|
||||
Future<void> loginWithOtp({
|
||||
required String email,
|
||||
required String otp,
|
||||
String? language,
|
||||
String? timezone,
|
||||
}) async {
|
||||
final user = await _repository.loginWithEmailOtp(email: email, otp: otp);
|
||||
final user = await _repository.loginWithEmailOtp(
|
||||
email: email,
|
||||
otp: otp,
|
||||
language: language,
|
||||
timezone: timezone,
|
||||
);
|
||||
_logger.info(message: 'User logged in', extra: {'user_id': user.id});
|
||||
_state = AuthState(status: AuthStatus.authenticated, user: user);
|
||||
notifyListeners();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
String formatRelativeTime(BuildContext context, DateTime dateTime) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final diff = DateTime.now().difference(dateTime);
|
||||
if (diff.inMinutes < 1) return l10n.timeJustNow;
|
||||
if (diff.inHours < 1) return l10n.timeMinutesAgo(diff.inMinutes);
|
||||
if (diff.inDays < 1) return l10n.timeHoursAgo(diff.inHours);
|
||||
if (diff.inDays < 30) return l10n.timeDaysAgo(diff.inDays);
|
||||
return '${dateTime.month}/${dateTime.day}';
|
||||
}
|
||||
|
||||
String formatCompactLocalDateTime(String isoString) {
|
||||
final dateTime = DateTime.parse(isoString).toLocal();
|
||||
final now = DateTime.now();
|
||||
final timeText =
|
||||
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
if (now.year == dateTime.year &&
|
||||
now.month == dateTime.month &&
|
||||
now.day == dateTime.day) {
|
||||
return timeText;
|
||||
}
|
||||
|
||||
final dateText =
|
||||
'${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}';
|
||||
return '$dateText $timeText';
|
||||
}
|
||||
Reference in New Issue
Block a user