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:
qzl
2026-04-17 13:11:09 +08:00
parent be30eb6eab
commit 913ed26f8d
17 changed files with 417 additions and 163 deletions
+1 -1
View File
@@ -325,7 +325,7 @@ class _EryaoAppState extends State<EryaoApp> {
}
Future<ProfileSettingsV1> _saveProfile(ProfileSettingsV1 updated) async {
final saved = await _profileApi.updateProfile(updated);
final saved = await _profileApi.updateSettings(updated);
if (!mounted) {
return saved;
}
@@ -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<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>
? 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<String, dynamic>
? (settingsRaw['privacy'] as Map<String, dynamic>? ??
const <String, dynamic>{})
: const <String, dynamic>{},
privacy: privacy,
notification: notification,
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 {
const ProfileSettingsV1({
this.version = 1,
@@ -92,7 +109,7 @@ class ProfileSettingsV1 {
this.avatarPath,
this.avatarUrl,
this.preferences = const PreferenceSettings(),
this.privacy = const <String, Object?>{},
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<String, Object?> privacy;
final PrivacySettings privacy;
final NotificationSettings notification;
final DivinationTutorialSettings divinationTutorial;
@@ -114,7 +131,7 @@ class ProfileSettingsV1 {
String? avatarPath,
String? avatarUrl,
PreferenceSettings? preferences,
Map<String, Object?>? privacy,
PrivacySettings? privacy,
NotificationSettings? notification,
DivinationTutorialSettings? divinationTutorial,
}) {
@@ -104,6 +104,21 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
],
),
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<GeneralSettingsScreen> {
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,
@@ -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,
),
),
);
}
}
+5 -1
View File
@@ -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"
}
+24
View File
@@ -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
+13
View File
@@ -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';
}
+24
View File
@@ -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 => '已開啟';
}
+5 -1
View File
@@ -483,5 +483,9 @@
"settingsInviteEmptyDescription": "每成功邀请一位好友注册,您将获得积分奖励",
"settingsInviteInputLabel": "输入邀请码(选填)",
"settingsInviteInputHint": "输入邀请码绑定您的邀请人",
"settingsInviteInvalidCode": "请输入有效的6位邀请码"
"settingsInviteInvalidCode": "请输入有效的6位邀请码",
"settingsDoNotSellTitle": "个性化广告推荐",
"settingsDoNotSellDescription": "关闭后,我们不会将您的个人信息用于广告推荐",
"settingsDoNotSellEnabled": "已关闭",
"settingsDoNotSellDisabled": "已开启"
}
+5 -1
View File
@@ -385,5 +385,9 @@
"wuXingTu": "土",
"wuXingJin": "金",
"wuXingShui": "水",
"retry": "重試"
"retry": "重試",
"settingsDoNotSellTitle": "個人化廣告推薦",
"settingsDoNotSellDescription": "關閉後,我們不會將您的個人資訊用於廣告推薦",
"settingsDoNotSellEnabled": "已關閉",
"settingsDoNotSellDisabled": "已開啟"
}