405 lines
9.6 KiB
Markdown
405 lines
9.6 KiB
Markdown
|
|
# Implementation Plan: App启动时语言和时区自动设置
|
|||
|
|
|
|||
|
|
## Phase 1: 前端 - 读取系统语言/时区
|
|||
|
|
|
|||
|
|
### 1.1 创建系统Locale工具函数
|
|||
|
|
|
|||
|
|
**文件**: `apps/lib/app/locale_utils.dart` (新建)
|
|||
|
|
|
|||
|
|
**Locale映射规则** (基于项目现有约定):
|
|||
|
|
|
|||
|
|
| 系统Locale | Flutter Locale | 存储Tag |
|
|||
|
|
|-----------|----------------|---------|
|
|||
|
|
| en, en-US, en-GB, ... | `Locale('en')` | `en-US` |
|
|||
|
|
| zh, zh-CN, zh-SG, zh-Hans-* | `Locale('zh')` | `zh-CN` |
|
|||
|
|
| zh-Hant, zh-TW, zh-HK, zh-MO | `Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant')` | `zh-Hant` |
|
|||
|
|
| 其他 | null (回退默认) | `zh-CN` |
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'dart:ui';
|
|||
|
|
import 'package:flutter/material.dart';
|
|||
|
|
|
|||
|
|
/// 从系统Locale映射到App支持的Locale
|
|||
|
|
/// 返回 null 表示不支持,调用方应使用默认值
|
|||
|
|
Locale? resolveSystemLocale(Locale systemLocale) {
|
|||
|
|
final lang = systemLocale.languageCode.toLowerCase();
|
|||
|
|
final script = systemLocale.scriptCode;
|
|||
|
|
final country = systemLocale.countryCode;
|
|||
|
|
|
|||
|
|
// 英文: en, en-US, en-GB, ... → Locale('en') → 存储 en-US
|
|||
|
|
if (lang == 'en') {
|
|||
|
|
return const Locale('en');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 中文处理
|
|||
|
|
if (lang == 'zh') {
|
|||
|
|
// 繁体: zh-Hant, zh-TW, zh-HK, zh-MO → Locale(zh, Hant) → 存储 zh-Hant
|
|||
|
|
if (script == 'Hant' || country == 'TW' || country == 'HK' || country == 'MO') {
|
|||
|
|
return const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant');
|
|||
|
|
}
|
|||
|
|
// 简体: zh, zh-CN, zh-SG, zh-Hans-* → Locale('zh') → 存储 zh-CN
|
|||
|
|
return const Locale('zh');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 其他语言不支持,回退到默认
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 获取系统首选Locale
|
|||
|
|
Locale getSystemLocale() {
|
|||
|
|
return PlatformDispatcher.instance.locale;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.2 创建系统时区工具函数
|
|||
|
|
|
|||
|
|
**文件**: `apps/lib/app/timezone_utils.dart` (新建)
|
|||
|
|
|
|||
|
|
**方案选择**:
|
|||
|
|
- 方案1: 使用 `flutter_timezone` 包 (推荐,直接返回IANA ID)
|
|||
|
|
- 方案2: 使用 `DateTime.now().timeZoneName` + 映射表 (无额外依赖,但需维护映射)
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'package:flutter_timezone/flutter_timezone.dart';
|
|||
|
|
|
|||
|
|
/// 获取系统时区IANA ID
|
|||
|
|
/// 例如: Asia/Shanghai, America/New_York, Europe/London
|
|||
|
|
Future<String> getSystemTimezone() async {
|
|||
|
|
try {
|
|||
|
|
final timezone = await FlutterTimezone.getLocalTimezone();
|
|||
|
|
// 验证是否为有效IANA ID
|
|||
|
|
if (timezone.isNotEmpty) {
|
|||
|
|
return timezone;
|
|||
|
|
}
|
|||
|
|
} catch (_) {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
// 回退到默认
|
|||
|
|
return 'Asia/Shanghai';
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**依赖添加** (`apps/pubspec.yaml`):
|
|||
|
|
```yaml
|
|||
|
|
dependencies:
|
|||
|
|
flutter_timezone: ^3.0.1
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.3 扩展SessionStore
|
|||
|
|
|
|||
|
|
**文件**: `apps/lib/core/auth/session_store.dart`
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 新增
|
|||
|
|
static const String _timezoneKey = 'selected_timezone';
|
|||
|
|
|
|||
|
|
Future<void> saveTimezone(String timezone) async {
|
|||
|
|
await _kvStore.setString(_timezoneKey, timezone);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<String?> getTimezone() async {
|
|||
|
|
return _kvStore.getString(_timezoneKey);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.4 修改App启动流程
|
|||
|
|
|
|||
|
|
**文件**: `apps/lib/app/app.dart`
|
|||
|
|
|
|||
|
|
**步骤1**: 添加 `_timezone` 状态变量声明
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 在 State 类顶部添加
|
|||
|
|
String _timezone = 'Asia/Shanghai';
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**步骤2**: 修改 `_bootstrap()` 方法
|
|||
|
|
```dart
|
|||
|
|
Future<void> _bootstrap() async {
|
|||
|
|
// 1. 语言处理
|
|||
|
|
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));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 时区处理
|
|||
|
|
final savedTimezone = await _sessionStore.getTimezone();
|
|||
|
|
final String timezone;
|
|||
|
|
if (savedTimezone != null) {
|
|||
|
|
timezone = savedTimezone;
|
|||
|
|
} else {
|
|||
|
|
timezone = await getSystemTimezone();
|
|||
|
|
await _sessionStore.saveTimezone(timezone);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 设置状态
|
|||
|
|
setState(() {
|
|||
|
|
_locale = locale;
|
|||
|
|
_timezone = timezone;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 4. 启动认证
|
|||
|
|
await _authBloc.start();
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 2: 前端 - 注册时传递语言/时区
|
|||
|
|
|
|||
|
|
### 2.1 扩展AuthApi
|
|||
|
|
|
|||
|
|
**文件**: `apps/lib/features/auth/data/apis/auth_api.dart`
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
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: data);
|
|||
|
|
return SessionResponse.fromJson(json);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.2 扩展AuthRepository
|
|||
|
|
|
|||
|
|
**文件**: `apps/lib/features/auth/data/repositories/auth_repository.dart`
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 接口定义
|
|||
|
|
abstract class AuthRepository {
|
|||
|
|
// ... 现有方法 ...
|
|||
|
|
Future<User> loginWithEmailOtp({
|
|||
|
|
required String email,
|
|||
|
|
required String otp,
|
|||
|
|
String? language,
|
|||
|
|
String? timezone,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 实现
|
|||
|
|
class AuthRepositoryImpl implements AuthRepository {
|
|||
|
|
// ... 现有代码 ...
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Future<User> loginWithEmailOtp({
|
|||
|
|
required String email,
|
|||
|
|
required String otp,
|
|||
|
|
String? language,
|
|||
|
|
String? timezone,
|
|||
|
|
}) async {
|
|||
|
|
final response = await _authApi.createEmailSession(
|
|||
|
|
email: email,
|
|||
|
|
token: otp,
|
|||
|
|
language: language,
|
|||
|
|
timezone: timezone,
|
|||
|
|
);
|
|||
|
|
// ... 现有的 session 处理逻辑 ...
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.3 扩展AuthBloc
|
|||
|
|
|
|||
|
|
**文件**: `apps/lib/features/auth/presentation/bloc/auth_bloc.dart`
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
Future<void> loginWithOtp({
|
|||
|
|
required String email,
|
|||
|
|
required String otp,
|
|||
|
|
String? language,
|
|||
|
|
String? timezone,
|
|||
|
|
}) async {
|
|||
|
|
final user = await _repository.loginWithEmailOtp(
|
|||
|
|
email: email,
|
|||
|
|
otp: otp,
|
|||
|
|
language: language,
|
|||
|
|
timezone: timezone,
|
|||
|
|
);
|
|||
|
|
// ...
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.4 修改App调用点
|
|||
|
|
|
|||
|
|
**文件**: `apps/lib/app/app.dart`
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// LoginScreen 的 onLoginWithOtp 回调
|
|||
|
|
onLoginWithOtp: (email, otp) {
|
|||
|
|
return _authBloc.loginWithOtp(
|
|||
|
|
email: email,
|
|||
|
|
otp: otp,
|
|||
|
|
language: languageTagFromLocale(_locale),
|
|||
|
|
timezone: _timezone,
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 3: 后端 - 接收语言/时区
|
|||
|
|
|
|||
|
|
### 3.1 扩展Schema
|
|||
|
|
|
|||
|
|
**文件**: `backend/src/v1/auth/schemas.py`
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
class EmailSessionCreateRequest(BaseModel):
|
|||
|
|
model_config = ConfigDict(extra="forbid")
|
|||
|
|
|
|||
|
|
email: str = Field(pattern=SUPABASE_EMAIL_PATTERN)
|
|||
|
|
token: str = Field(min_length=6, max_length=6)
|
|||
|
|
language: str | None = Field(default=None, max_length=20)
|
|||
|
|
timezone: str | None = Field(default=None, max_length=50)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.2 扩展AuthService
|
|||
|
|
|
|||
|
|
**文件**: `backend/src/v1/auth/service.py`
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
async def create_email_session(
|
|||
|
|
self,
|
|||
|
|
payload: EmailSessionCreateRequest,
|
|||
|
|
) -> SessionResponse:
|
|||
|
|
# ... 现有的 session 创建逻辑 ...
|
|||
|
|
|
|||
|
|
# 如果提供了语言/时区,更新Profile
|
|||
|
|
if payload.language or payload.timezone:
|
|||
|
|
await self._update_profile_preferences(
|
|||
|
|
user_id=user.id,
|
|||
|
|
language=payload.language,
|
|||
|
|
timezone=payload.timezone,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
async def _update_profile_preferences(
|
|||
|
|
self,
|
|||
|
|
user_id: UUID,
|
|||
|
|
language: str | None,
|
|||
|
|
timezone: str | None,
|
|||
|
|
) -> None:
|
|||
|
|
"""更新用户Profile的语言/时区偏好设置"""
|
|||
|
|
profile = await self.profile_repository.get_profile_by_user_id(user_id)
|
|||
|
|
if profile is None:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
settings = profile.settings or {}
|
|||
|
|
preferences = settings.get("preferences", {})
|
|||
|
|
|
|||
|
|
if language is not None:
|
|||
|
|
preferences["language"] = language
|
|||
|
|
if timezone is not None:
|
|||
|
|
preferences["timezone"] = timezone
|
|||
|
|
|
|||
|
|
settings["preferences"] = preferences
|
|||
|
|
profile.settings = settings
|
|||
|
|
|
|||
|
|
await self.profile_repository.save()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.3 AuthService依赖注入
|
|||
|
|
|
|||
|
|
**文件**: `backend/src/v1/auth/service.py`
|
|||
|
|
|
|||
|
|
AuthService 需要注入 `ProfileRepository` 或 `UserRepository` 以便更新 Profile。
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@dataclass
|
|||
|
|
class AuthService:
|
|||
|
|
# ... 现有依赖 ...
|
|||
|
|
profile_repository: SQLAlchemyUserRepository # 新增
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**或**: 复用现有的 `v1/users/service.py` 中的 `UserService.update_settings()` 方法。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 4: 前端 - 登录后同步时区
|
|||
|
|
|
|||
|
|
### 4.1 修改_refreshProfile
|
|||
|
|
|
|||
|
|
**文件**: `apps/lib/app/app.dart`
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
Future<void> _refreshProfile({required String userEmail}) async {
|
|||
|
|
// ... 现有逻辑 ...
|
|||
|
|
|
|||
|
|
final serverLanguage = profile.preferences.language;
|
|||
|
|
final serverTimezone = profile.preferences.timezone;
|
|||
|
|
|
|||
|
|
// 同步语言
|
|||
|
|
await _sessionStore.saveLocaleTag(serverLanguage);
|
|||
|
|
|
|||
|
|
// 同步时区
|
|||
|
|
await _sessionStore.saveTimezone(serverTimezone);
|
|||
|
|
|
|||
|
|
setState(() {
|
|||
|
|
_locale = localeFromLanguageTag(serverLanguage);
|
|||
|
|
_timezone = serverTimezone;
|
|||
|
|
_profileSettings = profile;
|
|||
|
|
// ...
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 5: 验证和测试
|
|||
|
|
|
|||
|
|
### 5.1 单元测试
|
|||
|
|
|
|||
|
|
- [ ] `resolveSystemLocale` 映射正确性
|
|||
|
|
- [ ] `getSystemTimezone` 返回有效IANA ID
|
|||
|
|
- [ ] `SessionStore` 时区存取
|
|||
|
|
|
|||
|
|
### 5.2 集成测试
|
|||
|
|
|
|||
|
|
- [ ] 新设备首次打开 → 系统语言/时区生效
|
|||
|
|
- [ ] 新用户注册 → 后端Profile正确
|
|||
|
|
- [ ] 已有用户登录 → 服务器值同步
|
|||
|
|
- [ ] 有本地存储 → 使用本地值
|
|||
|
|
|
|||
|
|
### 5.3 手动测试
|
|||
|
|
|
|||
|
|
- [ ] iOS系统语言设为英文 → App显示英文
|
|||
|
|
- [ ] iOS系统语言设为繁体中文 → App显示繁体
|
|||
|
|
- [ ] iOS系统时区设为非上海 → App时区正确
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 实施顺序
|
|||
|
|
|
|||
|
|
1. **Phase 1.3**: 扩展SessionStore(最小改动,无依赖)
|
|||
|
|
2. **Phase 1.1-1.2**: 创建工具函数
|
|||
|
|
3. **Phase 1.4**: 修改启动流程
|
|||
|
|
4. **Phase 3.1**: 后端Schema扩展
|
|||
|
|
5. **Phase 3.2-3.3**: 后端Service逻辑
|
|||
|
|
6. **Phase 2**: 前端注册流程扩展
|
|||
|
|
7. **Phase 4**: 登录后同步时区
|
|||
|
|
8. **Phase 5**: 测试验证
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 依赖
|
|||
|
|
|
|||
|
|
- 可选: `flutter_timezone` 包(如需精确时区ID)
|
|||
|
|
- 添加到 `apps/pubspec.yaml`
|
|||
|
|
- 或使用 `DateTime.now().timeZoneName` + 映射表(无依赖)
|