chore(task): archive 04-28-feat-locale-timezone-bootstrap

This commit is contained in:
ZL-Q
2026-04-28 17:17:39 +08:00
parent 9bc24fa0c4
commit 8de03314fd
3 changed files with 4 additions and 3 deletions
@@ -0,0 +1,404 @@
# 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` + 映射表(无依赖)
@@ -0,0 +1,106 @@
# PRD: App启动时语言和时区自动设置
## 背景
当前App启动时语言硬编码为 `zh`,不读取iOS系统语言;时区完全不处理。需要实现三种场景的自动设置逻辑。
## 需求
### 场景1:新用户首次打开App → 注册时写入后端
1. App无本地存储信息(首次打开)
2. 读取iOS系统语言和时区
3. 自动设置App界面语言和时区
4. 用户注册时,将语言和时区作为请求参数传给后端
5. 后端创建用户Profile时使用传入值
### 场景2:已有用户在新设备首次登录 → 从服务器同步
1. App无本地存储信息(新设备首次打开)
2. 读取iOS系统语言和时区作为临时值
3. 自动设置App界面语言和时区(临时)
4. 用户登录成功后,从服务器拉取Profile中的语言和时区
5. 用服务器值更新本地存储和App设置
### 场景3:有本地存储 → 使用本地值
1. App有本地存储的语言和时区信息
2. 直接使用本地值,不读取系统语言和时区
3. 登录后仍从服务器同步(确保一致性)
## 技术方案
### 前端
1. **读取系统语言**
- 使用 `PlatformDispatcher.instance.locale` 获取系统首选语言
- 通过 `resolveSystemLocale()` 映射到 App 支持的 Locale
2. **读取系统时区**
- 使用 `flutter_timezone` 包的 `getLocalTimezone()` 获取 IANA 时区 ID
- 直接使用返回值,后端会验证有效性
3. **本地存储扩展**
- `SessionStore` 新增 `saveTimezone()` / `getTimezone()`
- 现有 `saveLocaleTag()` / `getLocaleTag()` 已支持语言
4. **启动流程修改** (`app.dart:_bootstrap`)
```
if (本地有locale) {
使用本地locale
} else {
读取系统locale → 保存到本地
}
if (本地有时区) {
使用本地时区
} else {
读取系统时区 → 保存到本地
}
```
5. **注册请求扩展**
- `AuthApi.createEmailSession` 新增 `language` 和 `timezone` 参数
- `EmailSessionCreateRequest` schema 扩展
6. **登录后同步**
- 现有 `_refreshProfile()` 已同步语言
- 扩展同步时区
### 后端
1. **Schema扩展** (`v1/auth/schemas.py`)
```python
class EmailSessionCreateRequest(BaseModel):
email: str
token: str
language: str | None = None # BCP-47 tag
timezone: str | None = None # IANA timezone
```
2. **Service逻辑** (`v1/auth/service.py`)
- `create_email_session` 接收语言/时区
- 创建/更新Profile时使用传入值(如果提供)
3. **Profile默认值保持不变**
- `language: str = "zh-CN"`
- `timezone: str = "Asia/Shanghai"`
## 验收标准
1. 新设备首次打开App,界面语言与iOS系统语言一致
2. 新用户注册后,后端Profile的语言/时区与App一致
3. 已有用户登录后,App语言/时区与服务器Profile一致
4. 有本地存储时,App启动不请求系统语言/时区
5. 支持的语言映射:
- iOS `en` / `en-US` / `en-GB` → Flutter `Locale('en')` → 存储 `en-US`
- iOS `zh` / `zh-CN` / `zh-SG` / `zh-Hans-*` → Flutter `Locale('zh')` → 存储 `zh-CN`
- iOS `zh-Hant` / `zh-TW` / `zh-HK` / `zh-MO` → Flutter `Locale(zh, Hant)` → 存储 `zh-Hant`
6. 时区为有效IANA ID (如 `Asia/Shanghai`, `America/New_York`)
## 风险
1. iOS系统语言可能不在支持列表中 → 回退到 `zh-CN`
2. `flutter_timezone` 包可能返回无效时区 → 后端验证 + 前端回退默认值
3. 用户修改系统语言/时区后,App不会自动更新(符合场景3设计)
4. 新用户注册时网络请求失败 → Profile 使用后端默认值,用户可在设置中修改
@@ -0,0 +1,54 @@
{
"title": "App启动时语言和时区自动设置",
"slug": "04-28-feat-locale-timezone-bootstrap",
"status": "completed",
"created_at": "2026-04-28",
"phases": [
{
"name": "Phase 1: 前端读取系统语言/时区",
"status": "pending",
"items": [
"1.1 创建系统Locale工具函数",
"1.2 创建系统时区工具函数",
"1.3 扩展SessionStore存储时区",
"1.4 修改App启动流程_bootstrap"
]
},
{
"name": "Phase 2: 前端注册时传递语言/时区",
"status": "pending",
"items": [
"2.1 扩展AuthApi.createEmailSession",
"2.2 扩展AuthRepository",
"2.3 扩展AuthBloc.loginWithOtp",
"2.4 修改App调用点传递语言/时区"
]
},
{
"name": "Phase 3: 后端接收语言/时区",
"status": "pending",
"items": [
"3.1 扩展EmailSessionCreateRequest Schema",
"3.2 扩展AuthService.create_email_session",
"3.3 新增Profile更新方法"
]
},
{
"name": "Phase 4: 前端登录后同步时区",
"status": "pending",
"items": [
"4.1 修改_refreshProfile同步时区"
]
},
{
"name": "Phase 5: 测试验证",
"status": "pending",
"items": [
"5.1 单元测试",
"5.2 集成测试",
"5.3 手动测试"
]
}
],
"completedAt": "2026-04-28"
}