feat: 添加起卦教程首次访问追踪和Agent时间上下文
- 后端 ProfileSettingsV1 添加 DivinationTutorialSettings 字段 - 前端三个起卦页面添加首次访问检测,自动弹出教程 - 教程展示后更新 settings 标记,避免重复弹出 - 使用本地状态管理避免并发更新覆盖问题 - Agent 系统提示添加时间上下文信息
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
# 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<String, Object?> 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<void> _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<void> 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<String, dynamic>? 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()` |
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "divination-tutorial-first-visit",
|
||||
"name": "divination-tutorial-first-visit",
|
||||
"title": "divination-tutorial-first-visit",
|
||||
"description": "",
|
||||
"status": "planning",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"priority": "P2",
|
||||
"creator": "zl-q",
|
||||
"assignee": "zl-q",
|
||||
"createdAt": "2026-04-15",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "dev",
|
||||
"worktree_path": null,
|
||||
"current_phase": 0,
|
||||
"next_action": [
|
||||
{
|
||||
"phase": 1,
|
||||
"action": "implement"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"action": "check"
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"action": "finish"
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"action": "create-pr"
|
||||
}
|
||||
],
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import '../../../../shared/widgets/divination/yao_line_row.dart';
|
||||
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../../settings/data/models/profile_settings.dart';
|
||||
import '../../data/models/divination_backend_models.dart';
|
||||
import '../../data/apis/divination_api.dart';
|
||||
import '../../data/models/divination_params.dart';
|
||||
@@ -33,12 +34,17 @@ class AutoDivinationScreen extends StatefulWidget {
|
||||
required this.runService,
|
||||
this.divinationApi,
|
||||
required this.onCompleted,
|
||||
required this.profileSettings,
|
||||
required this.onProfileSettingsChanged,
|
||||
});
|
||||
|
||||
final DivinationParams params;
|
||||
final DivinationRunService runService;
|
||||
final DivinationApi? divinationApi;
|
||||
final Future<void> Function(DivinationResultData result) onCompleted;
|
||||
final ProfileSettingsV1 profileSettings;
|
||||
final Future<void> Function(ProfileSettingsV1 settings)
|
||||
onProfileSettingsChanged;
|
||||
|
||||
@override
|
||||
State<AutoDivinationScreen> createState() => _AutoDivinationScreenState();
|
||||
@@ -64,6 +70,8 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
|
||||
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
bool _spinLocked = false;
|
||||
bool _submitting = false;
|
||||
bool _tutorialChecked = false;
|
||||
late DivinationTutorialSettings _localTutorialSettings;
|
||||
|
||||
final GlobalKey<OnboardingState> _onboardingKey =
|
||||
GlobalKey<OnboardingState>();
|
||||
@@ -89,6 +97,37 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
|
||||
duration: const Duration(milliseconds: 500),
|
||||
)..repeat(reverse: true);
|
||||
_listenShake();
|
||||
_localTutorialSettings = widget.profileSettings.divinationTutorial;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_tutorialChecked) {
|
||||
_tutorialChecked = true;
|
||||
_checkFirstVisit();
|
||||
}
|
||||
}
|
||||
|
||||
void _checkFirstVisit() {
|
||||
if (!_localTutorialSettings.autoDivinationShown) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_showGuide();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markTutorialShown() async {
|
||||
setState(() {
|
||||
_localTutorialSettings = _localTutorialSettings.copyWith(
|
||||
autoDivinationShown: true,
|
||||
);
|
||||
});
|
||||
final updated = widget.profileSettings.copyWith(
|
||||
divinationTutorial: _localTutorialSettings,
|
||||
);
|
||||
await widget.onProfileSettingsChanged(updated);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -176,6 +215,7 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
|
||||
key: _onboardingKey,
|
||||
steps: steps,
|
||||
onChanged: _onGuideStepChanged,
|
||||
onEnd: (_) => _markTutorialShown(),
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../../../shared/widgets/gua_icon.dart';
|
||||
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../../settings/data/models/profile_settings.dart';
|
||||
import '../../data/apis/divination_api.dart';
|
||||
import '../../data/models/divination_params.dart';
|
||||
import '../../data/models/divination_result.dart';
|
||||
@@ -26,6 +27,8 @@ class DivinationScreen extends StatefulWidget {
|
||||
this.runServiceOverride,
|
||||
this.divinationApiOverride,
|
||||
this.allowVibration = true,
|
||||
required this.profileSettings,
|
||||
required this.onProfileSettingsChanged,
|
||||
});
|
||||
|
||||
final SessionStore sessionStore;
|
||||
@@ -34,6 +37,9 @@ class DivinationScreen extends StatefulWidget {
|
||||
final DivinationRunService? runServiceOverride;
|
||||
final DivinationApi? divinationApiOverride;
|
||||
final bool allowVibration;
|
||||
final ProfileSettingsV1 profileSettings;
|
||||
final Future<void> Function(ProfileSettingsV1 settings)
|
||||
onProfileSettingsChanged;
|
||||
|
||||
@override
|
||||
State<DivinationScreen> createState() => _DivinationScreenState();
|
||||
@@ -44,6 +50,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
|
||||
final TextEditingController _questionController = TextEditingController();
|
||||
late final DivinationApi _divinationApi;
|
||||
late final DivinationRunService _runService;
|
||||
bool _tutorialChecked = false;
|
||||
late DivinationTutorialSettings _localTutorialSettings;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -65,6 +73,47 @@ class _DivinationScreenState extends State<DivinationScreen> {
|
||||
userId: widget.userId,
|
||||
);
|
||||
_questionController.addListener(_syncQuestion);
|
||||
_localTutorialSettings = widget.profileSettings.divinationTutorial;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_tutorialChecked) {
|
||||
_tutorialChecked = true;
|
||||
_checkFirstVisit();
|
||||
}
|
||||
}
|
||||
|
||||
void _checkFirstVisit() {
|
||||
if (!_localTutorialSettings.divinationEntryShown) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
_showGuide(context, l10n);
|
||||
_markTutorialShown('divination_entry');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markTutorialShown(String key) async {
|
||||
setState(() {
|
||||
_localTutorialSettings = _localTutorialSettings.copyWith(
|
||||
divinationEntryShown: key == 'divination_entry'
|
||||
? true
|
||||
: _localTutorialSettings.divinationEntryShown,
|
||||
autoDivinationShown: key == 'auto_divination'
|
||||
? true
|
||||
: _localTutorialSettings.autoDivinationShown,
|
||||
manualDivinationShown: key == 'manual_divination'
|
||||
? true
|
||||
: _localTutorialSettings.manualDivinationShown,
|
||||
);
|
||||
});
|
||||
final updated = widget.profileSettings.copyWith(
|
||||
divinationTutorial: _localTutorialSettings,
|
||||
);
|
||||
await widget.onProfileSettingsChanged(updated);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -161,6 +210,10 @@ class _DivinationScreenState extends State<DivinationScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedSettings = widget.profileSettings.copyWith(
|
||||
divinationTutorial: _localTutorialSettings,
|
||||
);
|
||||
|
||||
if (_params.method == DivinationMethod.manual) {
|
||||
final nextParams = _params.copyWith(divinationTime: DateTime.now());
|
||||
Navigator.of(context).push(
|
||||
@@ -170,6 +223,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
|
||||
runService: _runService,
|
||||
divinationApi: _divinationApi,
|
||||
onCompleted: widget.onCompleted,
|
||||
profileSettings: updatedSettings,
|
||||
onProfileSettingsChanged: widget.onProfileSettingsChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -187,6 +242,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
|
||||
runService: _runService,
|
||||
divinationApi: _divinationApi,
|
||||
onCompleted: widget.onCompleted,
|
||||
profileSettings: updatedSettings,
|
||||
onProfileSettingsChanged: widget.onProfileSettingsChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../../../../shared/widgets/divination/yao_line_row.dart';
|
||||
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../../settings/data/models/profile_settings.dart';
|
||||
import '../../data/models/divination_backend_models.dart';
|
||||
import '../../data/apis/divination_api.dart';
|
||||
import '../../data/models/divination_params.dart';
|
||||
@@ -30,12 +31,17 @@ class ManualDivinationScreen extends StatefulWidget {
|
||||
required this.runService,
|
||||
this.divinationApi,
|
||||
required this.onCompleted,
|
||||
required this.profileSettings,
|
||||
required this.onProfileSettingsChanged,
|
||||
});
|
||||
|
||||
final DivinationParams params;
|
||||
final DivinationRunService runService;
|
||||
final DivinationApi? divinationApi;
|
||||
final Future<void> Function(DivinationResultData result) onCompleted;
|
||||
final ProfileSettingsV1 profileSettings;
|
||||
final Future<void> Function(ProfileSettingsV1 settings)
|
||||
onProfileSettingsChanged;
|
||||
|
||||
@override
|
||||
State<ManualDivinationScreen> createState() => _ManualDivinationScreenState();
|
||||
@@ -47,6 +53,8 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
|
||||
final List<YaoType?> _selectedYaos = List<YaoType?>.filled(6, null);
|
||||
late final AnimationController _blinkController;
|
||||
bool _submitting = false;
|
||||
bool _tutorialChecked = false;
|
||||
late DivinationTutorialSettings _localTutorialSettings;
|
||||
final GlobalKey<OnboardingState> _onboardingKey =
|
||||
GlobalKey<OnboardingState>();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
@@ -66,6 +74,37 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
)..repeat(reverse: true);
|
||||
_localTutorialSettings = widget.profileSettings.divinationTutorial;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_tutorialChecked) {
|
||||
_tutorialChecked = true;
|
||||
_checkFirstVisit();
|
||||
}
|
||||
}
|
||||
|
||||
void _checkFirstVisit() {
|
||||
if (!_localTutorialSettings.manualDivinationShown) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_showGuide();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markTutorialShown() async {
|
||||
setState(() {
|
||||
_localTutorialSettings = _localTutorialSettings.copyWith(
|
||||
manualDivinationShown: true,
|
||||
);
|
||||
});
|
||||
final updated = widget.profileSettings.copyWith(
|
||||
divinationTutorial: _localTutorialSettings,
|
||||
);
|
||||
await widget.onProfileSettingsChanged(updated);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -146,6 +185,7 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
|
||||
key: _onboardingKey,
|
||||
steps: guideSteps,
|
||||
onChanged: _onGuideStepChanged,
|
||||
onEnd: (_) => _markTutorialShown(),
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
|
||||
@@ -127,6 +127,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
allowVibration: widget.profileSettings.notification.allowVibration,
|
||||
notificationBloc: widget.notificationBloc,
|
||||
notificationRepository: widget.notificationRepository,
|
||||
profileSettings: widget.profileSettings,
|
||||
onProfileSettingsChanged: widget.onProfileSettingsChanged,
|
||||
),
|
||||
_ProfileTab(
|
||||
account: widget.account,
|
||||
@@ -160,6 +162,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
userId: widget.account,
|
||||
onCompleted: widget.onDivinationCompleted,
|
||||
allowVibration: widget.profileSettings.notification.allowVibration,
|
||||
profileSettings: widget.profileSettings,
|
||||
onProfileSettingsChanged: widget.onProfileSettingsChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -177,6 +181,8 @@ class _HomeTab extends StatelessWidget {
|
||||
required this.allowVibration,
|
||||
required this.notificationBloc,
|
||||
required this.notificationRepository,
|
||||
required this.profileSettings,
|
||||
required this.onProfileSettingsChanged,
|
||||
});
|
||||
|
||||
final List<DivinationResultData> historyItems;
|
||||
@@ -189,6 +195,9 @@ class _HomeTab extends StatelessWidget {
|
||||
final bool allowVibration;
|
||||
final NotificationBloc notificationBloc;
|
||||
final NotificationRepository notificationRepository;
|
||||
final ProfileSettingsV1 profileSettings;
|
||||
final Future<void> Function(ProfileSettingsV1 settings)
|
||||
onProfileSettingsChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -304,6 +313,9 @@ class _HomeTab extends StatelessWidget {
|
||||
userId: userId,
|
||||
onCompleted: onDivinationCompleted,
|
||||
allowVibration: allowVibration,
|
||||
profileSettings: profileSettings,
|
||||
onProfileSettingsChanged:
|
||||
onProfileSettingsChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -51,6 +51,14 @@ class ProfileApi {
|
||||
'allow_notifications': settings.notification.allowNotifications,
|
||||
'allow_vibration': settings.notification.allowVibration,
|
||||
},
|
||||
'divination_tutorial': {
|
||||
'divination_entry_shown':
|
||||
settings.divinationTutorial.divinationEntryShown,
|
||||
'auto_divination_shown':
|
||||
settings.divinationTutorial.autoDivinationShown,
|
||||
'manual_divination_shown':
|
||||
settings.divinationTutorial.manualDivinationShown,
|
||||
},
|
||||
},
|
||||
};
|
||||
final json = await _apiClient.rawDio.patch<Map<String, dynamic>>(
|
||||
@@ -119,6 +127,23 @@ class ProfileApi {
|
||||
)
|
||||
: const NotificationSettings();
|
||||
|
||||
final divinationTutorialRaw = settingsRaw is Map<String, dynamic>
|
||||
? settingsRaw['divination_tutorial']
|
||||
: null;
|
||||
final divinationTutorial = divinationTutorialRaw is Map<String, dynamic>
|
||||
? DivinationTutorialSettings(
|
||||
divinationEntryShown:
|
||||
(divinationTutorialRaw['divination_entry_shown'] as bool?) ??
|
||||
true,
|
||||
autoDivinationShown:
|
||||
(divinationTutorialRaw['auto_divination_shown'] as bool?) ??
|
||||
true,
|
||||
manualDivinationShown:
|
||||
(divinationTutorialRaw['manual_divination_shown'] as bool?) ??
|
||||
true,
|
||||
)
|
||||
: const DivinationTutorialSettings();
|
||||
|
||||
return ProfileSettingsV1(
|
||||
displayName: (json['display_name'] as String?) ?? '',
|
||||
bio: (json['bio'] as String?) ?? '',
|
||||
@@ -130,6 +155,7 @@ class ProfileApi {
|
||||
const <String, dynamic>{})
|
||||
: const <String, dynamic>{},
|
||||
notification: notification,
|
||||
divinationTutorial: divinationTutorial,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,31 @@ class NotificationSettings {
|
||||
}
|
||||
}
|
||||
|
||||
class DivinationTutorialSettings {
|
||||
const DivinationTutorialSettings({
|
||||
this.divinationEntryShown = true,
|
||||
this.autoDivinationShown = true,
|
||||
this.manualDivinationShown = true,
|
||||
});
|
||||
|
||||
final bool divinationEntryShown;
|
||||
final bool autoDivinationShown;
|
||||
final bool manualDivinationShown;
|
||||
|
||||
DivinationTutorialSettings copyWith({
|
||||
bool? divinationEntryShown,
|
||||
bool? autoDivinationShown,
|
||||
bool? manualDivinationShown,
|
||||
}) {
|
||||
return DivinationTutorialSettings(
|
||||
divinationEntryShown: divinationEntryShown ?? this.divinationEntryShown,
|
||||
autoDivinationShown: autoDivinationShown ?? this.autoDivinationShown,
|
||||
manualDivinationShown:
|
||||
manualDivinationShown ?? this.manualDivinationShown,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileSettingsV1 {
|
||||
const ProfileSettingsV1({
|
||||
this.version = 1,
|
||||
@@ -69,6 +94,7 @@ class ProfileSettingsV1 {
|
||||
this.preferences = const PreferenceSettings(),
|
||||
this.privacy = const <String, Object?>{},
|
||||
this.notification = const NotificationSettings(),
|
||||
this.divinationTutorial = const DivinationTutorialSettings(),
|
||||
});
|
||||
|
||||
final int version;
|
||||
@@ -79,6 +105,7 @@ class ProfileSettingsV1 {
|
||||
final PreferenceSettings preferences;
|
||||
final Map<String, Object?> privacy;
|
||||
final NotificationSettings notification;
|
||||
final DivinationTutorialSettings divinationTutorial;
|
||||
|
||||
ProfileSettingsV1 copyWith({
|
||||
int? version,
|
||||
@@ -89,6 +116,7 @@ class ProfileSettingsV1 {
|
||||
PreferenceSettings? preferences,
|
||||
Map<String, Object?>? privacy,
|
||||
NotificationSettings? notification,
|
||||
DivinationTutorialSettings? divinationTutorial,
|
||||
}) {
|
||||
return ProfileSettingsV1(
|
||||
version: version ?? this.version,
|
||||
@@ -99,6 +127,7 @@ class ProfileSettingsV1 {
|
||||
preferences: preferences ?? this.preferences,
|
||||
privacy: privacy ?? this.privacy,
|
||||
notification: notification ?? this.notification,
|
||||
divinationTutorial: divinationTutorial ?? this.divinationTutorial,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:meeyao_qianwen/features/divination/data/services/divination_run_
|
||||
import 'package:meeyao_qianwen/features/divination/presentation/screens/auto_divination_screen.dart';
|
||||
import 'package:meeyao_qianwen/features/divination/presentation/screens/divination_screen.dart';
|
||||
import 'package:meeyao_qianwen/features/divination/presentation/screens/manual_divination_screen.dart';
|
||||
import 'package:meeyao_qianwen/features/settings/data/models/profile_settings.dart';
|
||||
import 'package:meeyao_qianwen/l10n/app_localizations.dart';
|
||||
|
||||
void main() {
|
||||
@@ -18,6 +19,9 @@ void main() {
|
||||
api: DivinationApi(apiClient: ApiClient(baseUrl: 'http://localhost:5775')),
|
||||
);
|
||||
final sessionStore = SessionStore(LocalKvStore());
|
||||
final profileSettings = ProfileSettingsV1.defaultsForLocale(
|
||||
const Locale('zh'),
|
||||
);
|
||||
|
||||
testWidgets('divination screen navigates to auto screen', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
@@ -36,6 +40,8 @@ void main() {
|
||||
userId: 'user_test',
|
||||
onCompleted: (_) async {},
|
||||
runServiceOverride: runService,
|
||||
profileSettings: profileSettings,
|
||||
onProfileSettingsChanged: (_) async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -77,6 +83,8 @@ void main() {
|
||||
params: params,
|
||||
runService: runService,
|
||||
onCompleted: (_) async {},
|
||||
profileSettings: profileSettings,
|
||||
onProfileSettingsChanged: (_) async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -108,6 +116,8 @@ void main() {
|
||||
userId: 'user_test',
|
||||
onCompleted: (_) async {},
|
||||
runServiceOverride: runService,
|
||||
profileSettings: profileSettings,
|
||||
onProfileSettingsChanged: (_) async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:meeyao_qianwen/features/divination/data/apis/divination_api.dart
|
||||
import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart';
|
||||
import 'package:meeyao_qianwen/features/divination/data/services/divination_run_service.dart';
|
||||
import 'package:meeyao_qianwen/features/divination/presentation/screens/manual_divination_screen.dart';
|
||||
import 'package:meeyao_qianwen/features/settings/data/models/profile_settings.dart';
|
||||
import 'package:meeyao_qianwen/l10n/app_localizations.dart';
|
||||
|
||||
void main() {
|
||||
@@ -24,6 +25,9 @@ void main() {
|
||||
apiClient: ApiClient(baseUrl: 'http://localhost:5775'),
|
||||
),
|
||||
);
|
||||
final profileSettings = ProfileSettingsV1.defaultsForLocale(
|
||||
const Locale('zh'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@@ -39,6 +43,8 @@ void main() {
|
||||
params: params,
|
||||
runService: runService,
|
||||
onCompleted: (_) async {},
|
||||
profileSettings: profileSettings,
|
||||
onProfileSettingsChanged: (_) async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Sequence
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ag_ui.core.types import Tool
|
||||
from core.agentscope.prompts.agent_prompt import build_agent_prompt
|
||||
from core.agentscope.prompts.sections import wrap_section
|
||||
@@ -48,14 +50,31 @@ def _build_output_rules(*, ai_language: str) -> str:
|
||||
return wrap_section("output", "\n".join(rules))
|
||||
|
||||
|
||||
def _build_time_context(*, now_utc: datetime | None) -> str:
|
||||
if now_utc is None:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if now_utc.tzinfo is None:
|
||||
now_utc = now_utc.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
now_utc = now_utc.astimezone(timezone.utc)
|
||||
|
||||
return (
|
||||
"[Time Context]\n"
|
||||
f"- current_time_utc: {now_utc.isoformat()}\n"
|
||||
f"- This helps you understand the current time (distinct from divination_time in user input)."
|
||||
)
|
||||
|
||||
|
||||
def build_system_prompt(
|
||||
*,
|
||||
agent_type: AgentType,
|
||||
ai_language: str,
|
||||
llm_config: SystemAgentLLMConfig | None = None,
|
||||
tools: Sequence[Tool | dict[str, Any]] | None = None,
|
||||
now_utc: datetime | None = None,
|
||||
) -> str:
|
||||
sections: list[str | None] = [
|
||||
_build_time_context(now_utc=now_utc),
|
||||
_build_safety_section(),
|
||||
build_agent_prompt(
|
||||
agent_type=agent_type,
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from ag_ui.core.types import RunAgentInput
|
||||
@@ -275,6 +276,7 @@ class AgentScopeRunner:
|
||||
ai_language=ai_language,
|
||||
llm_config=stage_config.llm_config,
|
||||
tools=None,
|
||||
now_utc=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
_, worker_payload_raw = await finalize_json_response(
|
||||
|
||||
@@ -46,11 +46,20 @@ class NotificationSettings(BaseModel):
|
||||
allow_vibration: bool = True
|
||||
|
||||
|
||||
class DivinationTutorialSettings(BaseModel):
|
||||
divination_entry_shown: bool = False
|
||||
auto_divination_shown: bool = False
|
||||
manual_divination_shown: bool = False
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
ProfileSettingsUnion = ProfileSettingsV1
|
||||
|
||||
Reference in New Issue
Block a user