Files
eryao/.trellis/tasks/archive/2026-04/04-28-feat-locale-timezone-bootstrap/IMPLEMENTATION_PLAN.md
T

405 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` + 映射表(无依赖)