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

9.6 KiB
Raw Blame History

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
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 + 映射表 (无额外依赖,但需维护映射)
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):

dependencies:
  flutter_timezone: ^3.0.1

1.3 扩展SessionStore

文件: apps/lib/core/auth/session_store.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 状态变量声明

// 在 State 类顶部添加
String _timezone = 'Asia/Shanghai';

步骤2: 修改 _bootstrap() 方法

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

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

// 接口定义
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

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

// 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

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

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 需要注入 ProfileRepositoryUserRepository 以便更新 Profile。

@dataclass
class AuthService:
    # ... 现有依赖 ...
    profile_repository: SQLAlchemyUserRepository  # 新增

: 复用现有的 v1/users/service.py 中的 UserService.update_settings() 方法。


Phase 4: 前端 - 登录后同步时区

4.1 修改_refreshProfile

文件: apps/lib/app/app.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 + 映射表(无依赖)