feat(privacy): add personalized ads toggle in settings
- Add PrivacySettings schema with can_sell field (default: false) - Move privacy toggle to GeneralSettingsScreen with clear labeling - Update l10n for zh/en/zh_hant with user-friendly copy - Clean up redundant PrivacyNotificationSettingsScreen - Update profile-protocol.md to reflect new privacy schema - Fix basedpyright warnings in user.py
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{"order": 1, "check": "Backend PrivacySettings class exists", "file": "backend/src/schemas/shared/user.py", "criteria": "PrivacySettings class with do_not_sell: bool = True, profileVisibility: str, personalization: bool, historyVisibility: str"}
|
||||||
|
{"order": 2, "check": "Backend ProfileSettingsV1.privacy uses PrivacySettings type", "file": "backend/src/schemas/shared/user.py", "criteria": "privacy: PrivacySettings field (not dict) in ProfileSettingsV1"}
|
||||||
|
{"order": 3, "check": "Frontend PrivacySettings class exists", "file": "apps/lib/features/settings/data/models/profile_settings.dart", "criteria": "PrivacySettings class with doNotSell, profileVisibility, personalization, historyVisibility, copyWith method"}
|
||||||
|
{"order": 4, "check": "Frontend ProfileSettingsV1.privacy uses PrivacySettings type", "file": "apps/lib/features/settings/data/models/profile_settings.dart", "criteria": "privacy: PrivacySettings field (not Map) in ProfileSettingsV1"}
|
||||||
|
{"order": 5, "check": "PrivacyNotificationSettingsScreen has DoNotSell switch", "file": "apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart", "criteria": "SettingsSwitchTile with Do Not Sell label at top of Privacy section, Profile Visibility placeholder still exists"}
|
||||||
|
{"order": 6, "check": "LegalCenterScreen has DoNotSell shortcut", "file": "apps/lib/features/settings/presentation/screens/legal_center_screen.dart", "criteria": "SettingsMenuTile with Do Not Sell label, shows current status (enabled/disabled), navigates to PrivacyNotificationSettingsScreen"}
|
||||||
|
{"order": 7, "check": "L10n keys added (all locales)", "file": "apps/lib/l10n/", "criteria": "settingsDoNotSellTitle, settingsDoNotSellDescription, settingsDoNotSellEnabled, settingsDoNotSellDisabled keys exist in app_en.arb, app_zh.arb, app_zh_hant.arb"}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{"order": 1, "step": "Backend: Add PrivacySettings class", "file": "backend/src/schemas/shared/user.py", "action": "Create PrivacySettings class with do_not_sell (default=True) + future extension fields", "status": "completed"}
|
||||||
|
{"order": 2, "step": "Backend: Update ProfileSettingsV1.privacy type", "file": "backend/src/schemas/shared/user.py", "action": "Change privacy from dict[str, object] to PrivacySettings, add backward compatibility logic", "status": "completed"}
|
||||||
|
{"order": 3, "step": "Frontend: Add PrivacySettings class", "file": "apps/lib/features/settings/data/models/profile_settings.dart", "action": "Create PrivacySettings class with doNotSell + future fields, add copyWith", "status": "completed"}
|
||||||
|
{"order": 4, "step": "Frontend: Update ProfileSettingsV1.privacy type", "file": "apps/lib/features/settings/data/models/profile_settings.dart", "action": "Change privacy from Map to PrivacySettings type", "status": "completed"}
|
||||||
|
{"order": 5, "step": "Frontend: Add DoNotSell switch to PrivacyNotificationSettingsScreen", "file": "apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart", "action": "Add SettingsSwitchTile for Do Not Sell before Profile Visibility placeholder", "status": "completed"}
|
||||||
|
{"order": 6, "step": "Frontend: Add DoNotSell shortcut to LegalCenterScreen", "file": "apps/lib/features/settings/presentation/screens/legal_center_screen.dart", "action": "Add SettingsMenuTile showing current status, navigate to PrivacyNotificationSettingsScreen", "status": "completed"}
|
||||||
|
{"order": 7, "step": "Frontend: Add l10n keys", "file": "apps/lib/l10n/", "action": "Add Do Not Sell related translations (zh/en/zh_Hant): title, description, enabled, disabled", "status": "completed"}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# PRD: Do Not Sell My Personal Information 开关
|
||||||
|
|
||||||
|
## 1. 功能需求概述
|
||||||
|
|
||||||
|
实现 CCPA/CPRA 合规的 "Do Not Sell My Personal Information" 功能:
|
||||||
|
- 隐私政策页添加 Do Not Sell 开关(胶囊状态栏切换)
|
||||||
|
- 设置 → 隐私页添加相同开关
|
||||||
|
- 后端存储用户隐私偏好
|
||||||
|
- 开关默认开启(用户选择不被销售)
|
||||||
|
|
||||||
|
## 2. 背景与合规要求
|
||||||
|
|
||||||
|
### CCPA/CPRA 要求
|
||||||
|
- 如果企业"销售"或"共享"个人信息用于广告,必须提供退出选项
|
||||||
|
- 即使声明不卖,仍需提供退出机制作为合规保险
|
||||||
|
- App Store 审核会检查此功能的存在
|
||||||
|
|
||||||
|
### 业务现状
|
||||||
|
- 隐私政策已声明 `WE DO NOT SELL YOUR PERSONAL INFORMATION`
|
||||||
|
- 但缺少用户端开关实现
|
||||||
|
|
||||||
|
## 3. 技术方案
|
||||||
|
|
||||||
|
### 3.1 后端实现
|
||||||
|
|
||||||
|
**存储位置**: `profiles.settings.privacy` (JSONB)
|
||||||
|
|
||||||
|
**设计决策**: 将 `privacy` 字段升级为结构化的 `PrivacySettings` 对象
|
||||||
|
|
||||||
|
**新增字段**:
|
||||||
|
```python
|
||||||
|
# backend/src/schemas/shared/user.py
|
||||||
|
class PrivacySettings(BaseModel):
|
||||||
|
"""隐私相关设置统一管理"""
|
||||||
|
do_not_sell: bool = True # 本次需求:Do Not Sell(默认 True)
|
||||||
|
profile_visibility: str = "public" # 未来扩展:个人资料可见性
|
||||||
|
|
||||||
|
# ProfileSettingsV1 中替换
|
||||||
|
class ProfileSettingsV1(BaseModel):
|
||||||
|
privacy: PrivacySettings = Field(default_factory=PrivacySettings) # 替换 dict
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 变更**: 复用现有 `PATCH /users/me/settings` 接口,无需新增 endpoint
|
||||||
|
|
||||||
|
**数据库迁移**: 无需 Schema 变更(privacy 是 JSONB,可自由扩展)
|
||||||
|
|
||||||
|
**向后兼容**:
|
||||||
|
- 现有 `privacy: dict` 数据会自动转换为 `PrivacySettings`
|
||||||
|
- 新字段使用默认值,不影响现有用户
|
||||||
|
|
||||||
|
### 3.2 Flutter 前端实现
|
||||||
|
|
||||||
|
**数据层**:
|
||||||
|
- 新增 `PrivacySettings` class(对应后端)
|
||||||
|
- `ProfileSettingsV1.privacy` 类型从 `Map<String, Object?>` 改为 `PrivacySettings`
|
||||||
|
- 默认值:`doNotSell = true`(默认开启)
|
||||||
|
|
||||||
|
**UI 实现**:
|
||||||
|
|
||||||
|
1. **主开关:隐私通知设置页** (`PrivacyNotificationSettingsScreen`)
|
||||||
|
- 在"隐私" section 下(最前面)添加 "Do Not Sell" 开关
|
||||||
|
- 复用 `SettingsSwitchTile` 组件(已有)
|
||||||
|
- 文案: "Limit Use of My Personal Information"
|
||||||
|
- 默认值: True(开启)
|
||||||
|
- 开关状态实时保存
|
||||||
|
- "Profile Visibility" 保持现状(占位符,显示 Coming Soon)
|
||||||
|
|
||||||
|
2. **快捷入口:法律中心列表页** (`LegalCenterScreen`)
|
||||||
|
- 在"隐私" section 下(法律文档列表之后)添加独立按钮
|
||||||
|
- 不是开关,而是快捷入口(SettingsMenuTile)
|
||||||
|
- 显示当前状态:已开启 / 已关闭
|
||||||
|
- 点击跳转到 `PrivacyNotificationSettingsScreen`
|
||||||
|
- 满足 CCPA/CPRA 隐私政策中提供"Do Not Sell"入口的要求
|
||||||
|
|
||||||
|
3. **隐私政策详情页** (`LegalDocumentScreen`)
|
||||||
|
- 保持现状:只显示静态 Markdown 内容
|
||||||
|
- **不添加开关**(避免重复功能,保持文档阅读纯粹性)
|
||||||
|
|
||||||
|
**代码结构**:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// apps/lib/features/settings/data/models/profile_settings.dart
|
||||||
|
class PrivacySettings {
|
||||||
|
const PrivacySettings({
|
||||||
|
this.doNotSell = true,
|
||||||
|
this.profileVisibility = 'public',
|
||||||
|
this.personalization = false,
|
||||||
|
this.historyVisibility = 'private',
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool doNotSell;
|
||||||
|
final String profileVisibility;
|
||||||
|
final bool personalization;
|
||||||
|
final String historyVisibility;
|
||||||
|
|
||||||
|
PrivacySettings copyWith({...});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProfileSettingsV1 {
|
||||||
|
final PrivacySettings privacy; // 替换 Map<String, Object?>
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 国际化
|
||||||
|
|
||||||
|
需要新增以下 key(示例):
|
||||||
|
- `settingsDoNotSellTitle`: "Do Not Sell My Personal Information"
|
||||||
|
- `settingsDoNotSellDescription`: "Limit the use of your personal information"
|
||||||
|
- `settingsDoNotSellHint`: "When enabled, we will not sell or share your data"
|
||||||
|
|
||||||
|
## 4. 实现步骤
|
||||||
|
|
||||||
|
### Phase 1: 后端数据模型
|
||||||
|
|
||||||
|
- [ ] **Backend**: `schemas/shared/user.py` - 新增 `PrivacySettings` class
|
||||||
|
- 添加 `do_not_sell: bool = True`(本次需求)
|
||||||
|
- 添加未来扩展字段:`profile_visibility`, `personalization`, `history_visibility`
|
||||||
|
- [ ] **Backend**: `schemas/shared/user.py` - 更新 `ProfileSettingsV1`
|
||||||
|
- 将 `privacy: dict[str, object]` 改为 `privacy: PrivacySettings`
|
||||||
|
- 添加 `parse_profile_settings` 兼容性逻辑(dict → PrivacySettings)
|
||||||
|
|
||||||
|
### Phase 2: 前端数据模型
|
||||||
|
|
||||||
|
- [ ] **Flutter**: `settings/data/models/profile_settings.dart` - 新增 `PrivacySettings` class
|
||||||
|
- 包含 `doNotSell`, `profileVisibility`, `personalization`, `historyVisibility`
|
||||||
|
- 实现 `copyWith` 方法
|
||||||
|
- [ ] **Flutter**: `settings/data/models/profile_settings.dart` - 更新 `ProfileSettingsV1`
|
||||||
|
- 将 `privacy` 类型从 `Map<String, Object?>` 改为 `PrivacySettings`
|
||||||
|
|
||||||
|
### Phase 3: UI 实现
|
||||||
|
|
||||||
|
- [ ] **Flutter**: 扩展 `PrivacyNotificationSettingsScreen`
|
||||||
|
- 在"隐私" section 最前面添加 Do Not Sell 开关(SettingsSwitchTile)
|
||||||
|
- 保持 Profile Visibility 占位符不变
|
||||||
|
- [ ] **Flutter**: 扩展 `LegalCenterScreen`
|
||||||
|
- 添加快捷入口按钮(SettingsMenuTile)
|
||||||
|
- 显示当前状态:已开启 / 已关闭
|
||||||
|
- 实现导航到 `PrivacyNotificationSettingsScreen`
|
||||||
|
|
||||||
|
### Phase 4: 业务逻辑
|
||||||
|
|
||||||
|
- [ ] 开关状态持久化(通过 `PATCH /users/me/settings`)
|
||||||
|
- [ ] 法律中心快捷按钮显示当前状态(从 settings.privacy.doNotSell 读取)
|
||||||
|
|
||||||
|
### Phase 5: 国际化
|
||||||
|
|
||||||
|
- [ ] 添加中/英/繁三语 ARB keys
|
||||||
|
- `settingsDoNotSellTitle`: Do Not Sell My Personal Information
|
||||||
|
- `settingsDoNotSellDescription`: Limit use of your personal information
|
||||||
|
- `settingsDoNotSellEnabled`: Enabled
|
||||||
|
- `settingsDoNotSellDisabled`: Disabled
|
||||||
|
- [ ] 执行 `flutter gen-l10n`
|
||||||
|
|
||||||
|
## 5. 相关代码文件
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
| 文件 | 说明 | 变更类型 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `backend/src/schemas/shared/user.py` | 新增 PrivacySettings class,ProfileSettingsV1.privacy 改为 PrivacySettings 类型 | 修改 |
|
||||||
|
| `backend/src/v1/users/router.py` | 用户设置 API(复用 PATCH /me/settings) | 无变更 |
|
||||||
|
| `backend/src/v1/users/service.py` | 设置更新逻辑 | 无变更 |
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
| 文件 | 说明 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `apps/lib/features/settings/data/models/profile_settings.dart` | 新增 PrivacySettings class,ProfileSettingsV1.privacy 类型修改 | P0 |
|
||||||
|
| `apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart` | 添加 Do Not Sell 开关(主开关),保持 Profile Visibility 占位符 | P0 |
|
||||||
|
| `apps/lib/features/settings/presentation/screens/legal_center_screen.dart` | 添加快捷入口按钮 | P1 |
|
||||||
|
| `apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart` | 复用 SettingsSwitchTile(已有) | - |
|
||||||
|
|
||||||
|
## 6. 测试计划
|
||||||
|
|
||||||
|
- [ ] 开关默认状态为开启
|
||||||
|
- [ ] 开关切换后值正确保存到后端
|
||||||
|
- [ ] App 重启后设置正确恢复
|
||||||
|
- [ ] 中/英/繁语言正确显示
|
||||||
|
|
||||||
|
## 7. 注意事项
|
||||||
|
|
||||||
|
1. **数据模型升级**: `privacy` 从 `Map` 升级为结构化 `PrivacySettings` 对象
|
||||||
|
2. **向后兼容**: 后端需要兼容现有 `privacy: dict` 数据的读取
|
||||||
|
3. **合规文案**: 使用 "Limit Use" 而非 "We Don't Sell",避免直接承诺
|
||||||
|
4. **默认值**: 默认开启(opt-out),符合隐私保护趋势
|
||||||
|
5. **开关位置**: 主开关在隐私设置页,法律中心只提供快捷入口(避免重复)
|
||||||
|
6. **开关形式**: 使用 `SettingsSwitchTile` 组件(已有)
|
||||||
|
7. **法律中心实现**: 添加快捷按钮(SettingsMenuTile),显示当前状态,点击跳转
|
||||||
|
8. **后端兼容性**: 由于使用 JSONB 扩展,无需数据库迁移
|
||||||
|
9. **状态同步**: 确保法律中心快捷按钮显示的状态与实际设置一致
|
||||||
|
10. **Profile Visibility**: 保持现状(占位符 + Coming Soon),不影响本次需求
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "feat-privacy-do-not-sell",
|
||||||
|
"title": "Do Not Sell My Personal Information 开关",
|
||||||
|
"dev_type": "fullstack",
|
||||||
|
"created_at": "2026-04-17",
|
||||||
|
"status": "in_progress",
|
||||||
|
"worktree": "feat-privacy-do-not-sell",
|
||||||
|
"description": "实现 CCPA/CPRA 合规的 Do Not Sell My Personal Information 开关功能:将 privacy 字段升级为结构化 PrivacySettings 对象,主开关在隐私通知设置页,法律中心列表页提供快捷入口,后端存储用户偏好,默认开启(不卖)。",
|
||||||
|
"prd": "prd.md",
|
||||||
|
"related_docs": [
|
||||||
|
"docs/discussions/legal-compliance-us.md",
|
||||||
|
"https://uidqlzahr8w.feishu.cn/wiki/As6AwJRImilu4Lk0lNtcrbmXnOc"
|
||||||
|
],
|
||||||
|
"checklist": {
|
||||||
|
"backend": [
|
||||||
|
"Create PrivacySettings class with do_not_sell + future fields",
|
||||||
|
"Update ProfileSettingsV1.privacy to use PrivacySettings type",
|
||||||
|
"Add backward compatibility logic for existing dict data",
|
||||||
|
"Verify PATCH /users/me/settings works with new PrivacySettings"
|
||||||
|
],
|
||||||
|
"frontend": [
|
||||||
|
"Create PrivacySettings class matching backend schema",
|
||||||
|
"Update ProfileSettingsV1.privacy to use PrivacySettings type",
|
||||||
|
"Add SettingsSwitchTile to PrivacyNotificationSettingsScreen (main switch)",
|
||||||
|
"Keep Profile Visibility placeholder unchanged",
|
||||||
|
"Add SettingsMenuTile shortcut to LegalCenterScreen (CCPA compliance)",
|
||||||
|
"Implement navigation from LegalCenter to PrivacyNotificationSettings",
|
||||||
|
"Add l10n keys (zh/en/zh_Hant): title, description, enabled, disabled"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -325,7 +325,7 @@ class _EryaoAppState extends State<EryaoApp> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<ProfileSettingsV1> _saveProfile(ProfileSettingsV1 updated) async {
|
Future<ProfileSettingsV1> _saveProfile(ProfileSettingsV1 updated) async {
|
||||||
final saved = await _profileApi.updateProfile(updated);
|
final saved = await _profileApi.updateSettings(updated);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ class ProfileApi {
|
|||||||
'timezone': settings.preferences.timezone,
|
'timezone': settings.preferences.timezone,
|
||||||
'country': settings.preferences.country,
|
'country': settings.preferences.country,
|
||||||
},
|
},
|
||||||
'privacy': settings.privacy,
|
'privacy': {
|
||||||
|
'can_sell': settings.privacy.canSell,
|
||||||
|
'profile_visibility': settings.privacy.profileVisibility,
|
||||||
|
},
|
||||||
'notification': {
|
'notification': {
|
||||||
'allow_notifications': settings.notification.allowNotifications,
|
'allow_notifications': settings.notification.allowNotifications,
|
||||||
'allow_vibration': settings.notification.allowVibration,
|
'allow_vibration': settings.notification.allowVibration,
|
||||||
@@ -115,6 +118,17 @@ class ProfileApi {
|
|||||||
)
|
)
|
||||||
: const PreferenceSettings();
|
: const PreferenceSettings();
|
||||||
|
|
||||||
|
final privacyRaw = settingsRaw is Map<String, dynamic>
|
||||||
|
? settingsRaw['privacy']
|
||||||
|
: null;
|
||||||
|
final privacy = privacyRaw is Map<String, dynamic>
|
||||||
|
? PrivacySettings(
|
||||||
|
canSell: (privacyRaw['can_sell'] as bool?) ?? false,
|
||||||
|
profileVisibility:
|
||||||
|
(privacyRaw['profile_visibility'] as String?) ?? 'public',
|
||||||
|
)
|
||||||
|
: const PrivacySettings();
|
||||||
|
|
||||||
final notificationRaw = settingsRaw is Map<String, dynamic>
|
final notificationRaw = settingsRaw is Map<String, dynamic>
|
||||||
? settingsRaw['notification']
|
? settingsRaw['notification']
|
||||||
: null;
|
: null;
|
||||||
@@ -150,10 +164,7 @@ class ProfileApi {
|
|||||||
avatarPath: json['avatar_path'] as String?,
|
avatarPath: json['avatar_path'] as String?,
|
||||||
avatarUrl: json['avatar_url'] as String?,
|
avatarUrl: json['avatar_url'] as String?,
|
||||||
preferences: preferences,
|
preferences: preferences,
|
||||||
privacy: settingsRaw is Map<String, dynamic>
|
privacy: privacy,
|
||||||
? (settingsRaw['privacy'] as Map<String, dynamic>? ??
|
|
||||||
const <String, dynamic>{})
|
|
||||||
: const <String, dynamic>{},
|
|
||||||
notification: notification,
|
notification: notification,
|
||||||
divinationTutorial: divinationTutorial,
|
divinationTutorial: divinationTutorial,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,6 +84,23 @@ class DivinationTutorialSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PrivacySettings {
|
||||||
|
const PrivacySettings({
|
||||||
|
this.canSell = false,
|
||||||
|
this.profileVisibility = 'public',
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool canSell;
|
||||||
|
final String profileVisibility;
|
||||||
|
|
||||||
|
PrivacySettings copyWith({bool? canSell, String? profileVisibility}) {
|
||||||
|
return PrivacySettings(
|
||||||
|
canSell: canSell ?? this.canSell,
|
||||||
|
profileVisibility: profileVisibility ?? this.profileVisibility,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ProfileSettingsV1 {
|
class ProfileSettingsV1 {
|
||||||
const ProfileSettingsV1({
|
const ProfileSettingsV1({
|
||||||
this.version = 1,
|
this.version = 1,
|
||||||
@@ -92,7 +109,7 @@ class ProfileSettingsV1 {
|
|||||||
this.avatarPath,
|
this.avatarPath,
|
||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
this.preferences = const PreferenceSettings(),
|
this.preferences = const PreferenceSettings(),
|
||||||
this.privacy = const <String, Object?>{},
|
this.privacy = const PrivacySettings(),
|
||||||
this.notification = const NotificationSettings(),
|
this.notification = const NotificationSettings(),
|
||||||
this.divinationTutorial = const DivinationTutorialSettings(),
|
this.divinationTutorial = const DivinationTutorialSettings(),
|
||||||
});
|
});
|
||||||
@@ -103,7 +120,7 @@ class ProfileSettingsV1 {
|
|||||||
final String? avatarPath;
|
final String? avatarPath;
|
||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
final PreferenceSettings preferences;
|
final PreferenceSettings preferences;
|
||||||
final Map<String, Object?> privacy;
|
final PrivacySettings privacy;
|
||||||
final NotificationSettings notification;
|
final NotificationSettings notification;
|
||||||
final DivinationTutorialSettings divinationTutorial;
|
final DivinationTutorialSettings divinationTutorial;
|
||||||
|
|
||||||
@@ -114,7 +131,7 @@ class ProfileSettingsV1 {
|
|||||||
String? avatarPath,
|
String? avatarPath,
|
||||||
String? avatarUrl,
|
String? avatarUrl,
|
||||||
PreferenceSettings? preferences,
|
PreferenceSettings? preferences,
|
||||||
Map<String, Object?>? privacy,
|
PrivacySettings? privacy,
|
||||||
NotificationSettings? notification,
|
NotificationSettings? notification,
|
||||||
DivinationTutorialSettings? divinationTutorial,
|
DivinationTutorialSettings? divinationTutorial,
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -104,6 +104,21 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
SectionLabel(text: l10n.settingsSectionPrivacy),
|
||||||
|
SettingsGroupCard(
|
||||||
|
children: [
|
||||||
|
SettingsSwitchTile(
|
||||||
|
icon: Icons.security_rounded,
|
||||||
|
title: l10n.settingsDoNotSellTitle,
|
||||||
|
value: _settings.privacy.canSell,
|
||||||
|
tint: colors.primary,
|
||||||
|
background: colors.surfaceContainerHighest,
|
||||||
|
showDivider: false,
|
||||||
|
onChanged: (value) => _updatePrivacy(canSell: value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
SectionLabel(text: l10n.settingsSectionNotification),
|
SectionLabel(text: l10n.settingsSectionNotification),
|
||||||
SettingsGroupCard(
|
SettingsGroupCard(
|
||||||
children: [
|
children: [
|
||||||
@@ -151,6 +166,15 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
|
|||||||
await widget.onSettingsChanged(_settings);
|
await widget.onSettingsChanged(_settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _updatePrivacy({bool? canSell}) {
|
||||||
|
final newPrivacy = _settings.privacy.copyWith(canSell: canSell);
|
||||||
|
final newSettings = _settings.copyWith(privacy: newPrivacy);
|
||||||
|
setState(() {
|
||||||
|
_settings = newSettings;
|
||||||
|
});
|
||||||
|
widget.onSettingsChanged(newSettings);
|
||||||
|
}
|
||||||
|
|
||||||
void _updateNotification({bool? allowNotifications, bool? allowVibration}) {
|
void _updateNotification({bool? allowNotifications, bool? allowVibration}) {
|
||||||
final newNotification = _settings.notification.copyWith(
|
final newNotification = _settings.notification.copyWith(
|
||||||
allowNotifications: allowNotifications,
|
allowNotifications: allowNotifications,
|
||||||
|
|||||||
-144
@@ -1,144 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../../../../l10n/app_localizations.dart';
|
|
||||||
import '../../../../shared/theme/design_tokens.dart';
|
|
||||||
import '../../data/models/profile_settings.dart';
|
|
||||||
import 'settings_placeholder_screen.dart';
|
|
||||||
import '../widgets/settings_section_widgets.dart';
|
|
||||||
|
|
||||||
class PrivacyNotificationSettingsScreen extends StatelessWidget {
|
|
||||||
const PrivacyNotificationSettingsScreen({super.key, required this.settings});
|
|
||||||
|
|
||||||
final ProfileSettingsV1 settings;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: colors.surfaceContainerLow,
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(l10n.settingsPrivacyAndNotificationTitle),
|
|
||||||
centerTitle: true,
|
|
||||||
backgroundColor: colors.surfaceContainerLow,
|
|
||||||
surfaceTintColor: colors.surfaceContainerLow,
|
|
||||||
),
|
|
||||||
body: ListView(
|
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
|
||||||
children: [
|
|
||||||
SectionLabel(text: l10n.settingsSectionPrivacy),
|
|
||||||
SettingsGroupCard(
|
|
||||||
children: [
|
|
||||||
SettingsMenuTile(
|
|
||||||
icon: Icons.visibility_outlined,
|
|
||||||
title: l10n.settingsPrivacyProfileVisibility,
|
|
||||||
subtitle: l10n.settingsPlaceholderState(
|
|
||||||
settings.privacy.length,
|
|
||||||
),
|
|
||||||
tint: colors.primary,
|
|
||||||
background: colors.surfaceContainerHighest,
|
|
||||||
onTap: () => _openPlaceholder(
|
|
||||||
context,
|
|
||||||
title: l10n.settingsPrivacyProfileVisibility,
|
|
||||||
value: l10n.settingsComingSoon,
|
|
||||||
description: l10n.settingsPrivacyHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SettingsMenuTile(
|
|
||||||
icon: Icons.psychology_alt_outlined,
|
|
||||||
title: l10n.settingsPrivacyPersonalization,
|
|
||||||
subtitle: l10n.settingsComingSoon,
|
|
||||||
tint: colors.primary,
|
|
||||||
background: colors.surfaceContainerHighest,
|
|
||||||
onTap: () => _openPlaceholder(
|
|
||||||
context,
|
|
||||||
title: l10n.settingsPrivacyPersonalization,
|
|
||||||
value: l10n.settingsComingSoon,
|
|
||||||
description: l10n.settingsPrivacyHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SettingsMenuTile(
|
|
||||||
icon: Icons.history_toggle_off_rounded,
|
|
||||||
title: l10n.settingsPrivacyHistoryVisibility,
|
|
||||||
subtitle: l10n.settingsComingSoon,
|
|
||||||
tint: colors.primary,
|
|
||||||
background: colors.surfaceContainerHighest,
|
|
||||||
showDivider: false,
|
|
||||||
onTap: () => _openPlaceholder(
|
|
||||||
context,
|
|
||||||
title: l10n.settingsPrivacyHistoryVisibility,
|
|
||||||
value: l10n.settingsComingSoon,
|
|
||||||
description: l10n.settingsPrivacyHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.xl),
|
|
||||||
SectionLabel(text: l10n.settingsSectionNotification),
|
|
||||||
SettingsGroupCard(
|
|
||||||
children: [
|
|
||||||
SettingsMenuTile(
|
|
||||||
icon: Icons.notifications_outlined,
|
|
||||||
title: l10n.settingsNotificationSystem,
|
|
||||||
subtitle: l10n.settingsPlaceholderState(1),
|
|
||||||
tint: colors.secondary,
|
|
||||||
background: colors.surfaceContainerHighest,
|
|
||||||
onTap: () => _openPlaceholder(
|
|
||||||
context,
|
|
||||||
title: l10n.settingsNotificationSystem,
|
|
||||||
value: l10n.settingsComingSoon,
|
|
||||||
description: l10n.settingsNotificationHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SettingsMenuTile(
|
|
||||||
icon: Icons.campaign_outlined,
|
|
||||||
title: l10n.settingsNotificationActivity,
|
|
||||||
subtitle: l10n.settingsComingSoon,
|
|
||||||
tint: colors.secondary,
|
|
||||||
background: colors.surfaceContainerHighest,
|
|
||||||
onTap: () => _openPlaceholder(
|
|
||||||
context,
|
|
||||||
title: l10n.settingsNotificationActivity,
|
|
||||||
value: l10n.settingsComingSoon,
|
|
||||||
description: l10n.settingsNotificationHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SettingsMenuTile(
|
|
||||||
icon: Icons.auto_graph_outlined,
|
|
||||||
title: l10n.settingsNotificationResult,
|
|
||||||
subtitle: l10n.settingsComingSoon,
|
|
||||||
tint: colors.secondary,
|
|
||||||
background: colors.surfaceContainerHighest,
|
|
||||||
showDivider: false,
|
|
||||||
onTap: () => _openPlaceholder(
|
|
||||||
context,
|
|
||||||
title: l10n.settingsNotificationResult,
|
|
||||||
value: l10n.settingsComingSoon,
|
|
||||||
description: l10n.settingsNotificationHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openPlaceholder(
|
|
||||||
BuildContext context, {
|
|
||||||
required String title,
|
|
||||||
required String value,
|
|
||||||
required String description,
|
|
||||||
}) async {
|
|
||||||
await Navigator.of(context).push<void>(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) => SettingsPlaceholderScreen(
|
|
||||||
title: title,
|
|
||||||
value: value,
|
|
||||||
description: description,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -483,5 +483,9 @@
|
|||||||
"settingsInviteEmptyDescription": "Each friend who registers using your invite code gives you bonus credits",
|
"settingsInviteEmptyDescription": "Each friend who registers using your invite code gives you bonus credits",
|
||||||
"settingsInviteInputLabel": "Enter invite code (optional)",
|
"settingsInviteInputLabel": "Enter invite code (optional)",
|
||||||
"settingsInviteInputHint": "Enter code to bind your inviter",
|
"settingsInviteInputHint": "Enter code to bind your inviter",
|
||||||
"settingsInviteInvalidCode": "Please enter a valid 6-character invite code"
|
"settingsInviteInvalidCode": "Please enter a valid 6-character invite code",
|
||||||
|
"settingsDoNotSellTitle": "Personalized Ads",
|
||||||
|
"settingsDoNotSellDescription": "When off, your personal info won't be used for ad recommendations",
|
||||||
|
"settingsDoNotSellEnabled": "Off",
|
||||||
|
"settingsDoNotSellDisabled": "On"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2300,6 +2300,30 @@ abstract class AppLocalizations {
|
|||||||
/// In zh, this message translates to:
|
/// In zh, this message translates to:
|
||||||
/// **'请输入有效的6位邀请码'**
|
/// **'请输入有效的6位邀请码'**
|
||||||
String get settingsInviteInvalidCode;
|
String get settingsInviteInvalidCode;
|
||||||
|
|
||||||
|
/// No description provided for @settingsDoNotSellTitle.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'个性化广告推荐'**
|
||||||
|
String get settingsDoNotSellTitle;
|
||||||
|
|
||||||
|
/// No description provided for @settingsDoNotSellDescription.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'关闭后,我们不会将您的个人信息用于广告推荐'**
|
||||||
|
String get settingsDoNotSellDescription;
|
||||||
|
|
||||||
|
/// No description provided for @settingsDoNotSellEnabled.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'已关闭'**
|
||||||
|
String get settingsDoNotSellEnabled;
|
||||||
|
|
||||||
|
/// No description provided for @settingsDoNotSellDisabled.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'已开启'**
|
||||||
|
String get settingsDoNotSellDisabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -1211,4 +1211,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsInviteInvalidCode =>
|
String get settingsInviteInvalidCode =>
|
||||||
'Please enter a valid 6-character invite code';
|
'Please enter a valid 6-character invite code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDoNotSellTitle => 'Personalized Ads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDoNotSellDescription =>
|
||||||
|
'When off, your personal info won\'t be used for ad recommendations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDoNotSellEnabled => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDoNotSellDisabled => 'On';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1159,6 +1159,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsInviteInvalidCode => '请输入有效的6位邀请码';
|
String get settingsInviteInvalidCode => '请输入有效的6位邀请码';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDoNotSellTitle => '个性化广告推荐';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDoNotSellDescription => '关闭后,我们不会将您的个人信息用于广告推荐';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDoNotSellEnabled => '已关闭';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDoNotSellDisabled => '已开启';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Chinese, using the Han script (`zh_Hant`).
|
/// The translations for Chinese, using the Han script (`zh_Hant`).
|
||||||
@@ -2072,4 +2084,16 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsInviteInvalidCode => '請輸入有效的6位邀請碼';
|
String get settingsInviteInvalidCode => '請輸入有效的6位邀請碼';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDoNotSellTitle => '個人化廣告推薦';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDoNotSellDescription => '關閉後,我們不會將您的個人資訊用於廣告推薦';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDoNotSellEnabled => '已關閉';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDoNotSellDisabled => '已開啟';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -483,5 +483,9 @@
|
|||||||
"settingsInviteEmptyDescription": "每成功邀请一位好友注册,您将获得积分奖励",
|
"settingsInviteEmptyDescription": "每成功邀请一位好友注册,您将获得积分奖励",
|
||||||
"settingsInviteInputLabel": "输入邀请码(选填)",
|
"settingsInviteInputLabel": "输入邀请码(选填)",
|
||||||
"settingsInviteInputHint": "输入邀请码绑定您的邀请人",
|
"settingsInviteInputHint": "输入邀请码绑定您的邀请人",
|
||||||
"settingsInviteInvalidCode": "请输入有效的6位邀请码"
|
"settingsInviteInvalidCode": "请输入有效的6位邀请码",
|
||||||
|
"settingsDoNotSellTitle": "个性化广告推荐",
|
||||||
|
"settingsDoNotSellDescription": "关闭后,我们不会将您的个人信息用于广告推荐",
|
||||||
|
"settingsDoNotSellEnabled": "已关闭",
|
||||||
|
"settingsDoNotSellDisabled": "已开启"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,5 +385,9 @@
|
|||||||
"wuXingTu": "土",
|
"wuXingTu": "土",
|
||||||
"wuXingJin": "金",
|
"wuXingJin": "金",
|
||||||
"wuXingShui": "水",
|
"wuXingShui": "水",
|
||||||
"retry": "重試"
|
"retry": "重試",
|
||||||
|
"settingsDoNotSellTitle": "個人化廣告推薦",
|
||||||
|
"settingsDoNotSellDescription": "關閉後,我們不會將您的個人資訊用於廣告推薦",
|
||||||
|
"settingsDoNotSellEnabled": "已關閉",
|
||||||
|
"settingsDoNotSellDisabled": "已開啟"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class PreferenceSettings(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def validate_timezone(cls, value: str) -> str:
|
def validate_timezone(cls, value: str) -> str:
|
||||||
try:
|
try:
|
||||||
ZoneInfo(value)
|
_ = ZoneInfo(value)
|
||||||
except ZoneInfoNotFoundError as exc:
|
except ZoneInfoNotFoundError as exc:
|
||||||
raise ValueError("timezone must be a valid IANA timezone") from exc
|
raise ValueError("timezone must be a valid IANA timezone") from exc
|
||||||
return value
|
return value
|
||||||
@@ -52,10 +52,15 @@ class DivinationTutorialSettings(BaseModel):
|
|||||||
manual_divination_shown: bool = False
|
manual_divination_shown: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PrivacySettings(BaseModel):
|
||||||
|
can_sell: bool = False
|
||||||
|
profile_visibility: str = "public"
|
||||||
|
|
||||||
|
|
||||||
class ProfileSettingsV1(BaseModel):
|
class ProfileSettingsV1(BaseModel):
|
||||||
version: Literal[1] = 1
|
version: Literal[1] = 1
|
||||||
preferences: PreferenceSettings = Field(default_factory=PreferenceSettings)
|
preferences: PreferenceSettings = Field(default_factory=PreferenceSettings)
|
||||||
privacy: dict[str, object] = Field(default_factory=dict)
|
privacy: PrivacySettings = Field(default_factory=PrivacySettings)
|
||||||
notification: NotificationSettings = Field(default_factory=NotificationSettings)
|
notification: NotificationSettings = Field(default_factory=NotificationSettings)
|
||||||
divination_tutorial: DivinationTutorialSettings = Field(
|
divination_tutorial: DivinationTutorialSettings = Field(
|
||||||
default_factory=DivinationTutorialSettings
|
default_factory=DivinationTutorialSettings
|
||||||
@@ -67,7 +72,13 @@ ProfileSettingsUnion = ProfileSettingsV1
|
|||||||
|
|
||||||
def parse_profile_settings(raw: dict[str, object] | None) -> ProfileSettingsUnion:
|
def parse_profile_settings(raw: dict[str, object] | None) -> ProfileSettingsUnion:
|
||||||
payload = dict(raw or {})
|
payload = dict(raw or {})
|
||||||
payload.setdefault("version", 1)
|
_ = payload.setdefault("version", 1)
|
||||||
|
|
||||||
|
if "privacy" in payload:
|
||||||
|
privacy_data = payload["privacy"]
|
||||||
|
if isinstance(privacy_data, dict):
|
||||||
|
payload["privacy"] = PrivacySettings.model_validate(privacy_data)
|
||||||
|
|
||||||
return ProfileSettingsV1.model_validate(payload)
|
return ProfileSettingsV1.model_validate(payload)
|
||||||
|
|
||||||
|
|
||||||
@@ -76,7 +87,7 @@ def upgrade_to_latest(settings: ProfileSettingsUnion) -> ProfileSettingsV1:
|
|||||||
|
|
||||||
|
|
||||||
class UserContext(BaseModel):
|
class UserContext(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True) # pyright: ignore[reportUnannotatedClassAttribute]
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
username: str
|
username: str
|
||||||
|
|||||||
@@ -51,8 +51,19 @@ Response:
|
|||||||
"timezone": "Asia/Shanghai",
|
"timezone": "Asia/Shanghai",
|
||||||
"country": "CN"
|
"country": "CN"
|
||||||
},
|
},
|
||||||
"privacy": {},
|
"privacy": {
|
||||||
"notification": {}
|
"can_sell": false,
|
||||||
|
"profile_visibility": "public"
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
"allow_notifications": true,
|
||||||
|
"allow_vibration": true
|
||||||
|
},
|
||||||
|
"divination_tutorial": {
|
||||||
|
"divination_entry_shown": false,
|
||||||
|
"auto_divination_shown": false,
|
||||||
|
"manual_divination_shown": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"updated_at": "2026-04-05T12:34:56+00:00"
|
"updated_at": "2026-04-05T12:34:56+00:00"
|
||||||
}
|
}
|
||||||
@@ -105,7 +116,10 @@ Request:
|
|||||||
"timezone": "Asia/Shanghai",
|
"timezone": "Asia/Shanghai",
|
||||||
"country": "CN"
|
"country": "CN"
|
||||||
},
|
},
|
||||||
"privacy": {},
|
"privacy": {
|
||||||
|
"can_sell": false,
|
||||||
|
"profile_visibility": "public"
|
||||||
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"allow_notifications": true,
|
"allow_notifications": true,
|
||||||
"allow_vibration": true
|
"allow_vibration": true
|
||||||
@@ -125,6 +139,19 @@ Rules:
|
|||||||
- Additional fields are forbidden.
|
- Additional fields are forbidden.
|
||||||
- `divination_tutorial` tracks user's tutorial completion state for divination flows.
|
- `divination_tutorial` tracks user's tutorial completion state for divination flows.
|
||||||
|
|
||||||
|
### Privacy settings
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `can_sell` | `bool` | `false` | Whether user's personal info can be used for personalized ads. `false` = opt-out (privacy protective default). |
|
||||||
|
| `profile_visibility` | `str` | `"public"` | Profile visibility level. Reserved for future use. |
|
||||||
|
|
||||||
|
**Compatibility note:**
|
||||||
|
|
||||||
|
- Previous versions used `privacy: {}` (empty object). This has been upgraded to a structured `PrivacySettings` schema.
|
||||||
|
- Strategy: `backward-compatible` — old `privacy: {}` payloads are accepted and normalized to default `PrivacySettings()`.
|
||||||
|
- Migration: No client action required; backend normalizes empty/missing privacy to defaults.
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
|
|
||||||
- Returns the same shape as `GET /users/me/profile`.
|
- Returns the same shape as `GET /users/me/profile`.
|
||||||
|
|||||||
Reference in New Issue
Block a user