feat: 添加 BMI 计算和训练计划排课功能
- 新增 BMI 计算工具,支持用户输入体重和身高计算 BMI 值,并根据结果提供分类和建议 - 在训练计划中集成排课功能,允许用户选择和安排训练动作 - 更新个人信息页面,添加出生日期字段,支持用户完善个人资料 - 优化训练计划卡片样式,提升用户体验 - 更新相关依赖,确保项目兼容性和功能完整性
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
import { BMICard } from '@/components/BMICard';
|
||||
import { CircularRing } from '@/components/CircularRing';
|
||||
import { ProgressBar } from '@/components/ProgressBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
@@ -27,6 +28,7 @@ export default function ExploreScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
// 使用 dayjs:当月日期与默认选中“今天”
|
||||
const days = getMonthDaysZh();
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
@@ -225,6 +227,12 @@ export default function ExploreScreen() {
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* BMI 指数卡片 */}
|
||||
<BMICard
|
||||
weight={userProfile?.weight}
|
||||
height={userProfile?.height}
|
||||
/>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
|
||||
@@ -10,106 +10,63 @@ import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import type { Href } from 'expo-router';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Alert, Image, SafeAreaView, 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/avatar/avatarGirl01.jpeg';
|
||||
|
||||
export default function PersonalScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
const insets = useSafeAreaInsets();
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
const colorScheme = useColorScheme();
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
||||
|
||||
// 计算底部间距
|
||||
const bottomPadding = useMemo(() => {
|
||||
// 统一的页面底部留白:TabBar 高度 + TabBar 与底部的额外间距 + 安全区底部
|
||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||
}, [tabBarHeight, insets?.bottom]);
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
// 颜色主题
|
||||
const colors = Colors[colorScheme ?? 'light'];
|
||||
const theme = (colorScheme ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg';
|
||||
|
||||
type UserProfile = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
gender?: 'male' | 'female' | '';
|
||||
age?: string;
|
||||
weightKg?: number;
|
||||
heightCm?: number;
|
||||
avatarUri?: string | null;
|
||||
};
|
||||
// 直接使用 Redux 中的用户信息,避免重复状态管理
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
|
||||
const userProfileFromRedux = useAppSelector((s) => s.user.profile);
|
||||
const [profile, setProfile] = useState<UserProfile>({});
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [p, o] = await Promise.all([
|
||||
AsyncStorage.getItem('@user_profile'),
|
||||
AsyncStorage.getItem('@user_personal_info'),
|
||||
]);
|
||||
let next: UserProfile = {};
|
||||
if (o) {
|
||||
try {
|
||||
const parsed = JSON.parse(o);
|
||||
next = {
|
||||
...next,
|
||||
age: parsed?.age ? String(parsed.age) : undefined,
|
||||
gender: parsed?.gender || '',
|
||||
heightCm: parsed?.height ? parseFloat(parsed.height) : undefined,
|
||||
weightKg: parsed?.weight ? parseFloat(parsed.weight) : undefined,
|
||||
};
|
||||
} catch { }
|
||||
}
|
||||
if (p) {
|
||||
try { next = { ...next, ...JSON.parse(p) }; } catch { }
|
||||
}
|
||||
setProfile(next);
|
||||
} catch (e) {
|
||||
console.warn('加载用户资料失败', e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useFocusEffect(React.useCallback(() => {
|
||||
// 聚焦时只拉后端,避免与本地 load 循环触发
|
||||
dispatch(fetchMyProfile());
|
||||
return () => { };
|
||||
}, [dispatch]));
|
||||
useEffect(() => {
|
||||
const r = userProfileFromRedux as any;
|
||||
if (!r) return;
|
||||
setProfile((prev) => {
|
||||
const next = { ...prev } as any;
|
||||
const nameNext = (r.name && String(r.name)) || prev.name;
|
||||
const genderNext = (r.gender === 'male' || r.gender === 'female') ? r.gender : (prev.gender ?? '');
|
||||
const avatarUriNext = typeof r.avatar === 'string' && (r.avatar.startsWith('http') || r.avatar.startsWith('data:'))
|
||||
? r.avatar
|
||||
: prev.avatarUri;
|
||||
let changed = false;
|
||||
if (next.name !== nameNext) { next.name = nameNext; changed = true; }
|
||||
if (next.gender !== genderNext) { next.gender = genderNext; changed = true; }
|
||||
if (next.avatarUri !== avatarUriNext) { next.avatarUri = avatarUriNext; changed = true; }
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [userProfileFromRedux]);
|
||||
// 页面聚焦时获取最新用户信息
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
dispatch(fetchMyProfile());
|
||||
}, [dispatch])
|
||||
);
|
||||
|
||||
// 数据格式化函数
|
||||
const formatHeight = () => {
|
||||
if (profile.heightCm == null) return '--';
|
||||
return `${Math.round(profile.heightCm)}cm`;
|
||||
if (userProfile.height == null) return '--';
|
||||
return `${Math.round(userProfile.height)}cm`;
|
||||
};
|
||||
|
||||
const formatWeight = () => {
|
||||
if (profile.weightKg == null) return '--';
|
||||
return `${round(profile.weightKg, 1)}kg`;
|
||||
if (userProfile.weight == null) return '--';
|
||||
return `${Math.round(userProfile.weight * 10) / 10}kg`;
|
||||
};
|
||||
|
||||
const formatAge = () => (profile.age ? `${profile.age}岁` : '--');
|
||||
|
||||
const round = (n: number, d = 0) => {
|
||||
const p = Math.pow(10, d); return Math.round(n * p) / p;
|
||||
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;
|
||||
|
||||
// 颜色令牌
|
||||
const colorTokens = colors;
|
||||
|
||||
const handleResetOnboarding = () => {
|
||||
Alert.alert(
|
||||
'重置引导',
|
||||
@@ -137,7 +94,6 @@ export default function PersonalScreen() {
|
||||
};
|
||||
|
||||
|
||||
const displayName = (profile.name && profile.name.trim()) ? profile.name : DEFAULT_MEMBER_NAME;
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
Alert.alert(
|
||||
@@ -172,7 +128,7 @@ export default function PersonalScreen() {
|
||||
{/* 头像 */}
|
||||
<View style={styles.avatarContainer}>
|
||||
<View style={[styles.avatar, { backgroundColor: colorTokens.ornamentAccent }]}>
|
||||
<Image source={{ uri: profile.avatarUri || DEFAULT_AVATAR_URL }} style={{ width: '100%', height: '100%' }} />
|
||||
<Image source={{ uri: userProfile.avatar || DEFAULT_AVATAR_URL }} style={{ width: '100%', height: '100%' }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -206,6 +162,7 @@ export default function PersonalScreen() {
|
||||
</View>
|
||||
);
|
||||
|
||||
// 菜单项组件
|
||||
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
|
||||
<View style={[styles.menuSection, { backgroundColor: colorTokens.card }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>{title}</Text>
|
||||
@@ -216,8 +173,15 @@ export default function PersonalScreen() {
|
||||
onPress={item.onPress}
|
||||
>
|
||||
<View style={styles.menuItemLeft}>
|
||||
<View style={[styles.menuIcon, { backgroundColor: 'rgba(187,242,70,0.12)' }]}>
|
||||
<Ionicons name={item.icon} size={20} color={'#192126'} />
|
||||
<View style={[
|
||||
styles.menuIcon,
|
||||
{ backgroundColor: item.isDanger ? 'rgba(255,68,68,0.12)' : 'rgba(187,242,70,0.12)' }
|
||||
]}>
|
||||
<Ionicons
|
||||
name={item.icon}
|
||||
size={20}
|
||||
color={item.isDanger ? colors.danger : colors.onPrimary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.menuItemText, { color: colorTokens.text }]}>{item.title}</Text>
|
||||
</View>
|
||||
@@ -237,8 +201,8 @@ export default function PersonalScreen() {
|
||||
</View>
|
||||
);
|
||||
|
||||
// 动态创建样式
|
||||
const dynamicStyles = {
|
||||
// 动态样式
|
||||
const dynamicStyles = StyleSheet.create({
|
||||
editButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: 20,
|
||||
@@ -246,13 +210,13 @@ export default function PersonalScreen() {
|
||||
borderRadius: 20,
|
||||
},
|
||||
editButtonText: {
|
||||
color: '#192126',
|
||||
color: colors.onPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
fontWeight: '600',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold' as const,
|
||||
fontWeight: 'bold',
|
||||
color: colors.primary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
@@ -261,83 +225,80 @@ export default function PersonalScreen() {
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const accountItems = [
|
||||
// 菜单项配置
|
||||
const menuSections = [
|
||||
{
|
||||
icon: 'flag-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: '目标管理',
|
||||
onPress: () => router.push('/profile/goals' as Href),
|
||||
title: '账户',
|
||||
items: [
|
||||
{
|
||||
icon: 'flag-outline' as const,
|
||||
title: '目标管理',
|
||||
onPress: () => router.push('/profile/goals' as Href),
|
||||
},
|
||||
{
|
||||
icon: 'stats-chart-outline' as const,
|
||||
title: '训练进度',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'stats-chart-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: '训练进度',
|
||||
},
|
||||
];
|
||||
|
||||
const notificationItems = [
|
||||
{
|
||||
icon: 'notifications-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: '消息推送',
|
||||
type: 'switch',
|
||||
},
|
||||
];
|
||||
|
||||
const otherItems = [
|
||||
{
|
||||
icon: 'mail-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: '联系我们',
|
||||
title: '通知',
|
||||
items: [
|
||||
{
|
||||
icon: 'notifications-outline' as const,
|
||||
title: '消息推送',
|
||||
type: 'switch' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'shield-checkmark-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: '隐私政策',
|
||||
title: '其他',
|
||||
items: [
|
||||
{
|
||||
icon: 'mail-outline' as const,
|
||||
title: '联系我们',
|
||||
},
|
||||
{
|
||||
icon: 'shield-checkmark-outline' as const,
|
||||
title: '隐私政策',
|
||||
},
|
||||
{
|
||||
icon: 'settings-outline' as const,
|
||||
title: '设置',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'settings-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: '设置',
|
||||
title: '账号与安全',
|
||||
items: [
|
||||
{
|
||||
icon: 'trash-outline' as const,
|
||||
title: '注销帐号',
|
||||
onPress: handleDeleteAccount,
|
||||
isDanger: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const securityItems = [
|
||||
{
|
||||
icon: 'trash-outline',
|
||||
iconBg: '#FFE8E8',
|
||||
iconColor: '#FF4444',
|
||||
title: '注销帐号',
|
||||
onPress: handleDeleteAccount,
|
||||
},
|
||||
];
|
||||
|
||||
const developerItems = [
|
||||
{
|
||||
icon: 'refresh-outline',
|
||||
iconBg: '#FFE8E8',
|
||||
iconColor: '#FF4444',
|
||||
title: '重置引导流程',
|
||||
onPress: handleResetOnboarding,
|
||||
title: '开发者',
|
||||
items: [
|
||||
{
|
||||
icon: 'refresh-outline' as const,
|
||||
title: '重置引导流程',
|
||||
onPress: handleResetOnboarding,
|
||||
isDanger: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -352,11 +313,9 @@ export default function PersonalScreen() {
|
||||
>
|
||||
<UserInfoSection />
|
||||
<StatsSection />
|
||||
<MenuSection title="账户" items={accountItems} />
|
||||
<MenuSection title="通知" items={notificationItems} />
|
||||
<MenuSection title="其他" items={otherItems} />
|
||||
<MenuSection title="账号与安全" items={securityItems} />
|
||||
<MenuSection title="开发者" items={developerItems} />
|
||||
{menuSections.map((section, index) => (
|
||||
<MenuSection key={index} title={section.title} items={section.items} />
|
||||
))}
|
||||
|
||||
{/* 底部浮动按钮 */}
|
||||
<View style={[styles.floatingButtonContainer, { bottom: Math.max(30, tabBarHeight / 2) + (insets?.bottom ?? 0) }]}>
|
||||
@@ -373,7 +332,6 @@ export default function PersonalScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5', // 浅灰色背景
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
@@ -381,18 +339,13 @@ const styles = StyleSheet.create({
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
// 用户信息区域
|
||||
userInfoCard: {
|
||||
borderRadius: 16,
|
||||
marginBottom: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
@@ -409,57 +362,26 @@ const styles = StyleSheet.create({
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#E8D4F0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
avatarContent: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarIcon: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarFace: {
|
||||
width: 25,
|
||||
height: 25,
|
||||
borderRadius: 12.5,
|
||||
backgroundColor: '#D4A574',
|
||||
marginBottom: 5,
|
||||
},
|
||||
avatarBody: {
|
||||
width: 30,
|
||||
height: 20,
|
||||
borderRadius: 15,
|
||||
backgroundColor: '#F4C842',
|
||||
},
|
||||
userDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
userName: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#192126',
|
||||
marginBottom: 4,
|
||||
},
|
||||
|
||||
// 统计信息区域
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
@@ -471,19 +393,15 @@ const styles = StyleSheet.create({
|
||||
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#687076',
|
||||
},
|
||||
// 菜单区域
|
||||
menuSection: {
|
||||
marginBottom: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
color: '#192126',
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
@@ -511,7 +429,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: 16,
|
||||
color: '#192126',
|
||||
flex: 1,
|
||||
},
|
||||
switch: {
|
||||
|
||||
Reference in New Issue
Block a user