feat(profile): enhance settings and privacy UI with icons and theme picker
All checks were successful
Frontend CI / ota-ios (push) Successful in 1m12s
Frontend CI / ota-android (push) Successful in 1m22s
Frontend CI / build-and-push-web (push) Successful in 54s
Frontend CI / build-android-apk (push) Successful in 1h14m5s

Refactor the profile settings and privacy screens to provide a more
modern and intuitive user experience.

- Add icons to visibility options and privacy settings items
- Implement a theme selection dropdown in the settings screen
- Restructure privacy settings layout into grouped containers
- Move account management items (blocked users, deletion) from general
  settings to privacy settings
- Improve visual hierarchy and spacing in settings screens
This commit is contained in:
2026-05-09 00:34:09 +08:00
parent d0ea2c5aea
commit d42083a4aa
2 changed files with 294 additions and 106 deletions

View File

@@ -12,31 +12,43 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { authService } from '../../services';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
import {
spacing,
borderRadius,
fontSizes,
useAppColors,
type AppColors,
} from '../../theme';
import { SimpleHeader, Text } from '../../components/common';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
import type { PrivacySettingsDTO, VisibilityLevel } from '../../types/dto';
import * as hrefs from '../../navigation/hrefs';
const CONTENT_MAX_WIDTH = 720;
const VISIBILITY_OPTIONS: { value: VisibilityLevel; label: string }[] = [
{ value: 'everyone', label: '所有人可见' },
{ value: 'following', label: '仅关注的人可见' },
{ value: 'self', label: '仅自己可见' },
const VISIBILITY_OPTIONS: { value: VisibilityLevel; label: string; icon: string }[] = [
{ value: 'everyone', label: '所有人可见', icon: 'earth' },
{ value: 'following', label: '仅关注的人可见', icon: 'account-group-outline' },
{ value: 'self', label: '仅自己可见', icon: 'lock-outline' },
];
const PRIVACY_ITEMS: { key: keyof PrivacySettingsDTO; title: string; description: string }[] = [
{ key: 'followers_visibility', title: '粉丝列表', description: '谁可以查看你的粉丝列表' },
{ key: 'following_visibility', title: '关注列表', description: '谁可以查看你的关注列表' },
{ key: 'posts_visibility', title: '帖子列表', description: '谁可以查看你的帖子列表' },
{ key: 'favorites_visibility', title: '收藏列表', description: '谁可以查看你的收藏列表' },
const PRIVACY_ITEMS: { key: keyof PrivacySettingsDTO; title: string; description: string; icon: string }[] = [
{ key: 'followers_visibility', title: '粉丝列表', description: '谁可以查看你的粉丝列表', icon: 'account-group' },
{ key: 'following_visibility', title: '关注列表', description: '谁可以查看你的关注列表', icon: 'account-heart' },
{ key: 'posts_visibility', title: '帖子列表', description: '谁可以查看你的帖子列表', icon: 'text-box-outline' },
{ key: 'favorites_visibility', title: '收藏列表', description: '谁可以查看你的收藏列表', icon: 'star-outline' },
];
const PRIVACY_NAV_ITEMS: { key: string; title: string; description: string; icon: string; danger?: boolean }[] = [
{ key: 'blocked_users', title: '黑名单', description: '管理你屏蔽的用户', icon: 'account-off-outline' },
{ key: 'account_deletion', title: '注销账号', description: '永久删除你的账号', icon: 'account-remove-outline', danger: true },
];
function createPrivacySettingsStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.paper,
backgroundColor: colors.background.default,
},
loadingWrap: {
flex: 1,
@@ -44,56 +56,68 @@ function createPrivacySettingsStyles(colors: AppColors) {
justifyContent: 'center',
},
scrollContent: {
paddingVertical: spacing.lg,
paddingVertical: spacing.md,
},
content: {
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
section: {
marginBottom: spacing['2xl'],
groupHeader: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
sectionHeader: {
paddingHorizontal: spacing['2xl'],
marginBottom: spacing.sm,
marginTop: spacing.sm,
},
sectionTitle: {
groupTitle: {
fontWeight: '600',
fontSize: fontSizes.sm,
color: colors.text.secondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
// 扁平式布局:整行横向排列,底部边框分隔
groupContainer: {
backgroundColor: colors.background.paper,
borderTopWidth: StyleSheet.hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
overflow: 'visible',
},
item: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.lg,
marginHorizontal: spacing['2xl'],
paddingVertical: 14,
paddingHorizontal: spacing.lg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
backgroundColor: colors.background.paper,
},
itemLast: {
borderBottomWidth: 0,
},
itemTextWrap: {
itemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 34,
height: 34,
borderRadius: borderRadius.md,
backgroundColor: colors.background.default,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
dangerIconContainer: {
backgroundColor: colors.error.light + '20',
},
itemContent: {
flex: 1,
},
itemTitle: {
fontWeight: '600',
fontSize: fontSizes.md,
color: colors.text.primary,
marginBottom: spacing.xs,
fontWeight: '500',
},
itemDescription: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
marginTop: 1,
},
// 无背景色的选择框,紧贴右侧
selectBox: {
flexDirection: 'row',
alignItems: 'center',
@@ -104,10 +128,9 @@ function createPrivacySettingsStyles(colors: AppColors) {
color: colors.text.secondary,
marginRight: spacing.xs,
},
// 原位展开的下拉菜单
dropdownMenu: {
position: 'absolute',
right: 0,
right: spacing.lg,
top: '100%',
marginTop: 4,
backgroundColor: colors.background.paper,
@@ -120,13 +143,17 @@ function createPrivacySettingsStyles(colors: AppColors) {
shadowRadius: 4,
elevation: 4,
zIndex: 100,
minWidth: 160,
minWidth: 180,
overflow: 'hidden',
},
dropdownOption: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
paddingVertical: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
gap: spacing.sm,
},
dropdownOptionLast: {
borderBottomWidth: 0,
@@ -139,6 +166,22 @@ function createPrivacySettingsStyles(colors: AppColors) {
color: colors.primary.main,
fontWeight: '600',
},
dropdownOptionIcon: {
width: 22,
alignItems: 'center',
},
navItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: spacing.lg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
backgroundColor: colors.background.paper,
},
navItemLast: {
borderBottomWidth: 0,
},
});
}
@@ -152,7 +195,7 @@ export const PrivacySettingsScreen: React.FC = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [openPicker, setOpenPicker] = useState<keyof PrivacySettingsDTO | null>(null);
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
const responsivePadding = useResponsiveSpacing({ xs: 0, sm: 0, md: 0, lg: 24, xl: 32 });
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
@@ -216,42 +259,52 @@ export const PrivacySettingsScreen: React.FC = () => {
<SimpleHeader title="隐私设置" onBack={() => router.back()} />
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
<View style={styles.content}>
<View style={styles.section}>
{/* 可见性设置分组 */}
<View style={styles.groupHeader}>
<Text variant="caption" style={styles.groupTitle}>
</Text>
</View>
<View style={[styles.groupContainer, { overflow: 'visible', zIndex: openPicker ? 10 : 1 }]}>
{PRIVACY_ITEMS.map((item, index) => {
const isOpen = openPicker === item.key;
const isLast = index === PRIVACY_ITEMS.length - 1;
return (
<View
key={item.key}
style={[
styles.item,
index === PRIVACY_ITEMS.length - 1 && styles.itemLast,
]}
>
<View style={styles.itemTextWrap}>
<Text variant="body" style={styles.itemTitle}>
{item.title}
</Text>
<Text variant="caption" color={colors.text.secondary} style={styles.itemDescription}>
{item.description}
</Text>
</View>
<View key={item.key} style={{ overflow: 'visible', zIndex: isOpen ? 10 : 1 }}>
<TouchableOpacity
style={styles.selectBox}
style={[styles.item, !isOpen && isLast && styles.itemLast]}
onPress={() => setOpenPicker(isOpen ? null : item.key)}
activeOpacity={0.7}
>
<Text style={styles.selectBoxText}>
{getLabel(settings?.[item.key])}
</Text>
<MaterialCommunityIcons
name={isOpen ? 'chevron-up' : 'chevron-down'}
size={18}
color={colors.text.secondary}
/>
<View style={styles.itemLeft}>
<View style={styles.iconContainer}>
<MaterialCommunityIcons
name={item.icon as any}
size={18}
color={colors.text.secondary}
/>
</View>
<View style={styles.itemContent}>
<Text variant="body" style={styles.itemTitle}>
{item.title}
</Text>
<Text variant="caption" color={colors.text.hint} style={styles.itemDescription}>
{item.description}
</Text>
</View>
</View>
<View style={styles.selectBox}>
<Text style={styles.selectBoxText}>
{getLabel(settings?.[item.key])}
</Text>
<MaterialCommunityIcons
name={isOpen ? 'chevron-up' : 'chevron-down'}
size={16}
color={colors.text.secondary}
/>
</View>
</TouchableOpacity>
{/* 原位展开的下拉选项 */}
{isOpen && (
<View style={styles.dropdownMenu}>
{VISIBILITY_OPTIONS.map((option, optIndex) => {
@@ -269,11 +322,18 @@ export const PrivacySettingsScreen: React.FC = () => {
}}
activeOpacity={0.7}
>
<View style={styles.dropdownOptionIcon}>
<MaterialCommunityIcons
name={option.icon as any}
size={16}
color={isActive ? colors.primary.main : colors.text.secondary}
/>
</View>
<Text
variant="body"
style={[
styles.dropdownOptionText,
isActive ? styles.dropdownOptionTextActive : undefined,
isActive && styles.dropdownOptionTextActive,
]}
>
{option.label}
@@ -287,6 +347,49 @@ export const PrivacySettingsScreen: React.FC = () => {
);
})}
</View>
{/* 账号管理分组 */}
<View style={[styles.groupHeader, { marginTop: spacing.xl }]}>
<Text variant="caption" style={styles.groupTitle}>
</Text>
</View>
<View style={styles.groupContainer}>
{PRIVACY_NAV_ITEMS.map((item, index) => (
<TouchableOpacity
key={item.key}
style={[styles.navItem, index === PRIVACY_NAV_ITEMS.length - 1 && styles.navItemLast]}
onPress={() => {
if (item.key === 'blocked_users') router.push(hrefs.hrefProfileBlocked());
else if (item.key === 'account_deletion') router.push(hrefs.hrefProfileDeletion());
}}
activeOpacity={0.7}
>
<View style={styles.itemLeft}>
<View style={[styles.iconContainer, item.danger && styles.dangerIconContainer]}>
<MaterialCommunityIcons
name={item.icon as any}
size={18}
color={item.danger ? colors.error.main : colors.text.secondary}
/>
</View>
<View style={styles.itemContent}>
<Text
variant="body"
color={item.danger ? colors.error.main : colors.text.primary}
style={styles.itemTitle}
>
{item.title}
</Text>
<Text variant="caption" color={colors.text.hint} style={styles.itemDescription}>
{item.description}
</Text>
</View>
</View>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</TouchableOpacity>
))}
</View>
</View>
</ScrollView>
</SafeAreaView>

View File

@@ -4,7 +4,7 @@
* 采用扁平化设计,全宽布局,简洁分割线
*/
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import {
View,
StyleSheet,
@@ -34,6 +34,12 @@ import * as hrefs from '../../navigation/hrefs';
const APP_VERSION = Constants.expoConfig?.version || '1.0.0';
const THEME_OPTIONS: { value: 'system' | 'light' | 'dark'; label: string }[] = [
{ value: 'system', label: '跟随系统' },
{ value: 'light', label: '浅色' },
{ value: 'dark', label: '深色' },
];
interface SettingsItem {
key: string;
title: string;
@@ -42,6 +48,7 @@ interface SettingsItem {
showArrow?: boolean;
danger?: boolean;
subtitle?: string;
isThemePicker?: boolean;
}
const SETTINGS_GROUPS_BASE = [
@@ -52,8 +59,6 @@ const SETTINGS_GROUPS_BASE = [
{ key: 'verification', title: '身份认证', icon: 'check-decagram', showArrow: true, subtitle: '获得认证标识,解锁更多功能' },
{ key: 'privacy', title: '隐私设置', icon: 'shield-account-outline', showArrow: true },
{ key: 'security', title: '账号安全', icon: 'lock-outline', showArrow: true },
{ key: 'blocked_users', title: '黑名单', icon: 'account-off-outline', showArrow: true },
{ key: 'account_deletion', title: '注销账号', icon: 'account-remove-outline', showArrow: true, danger: true },
],
},
{
@@ -87,6 +92,8 @@ function createSettingsStyles(colors: AppColors) {
borderTopWidth: StyleSheet.hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
overflow: 'visible',
zIndex: 1,
},
groupHeader: {
paddingHorizontal: spacing.lg,
@@ -134,6 +141,53 @@ function createSettingsStyles(colors: AppColors) {
subtitle: {
marginTop: 2,
},
// 无背景色的选择框,紧贴右侧
selectBox: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.xs,
},
selectBoxText: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
marginRight: spacing.xs,
},
// 原位展开的下拉菜单
dropdownMenu: {
position: 'absolute',
right: 0,
top: '100%',
marginTop: 4,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 4,
zIndex: 100,
minWidth: 120,
overflow: 'hidden',
},
dropdownOption: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
dropdownOptionLast: {
borderBottomWidth: 0,
},
dropdownOptionText: {
fontSize: fontSizes.sm,
color: colors.text.primary,
},
dropdownOptionTextActive: {
color: colors.primary.main,
fontWeight: '600',
},
logoutButton: {
flexDirection: 'row',
alignItems: 'center',
@@ -172,12 +226,11 @@ export const SettingsScreen: React.FC = () => {
const styles = useMemo(() => createSettingsStyles(colors), [colors]);
const themePreference = useThemePreference();
const setThemePreference = useSetThemePreference();
const [openPicker, setOpenPicker] = useState<string | null>(null);
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
const settingsGroups = useMemo(() => {
const themeSubtitle =
themePreference === 'system' ? '跟随系统' : themePreference === 'dark' ? '深色' : '浅色';
return [
{
title: '个性化',
@@ -193,8 +246,7 @@ export const SettingsScreen: React.FC = () => {
key: 'theme',
title: '主题模式',
icon: 'theme-light-dark',
showArrow: true,
subtitle: themeSubtitle,
isThemePicker: true,
},
],
},
@@ -208,31 +260,7 @@ export const SettingsScreen: React.FC = () => {
router.push(hrefs.hrefProfileChatSettings());
break;
case 'theme': {
Alert.alert(
'主题模式',
'选择应用界面明暗',
[
{ text: '取消', style: 'cancel' },
{
text: '跟随系统',
onPress: () => {
void setThemePreference('system');
},
},
{
text: '浅色',
onPress: () => {
void setThemePreference('light');
},
},
{
text: '深色',
onPress: () => {
void setThemePreference('dark');
},
},
]
);
setOpenPicker(openPicker === 'theme' ? null : 'theme');
break;
}
@@ -289,12 +317,19 @@ export const SettingsScreen: React.FC = () => {
}
};
const renderSettingItem = (item: SettingsItem, index: number, total: number) => (
const getThemeLabel = (value: string) =>
THEME_OPTIONS.find((o) => o.value === value)?.label ?? '跟随系统';
const renderSettingItem = (item: SettingsItem, index: number, total: number) => {
const isThemeOpen = item.isThemePicker && openPicker === 'theme';
const themeLabel = item.isThemePicker ? getThemeLabel(themePreference) : undefined;
return (
<TouchableOpacity
key={item.key}
style={[
styles.settingItem,
index === total - 1 && styles.settingItemLast,
index === total - 1 && !isThemeOpen && styles.settingItemLast,
]}
onPress={() => (item.onPress ? item.onPress() : handleItemPress(item.key))}
activeOpacity={0.7}
@@ -318,14 +353,64 @@ export const SettingsScreen: React.FC = () => {
)}
</View>
</View>
{item.showArrow && (
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
{item.isThemePicker ? (
<View style={{ position: 'relative' }}>
<View style={styles.selectBox}>
<Text style={styles.selectBoxText}>{themeLabel}</Text>
<MaterialCommunityIcons
name={isThemeOpen ? 'chevron-up' : 'chevron-down'}
size={18}
color={colors.text.secondary}
/>
</View>
{isThemeOpen && (
<View style={styles.dropdownMenu}>
{THEME_OPTIONS.map((option, optIndex) => {
const isActive = themePreference === option.value;
return (
<TouchableOpacity
key={option.value}
style={[
styles.dropdownOption,
optIndex === THEME_OPTIONS.length - 1 && styles.dropdownOptionLast,
]}
onPress={() => {
void setThemePreference(option.value);
setOpenPicker(null);
}}
activeOpacity={0.7}
>
<Text
variant="body"
style={[
styles.dropdownOptionText,
isActive ? styles.dropdownOptionTextActive : undefined,
]}
>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
)}
</View>
) : (
item.showArrow && (
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
)
)}
</TouchableOpacity>
);
);
};
const renderGroup = (group: (typeof settingsGroups)[0]) => (
<View key={group.title}>
const renderGroup = (group: (typeof settingsGroups)[0], groupIndex: number) => (
<View
key={group.title}
style={[
{ overflow: 'visible', zIndex: group.items.some((i) => i.isThemePicker && openPicker === 'theme') ? 10 : groupIndex },
]}
>
<View style={styles.groupHeader}>
<Text variant="caption" style={styles.groupTitle}>
{group.title}
@@ -352,7 +437,7 @@ export const SettingsScreen: React.FC = () => {
const renderContent = () => (
<>
{settingsGroups.map((group) => renderGroup(group))}
{settingsGroups.map((group, index) => renderGroup(group, index))}
{renderLogoutButton()}
<View style={styles.footer}>
<Text variant="caption" color={colors.text.hint}>