From 913ed26f8de434a64d795a68a5326e2aa34ad358 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 17 Apr 2026 13:11:09 +0800 Subject: [PATCH] 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 --- .../check.jsonl | 7 + .../implement.jsonl | 7 + .../04-17-feat-privacy-do-not-sell/prd.md | 190 ++++++++++++++++++ .../04-17-feat-privacy-do-not-sell/task.json | 31 +++ apps/lib/app/app.dart | 2 +- .../settings/data/apis/profile_api.dart | 21 +- .../data/models/profile_settings.dart | 23 ++- .../screens/general_settings_screen.dart | 24 +++ .../privacy_notification_settings_screen.dart | 144 ------------- apps/lib/l10n/app_en.arb | 6 +- apps/lib/l10n/app_localizations.dart | 24 +++ apps/lib/l10n/app_localizations_en.dart | 13 ++ apps/lib/l10n/app_localizations_zh.dart | 24 +++ apps/lib/l10n/app_zh.arb | 6 +- apps/lib/l10n/app_zh_hant.arb | 6 +- backend/src/schemas/shared/user.py | 19 +- docs/protocols/profile/profile-protocol.md | 33 ++- 17 files changed, 417 insertions(+), 163 deletions(-) create mode 100644 .trellis/tasks/04-17-feat-privacy-do-not-sell/check.jsonl create mode 100644 .trellis/tasks/04-17-feat-privacy-do-not-sell/implement.jsonl create mode 100644 .trellis/tasks/04-17-feat-privacy-do-not-sell/prd.md create mode 100644 .trellis/tasks/04-17-feat-privacy-do-not-sell/task.json delete mode 100644 apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart diff --git a/.trellis/tasks/04-17-feat-privacy-do-not-sell/check.jsonl b/.trellis/tasks/04-17-feat-privacy-do-not-sell/check.jsonl new file mode 100644 index 0000000..5db5a57 --- /dev/null +++ b/.trellis/tasks/04-17-feat-privacy-do-not-sell/check.jsonl @@ -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"} diff --git a/.trellis/tasks/04-17-feat-privacy-do-not-sell/implement.jsonl b/.trellis/tasks/04-17-feat-privacy-do-not-sell/implement.jsonl new file mode 100644 index 0000000..8d3e4fe --- /dev/null +++ b/.trellis/tasks/04-17-feat-privacy-do-not-sell/implement.jsonl @@ -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"} diff --git a/.trellis/tasks/04-17-feat-privacy-do-not-sell/prd.md b/.trellis/tasks/04-17-feat-privacy-do-not-sell/prd.md new file mode 100644 index 0000000..9675f46 --- /dev/null +++ b/.trellis/tasks/04-17-feat-privacy-do-not-sell/prd.md @@ -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` 改为 `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 + // ... +} +``` + +### 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` 改为 `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),不影响本次需求 diff --git a/.trellis/tasks/04-17-feat-privacy-do-not-sell/task.json b/.trellis/tasks/04-17-feat-privacy-do-not-sell/task.json new file mode 100644 index 0000000..92beb77 --- /dev/null +++ b/.trellis/tasks/04-17-feat-privacy-do-not-sell/task.json @@ -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" + ] + } +} diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index 7cc1156..cf64fbc 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -325,7 +325,7 @@ class _EryaoAppState extends State { } Future _saveProfile(ProfileSettingsV1 updated) async { - final saved = await _profileApi.updateProfile(updated); + final saved = await _profileApi.updateSettings(updated); if (!mounted) { return saved; } diff --git a/apps/lib/features/settings/data/apis/profile_api.dart b/apps/lib/features/settings/data/apis/profile_api.dart index 5bcf12a..44687ec 100644 --- a/apps/lib/features/settings/data/apis/profile_api.dart +++ b/apps/lib/features/settings/data/apis/profile_api.dart @@ -46,7 +46,10 @@ class ProfileApi { 'timezone': settings.preferences.timezone, 'country': settings.preferences.country, }, - 'privacy': settings.privacy, + 'privacy': { + 'can_sell': settings.privacy.canSell, + 'profile_visibility': settings.privacy.profileVisibility, + }, 'notification': { 'allow_notifications': settings.notification.allowNotifications, 'allow_vibration': settings.notification.allowVibration, @@ -115,6 +118,17 @@ class ProfileApi { ) : const PreferenceSettings(); + final privacyRaw = settingsRaw is Map + ? settingsRaw['privacy'] + : null; + final privacy = privacyRaw is Map + ? PrivacySettings( + canSell: (privacyRaw['can_sell'] as bool?) ?? false, + profileVisibility: + (privacyRaw['profile_visibility'] as String?) ?? 'public', + ) + : const PrivacySettings(); + final notificationRaw = settingsRaw is Map ? settingsRaw['notification'] : null; @@ -150,10 +164,7 @@ class ProfileApi { avatarPath: json['avatar_path'] as String?, avatarUrl: json['avatar_url'] as String?, preferences: preferences, - privacy: settingsRaw is Map - ? (settingsRaw['privacy'] as Map? ?? - const {}) - : const {}, + privacy: privacy, notification: notification, divinationTutorial: divinationTutorial, ); diff --git a/apps/lib/features/settings/data/models/profile_settings.dart b/apps/lib/features/settings/data/models/profile_settings.dart index eb4a2a0..3edf2ab 100644 --- a/apps/lib/features/settings/data/models/profile_settings.dart +++ b/apps/lib/features/settings/data/models/profile_settings.dart @@ -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 { const ProfileSettingsV1({ this.version = 1, @@ -92,7 +109,7 @@ class ProfileSettingsV1 { this.avatarPath, this.avatarUrl, this.preferences = const PreferenceSettings(), - this.privacy = const {}, + this.privacy = const PrivacySettings(), this.notification = const NotificationSettings(), this.divinationTutorial = const DivinationTutorialSettings(), }); @@ -103,7 +120,7 @@ class ProfileSettingsV1 { final String? avatarPath; final String? avatarUrl; final PreferenceSettings preferences; - final Map privacy; + final PrivacySettings privacy; final NotificationSettings notification; final DivinationTutorialSettings divinationTutorial; @@ -114,7 +131,7 @@ class ProfileSettingsV1 { String? avatarPath, String? avatarUrl, PreferenceSettings? preferences, - Map? privacy, + PrivacySettings? privacy, NotificationSettings? notification, DivinationTutorialSettings? divinationTutorial, }) { diff --git a/apps/lib/features/settings/presentation/screens/general_settings_screen.dart b/apps/lib/features/settings/presentation/screens/general_settings_screen.dart index 7642b53..5cc5fd2 100644 --- a/apps/lib/features/settings/presentation/screens/general_settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/general_settings_screen.dart @@ -104,6 +104,21 @@ class _GeneralSettingsScreenState extends State { ], ), 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), SettingsGroupCard( children: [ @@ -151,6 +166,15 @@ class _GeneralSettingsScreenState extends State { 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}) { final newNotification = _settings.notification.copyWith( allowNotifications: allowNotifications, diff --git a/apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart b/apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart deleted file mode 100644 index 817a6a4..0000000 --- a/apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart +++ /dev/null @@ -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 _openPlaceholder( - BuildContext context, { - required String title, - required String value, - required String description, - }) async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SettingsPlaceholderScreen( - title: title, - value: value, - description: description, - ), - ), - ); - } -} diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index fec7411..398b802 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -483,5 +483,9 @@ "settingsInviteEmptyDescription": "Each friend who registers using your invite code gives you bonus credits", "settingsInviteInputLabel": "Enter invite code (optional)", "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" } diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index 907d55a..0ace5d1 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -2300,6 +2300,30 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'请输入有效的6位邀请码'** 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 diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart index cb2e8dd..c8920d0 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -1211,4 +1211,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsInviteInvalidCode => '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'; } diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index 9a106d3..f35c4fa 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -1159,6 +1159,18 @@ class AppLocalizationsZh extends AppLocalizations { @override 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`). @@ -2072,4 +2084,16 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get settingsInviteInvalidCode => '請輸入有效的6位邀請碼'; + + @override + String get settingsDoNotSellTitle => '個人化廣告推薦'; + + @override + String get settingsDoNotSellDescription => '關閉後,我們不會將您的個人資訊用於廣告推薦'; + + @override + String get settingsDoNotSellEnabled => '已關閉'; + + @override + String get settingsDoNotSellDisabled => '已開啟'; } diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index 8d29059..8c4e3cd 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -483,5 +483,9 @@ "settingsInviteEmptyDescription": "每成功邀请一位好友注册,您将获得积分奖励", "settingsInviteInputLabel": "输入邀请码(选填)", "settingsInviteInputHint": "输入邀请码绑定您的邀请人", - "settingsInviteInvalidCode": "请输入有效的6位邀请码" + "settingsInviteInvalidCode": "请输入有效的6位邀请码", + "settingsDoNotSellTitle": "个性化广告推荐", + "settingsDoNotSellDescription": "关闭后,我们不会将您的个人信息用于广告推荐", + "settingsDoNotSellEnabled": "已关闭", + "settingsDoNotSellDisabled": "已开启" } diff --git a/apps/lib/l10n/app_zh_hant.arb b/apps/lib/l10n/app_zh_hant.arb index 3dde481..d1c1f38 100644 --- a/apps/lib/l10n/app_zh_hant.arb +++ b/apps/lib/l10n/app_zh_hant.arb @@ -385,5 +385,9 @@ "wuXingTu": "土", "wuXingJin": "金", "wuXingShui": "水", - "retry": "重試" + "retry": "重試", + "settingsDoNotSellTitle": "個人化廣告推薦", + "settingsDoNotSellDescription": "關閉後,我們不會將您的個人資訊用於廣告推薦", + "settingsDoNotSellEnabled": "已關閉", + "settingsDoNotSellDisabled": "已開啟" } diff --git a/backend/src/schemas/shared/user.py b/backend/src/schemas/shared/user.py index 0f10fda..01dcc1c 100644 --- a/backend/src/schemas/shared/user.py +++ b/backend/src/schemas/shared/user.py @@ -27,7 +27,7 @@ class PreferenceSettings(BaseModel): @classmethod def validate_timezone(cls, value: str) -> str: try: - ZoneInfo(value) + _ = ZoneInfo(value) except ZoneInfoNotFoundError as exc: raise ValueError("timezone must be a valid IANA timezone") from exc return value @@ -52,10 +52,15 @@ class DivinationTutorialSettings(BaseModel): manual_divination_shown: bool = False +class PrivacySettings(BaseModel): + can_sell: bool = False + profile_visibility: str = "public" + + class ProfileSettingsV1(BaseModel): version: Literal[1] = 1 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) divination_tutorial: DivinationTutorialSettings = Field( default_factory=DivinationTutorialSettings @@ -67,7 +72,13 @@ ProfileSettingsUnion = ProfileSettingsV1 def parse_profile_settings(raw: dict[str, object] | None) -> ProfileSettingsUnion: 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) @@ -76,7 +87,7 @@ def upgrade_to_latest(settings: ProfileSettingsUnion) -> ProfileSettingsV1: class UserContext(BaseModel): - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=True) # pyright: ignore[reportUnannotatedClassAttribute] id: str username: str diff --git a/docs/protocols/profile/profile-protocol.md b/docs/protocols/profile/profile-protocol.md index eb8e84e..46520d1 100644 --- a/docs/protocols/profile/profile-protocol.md +++ b/docs/protocols/profile/profile-protocol.md @@ -51,8 +51,19 @@ Response: "timezone": "Asia/Shanghai", "country": "CN" }, - "privacy": {}, - "notification": {} + "privacy": { + "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" } @@ -105,7 +116,10 @@ Request: "timezone": "Asia/Shanghai", "country": "CN" }, - "privacy": {}, + "privacy": { + "can_sell": false, + "profile_visibility": "public" + }, "notification": { "allow_notifications": true, "allow_vibration": true @@ -125,6 +139,19 @@ Rules: - Additional fields are forbidden. - `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: - Returns the same shape as `GET /users/me/profile`.