- Added FoodCameraScreen for capturing food images with camera functionality. - Integrated image picker for selecting images from the gallery. - Created FloatingFoodOverlay for quick access to food library and scanning options. - Updated NutritionRadarCard to utilize FloatingFoodOverlay for adding food. - Enhanced ExploreScreen layout and styles for better user experience. - Removed unused SafeAreaView from ExploreScreen. - Updated profile edit screen to remove unnecessary state variables. - Updated avatar image source in profile edit screen. - Added ExpoCamera dependency for camera functionalities.
485 lines
13 KiB
TypeScript
485 lines
13 KiB
TypeScript
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
|
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
|
import { useNotifications } from '@/hooks/useNotifications';
|
|
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
|
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
|
import { useFocusEffect } from '@react-navigation/native';
|
|
import { Image } from 'expo-image';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
|
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
|
|
|
|
export default function PersonalScreen() {
|
|
const dispatch = useAppDispatch();
|
|
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
|
|
const insets = useSafeAreaInsets();
|
|
const tabBarHeight = useBottomTabBarHeight();
|
|
|
|
// 推送通知相关
|
|
const {
|
|
requestPermission,
|
|
sendNotification,
|
|
} = useNotifications();
|
|
|
|
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
|
|
|
// 计算底部间距
|
|
const bottomPadding = useMemo(() => {
|
|
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
|
}, [tabBarHeight, insets?.bottom]);
|
|
|
|
// 直接使用 Redux 中的用户信息,避免重复状态管理
|
|
const userProfile = useAppSelector((state) => state.user.profile);
|
|
|
|
// 页面聚焦时获取最新用户信息
|
|
useFocusEffect(
|
|
React.useCallback(() => {
|
|
dispatch(fetchMyProfile());
|
|
dispatch(fetchActivityHistory());
|
|
// 加载用户推送偏好设置
|
|
loadNotificationPreference();
|
|
}, [dispatch])
|
|
);
|
|
|
|
// 加载用户推送偏好设置
|
|
const loadNotificationPreference = async () => {
|
|
try {
|
|
const enabled = await getNotificationEnabled();
|
|
setNotificationEnabled(enabled);
|
|
} catch (error) {
|
|
console.error('加载推送偏好设置失败:', error);
|
|
}
|
|
};
|
|
|
|
// 数据格式化函数
|
|
const formatHeight = () => {
|
|
if (userProfile.height == null) return '--';
|
|
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
|
|
};
|
|
|
|
const formatWeight = () => {
|
|
if (userProfile.weight == null) return '--';
|
|
return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
|
|
};
|
|
|
|
const formatAge = () => {
|
|
if (!userProfile.birthDate) return '--';
|
|
const birthDate = new Date(userProfile.birthDate);
|
|
const today = new Date();
|
|
const age = today.getFullYear() - birthDate.getFullYear();
|
|
return `${age}岁`;
|
|
};
|
|
|
|
// 显示名称
|
|
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
|
|
|
// 初始化时加载推送偏好设置
|
|
useEffect(() => {
|
|
loadNotificationPreference();
|
|
}, []);
|
|
|
|
// 处理通知开关变化
|
|
const handleNotificationToggle = async (value: boolean) => {
|
|
if (value) {
|
|
try {
|
|
// 先检查系统权限
|
|
const status = await requestPermission();
|
|
if (status === 'granted') {
|
|
// 系统权限获取成功,保存用户偏好设置
|
|
await saveNotificationEnabled(true);
|
|
setNotificationEnabled(true);
|
|
|
|
// 发送测试通知
|
|
await sendNotification({
|
|
title: '通知已开启',
|
|
body: '您将收到运动提醒和重要通知',
|
|
sound: true,
|
|
priority: 'normal',
|
|
});
|
|
} else {
|
|
// 系统权限被拒绝,不更新用户偏好设置
|
|
Alert.alert(
|
|
'权限被拒绝',
|
|
'请在系统设置中开启通知权限,然后再尝试开启推送功能',
|
|
[
|
|
{ text: '取消', style: 'cancel' },
|
|
{ text: '去设置', onPress: () => Linking.openSettings() }
|
|
]
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error('开启推送通知失败:', error);
|
|
Alert.alert('错误', '请求通知权限失败');
|
|
}
|
|
} else {
|
|
try {
|
|
// 关闭推送,保存用户偏好设置
|
|
await saveNotificationEnabled(false);
|
|
setNotificationEnabled(false);
|
|
} catch (error) {
|
|
console.error('关闭推送通知失败:', error);
|
|
Alert.alert('错误', '保存设置失败');
|
|
}
|
|
}
|
|
};
|
|
|
|
// 用户信息头部
|
|
const UserHeader = () => (
|
|
<View style={[styles.sectionContainer, {
|
|
marginBottom: 0
|
|
}]}>
|
|
|
|
<View style={[styles.userInfoContainer,]}>
|
|
<View style={styles.avatarContainer}>
|
|
<Image
|
|
source={userProfile.avatar || DEFAULT_AVATAR_URL}
|
|
style={styles.avatar}
|
|
contentFit="cover"
|
|
transition={200}
|
|
cachePolicy="memory-disk"
|
|
/>
|
|
</View>
|
|
<View style={styles.userDetails}>
|
|
<Text style={styles.userName}>{displayName}</Text>
|
|
</View>
|
|
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
|
<Text style={styles.editButtonText}>编辑</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
</View>
|
|
);
|
|
|
|
// 数据统计部分
|
|
const StatsSection = () => (
|
|
<View style={styles.sectionContainer}>
|
|
<View style={[styles.cardContainer, {
|
|
backgroundColor: 'unset'
|
|
}]}>
|
|
<View style={styles.statsContainer}>
|
|
<View style={styles.statItem}>
|
|
<Text style={styles.statValue}>{formatHeight()}</Text>
|
|
<Text style={styles.statLabel}>身高</Text>
|
|
</View>
|
|
<View style={styles.statItem}>
|
|
<Text style={styles.statValue}>{formatWeight()}</Text>
|
|
<Text style={styles.statLabel}>体重</Text>
|
|
</View>
|
|
<View style={styles.statItem}>
|
|
<Text style={styles.statValue}>{formatAge()}</Text>
|
|
<Text style={styles.statLabel}>年龄</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
|
|
// 菜单项组件
|
|
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
|
|
<View style={styles.sectionContainer}>
|
|
<Text style={styles.sectionTitle}>{title}</Text>
|
|
<View style={styles.cardContainer}>
|
|
{items.map((item, index) => (
|
|
<TouchableOpacity
|
|
key={index}
|
|
style={[styles.menuItem, index === items.length - 1 && { borderBottomWidth: 0 }]}
|
|
onPress={item.type === 'switch' ? undefined : item.onPress}
|
|
disabled={item.type === 'switch'}
|
|
>
|
|
<View style={styles.menuItemLeft}>
|
|
<View style={[
|
|
styles.iconContainer,
|
|
{ backgroundColor: item.isDanger ? 'rgba(255,68,68,0.1)' : 'rgba(147, 112, 219, 0.1)' }
|
|
]}>
|
|
<Ionicons
|
|
name={item.icon}
|
|
size={20}
|
|
color={item.isDanger ? '#FF4444' : '#9370DB'}
|
|
/>
|
|
</View>
|
|
<Text style={styles.menuItemText}>{item.title}</Text>
|
|
</View>
|
|
{item.type === 'switch' ? (
|
|
<Switch
|
|
value={item.switchValue || false}
|
|
onValueChange={item.onSwitchChange || (() => { })}
|
|
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
|
|
thumbColor="#FFFFFF"
|
|
style={styles.switch}
|
|
/>
|
|
) : (
|
|
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
|
)}
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
|
|
// 菜单项配置
|
|
const menuSections = [
|
|
{
|
|
title: '通知',
|
|
items: [
|
|
{
|
|
icon: 'notifications-outline' as const,
|
|
title: '消息推送',
|
|
type: 'switch' as const,
|
|
switchValue: notificationEnabled,
|
|
onSwitchChange: handleNotificationToggle,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
title: '其他',
|
|
items: [
|
|
{
|
|
icon: 'shield-checkmark-outline' as const,
|
|
title: '隐私政策',
|
|
onPress: () => Linking.openURL(PRIVACY_POLICY_URL),
|
|
},
|
|
{
|
|
icon: 'document-text-outline' as const,
|
|
title: '用户协议',
|
|
onPress: () => Linking.openURL(USER_AGREEMENT_URL),
|
|
},
|
|
],
|
|
},
|
|
// 只有登录用户才显示账号与安全菜单
|
|
...(isLoggedIn ? [{
|
|
title: '账号与安全',
|
|
items: [
|
|
{
|
|
icon: 'log-out-outline' as const,
|
|
title: '退出登录',
|
|
onPress: confirmLogout,
|
|
isDanger: false,
|
|
},
|
|
{
|
|
icon: 'trash-outline' as const,
|
|
title: '注销帐号',
|
|
onPress: confirmDeleteAccount,
|
|
isDanger: true,
|
|
},
|
|
],
|
|
}] : []),
|
|
];
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
|
|
|
|
{/* 背景渐变 */}
|
|
<LinearGradient
|
|
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
|
style={styles.gradientBackground}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 0, y: 1 }}
|
|
/>
|
|
|
|
{/* 装饰性圆圈 */}
|
|
<View style={styles.decorativeCircle1} />
|
|
<View style={styles.decorativeCircle2} />
|
|
|
|
<ScrollView
|
|
style={styles.scrollView}
|
|
contentContainerStyle={{
|
|
paddingTop: insets.top,
|
|
paddingBottom: bottomPadding,
|
|
paddingHorizontal: 16,
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<UserHeader />
|
|
<StatsSection />
|
|
<View style={styles.fishRecordContainer}>
|
|
{/* <Image
|
|
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-profile-fish.png' }}
|
|
contentFit="cover"
|
|
style={{ width: 16, height: 16, marginLeft: 6 }}
|
|
transition={200}
|
|
cachePolicy="memory-disk"
|
|
/> */}
|
|
<Text style={styles.fishRecordText}>鱼干记录</Text>
|
|
</View>
|
|
<ActivityHeatMap />
|
|
{menuSections.map((section, index) => (
|
|
<MenuSection key={index} title={section.title} items={section.items} />
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
gradientBackground: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
},
|
|
decorativeCircle1: {
|
|
position: 'absolute',
|
|
top: 40,
|
|
right: 20,
|
|
width: 60,
|
|
height: 60,
|
|
borderRadius: 30,
|
|
backgroundColor: '#0EA5E9',
|
|
opacity: 0.1,
|
|
},
|
|
decorativeCircle2: {
|
|
position: 'absolute',
|
|
bottom: -15,
|
|
left: -15,
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
backgroundColor: '#0EA5E9',
|
|
opacity: 0.05,
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
},
|
|
// 部分容器
|
|
sectionContainer: {
|
|
marginBottom: 20,
|
|
},
|
|
sectionTitle: {
|
|
fontSize: 16,
|
|
fontWeight: 'bold',
|
|
color: '#2C3E50',
|
|
marginBottom: 10,
|
|
paddingHorizontal: 4,
|
|
},
|
|
// 卡片容器
|
|
cardContainer: {
|
|
backgroundColor: '#FFFFFF',
|
|
borderRadius: 12,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowOpacity: 0.05,
|
|
shadowRadius: 4,
|
|
elevation: 2,
|
|
overflow: 'hidden',
|
|
},
|
|
// 用户信息区域
|
|
userInfoContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
padding: 16,
|
|
},
|
|
avatarContainer: {
|
|
marginRight: 12,
|
|
},
|
|
avatar: {
|
|
width: 60,
|
|
height: 60,
|
|
borderRadius: 30,
|
|
borderWidth: 2,
|
|
borderColor: '#9370DB',
|
|
},
|
|
userDetails: {
|
|
flex: 1,
|
|
},
|
|
userName: {
|
|
fontSize: 18,
|
|
fontWeight: 'bold',
|
|
color: '#2C3E50',
|
|
marginBottom: 4,
|
|
},
|
|
userRole: {
|
|
fontSize: 14,
|
|
color: '#9370DB',
|
|
fontWeight: '500',
|
|
},
|
|
editButton: {
|
|
backgroundColor: '#9370DB',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 8,
|
|
borderRadius: 16,
|
|
},
|
|
editButtonText: {
|
|
color: '#FFFFFF',
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
},
|
|
// 数据统计
|
|
statsContainer: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
padding: 16,
|
|
},
|
|
statItem: {
|
|
alignItems: 'center',
|
|
flex: 1,
|
|
},
|
|
statValue: {
|
|
fontSize: 18,
|
|
fontWeight: 'bold',
|
|
color: '#9370DB',
|
|
marginBottom: 4,
|
|
},
|
|
statLabel: {
|
|
fontSize: 12,
|
|
color: '#6C757D',
|
|
fontWeight: '500',
|
|
},
|
|
// 菜单项
|
|
menuItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 16,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: '#F1F3F4',
|
|
},
|
|
menuItemLeft: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
flex: 1,
|
|
},
|
|
iconContainer: {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 6,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: 12,
|
|
},
|
|
menuItemText: {
|
|
fontSize: 15,
|
|
color: '#2C3E50',
|
|
fontWeight: '500',
|
|
},
|
|
switch: {
|
|
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
|
|
},
|
|
fishRecordContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'flex-start',
|
|
marginBottom: 10,
|
|
},
|
|
fishRecordText: {
|
|
fontSize: 16,
|
|
fontWeight: 'bold',
|
|
color: '#2C3E50',
|
|
marginLeft: 4,
|
|
},
|
|
});
|
|
|