feat(ui): unify design system to Twitter/X style across screens and improve message handling
Redesign multiple screens and components from card-based responsive design to Twitter/X flat design: - UserProfileHeader: adopt Twitter/X style with larger icons, simplified buttons, and block functionality - AppBackButton: update to chevron-left icon with larger size, remove background styling - Update SettingsScreen, EditProfileScreen, and UserProfileScreen with flat layout approach - ConversationListRow: convert from bordered cards to flat list rows with updated typography Improve message system with WebSocket notification handling: - Add real-time system unread count updates via WebSocket - Track lastSystemMessageAt for accurate system message timestamps - Add notification deduplication and read-status filtering - Combine system and conversation unread counts in background sync - Clear system notifications from notification center on mark as read
This commit is contained in:
@@ -1,50 +1,50 @@
|
||||
/**
|
||||
* UserProfileHeader 用户资料头部组件 - 美化版(响应式适配)
|
||||
* UserProfileHeader 用户资料头部组件 - Twitter/X 风格
|
||||
* 显示用户封面、头像、昵称、简介、关注/粉丝数
|
||||
* 采用现代卡片式设计,渐变封面,悬浮头像
|
||||
* 采用 Twitter/X 扁平化设计,左对齐布局
|
||||
* 支持互关状态显示
|
||||
* 在宽屏下显示更大的头像和封面
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
Pressable,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { User } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Button from '../common/Button';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { useResponsive } from '../../hooks';
|
||||
|
||||
interface UserProfileHeaderProps {
|
||||
user: User;
|
||||
isCurrentUser?: boolean;
|
||||
isBlocked?: boolean;
|
||||
onFollow: () => void;
|
||||
onSettings?: () => void;
|
||||
onEditProfile?: () => void;
|
||||
onMessage?: () => void;
|
||||
onMore?: () => void; // 点击更多按钮
|
||||
onPostsPress?: () => void; // 点击帖子数(可选)
|
||||
onFollowingPress?: () => void; // 点击关注数
|
||||
onFollowersPress?: () => void; // 点击粉丝数
|
||||
onAvatarPress?: () => void; // 点击头像编辑按钮
|
||||
onBlock?: () => void;
|
||||
onFollowingPress?: () => void;
|
||||
onFollowersPress?: () => void;
|
||||
onAvatarPress?: () => void;
|
||||
}
|
||||
|
||||
const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
user,
|
||||
isCurrentUser = false,
|
||||
isBlocked = false,
|
||||
onFollow,
|
||||
onSettings,
|
||||
onEditProfile,
|
||||
onMessage,
|
||||
onMore,
|
||||
onPostsPress,
|
||||
onBlock,
|
||||
onFollowingPress,
|
||||
onFollowersPress,
|
||||
onAvatarPress,
|
||||
@@ -52,108 +52,118 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createUserProfileHeaderStyles(colors), [colors]);
|
||||
const { isWideScreen, isDesktop, width } = useResponsive();
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const moreButtonRef = useRef<View>(null);
|
||||
|
||||
// 格式化数字
|
||||
const formatCount = (count: number | undefined): string => {
|
||||
if (count === undefined || count === null) {
|
||||
return '0';
|
||||
}
|
||||
if (count >= 10000) {
|
||||
return `${(count / 10000).toFixed(1)}w`;
|
||||
}
|
||||
if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}k`;
|
||||
}
|
||||
if (count === undefined || count === null) return '0';
|
||||
if (count >= 10000) return `${(count / 10000).toFixed(1)}w`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
// 获取帖子数量
|
||||
const getPostsCount = (): number => {
|
||||
return user.posts_count ?? 0;
|
||||
};
|
||||
const getPostsCount = (): number => user.posts_count ?? 0;
|
||||
const getFollowersCount = (): number => user.followers_count ?? 0;
|
||||
const getFollowingCount = (): number => user.following_count ?? 0;
|
||||
const getIsFollowing = (): boolean => user.is_following ?? false;
|
||||
const getIsFollowingMe = (): boolean => user.is_following_me ?? false;
|
||||
|
||||
// 获取粉丝数量
|
||||
const getFollowersCount = (): number => {
|
||||
return user.followers_count ?? 0;
|
||||
};
|
||||
|
||||
// 获取关注数量
|
||||
const getFollowingCount = (): number => {
|
||||
return user.following_count ?? 0;
|
||||
};
|
||||
|
||||
// 检查是否关注
|
||||
const getIsFollowing = (): boolean => {
|
||||
return user.is_following ?? false;
|
||||
};
|
||||
|
||||
// 检查对方是否关注了我
|
||||
const getIsFollowingMe = (): boolean => {
|
||||
return user.is_following_me ?? false;
|
||||
};
|
||||
|
||||
// 获取按钮配置(类似B站的互关逻辑)
|
||||
const getButtonConfig = (): { title: string; variant: 'primary' | 'outline'; icon?: string } => {
|
||||
const getButtonConfig = (): { title: string; variant: 'primary' | 'outline' | 'ghost'; icon?: string } => {
|
||||
const isFollowing = getIsFollowing();
|
||||
const isFollowingMe = getIsFollowingMe();
|
||||
|
||||
if (isFollowing && isFollowingMe) {
|
||||
// 已互关
|
||||
return { title: '互相关注', variant: 'outline', icon: 'account-check' };
|
||||
} else if (isFollowing) {
|
||||
// 已关注但对方未回关
|
||||
return { title: '已关注', variant: 'outline', icon: 'check' };
|
||||
} else if (isFollowingMe) {
|
||||
// 对方关注了我,但我没关注对方 - 显示回关
|
||||
return { title: '回关', variant: 'primary', icon: 'plus' };
|
||||
} else {
|
||||
// 互不关注
|
||||
return { title: '关注', variant: 'primary', icon: 'plus' };
|
||||
}
|
||||
};
|
||||
|
||||
// 根据屏幕尺寸计算封面高度
|
||||
const coverHeight = isDesktop ? 240 : isWideScreen ? 200 : (width * 9) / 16;
|
||||
|
||||
// 根据屏幕尺寸计算头像大小
|
||||
const avatarSize = isDesktop ? 120 : isWideScreen ? 100 : 90;
|
||||
const coverHeight = isDesktop ? 280 : isWideScreen ? 240 : (width * 10) / 16;
|
||||
const avatarSize = isDesktop ? 140 : isWideScreen ? 120 : 88;
|
||||
|
||||
const renderStatItem = ({
|
||||
value,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
onPress?: () => void;
|
||||
}) => {
|
||||
const content = (
|
||||
<View style={styles.statContent}>
|
||||
<Text variant="h3" style={styles.statNumber}>{value}</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.statLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
const handleMorePress = useCallback(() => {
|
||||
moreButtonRef.current?.measure((_fx, _fy, _w, h, px, py) => {
|
||||
const screenWidth = Dimensions.get('window').width;
|
||||
const menuWidth = 180;
|
||||
let x = px + _w / 2 - menuWidth / 2;
|
||||
if (x + menuWidth > screenWidth - 12) {
|
||||
x = screenWidth - menuWidth - 12;
|
||||
}
|
||||
if (x < 12) x = 12;
|
||||
setMenuPosition({ x, y: py + h + 4 });
|
||||
setMenuVisible(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (onPress) {
|
||||
const handleBlockPress = useCallback(() => {
|
||||
setMenuVisible(false);
|
||||
onBlock?.();
|
||||
}, [onBlock]);
|
||||
|
||||
const renderStatLink = (count: number, label: string, onPress?: () => void) => {
|
||||
if (!onPress) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.statItem, styles.statItemTouchable]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
{content}
|
||||
</TouchableOpacity>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statNumber}>{formatCount(count)}</Text>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={styles.statItem}>{content}</View>;
|
||||
return (
|
||||
<TouchableOpacity style={styles.statItem} onPress={onPress} activeOpacity={0.7}>
|
||||
<Text style={styles.statNumber}>{formatCount(count)}</Text>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDropdownMenu = () => (
|
||||
<Modal
|
||||
visible={menuVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setMenuVisible(false)}
|
||||
>
|
||||
<Pressable style={styles.menuBackdrop} onPress={() => setMenuVisible(false)}>
|
||||
<View
|
||||
style={[
|
||||
styles.menuContainer,
|
||||
{ left: menuPosition.x, top: menuPosition.y },
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.menuItem}
|
||||
onPress={handleBlockPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={isBlocked ? 'account-check-outline' : 'account-off-outline'}
|
||||
size={18}
|
||||
color={isBlocked ? colors.success.main : colors.error.main}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.menuItemText,
|
||||
{ color: isBlocked ? colors.success.main : colors.error.main },
|
||||
]}
|
||||
>
|
||||
{isBlocked ? '取消拉黑' : '拉黑用户'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 渐变封面背景 */}
|
||||
{/* 全宽封面背景 */}
|
||||
<View style={[styles.coverContainer, { height: coverHeight }]}>
|
||||
<View style={styles.coverTouchable}>
|
||||
{user.cover_url ? (
|
||||
@@ -163,382 +173,351 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={['#FF8F66', '#FF6B35', '#E5521D']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
/>
|
||||
<View style={[styles.coverFallback, { backgroundColor: colors.primary.main }]}>
|
||||
<MaterialCommunityIcons name="image-outline" size={48} color="rgba(255,255,255,0.4)" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 设置按钮 */}
|
||||
{isCurrentUser && onSettings && (
|
||||
<TouchableOpacity style={styles.settingsButton} onPress={onSettings}>
|
||||
<MaterialCommunityIcons name="cog-outline" size={22} color={colors.text.inverse} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 装饰性波浪 */}
|
||||
<View style={styles.waveDecoration}>
|
||||
<View style={styles.wave} />
|
||||
{/* 顶部导航栏按钮 */}
|
||||
<View style={styles.topBar}>
|
||||
{isCurrentUser && onSettings && (
|
||||
<TouchableOpacity style={styles.iconButton} onPress={onSettings}>
|
||||
<MaterialCommunityIcons name="cog-outline" size={20} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 用户信息卡片 */}
|
||||
<View style={[
|
||||
styles.profileCard,
|
||||
isWideScreen && styles.profileCardWide,
|
||||
]}>
|
||||
{/* 悬浮头像 */}
|
||||
<View style={[
|
||||
styles.avatarWrapper,
|
||||
isWideScreen && styles.avatarWrapperWide,
|
||||
]}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<Avatar
|
||||
source={user.avatar}
|
||||
size={avatarSize}
|
||||
name={user.nickname}
|
||||
/>
|
||||
{isCurrentUser && onAvatarPress && (
|
||||
<TouchableOpacity style={styles.editAvatarButton} onPress={onAvatarPress}>
|
||||
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
|
||||
{/* 用户信息区 */}
|
||||
<View style={styles.infoSection}>
|
||||
{/* 头像与操作按钮行 */}
|
||||
<View style={styles.avatarActionRow}>
|
||||
<View style={[styles.avatarWrapper, { marginTop: -avatarSize / 2 }]}>
|
||||
<View style={[styles.avatarContainer, { width: avatarSize, height: avatarSize }]}>
|
||||
<Avatar
|
||||
source={user.avatar}
|
||||
size={avatarSize - 6}
|
||||
name={user.nickname}
|
||||
/>
|
||||
{isCurrentUser && onAvatarPress && (
|
||||
<TouchableOpacity style={styles.editAvatarButton} onPress={onAvatarPress}>
|
||||
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionButtons}>
|
||||
{isCurrentUser ? (
|
||||
<TouchableOpacity style={styles.editProfileButton} onPress={onEditProfile} activeOpacity={0.8}>
|
||||
<Text style={styles.editProfileText}>编辑资料</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.buttonRow}>
|
||||
<View ref={moreButtonRef} collapsable={false}>
|
||||
<TouchableOpacity style={styles.iconButtonSmall} onPress={handleMorePress}>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={20} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.iconButtonSmall} onPress={onMessage}>
|
||||
<MaterialCommunityIcons name="email-outline" size={18} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.followBtn,
|
||||
getButtonConfig().variant === 'outline' && styles.followBtnOutline,
|
||||
getButtonConfig().variant === 'primary' && styles.followBtnPrimary,
|
||||
]}
|
||||
onPress={onFollow}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.followBtnText,
|
||||
...(getButtonConfig().variant === 'primary' ? [styles.followBtnTextPrimary] : []),
|
||||
]}
|
||||
>
|
||||
{getButtonConfig().title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 用户名和简介 */}
|
||||
<View style={styles.userInfo}>
|
||||
<Text variant="h2" style={[
|
||||
styles.nickname,
|
||||
isWideScreen ? styles.nicknameWide : {},
|
||||
]}>
|
||||
{user.nickname}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.username}>
|
||||
@{user.username}
|
||||
</Text>
|
||||
|
||||
{user.bio ? (
|
||||
<Text variant="body" color={colors.text.secondary} style={[
|
||||
styles.bio,
|
||||
isWideScreen ? styles.bioWide : {},
|
||||
]}>
|
||||
{user.bio}
|
||||
</Text>
|
||||
) : (
|
||||
<Text variant="body" color={colors.text.hint} style={styles.bioPlaceholder}>
|
||||
这个人很懒,还没有写简介~
|
||||
</Text>
|
||||
)}
|
||||
{/* 用户名 */}
|
||||
<View style={styles.nameSection}>
|
||||
<Text style={styles.nickname}>{user.nickname}</Text>
|
||||
<Text style={styles.username}>@{user.username}</Text>
|
||||
</View>
|
||||
|
||||
{/* 个人信息标签 */}
|
||||
{/* 简介 */}
|
||||
{user.bio ? (
|
||||
<Text style={styles.bio}>{user.bio}</Text>
|
||||
) : null}
|
||||
|
||||
{/* 元信息 */}
|
||||
<View style={styles.metaInfo}>
|
||||
{user.location?.trim() ? (
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
|
||||
{user.location}
|
||||
</Text>
|
||||
<View style={styles.metaItem}>
|
||||
<MaterialCommunityIcons name="map-marker-outline" size={14} color={colors.text.hint} />
|
||||
<Text style={styles.metaText}>{user.location}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{user.website?.trim() ? (
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
|
||||
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
|
||||
{user.website.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
<View style={styles.metaItem}>
|
||||
<MaterialCommunityIcons name="link-variant" size={14} color={colors.info.main} />
|
||||
<Text style={[styles.metaText, styles.metaLink]}>{user.website.replace(/^https?:\/\//, '')}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
|
||||
<View style={styles.metaItem}>
|
||||
<MaterialCommunityIcons name="calendar-outline" size={14} color={colors.text.hint} />
|
||||
<Text style={styles.metaText}>
|
||||
加入于 {new Date(user.created_at || Date.now()).getFullYear()}年
|
||||
{new Date(user.created_at || Date.now()).getMonth() + 1}月
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 统计数据 - 卡片式 */}
|
||||
<View style={[
|
||||
styles.statsCard,
|
||||
isWideScreen && styles.statsCardWide,
|
||||
]}>
|
||||
{renderStatItem({
|
||||
value: formatCount(getPostsCount()),
|
||||
label: '帖子',
|
||||
onPress: onPostsPress,
|
||||
})}
|
||||
<View style={styles.statDivider} />
|
||||
{renderStatItem({
|
||||
value: formatCount(getFollowingCount()),
|
||||
label: '关注',
|
||||
onPress: onFollowingPress,
|
||||
})}
|
||||
<View style={styles.statDivider} />
|
||||
{renderStatItem({
|
||||
value: formatCount(getFollowersCount()),
|
||||
label: '粉丝',
|
||||
onPress: onFollowersPress,
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View style={styles.actionButtons}>
|
||||
{isCurrentUser ? (
|
||||
<View style={styles.buttonRow} />
|
||||
) : (
|
||||
<View style={StyleSheet.flatten([
|
||||
styles.buttonRow,
|
||||
isWideScreen && styles.buttonRowWide,
|
||||
])}>
|
||||
<Button
|
||||
title={getButtonConfig().title}
|
||||
onPress={onFollow}
|
||||
variant={getButtonConfig().variant}
|
||||
style={StyleSheet.flatten([
|
||||
styles.followButton,
|
||||
isWideScreen && styles.followButtonWide,
|
||||
])}
|
||||
icon={getButtonConfig().icon}
|
||||
/>
|
||||
<TouchableOpacity style={styles.messageButton} onPress={onMessage}>
|
||||
<MaterialCommunityIcons name="message-text-outline" size={20} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.moreButton} onPress={onMore}>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={24} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{/* 统计数据 - Twitter 风格内联 */}
|
||||
<View style={styles.statsRow}>
|
||||
{renderStatLink(getFollowingCount(), '正在关注', onFollowingPress)}
|
||||
{renderStatLink(getFollowersCount(), '关注者', onFollowersPress)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{renderDropdownMenu()}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
function createUserProfileHeaderStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
coverContainer: {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
coverTouchable: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
coverImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
gradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
settingsButton: {
|
||||
position: 'absolute',
|
||||
top: spacing.lg,
|
||||
right: spacing.lg,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
waveDecoration: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 40,
|
||||
},
|
||||
wave: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: colors.background.default,
|
||||
borderTopLeftRadius: 30,
|
||||
borderTopRightRadius: 30,
|
||||
},
|
||||
profileCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: -50,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.lg,
|
||||
...shadows.md,
|
||||
},
|
||||
profileCardWide: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: -60,
|
||||
padding: spacing.xl,
|
||||
},
|
||||
avatarWrapper: {
|
||||
alignItems: 'center',
|
||||
marginTop: -60,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
avatarWrapperWide: {
|
||||
marginTop: -80,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
avatarContainer: {
|
||||
position: 'relative',
|
||||
padding: 4,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: 50,
|
||||
},
|
||||
editAvatarButton: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.primary.main,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
userInfo: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
nickname: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '700',
|
||||
},
|
||||
nicknameWide: {
|
||||
fontSize: fontSizes['3xl'],
|
||||
},
|
||||
username: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
bio: {
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
bioWide: {
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 24,
|
||||
maxWidth: 600,
|
||||
},
|
||||
bioPlaceholder: {
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.sm,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
metaInfo: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
metaTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
metaTagText: {
|
||||
marginLeft: spacing.xs,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
statsCard: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
statsCardWide: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
minHeight: 58,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
statItemTouchable: {
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
statContent: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
statNumber: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 0,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: fontSizes.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
statDivider: {
|
||||
width: 1,
|
||||
height: 24,
|
||||
backgroundColor: colors.divider + '55',
|
||||
},
|
||||
actionButtons: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
buttonRowWide: {
|
||||
justifyContent: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
editButton: {
|
||||
flex: 1,
|
||||
},
|
||||
followButton: {
|
||||
flex: 1,
|
||||
},
|
||||
followButtonWide: {
|
||||
flex: 0,
|
||||
minWidth: 120,
|
||||
},
|
||||
messageButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
moreButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
settingsButtonOnly: {
|
||||
alignSelf: 'center',
|
||||
padding: spacing.sm,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
// 封面
|
||||
coverContainer: {
|
||||
position: 'relative',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
coverTouchable: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
coverImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
coverFallback: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
// 顶部栏
|
||||
topBar: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.md,
|
||||
},
|
||||
iconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
...shadows.sm,
|
||||
},
|
||||
// 信息区
|
||||
infoSection: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.lg,
|
||||
},
|
||||
avatarActionRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
avatarWrapper: {
|
||||
// 负边距在 inline style 中计算
|
||||
},
|
||||
avatarContainer: {
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.background.paper,
|
||||
padding: 3,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
editAvatarButton: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.primary.main,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
// 操作按钮
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
iconButtonSmall: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
editProfileButton: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
editProfileText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
followBtn: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.full,
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
followBtnPrimary: {
|
||||
backgroundColor: colors.text.primary,
|
||||
},
|
||||
followBtnOutline: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
followBtnText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
followBtnTextPrimary: {
|
||||
color: colors.background.paper,
|
||||
},
|
||||
// 名字
|
||||
nameSection: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
nickname: {
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontWeight: '800',
|
||||
color: colors.text.primary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
username: {
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
// 简介
|
||||
bio: {
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 20,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
// 元信息
|
||||
metaInfo: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
metaItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
metaLink: {
|
||||
color: colors.info.main,
|
||||
},
|
||||
// 统计
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.lg,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
statNumber: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
// 下拉菜单
|
||||
menuBackdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
menuContainer: {
|
||||
position: 'absolute',
|
||||
minWidth: 160,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
...shadows.lg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 使用 React.memo 避免不必要的重新渲染
|
||||
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);
|
||||
import { shadows } from '../../theme';
|
||||
|
||||
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);
|
||||
export default MemoizedUserProfileHeader;
|
||||
|
||||
@@ -2,56 +2,54 @@ import React from 'react';
|
||||
import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { borderRadius, spacing, useAppColors } from '../../theme';
|
||||
import { useAppColors } from '../../theme';
|
||||
|
||||
type AppBackButtonIcon = 'arrow-left' | 'close';
|
||||
type AppBackButtonIcon = 'chevron-left' | 'arrow-left' | 'close';
|
||||
|
||||
interface AppBackButtonProps {
|
||||
onPress: () => void;
|
||||
icon?: AppBackButtonIcon;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
iconColor?: string;
|
||||
backgroundColor?: string;
|
||||
hitSlop?: number;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const AppBackButton: React.FC<AppBackButtonProps> = ({
|
||||
onPress,
|
||||
icon = 'arrow-left',
|
||||
icon = 'chevron-left',
|
||||
style,
|
||||
iconColor: iconColorProp,
|
||||
backgroundColor: backgroundColorProp,
|
||||
hitSlop = 8,
|
||||
testID,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const iconColor = iconColorProp ?? colors.text.primary;
|
||||
const backgroundColor = backgroundColorProp ?? colors.background.paper;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={[styles.button, { backgroundColor }, style]}
|
||||
activeOpacity={0.75}
|
||||
style={[styles.button, style]}
|
||||
activeOpacity={0.6}
|
||||
hitSlop={hitSlop}
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={icon === 'close' ? '关闭' : '返回'}
|
||||
>
|
||||
<MaterialCommunityIcons name={icon} size={22} color={iconColor} />
|
||||
<MaterialCommunityIcons name={icon} size={36} color={iconColor} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.full,
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs,
|
||||
marginLeft: -12,
|
||||
marginRight: 0,
|
||||
padding: 0,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user