# PRD: Divination Tutorial First-Visit Tracking ## Background 用户首次进入起卦相关页面时,应该自动弹出教程引导。当前实现已有教程弹窗逻辑(用户可主动点击按钮触发),但缺少"首次访问自动弹出"的能力。 需要区分三个页面的首次访问状态: 1. **起卦方式选择页面** (divination_screen.dart) - 显示手动起卦教程弹窗 (DivinationGuideDialog) 2. **自动起卦页面** (auto_divination_screen.dart) - 显示 Onboarding 步骤引导 3. **手动起卦页面** (manual_divination_screen.dart) - 显示 Onboarding 步骤引导 ## Goal 1. 在用户 profile 的 settings 中添加字段,记录三个页面的教程是否已展示 2. 前端在进入对应页面时检查 settings,首次访问自动弹出教程 3. 教程展示后,前端调用后端 API 更新 settings 标记为已展示 4. 老用户(settings 中没有这些字段)默认视为已看过教程,不弹出 ## Scope - **In scope**: - 后端: ProfileSettingsV1 schema 添加 `divination_tutorial` 字段 - 后端: 更新 API 端点支持新字段 - 前端: ProfileSettingsV1 model 添加对应字段 - 前端: 三个起卦页面的首次访问检测和教程自动弹出逻辑 - **Out of scope**: - 新增教程内容(复用现有教程逻辑) - 用户主动触发教程的按钮(已存在,保持不变) ## Current Architecture ### 前端现有教程逻辑 | 页面 | 文件路径 | 教程触发方式 | |------|----------|-------------| | 起卦方式选择 | `apps/lib/features/divination/presentation/screens/divination_screen.dart` | `DivinationGuideDialog` 弹窗,`_showGuide()` 函数 | | 自动起卦 | `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart` | `Onboarding` 组件,`_showGuide()` 函数 | | 手动起卦 | `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart` | `Onboarding` 组件,`_showGuide()` 函数 | ### 后端 Settings 结构 **文件**: `backend/src/schemas/shared/user.py` ```python class ProfileSettingsV1(BaseModel): version: Literal[1] = 1 preferences: PreferenceSettings = Field(default_factory=PreferenceSettings) privacy: dict[str, object] = Field(default_factory=dict) notification: NotificationSettings = Field(default_factory=NotificationSettings) ``` ### 前端 Settings Model **文件**: `apps/lib/features/settings/data/models/profile_settings.dart` ```dart class ProfileSettingsV1 { final int version; final String displayName; final String bio; final String? avatarPath; final String? avatarUrl; final PreferenceSettings preferences; final Map privacy; final NotificationSettings notification; } ``` ### 更新 Settings API - **端点**: `PATCH /users/me/settings` - **Service**: `backend/src/v1/users/service.py` → `update_settings()` - **前端 API**: `apps/lib/features/settings/data/apis/profile_api.dart` → `updateSettings()` ## Technical Design ### 1. 后端 Schema 变更 **新增 `DivinationTutorialSettings`**: ```python class DivinationTutorialSettings(BaseModel): """起卦教程首次访问状态""" divination_entry_shown: bool = False # 起卦方式选择页教程已展示 auto_divination_shown: bool = False # 自动起卦页教程已展示 manual_divination_shown: bool = False # 手动起卦页教程已展示 ``` **修改 `ProfileSettingsV1`**: ```python class ProfileSettingsV1(BaseModel): version: Literal[1] = 1 preferences: PreferenceSettings = Field(default_factory=PreferenceSettings) privacy: dict[str, object] = Field(default_factory=dict) notification: NotificationSettings = Field(default_factory=NotificationSettings) divination_tutorial: DivinationTutorialSettings = Field(default_factory=DivinationTutorialSettings) ``` ### 2. 前端 Model 变更 **新增 `DivinationTutorialSettings`**: ```dart class DivinationTutorialSettings { const DivinationTutorialSettings({ this.divinationEntryShown = false, this.autoDivinationShown = false, this.manualDivinationShown = false, }); final bool divinationEntryShown; final bool autoDivinationShown; final bool manualDivinationShown; DivinationTutorialSettings copyWith({...}); } ``` **修改 `ProfileSettingsV1`**: ```dart class ProfileSettingsV1 { // ... existing fields ... final DivinationTutorialSettings divinationTutorial; ProfileSettingsV1 copyWith({ // ... existing params ... DivinationTutorialSettings? divinationTutorial, }); } ``` ### 3. 前端页面逻辑变更 **起卦方式选择页面** (`divination_screen.dart`): ```dart // 在 initState 或 didChangeDependencies 中检查 void _checkFirstVisit() { final settings = // 从 widget 或 context 获取 profileSettings if (!settings.divinationTutorial.divinationEntryShown) { // 延迟显示,确保页面渲染完成 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showGuide(context, l10n); }); // 调用 API 更新 settings _markTutorialShown('divination_entry'); } } Future _markTutorialShown(String tutorialKey) async { // 调用 onProfileSettingsChanged 更新 settings } ``` **自动起卦页面** (`auto_divination_screen.dart`): ```dart void _checkFirstVisit() { final settings = // 获取 profileSettings if (!settings.divinationTutorial.autoDivinationShown) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showGuide(); }); _markTutorialShown('auto_divination'); } } ``` **手动起卦页面** (`manual_divination_screen.dart`): ```dart void _checkFirstVisit() { final settings = // 获取 profileSettings if (!settings.divinationTutorial.manualDivinationShown) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showGuide(); }); _markTutorialShown('manual_divination'); } } ``` ### 4. Settings 传递方式(已确认) **采用构造函数参数传入方式**(选项 A)。 **调用链分析**: ``` EryaoApp (app.dart) └── HomeScreen ├── profileSettings: ProfileSettingsV1 ├── onProfileSettingsChanged: Future Function(ProfileSettingsV1) └── _onStartDivination() └── DivinationScreen <-- 需要添加 profileSettings 和 onProfileSettingsChanged ├── _onStart() → AutoDivinationScreen <-- 需要添加 └── _onStart() → ManualDivinationScreen <-- 需要添加 ``` **需要修改的文件**: | 文件 | 修改内容 | |------|----------| | `home_screen.dart:155-166` | `_onStartDivination()` 传入 `profileSettings` 和 `onProfileSettingsChanged` | | `divination_screen.dart` | 构造函数添加 `profileSettings` 和 `onProfileSettingsChanged`;添加首次访问检测 | | `divination_screen.dart:164-176` | `_onStart()` 传入参数到 `ManualDivinationScreen` 和 `AutoDivinationScreen` | | `auto_divination_screen.dart` | 构造函数添加参数;添加首次访问检测 | | `manual_divination_screen.dart` | 构造函数添加参数;添加首次访问检测 | ### 5. 教程关闭回调处理 **Onboarding 组件** (用于自动/手动起卦页面): - `Onboarding` 组件有 `onEnd` 回调参数 - 在 `onEnd` 中调用 `_markTutorialShown()` **DivinationGuideDialog** (用于起卦方式选择页面): - 这是一个 `Dialog`,关闭时 `Navigator.pop()` 被调用 - 需要在 `showDialog` 的 `.then()` 中处理关闭回调 - 或者在弹窗显示后立即标记(因为用户已经看到了) **推荐方案**: 在弹窗显示后立即标记,无需等待关闭 - 用户看到教程即视为"已展示" - 避免因用户快速关闭导致 settings 未更新 ### 6. 老用户兼容 - 后端新字段默认值为 `false` - 数据库中现有用户 settings 没有 `divination_tutorial` 字段 - 前端解析时: - 如果字段不存在,**视为已看过教程**(不弹出) - 实现方式: JSON 解析时提供默认值 `true`,或检查字段是否存在 **前端兼容逻辑**: ```dart factory DivinationTutorialSettings.fromJson(Map? json) { if (json == null) { // 老用户没有这个字段,默认全部 true(已看过) return const DivinationTutorialSettings( divinationEntryShown: true, autoDivinationShown: true, manualDivinationShown: true, ); } return DivinationTutorialSettings( divinationEntryShown: json['divination_entry_shown'] ?? true, autoDivinationShown: json['auto_divination_shown'] ?? true, manualDivinationShown: json['manual_divination_shown'] ?? true, ); } ``` ### 6. API 更新流程 用户首次看到教程后: 1. 前端调用 `_markTutorialShown('auto_divination')` 2. 构建 `ProfileSettingsV1` 的 copyWith 更新对应字段 3. 调用 `onProfileSettingsChanged(updatedSettings)` 4. 后端保存新 settings **注意**: 只更新一个字段时,需要保留其他字段不变。 ### 7. Onboarding 组件 onEnd 回调 查看 `auto_divination_screen.dart:145-148`: ```dart return Onboarding( key: _onboardingKey, steps: steps, onChanged: _onGuideStepChanged, // 需要添加: onEnd: _markTutorialShown, ); ``` `manual_divination_screen.dart` 同理。 ## Implementation Checklist ### 后端 - [ ] `backend/src/schemas/shared/user.py` 添加 `DivinationTutorialSettings` 和修改 `ProfileSettingsV1` - [ ] 确认 API 端点无需修改(使用现有的 PATCH + 完整 settings 替换) - [ ] 单元测试: 新 schema 的序列化/反序列化 ### 前端 - [ ] `apps/lib/features/settings/data/models/profile_settings.dart` 添加 `DivinationTutorialSettings` - [ ] `apps/lib/features/settings/data/apis/profile_api.dart` 更新 `_toSettings()` 解析逻辑 - [ ] 确认三个页面如何获取 `ProfileSettingsV1`(需要调查) - [ ] `divination_screen.dart` 添加首次访问检测 - [ ] `auto_divination_screen.dart` 添加首次访问检测 - [ ] `manual_divination_screen.dart` 添加首次访问检测 - [ ] 测试老用户兼容性(settings 无此字段时不弹出) ### 测试场景 1. **新用户首次访问**: 进入页面 → 自动弹出教程 → settings 更新为 shown 2. **新用户再次访问**: 进入页面 → 不弹出教程 3. **老用户首次访问**: settings 无此字段 → 不弹出教程(视为已看过) 4. **用户主动触发**: 点击教程按钮 → 正常弹出(无论是否首次) ## Risks & Mitigations | 风险 | 缓解措施 | |------|---------| | 前端解析 settings 时字段缺失 | 使用 `?? true` 默认值,老用户视为已看过 | | 教程弹出时机过早导致 UI 闪烁 | 使用 `addPostFrameCallback` 延迟到下一帧 | | API 调用失败导致 settings 未更新 | 不影响用户体验,下次访问仍会弹出(符合预期) | | 并发更新 settings 可能覆盖其他字段 | 前端使用 copyWith 保留其他字段 | ## Open Questions 1. ~~**Settings 传递方式**~~: 已确认使用构造函数参数传入。 2. ~~**教程关闭回调**~~: 建议在弹窗显示后立即标记,无需等待关闭。 ## File Changes Summary | 文件 | 类型 | 修改内容 | |------|------|----------| | `backend/src/schemas/shared/user.py` | 后端 | 添加 `DivinationTutorialSettings` 和修改 `ProfileSettingsV1` | | `apps/lib/features/settings/data/models/profile_settings.dart` | 前端 | 添加 `DivinationTutorialSettings` 和修改 `ProfileSettingsV1` | | `apps/lib/features/settings/data/apis/profile_api.dart` | 前端 | 更新 `_toSettings()` 解析新字段 | | `apps/lib/features/home/presentation/screens/home_screen.dart` | 前端 | `_onStartDivination()` 传入 `profileSettings` 和 `onProfileSettingsChanged` | | `apps/lib/features/divination/presentation/screens/divination_screen.dart` | 前端 | 添加构造函数参数 + 首次访问检测 + `_markTutorialShown()` | | `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart` | 前端 | 添加构造函数参数 + 首次访问检测 + `_markTutorialShown()` | | `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart` | 前端 | 添加构造函数参数 + 首次访问检测 + `_markTutorialShown()` |