feat: 添加 BMI 计算和训练计划排课功能

- 新增 BMI 计算工具,支持用户输入体重和身高计算 BMI 值,并根据结果提供分类和建议
- 在训练计划中集成排课功能,允许用户选择和安排训练动作
- 更新个人信息页面,添加出生日期字段,支持用户完善个人资料
- 优化训练计划卡片样式,提升用户体验
- 更新相关依赖,确保项目兼容性和功能完整性
This commit is contained in:
richarjiang
2025-08-15 10:45:37 +08:00
parent 807e185761
commit f95401c1ce
14 changed files with 3309 additions and 374 deletions

View File

@@ -1,4 +1,5 @@
import { AnimatedNumber } from '@/components/AnimatedNumber'; import { AnimatedNumber } from '@/components/AnimatedNumber';
import { BMICard } from '@/components/BMICard';
import { CircularRing } from '@/components/CircularRing'; import { CircularRing } from '@/components/CircularRing';
import { ProgressBar } from '@/components/ProgressBar'; import { ProgressBar } from '@/components/ProgressBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
@@ -27,6 +28,7 @@ export default function ExploreScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000; const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile);
// 使用 dayjs当月日期与默认选中“今天” // 使用 dayjs当月日期与默认选中“今天”
const days = getMonthDaysZh(); const days = getMonthDaysZh();
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
@@ -225,6 +227,12 @@ export default function ExploreScreen() {
</View> </View>
</View> </View>
</View> </View>
{/* BMI 指数卡片 */}
<BMICard
weight={userProfile?.weight}
height={userProfile?.height}
/>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
</View> </View>

View File

@@ -10,106 +10,63 @@ import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import type { Href } from 'expo-router'; import type { Href } from 'expo-router';
import { router } 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 { Alert, Image, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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() { export default function PersonalScreen() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const tabBarHeight = useBottomTabBarHeight(); const tabBarHeight = useBottomTabBarHeight();
const colorScheme = useColorScheme();
const [notificationEnabled, setNotificationEnabled] = useState(true);
// 计算底部间距
const bottomPadding = useMemo(() => { const bottomPadding = useMemo(() => {
// 统一的页面底部留白TabBar 高度 + TabBar 与底部的额外间距 + 安全区底部
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0); return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]); }, [tabBarHeight, insets?.bottom]);
const [notificationEnabled, setNotificationEnabled] = useState(true);
const colorScheme = useColorScheme(); // 颜色主题
const colors = Colors[colorScheme ?? 'light']; const colors = Colors[colorScheme ?? 'light'];
const theme = (colorScheme ?? 'light') as 'light' | 'dark'; 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 = { // 直接使用 Redux 中的用户信息,避免重复状态管理
name?: string; const userProfile = useAppSelector((state) => state.user.profile);
email?: string;
gender?: 'male' | 'female' | '';
age?: string;
weightKg?: number;
heightCm?: number;
avatarUri?: string | null;
};
const userProfileFromRedux = useAppSelector((s) => s.user.profile); // 页面聚焦时获取最新用户信息
const [profile, setProfile] = useState<UserProfile>({}); useFocusEffect(
React.useCallback(() => {
const load = async () => { dispatch(fetchMyProfile());
try { }, [dispatch])
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]);
// 数据格式化函数
const formatHeight = () => { const formatHeight = () => {
if (profile.heightCm == null) return '--'; if (userProfile.height == null) return '--';
return `${Math.round(profile.heightCm)}cm`; return `${Math.round(userProfile.height)}cm`;
}; };
const formatWeight = () => { const formatWeight = () => {
if (profile.weightKg == null) return '--'; if (userProfile.weight == null) return '--';
return `${round(profile.weightKg, 1)}kg`; return `${Math.round(userProfile.weight * 10) / 10}kg`;
}; };
const formatAge = () => (profile.age ? `${profile.age}` : '--'); const formatAge = () => {
if (!userProfile.birthDate) return '--';
const round = (n: number, d = 0) => { const birthDate = new Date(userProfile.birthDate);
const p = Math.pow(10, d); return Math.round(n * p) / p; 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 = () => { const handleResetOnboarding = () => {
Alert.alert( Alert.alert(
'重置引导', '重置引导',
@@ -137,7 +94,6 @@ export default function PersonalScreen() {
}; };
const displayName = (profile.name && profile.name.trim()) ? profile.name : DEFAULT_MEMBER_NAME;
const handleDeleteAccount = () => { const handleDeleteAccount = () => {
Alert.alert( Alert.alert(
@@ -172,7 +128,7 @@ export default function PersonalScreen() {
{/* 头像 */} {/* 头像 */}
<View style={styles.avatarContainer}> <View style={styles.avatarContainer}>
<View style={[styles.avatar, { backgroundColor: colorTokens.ornamentAccent }]}> <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>
</View> </View>
@@ -206,6 +162,7 @@ export default function PersonalScreen() {
</View> </View>
); );
// 菜单项组件
const MenuSection = ({ title, items }: { title: string; items: any[] }) => ( const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
<View style={[styles.menuSection, { backgroundColor: colorTokens.card }]}> <View style={[styles.menuSection, { backgroundColor: colorTokens.card }]}>
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>{title}</Text> <Text style={[styles.sectionTitle, { color: colorTokens.text }]}>{title}</Text>
@@ -216,8 +173,15 @@ export default function PersonalScreen() {
onPress={item.onPress} onPress={item.onPress}
> >
<View style={styles.menuItemLeft}> <View style={styles.menuItemLeft}>
<View style={[styles.menuIcon, { backgroundColor: 'rgba(187,242,70,0.12)' }]}> <View style={[
<Ionicons name={item.icon} size={20} color={'#192126'} /> 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> </View>
<Text style={[styles.menuItemText, { color: colorTokens.text }]}>{item.title}</Text> <Text style={[styles.menuItemText, { color: colorTokens.text }]}>{item.title}</Text>
</View> </View>
@@ -237,8 +201,8 @@ export default function PersonalScreen() {
</View> </View>
); );
// 动态创建样式 // 动态样式
const dynamicStyles = { const dynamicStyles = StyleSheet.create({
editButton: { editButton: {
backgroundColor: colors.primary, backgroundColor: colors.primary,
paddingHorizontal: 20, paddingHorizontal: 20,
@@ -246,13 +210,13 @@ export default function PersonalScreen() {
borderRadius: 20, borderRadius: 20,
}, },
editButtonText: { editButtonText: {
color: '#192126', color: colors.onPrimary,
fontSize: 14, fontSize: 14,
fontWeight: '600' as const, fontWeight: '600',
}, },
statValue: { statValue: {
fontSize: 18, fontSize: 18,
fontWeight: 'bold' as const, fontWeight: 'bold',
color: colors.primary, color: colors.primary,
marginBottom: 4, marginBottom: 4,
}, },
@@ -261,83 +225,80 @@ export default function PersonalScreen() {
height: 56, height: 56,
borderRadius: 28, borderRadius: 28,
backgroundColor: colors.primary, backgroundColor: colors.primary,
alignItems: 'center' as const, alignItems: 'center',
justifyContent: 'center' as const, justifyContent: 'center',
shadowColor: colors.primary, shadowColor: colors.primary,
shadowOffset: { shadowOffset: { width: 0, height: 4 },
width: 0,
height: 4,
},
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 8, shadowRadius: 8,
elevation: 8, elevation: 8,
}, },
}; });
const accountItems = [ // 菜单项配置
const menuSections = [
{ {
icon: 'flag-outline', title: '账户',
iconBg: '#E8F5E8', items: [
iconColor: '#4ADE80', {
title: '目标管理', icon: 'flag-outline' as const,
onPress: () => router.push('/profile/goals' as Href), title: '目标管理',
onPress: () => router.push('/profile/goals' as Href),
},
{
icon: 'stats-chart-outline' as const,
title: '训练进度',
},
],
}, },
{ {
icon: 'stats-chart-outline', title: '通知',
iconBg: '#E8F5E8', items: [
iconColor: '#4ADE80', {
title: '训练进度', icon: 'notifications-outline' as const,
}, title: '消息推送',
]; type: 'switch' as const,
},
const notificationItems = [ ],
{
icon: 'notifications-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '消息推送',
type: 'switch',
},
];
const otherItems = [
{
icon: 'mail-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '联系我们',
}, },
{ {
icon: 'shield-checkmark-outline', title: '其他',
iconBg: '#E8F5E8', items: [
iconColor: '#4ADE80', {
title: '隐私政策', icon: 'mail-outline' as const,
title: '联系我们',
},
{
icon: 'shield-checkmark-outline' as const,
title: '隐私政策',
},
{
icon: 'settings-outline' as const,
title: '设置',
},
],
}, },
{ {
icon: 'settings-outline', title: '账号与安全',
iconBg: '#E8F5E8', items: [
iconColor: '#4ADE80', {
title: '设置', icon: 'trash-outline' as const,
title: '注销帐号',
onPress: handleDeleteAccount,
isDanger: true,
},
],
}, },
];
const securityItems = [
{ {
icon: 'trash-outline', title: '开发者',
iconBg: '#FFE8E8', items: [
iconColor: '#FF4444', {
title: '注销帐号', icon: 'refresh-outline' as const,
onPress: handleDeleteAccount, title: '重置引导流程',
}, onPress: handleResetOnboarding,
]; isDanger: true,
},
const developerItems = [ ],
{
icon: 'refresh-outline',
iconBg: '#FFE8E8',
iconColor: '#FF4444',
title: '重置引导流程',
onPress: handleResetOnboarding,
}, },
]; ];
@@ -352,11 +313,9 @@ export default function PersonalScreen() {
> >
<UserInfoSection /> <UserInfoSection />
<StatsSection /> <StatsSection />
<MenuSection title="账户" items={accountItems} /> {menuSections.map((section, index) => (
<MenuSection title="通知" items={notificationItems} /> <MenuSection key={index} title={section.title} items={section.items} />
<MenuSection title="其他" items={otherItems} /> ))}
<MenuSection title="账号与安全" items={securityItems} />
<MenuSection title="开发者" items={developerItems} />
{/* 底部浮动按钮 */} {/* 底部浮动按钮 */}
<View style={[styles.floatingButtonContainer, { bottom: Math.max(30, tabBarHeight / 2) + (insets?.bottom ?? 0) }]}> <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({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#F5F5F5', // 浅灰色背景
}, },
safeArea: { safeArea: {
flex: 1, flex: 1,
@@ -381,18 +339,13 @@ const styles = StyleSheet.create({
scrollView: { scrollView: {
flex: 1, flex: 1,
paddingHorizontal: 20, paddingHorizontal: 20,
backgroundColor: '#F5F5F5',
}, },
// 用户信息区域 // 用户信息区域
userInfoCard: { userInfoCard: {
borderRadius: 16, borderRadius: 16,
marginBottom: 20, marginBottom: 20,
backgroundColor: '#FFFFFF',
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { shadowOffset: { width: 0, height: 2 },
width: 0,
height: 2,
},
shadowOpacity: 0.08, shadowOpacity: 0.08,
shadowRadius: 6, shadowRadius: 6,
elevation: 3, elevation: 3,
@@ -409,57 +362,26 @@ const styles = StyleSheet.create({
width: 80, width: 80,
height: 80, height: 80,
borderRadius: 40, borderRadius: 40,
backgroundColor: '#E8D4F0',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
overflow: 'hidden', 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: { userDetails: {
flex: 1, flex: 1,
}, },
userName: { userName: {
fontSize: 18, fontSize: 18,
fontWeight: 'bold', fontWeight: 'bold',
color: '#192126',
marginBottom: 4, marginBottom: 4,
}, },
// 统计信息区域
statsContainer: { statsContainer: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
backgroundColor: '#FFFFFF',
borderRadius: 16, borderRadius: 16,
padding: 20, padding: 20,
marginBottom: 20, marginBottom: 20,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { shadowOffset: { width: 0, height: 2 },
width: 0,
height: 2,
},
shadowOpacity: 0.1, shadowOpacity: 0.1,
shadowRadius: 4, shadowRadius: 4,
elevation: 3, elevation: 3,
@@ -471,19 +393,15 @@ const styles = StyleSheet.create({
statLabel: { statLabel: {
fontSize: 12, fontSize: 12,
color: '#687076',
}, },
// 菜单区域
menuSection: { menuSection: {
marginBottom: 20, marginBottom: 20,
backgroundColor: '#FFFFFF',
padding: 16, padding: 16,
borderRadius: 16, borderRadius: 16,
}, },
sectionTitle: { sectionTitle: {
fontSize: 20, fontSize: 20,
fontWeight: '800', fontWeight: '800',
color: '#192126',
marginBottom: 12, marginBottom: 12,
paddingHorizontal: 4, paddingHorizontal: 4,
}, },
@@ -511,7 +429,6 @@ const styles = StyleSheet.create({
}, },
menuItemText: { menuItemText: {
fontSize: 16, fontSize: 16,
color: '#192126',
flex: 1, flex: 1,
}, },
switch: { switch: {

View File

@@ -3,7 +3,7 @@ import { useThemeColor } from '@/hooks/useThemeColor';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { ActivityIndicator, Text, View } from 'react-native'; import { ActivityIndicator, View } from 'react-native';
const ONBOARDING_COMPLETED_KEY = '@onboarding_completed'; const ONBOARDING_COMPLETED_KEY = '@onboarding_completed';
@@ -20,15 +20,12 @@ export default function SplashScreen() {
try { try {
const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY); const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
// 添加一个短暂的延迟以显示启动画面 if (onboardingCompleted === 'true') {
setTimeout(() => { router.replace('/(tabs)');
if (onboardingCompleted === 'true') { } else {
router.replace('/(tabs)'); router.replace('/onboarding');
} else { }
router.replace('/onboarding'); setIsLoading(false);
}
setIsLoading(false);
}, 1000);
} catch (error) { } catch (error) {
console.error('检查引导状态失败:', error); console.error('检查引导状态失败:', error);
// 如果出现错误,默认显示引导页面 // 如果出现错误,默认显示引导页面
@@ -59,11 +56,7 @@ export default function SplashScreen() {
alignItems: 'center', alignItems: 'center',
marginBottom: 20, marginBottom: 20,
}}> }}>
<Text style={{
fontSize: 32,
}}>
🧘
</Text>
</View> </View>
<ActivityIndicator size="large" color={primaryColor} /> <ActivityIndicator size="large" color={primaryColor} />
</ThemedView> </ThemedView>

View File

@@ -195,6 +195,7 @@ export default function EditProfileScreen() {
avatar: next.avatarUri || undefined, avatar: next.avatarUri || undefined,
weight: next.weight || undefined, weight: next.weight || undefined,
height: next.height || undefined, height: next.height || undefined,
birthDate: next.birthDate ? new Date(next.birthDate).getTime() / 1000 : undefined,
}); });
// 拉取最新用户信息,刷新全局状态 // 拉取最新用户信息,刷新全局状态
await dispatch(fetchMyProfile() as any); await dispatch(fetchMyProfile() as any);
@@ -363,13 +364,13 @@ export default function EditProfileScreen() {
<Text style={[styles.textInput, { color: profile.birthDate ? textColor : placeholderColor }]}> <Text style={[styles.textInput, { color: profile.birthDate ? textColor : placeholderColor }]}>
{profile.birthDate {profile.birthDate
? (() => { ? (() => {
try { try {
const d = new Date(profile.birthDate); const d = new Date(profile.birthDate);
return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }).format(d); return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }).format(d);
} catch { } catch {
return profile.birthDate; return profile.birthDate;
} }
})() })()
: '选择出生日期(可选)'} : '选择出生日期(可选)'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,737 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ThemedText } from '@/components/ThemedText';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { palette } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { buildClassicalSession } from '@/utils/classicalSession';
// 训练计划排课项目类型
export interface ScheduleExercise {
key: string;
name: string;
category: string;
sets: number;
reps?: number;
durationSec?: number;
restSec?: number;
note?: string;
itemType?: 'exercise' | 'rest' | 'note';
completed?: boolean;
}
// 训练计划排课数据
export interface PlanSchedule {
planId: string;
exercises: ScheduleExercise[];
note?: string;
lastModified: string;
}
const GOAL_TEXT: Record<string, { title: string; color: string; description: string }> = {
postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' },
fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' },
posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' },
core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' },
flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' },
rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' },
stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' },
};
// 动态背景组件
function DynamicBackground({ color }: { color: string }) {
return (
<View style={StyleSheet.absoluteFillObject}>
<LinearGradient
colors={['#F9FBF2', '#FFFFFF', '#F5F9F0']}
style={StyleSheet.absoluteFillObject}
/>
<View style={[styles.backgroundOrb, { backgroundColor: `${color}15` }]} />
<View style={[styles.backgroundOrb2, { backgroundColor: `${color}10` }]} />
</View>
);
}
export default function PlanScheduleScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
const params = useLocalSearchParams<{ planId?: string; newExercise?: string }>();
const { plans } = useAppSelector((s) => s.trainingPlan);
const planId = params.planId;
const plan = useMemo(() => plans.find(p => p.id === planId), [plans, planId]);
// 排课数据状态
const [exercises, setExercises] = useState<ScheduleExercise[]>([]);
const [scheduleNote, setScheduleNote] = useState('');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// 一键排课配置
const [genVisible, setGenVisible] = useState(false);
const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner');
const [genWithRests, setGenWithRests] = useState(true);
const [genWithNotes, setGenWithNotes] = useState(true);
const [genRest, setGenRest] = useState('30');
const goalConfig = plan ? (GOAL_TEXT[plan.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' }) : null;
useEffect(() => {
if (!plan) {
Alert.alert('错误', '找不到指定的训练计划', [
{ text: '确定', onPress: () => router.back() }
]);
return;
}
// TODO: 从存储中加载已有的排课数据
// loadPlanSchedule(planId);
}, [plan, planId]);
// 处理从选择页面传回的新动作
useEffect(() => {
if (params.newExercise) {
try {
const newExercise: ScheduleExercise = JSON.parse(params.newExercise);
setExercises(prev => [...prev, newExercise]);
setHasUnsavedChanges(true);
// 清除路由参数,避免重复添加
router.setParams({ newExercise: undefined } as any);
} catch (error) {
console.error('解析新动作数据失败:', error);
}
}
}, [params.newExercise]);
const handleSave = async () => {
if (!plan) return;
try {
// TODO: 保存排课数据到存储
const scheduleData: PlanSchedule = {
planId: plan.id,
exercises,
note: scheduleNote,
lastModified: new Date().toISOString(),
};
console.log('保存排课数据:', scheduleData);
setHasUnsavedChanges(false);
Alert.alert('保存成功', '训练计划排课已保存');
} catch (error) {
console.error('保存排课失败:', error);
Alert.alert('保存失败', '请稍后重试');
}
};
const handleAddExercise = () => {
router.push(`/training-plan/schedule/select?planId=${planId}` as any);
};
const handleRemoveExercise = (key: string) => {
Alert.alert('确认移除', '确定要移除该动作吗?', [
{ text: '取消', style: 'cancel' },
{
text: '移除',
style: 'destructive',
onPress: () => {
setExercises(prev => prev.filter(ex => ex.key !== key));
setHasUnsavedChanges(true);
},
},
]);
};
const handleToggleCompleted = (key: string) => {
setExercises(prev => prev.map(ex =>
ex.key === key ? { ...ex, completed: !ex.completed } : ex
));
setHasUnsavedChanges(true);
};
const onGenerate = () => {
const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10)));
const { items, note } = buildClassicalSession({
withSectionRests: genWithRests,
restSeconds: restSec,
withNotes: genWithNotes,
level: genLevel
});
// 转换为排课格式
const scheduleItems: ScheduleExercise[] = items.map((item, index) => ({
key: `generated_${Date.now()}_${index}`,
name: item.name,
category: item.category,
sets: item.sets,
reps: item.reps,
durationSec: item.durationSec,
restSec: item.restSec,
note: item.note,
itemType: item.itemType,
completed: false,
}));
setExercises(scheduleItems);
setScheduleNote(note || '');
setHasUnsavedChanges(true);
setGenVisible(false);
Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。');
};
if (!plan || !goalConfig) {
return (
<SafeAreaView style={styles.safeArea}>
<HeaderBar title="训练排课" onBack={() => router.back()} />
<View style={styles.errorContainer}>
<ThemedText style={styles.errorText}></ThemedText>
</View>
</SafeAreaView>
);
}
return (
<View style={styles.safeArea}>
{/* 动态背景 */}
<DynamicBackground color={goalConfig.color} />
<SafeAreaView style={styles.contentWrapper}>
<HeaderBar
title="训练排课"
onBack={() => router.back()}
withSafeTop={false}
tone='light'
transparent={true}
right={hasUnsavedChanges ? (
<TouchableOpacity onPress={handleSave} style={styles.saveBtn}>
<ThemedText style={styles.saveBtnText}></ThemedText>
</TouchableOpacity>
) : undefined}
/>
<View style={styles.content}>
{/* 计划信息头部 */}
<Animated.View entering={FadeInUp.duration(600)} style={[styles.planHeader, { backgroundColor: `${goalConfig.color}20` }]}>
<View style={[styles.planColorIndicator, { backgroundColor: goalConfig.color }]} />
<View style={styles.planInfo}>
<ThemedText style={styles.planTitle}>{goalConfig.title}</ThemedText>
<ThemedText style={styles.planDescription}>{goalConfig.description}</ThemedText>
</View>
</Animated.View>
{/* 操作按钮区域 */}
<View style={styles.actionRow}>
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: goalConfig.color }]}
onPress={handleAddExercise}
>
<Ionicons name="add" size={16} color="#FFFFFF" style={{ marginRight: 4 }} />
<Text style={styles.primaryBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: goalConfig.color }]}
onPress={() => setGenVisible(true)}
>
<Ionicons name="flash" size={16} color={goalConfig.color} style={{ marginRight: 4 }} />
<Text style={[styles.secondaryBtnText, { color: goalConfig.color }]}></Text>
</TouchableOpacity>
</View>
{/* 动作列表 */}
<FlatList
data={exercises}
keyExtractor={(item) => item.key}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<Animated.View entering={FadeInUp.delay(200).duration(600)} style={styles.emptyContainer}>
<View style={[styles.emptyIcon, { backgroundColor: `${goalConfig.color}20` }]}>
<ThemedText style={styles.emptyIconText}>💪</ThemedText>
</View>
<ThemedText style={styles.emptyText}></ThemedText>
<ThemedText style={styles.emptySubtext}>"添加动作"使"一键排课"</ThemedText>
</Animated.View>
}
renderItem={({ item, index }) => {
const isRest = item.itemType === 'rest';
const isNote = item.itemType === 'note';
if (isRest || isNote) {
return (
<Animated.View
entering={FadeInUp.delay(index * 50).duration(400)}
style={styles.inlineRow}
>
<Ionicons
name={isRest ? 'time-outline' : 'information-circle-outline'}
size={14}
color="#888F92"
/>
<View style={[
styles.inlineBadge,
isRest ? styles.inlineBadgeRest : styles.inlineBadgeNote
]}>
<Text style={[
isNote ? styles.inlineTextItalic : styles.inlineText,
{ color: '#888F92' }
]}>
{isRest ? `间隔休息 ${item.restSec ?? 30}s` : (item.note || '提示')}
</Text>
</View>
<TouchableOpacity
style={styles.inlineRemoveBtn}
onPress={() => handleRemoveExercise(item.key)}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Ionicons name="close-outline" size={16} color="#888F92" />
</TouchableOpacity>
</Animated.View>
);
}
return (
<Animated.View
entering={FadeInUp.delay(index * 50).duration(400)}
style={styles.exerciseCard}
>
<View style={styles.exerciseContent}>
<View style={styles.exerciseInfo}>
<ThemedText style={styles.exerciseName}>{item.name}</ThemedText>
<ThemedText style={styles.exerciseCategory}>{item.category}</ThemedText>
<ThemedText style={styles.exerciseMeta}>
{item.sets}
{item.reps ? ` · 每组 ${item.reps}` : ''}
{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''}
</ThemedText>
</View>
<View style={styles.exerciseActions}>
<TouchableOpacity
style={styles.completeBtn}
onPress={() => handleToggleCompleted(item.key)}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Ionicons
name={item.completed ? 'checkmark-circle' : 'checkmark-circle-outline'}
size={24}
color={item.completed ? goalConfig.color : '#888F92'}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.removeBtn}
onPress={() => handleRemoveExercise(item.key)}
>
<Text style={styles.removeBtnText}></Text>
</TouchableOpacity>
</View>
</View>
</Animated.View>
);
}}
/>
</View>
{/* 一键排课配置弹窗 */}
<Modal visible={genVisible} transparent animationType="fade" onRequestClose={() => setGenVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setGenVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalSheet} onPress={(e) => e.stopPropagation() as any}>
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalLabel}></Text>
<View style={styles.segmentedRow}>
{(['beginner', 'intermediate', 'advanced'] as const).map((lv) => (
<TouchableOpacity
key={lv}
style={[
styles.segment,
genLevel === lv && { backgroundColor: goalConfig.color }
]}
onPress={() => setGenLevel(lv)}
>
<Text style={[
styles.segmentText,
genLevel === lv && { color: '#FFFFFF' }
]}>
{lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'}
</Text>
</TouchableOpacity>
))}
</View>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}></Text>
<Switch value={genWithRests} onValueChange={setGenWithRests} />
</View>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}></Text>
<Switch value={genWithNotes} onValueChange={setGenWithNotes} />
</View>
<View style={styles.inputRow}>
<Text style={styles.inputLabel}></Text>
<TextInput
value={genRest}
onChangeText={setGenRest}
keyboardType="number-pad"
style={styles.input}
/>
</View>
<TouchableOpacity
style={[styles.generateBtn, { backgroundColor: goalConfig.color }]}
onPress={onGenerate}
>
<Text style={styles.generateBtnText}></Text>
</TouchableOpacity>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
contentWrapper: {
flex: 1,
},
content: {
flex: 1,
paddingHorizontal: 20,
},
// 动态背景
backgroundOrb: {
position: 'absolute',
width: 300,
height: 300,
borderRadius: 150,
top: -150,
right: -100,
},
backgroundOrb2: {
position: 'absolute',
width: 400,
height: 400,
borderRadius: 200,
bottom: -200,
left: -150,
},
// 计划信息头部
planHeader: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 16,
marginBottom: 16,
},
planColorIndicator: {
width: 4,
height: 40,
borderRadius: 2,
marginRight: 12,
},
planInfo: {
flex: 1,
},
planTitle: {
fontSize: 18,
fontWeight: '800',
color: '#192126',
marginBottom: 4,
},
planDescription: {
fontSize: 13,
color: '#5E6468',
opacity: 0.8,
},
// 操作按钮
actionRow: {
flexDirection: 'row',
gap: 12,
marginBottom: 20,
},
primaryBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 4,
},
primaryBtnText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '700',
},
secondaryBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1.5,
backgroundColor: '#FFFFFF',
},
secondaryBtnText: {
fontSize: 14,
fontWeight: '700',
},
// 保存按钮
saveBtn: {
backgroundColor: palette.primary,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
shadowColor: palette.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 4,
},
saveBtnText: {
color: palette.ink,
fontWeight: '800',
fontSize: 14,
},
// 列表
listContent: {
paddingBottom: 40,
},
// 空状态
emptyContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyIcon: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
emptyIconText: {
fontSize: 32,
},
emptyText: {
fontSize: 18,
color: '#192126',
fontWeight: '600',
marginBottom: 4,
},
emptySubtext: {
fontSize: 14,
color: '#5E6468',
textAlign: 'center',
lineHeight: 20,
},
// 动作卡片
exerciseCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 3,
},
exerciseContent: {
flexDirection: 'row',
alignItems: 'center',
},
exerciseInfo: {
flex: 1,
},
exerciseName: {
fontSize: 16,
fontWeight: '800',
color: '#192126',
marginBottom: 4,
},
exerciseCategory: {
fontSize: 12,
color: '#888F92',
marginBottom: 4,
},
exerciseMeta: {
fontSize: 12,
color: '#5E6468',
},
exerciseActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
completeBtn: {
padding: 4,
},
removeBtn: {
backgroundColor: '#F3F4F6',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 8,
},
removeBtnText: {
color: '#384046',
fontWeight: '700',
fontSize: 12,
},
// 内联项目(休息、提示)
inlineRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
inlineBadge: {
marginLeft: 6,
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 999,
paddingVertical: 6,
paddingHorizontal: 10,
flex: 1,
},
inlineBadgeRest: {
backgroundColor: '#F8FAFC',
},
inlineBadgeNote: {
backgroundColor: '#F9FAFB',
},
inlineText: {
fontSize: 12,
fontWeight: '700',
},
inlineTextItalic: {
fontSize: 12,
fontStyle: 'italic',
},
inlineRemoveBtn: {
marginLeft: 6,
padding: 4,
borderRadius: 999,
},
// 错误状态
errorContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
errorText: {
fontSize: 16,
color: '#ED4747',
fontWeight: '600',
},
// 弹窗样式
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
alignItems: 'center',
justifyContent: 'flex-end',
},
modalSheet: {
width: '100%',
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: 16,
paddingTop: 14,
paddingBottom: 24,
},
modalTitle: {
fontSize: 16,
fontWeight: '800',
marginBottom: 16,
color: '#192126',
},
modalLabel: {
fontSize: 12,
color: '#888F92',
marginBottom: 8,
fontWeight: '600',
},
segmentedRow: {
flexDirection: 'row',
gap: 8,
marginBottom: 16,
},
segment: {
flex: 1,
borderRadius: 999,
borderWidth: 1,
borderColor: '#E5E7EB',
paddingVertical: 8,
alignItems: 'center',
},
segmentText: {
fontWeight: '700',
color: '#384046',
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
switchLabel: {
fontWeight: '700',
color: '#384046',
},
inputRow: {
marginBottom: 20,
},
inputLabel: {
fontSize: 12,
color: '#888F92',
marginBottom: 8,
fontWeight: '600',
},
input: {
height: 40,
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 10,
paddingHorizontal: 12,
color: '#384046',
},
generateBtn: {
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
},
generateBtnText: {
color: '#FFFFFF',
fontWeight: '800',
fontSize: 14,
},
});

View File

@@ -0,0 +1,724 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { palette } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { fetchExerciseConfig, normalizeToLibraryItems } from '@/services/exercises';
import { EXERCISE_LIBRARY, getCategories } from '@/utils/exerciseLibrary';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Haptics from 'expo-haptics';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import type { ScheduleExercise } from './index';
const GOAL_TEXT: Record<string, { title: string; color: string; description: string }> = {
postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' },
fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' },
posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' },
core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' },
flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' },
rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' },
stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' },
};
// 动态背景组件
function DynamicBackground({ color }: { color: string }) {
return (
<View style={StyleSheet.absoluteFillObject}>
<LinearGradient
colors={['#F9FBF2', '#FFFFFF', '#F5F9F0']}
style={StyleSheet.absoluteFillObject}
/>
<View style={[styles.backgroundOrb, { backgroundColor: `${color}15` }]} />
<View style={[styles.backgroundOrb2, { backgroundColor: `${color}10` }]} />
</View>
);
}
export default function SelectExerciseForScheduleScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ planId?: string }>();
const { plans } = useAppSelector((s) => s.trainingPlan);
const planId = params.planId;
const plan = useMemo(() => plans.find(p => p.id === planId), [plans, planId]);
const goalConfig = plan ? (GOAL_TEXT[plan.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' }) : null;
const [keyword, setKeyword] = useState('');
const [category, setCategory] = useState<string>('全部');
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [sets, setSets] = useState(3);
const [reps, setReps] = useState<number | undefined>(undefined);
const [showCustomReps, setShowCustomReps] = useState(false);
const [customRepsInput, setCustomRepsInput] = useState('');
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const [serverLibrary, setServerLibrary] = useState<{ key: string; name: string; description: string; category: string }[] | null>(null);
const [serverCategories, setServerCategories] = useState<string[] | null>(null);
const controlsOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
}, []);
useEffect(() => {
let aborted = false;
const CACHE_KEY = '@exercise_config_v1';
(async () => {
try {
const cached = await AsyncStorage.getItem(CACHE_KEY);
if (cached && !aborted) {
const parsed = JSON.parse(cached);
const items = normalizeToLibraryItems(parsed);
if (items.length) {
setServerLibrary(items);
const cats = Array.from(new Set(items.map((i) => i.category)));
setServerCategories(cats);
}
}
} catch { }
try {
const resp = await fetchExerciseConfig();
console.log('fetchExerciseConfig', resp);
if (aborted) return;
const items = normalizeToLibraryItems(resp);
setServerLibrary(items);
const cats = Array.from(new Set(items.map((i) => i.category)));
setServerCategories(cats);
try { await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(resp)); } catch { }
} catch (err) { }
})();
return () => { aborted = true; };
}, []);
const categories = useMemo(() => {
const base = serverCategories ?? getCategories();
return ['全部', ...base];
}, [serverCategories]);
const mainCategories = useMemo(() => {
const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑'];
const exists = (name: string) => categories.includes(name);
const picked = preferred.filter(exists);
const rest = categories.filter((c) => !picked.includes(c));
while (picked.length < 5 && rest.length) picked.push(rest.shift() as string);
return picked;
}, [categories]);
const library = useMemo(() => serverLibrary ?? EXERCISE_LIBRARY, [serverLibrary]);
const filtered = useMemo(() => {
const kw = keyword.trim().toLowerCase();
const base = kw
? library.filter((e) => e.name.toLowerCase().includes(kw) || (e.description || '').toLowerCase().includes(kw))
: library;
if (category === '全部') return base;
return base.filter((e) => e.category === category);
}, [keyword, category, library]);
const selected = useMemo(() => library.find((e) => e.key === selectedKey) || null, [selectedKey, library]);
useEffect(() => {
Animated.timing(controlsOpacity, {
toValue: selected ? 1 : 0,
duration: selected ? 220 : 160,
useNativeDriver: true,
}).start();
}, [selected, controlsOpacity]);
const handleAdd = () => {
if (!selected || !plan) return;
const exerciseData: ScheduleExercise = {
key: `${selected.key}_${Date.now()}`,
name: selected.name,
category: selected.category,
sets: Math.max(1, sets),
reps: reps && reps > 0 ? reps : undefined,
itemType: 'exercise',
completed: false,
};
console.log('添加动作到排课:', exerciseData);
// 通过路由参数传递数据回到排课页面
router.push({
pathname: '/training-plan/schedule',
params: {
planId: planId,
newExercise: JSON.stringify(exerciseData)
}
} as any);
};
const onSelectItem = (key: string) => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
if (selectedKey === key) {
setSelectedKey(null);
return;
}
setSets(3);
setReps(undefined);
setShowCustomReps(false);
setCustomRepsInput('');
setSelectedKey(key);
};
if (!plan || !goalConfig) {
return (
<SafeAreaView style={styles.safeArea}>
<HeaderBar title="选择动作" onBack={() => router.back()} />
<View style={styles.errorContainer}>
<ThemedText style={styles.errorText}></ThemedText>
</View>
</SafeAreaView>
);
}
return (
<View style={styles.safeArea}>
{/* 动态背景 */}
<DynamicBackground color={goalConfig.color} />
<SafeAreaView style={styles.contentWrapper}>
<HeaderBar
title="选择动作"
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
tone="light"
/>
<View style={styles.content}>
{/* 计划信息头部 */}
<View style={[styles.planHeader, { backgroundColor: `${goalConfig.color}20` }]}>
<View style={[styles.planColorIndicator, { backgroundColor: goalConfig.color }]} />
<View style={styles.planInfo}>
<ThemedText style={styles.planTitle}>{goalConfig.title}</ThemedText>
<ThemedText style={styles.planDescription}></ThemedText>
</View>
</View>
{/* 大分类宫格 */}
<View style={styles.catGrid}>
{[...mainCategories, '更多'].map((item) => {
const active = category === item;
const meta: Record<string, { bg: string }> = {
: { bg: `${goalConfig.color}22` },
: { bg: `${goalConfig.color}18` },
: { bg: 'rgba(149,204,227,0.20)' },
: { bg: 'rgba(164,138,237,0.20)' },
: { bg: 'rgba(252,196,111,0.22)' },
: { bg: 'rgba(237,71,71,0.18)' },
: { bg: 'rgba(149,204,227,0.18)' },
: { bg: 'rgba(24,24,27,0.06)' },
};
const scale = new Animated.Value(1);
const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
const handlePress = () => {
onPressOut();
if (item === '更多') {
setShowCategoryPicker(true);
Haptics.selectionAsync();
} else {
setCategory(item);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
};
return (
<Animated.View key={item} style={[styles.catTileWrapper, { transform: [{ scale }] }]}>
<TouchableOpacity
activeOpacity={0.9}
onPressIn={onPressIn}
onPressOut={handlePress}
style={[
styles.catTile,
{ backgroundColor: meta[item]?.bg ?? 'rgba(24,24,27,0.06)' },
active && { borderWidth: 2, borderColor: goalConfig.color }
]}
>
<Text style={[
styles.catText,
{ color: active ? goalConfig.color : '#384046' },
active && { fontWeight: '800' }
]}>
{item}
</Text>
</TouchableOpacity>
</Animated.View>
);
})}
</View>
{/* 分类选择弹层 */}
<Modal
visible={showCategoryPicker}
animationType="fade"
transparent
onRequestClose={() => setShowCategoryPicker(false)}
>
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setShowCategoryPicker(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalSheet}
onPress={(e) => e.stopPropagation() as any}
>
<Text style={styles.modalTitle}></Text>
<View style={styles.catGridModal}>
{categories.filter((c) => c !== '全部').map((c) => {
const scale = new Animated.Value(1);
const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
return (
<Animated.View key={c} style={[styles.catTileWrapper, { transform: [{ scale }] }]}>
<TouchableOpacity
onPressIn={onPressIn}
onPressOut={() => {
onPressOut();
setCategory(c);
setShowCategoryPicker(false);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}}
activeOpacity={0.9}
style={[styles.catTile, { backgroundColor: 'rgba(24,24,27,0.06)' }]}
>
<Text style={styles.catText}>{c}</Text>
</TouchableOpacity>
</Animated.View>
);
})}
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
{/* 搜索框 */}
<View style={styles.searchRow}>
<TextInput
value={keyword}
onChangeText={setKeyword}
placeholder="搜索动作名称/要点"
placeholderTextColor="#888F92"
style={[styles.searchInput, { borderColor: `${goalConfig.color}30` }]}
/>
</View>
{/* 动作列表 */}
<FlatList
data={filtered}
keyExtractor={(item) => item.key}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => {
const isSelected = item.key === selectedKey;
return (
<TouchableOpacity
style={[
styles.itemCard,
isSelected && { borderWidth: 2, borderColor: goalConfig.color },
]}
onPress={() => onSelectItem(item.key)}
activeOpacity={0.9}
>
<View style={{ flex: 1 }}>
<Text style={styles.itemTitle}>{item.name}</Text>
<Text style={styles.itemMeta}>{item.category}</Text>
<Text style={styles.itemDesc}>{item.description}</Text>
</View>
{isSelected && <Ionicons name="chevron-down" size={20} color={goalConfig.color} />}
{isSelected && (
<Animated.View style={[styles.expandedBox, { opacity: controlsOpacity }]}>
<View style={styles.controlsRow}>
<View style={styles.counterBox}>
<Text style={styles.counterLabel}></Text>
<View style={styles.counterRow}>
<TouchableOpacity
style={styles.counterBtn}
onPress={() => setSets(Math.max(1, sets - 1))}
>
<Text style={styles.counterBtnText}>-</Text>
</TouchableOpacity>
<Text style={styles.counterValue}>{sets}</Text>
<TouchableOpacity
style={styles.counterBtn}
onPress={() => setSets(Math.min(20, sets + 1))}
>
<Text style={styles.counterBtnText}>+</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.counterBox}>
<Text style={styles.counterLabel}></Text>
<View style={styles.repsChipsRow}>
{[6, 8, 10, 12, 15, 20, 25, 30].map((v) => {
const active = reps === v;
return (
<TouchableOpacity
key={v}
style={[
styles.repChip,
active && { backgroundColor: goalConfig.color, borderColor: goalConfig.color }
]}
onPress={() => {
setReps(v);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}}
>
<Text style={[styles.repChipText, active && { color: '#FFFFFF' }]}>{v}</Text>
</TouchableOpacity>
);
})}
<TouchableOpacity
style={styles.repChipGhost}
onPress={() => {
setShowCustomReps((s) => !s);
Haptics.selectionAsync();
}}
>
<Text style={styles.repChipGhostText}></Text>
</TouchableOpacity>
</View>
{showCustomReps && (
<View style={styles.customRepsRow}>
<TextInput
keyboardType="number-pad"
value={customRepsInput}
onChangeText={setCustomRepsInput}
placeholder="输入次数 (1-100)"
placeholderTextColor="#888F92"
style={styles.customRepsInput}
/>
<TouchableOpacity
style={[styles.customRepsBtn, { backgroundColor: goalConfig.color }]}
onPress={() => {
const n = Math.max(1, Math.min(100, parseInt(customRepsInput || '0', 10)));
if (!Number.isNaN(n)) {
setReps(n);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}}
>
<Text style={styles.customRepsBtnText}></Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
<TouchableOpacity
style={[
styles.addBtn,
{ backgroundColor: goalConfig.color },
(!reps || reps <= 0) && { opacity: 0.5 }
]}
disabled={!reps || reps <= 0}
onPress={handleAdd}
>
<Text style={styles.addBtnText}></Text>
</TouchableOpacity>
</Animated.View>
)}
</TouchableOpacity>
);
}}
/>
</View>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
contentWrapper: {
flex: 1,
},
content: {
flex: 1,
paddingHorizontal: 20,
},
// 动态背景
backgroundOrb: {
position: 'absolute',
width: 300,
height: 300,
borderRadius: 150,
top: -150,
right: -100,
},
backgroundOrb2: {
position: 'absolute',
width: 400,
height: 400,
borderRadius: 200,
bottom: -200,
left: -150,
},
// 计划信息头部
planHeader: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 16,
marginBottom: 16,
},
planColorIndicator: {
width: 4,
height: 40,
borderRadius: 2,
marginRight: 12,
},
planInfo: {
flex: 1,
},
planTitle: {
fontSize: 18,
fontWeight: '800',
color: '#192126',
marginBottom: 4,
},
planDescription: {
fontSize: 13,
color: '#5E6468',
opacity: 0.8,
},
// 分类网格
catGrid: {
paddingTop: 10,
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 16,
},
catTileWrapper: {
width: '33.33%',
padding: 6,
},
catTile: {
borderRadius: 14,
paddingVertical: 16,
paddingHorizontal: 8,
alignItems: 'center',
justifyContent: 'center',
},
catText: {
fontSize: 13,
fontWeight: '700',
color: '#384046',
},
// 搜索框
searchRow: {
marginBottom: 16,
},
searchInput: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
color: '#384046',
borderWidth: 1,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
// 列表
listContent: {
paddingBottom: 40,
},
// 动作卡片
itemCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 3,
},
itemTitle: {
fontSize: 16,
fontWeight: '800',
color: '#192126',
marginBottom: 4,
},
itemMeta: {
fontSize: 12,
color: '#888F92',
marginBottom: 4,
},
itemDesc: {
fontSize: 12,
color: '#5E6468',
lineHeight: 16,
},
// 展开的控制区域
expandedBox: {
marginTop: 12,
},
controlsRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
flexWrap: 'wrap',
marginBottom: 16,
},
counterBox: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
padding: 12,
minWidth: 120,
},
counterLabel: {
fontSize: 10,
color: '#888F92',
marginBottom: 8,
fontWeight: '600',
},
counterRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
counterBtn: {
backgroundColor: '#E5E7EB',
width: 28,
height: 28,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
},
counterBtnText: {
fontWeight: '800',
color: '#384046',
},
counterValue: {
minWidth: 40,
textAlign: 'center',
fontWeight: '700',
color: '#384046',
},
repsChipsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 6,
},
repChip: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 999,
backgroundColor: '#F3F4F6',
borderWidth: 1,
borderColor: '#E5E7EB',
},
repChipText: {
color: '#384046',
fontWeight: '700',
fontSize: 12,
},
repChipGhost: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 999,
borderWidth: 1,
backgroundColor: 'transparent',
borderColor: '#E5E7EB',
},
repChipGhostText: {
fontWeight: '700',
color: '#384046',
fontSize: 12,
},
customRepsRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
marginTop: 8,
},
customRepsInput: {
flex: 1,
height: 40,
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 10,
paddingHorizontal: 12,
color: '#384046',
},
customRepsBtn: {
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
},
customRepsBtnText: {
fontWeight: '800',
color: '#FFFFFF',
fontSize: 12,
},
addBtn: {
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
},
addBtnText: {
color: '#FFFFFF',
fontWeight: '800',
fontSize: 14,
},
// 错误状态
errorContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
errorText: {
fontSize: 16,
color: '#ED4747',
fontWeight: '600',
},
// 弹窗样式
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
alignItems: 'center',
justifyContent: 'flex-end',
},
modalSheet: {
width: '100%',
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: 16,
paddingTop: 14,
paddingBottom: 24,
},
modalTitle: {
fontSize: 16,
fontWeight: '800',
marginBottom: 16,
color: '#192126',
},
catGridModal: {
flexDirection: 'row',
flexWrap: 'wrap',
},
});

439
components/BMICard.tsx Normal file
View File

@@ -0,0 +1,439 @@
import {
BMI_CATEGORIES,
canCalculateBMI,
getBMIResult,
type BMIResult
} from '@/utils/bmi';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import React, { useState } from 'react';
import {
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
interface BMICardProps {
weight?: number;
height?: number;
style?: any;
}
export function BMICard({ weight, height, style }: BMICardProps) {
const router = useRouter();
const [showInfoModal, setShowInfoModal] = useState(false);
const canCalculate = canCalculateBMI(weight, height);
let bmiResult: BMIResult | null = null;
if (canCalculate && weight && height) {
try {
bmiResult = getBMIResult(weight, height);
} catch (error) {
console.warn('BMI 计算错误:', error);
}
}
const handleGoToProfile = () => {
router.push('/profile/edit');
};
const handleShowInfoModal = () => {
setShowInfoModal(true);
};
const handleHideInfoModal = () => {
setShowInfoModal(false);
};
const renderContent = () => {
if (!canCalculate) {
// 缺少数据的情况
return (
<View style={styles.incompleteContent}>
<View style={styles.cardHeader}>
<View style={styles.titleRow}>
<View style={styles.iconSquare}>
<Ionicons name="fitness-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}>BMI </Text>
</View>
<TouchableOpacity
onPress={handleShowInfoModal}
style={styles.infoButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="information-circle-outline" size={20} color="#9AA3AE" />
</TouchableOpacity>
</View>
<View style={styles.missingDataContainer}>
<Ionicons name="alert-circle-outline" size={24} color="#F59E0B" />
<Text style={styles.missingDataText}>
{!weight && !height ? '请完善身高和体重信息' :
!weight ? '请完善体重信息' : '请完善身高信息'}
</Text>
</View>
<TouchableOpacity
onPress={handleGoToProfile}
style={styles.completeButton}
activeOpacity={0.8}
>
<Text style={styles.completeButtonText}></Text>
<Ionicons name="chevron-forward" size={16} color="#6B7280" />
</TouchableOpacity>
</View>
);
}
// 有完整数据的情况
return (
<View style={[styles.completeContent, { backgroundColor: bmiResult?.backgroundColor }]}>
<View style={styles.cardHeader}>
<View style={styles.titleRow}>
<View style={styles.iconSquare}>
<Ionicons name="fitness-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}>BMI </Text>
</View>
<TouchableOpacity
onPress={handleShowInfoModal}
style={styles.infoButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="information-circle-outline" size={20} color="#9AA3AE" />
</TouchableOpacity>
</View>
<View style={styles.bmiValueContainer}>
<Text style={[styles.bmiValue, { color: bmiResult?.color }]}>
{bmiResult?.value}
</Text>
<View style={[styles.categoryBadge, { backgroundColor: bmiResult?.color + '20' }]}>
<Text style={[styles.categoryText, { color: bmiResult?.color }]}>
{bmiResult?.category.name}
</Text>
</View>
</View>
<Text style={[styles.bmiDescription, { color: bmiResult?.color }]}>
{bmiResult?.description}
</Text>
<Text style={styles.encouragementText}>
{bmiResult?.category.encouragement}
</Text>
</View>
);
};
return (
<>
<View style={[styles.card, style]}>
{renderContent()}
</View>
{/* BMI 信息弹窗 */}
<Modal
visible={showInfoModal}
transparent
animationType="fade"
onRequestClose={handleHideInfoModal}
>
<Pressable
style={styles.modalBackdrop}
onPress={handleHideInfoModal}
>
<Pressable style={styles.modalContainer} onPress={(e) => e.stopPropagation()}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>BMI </Text>
<TouchableOpacity
onPress={handleHideInfoModal}
style={styles.closeButton}
activeOpacity={0.7}
>
<Ionicons name="close" size={20} color="#6B7280" />
</TouchableOpacity>
</View>
<ScrollView
style={styles.modalBody}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 20 }}
>
<Text style={styles.modalDescription}>
BMI(kg) ÷ ²(m)
</Text>
<Text style={styles.sectionTitle}>BMI </Text>
{BMI_CATEGORIES.map((category, index) => {
const colors = index === 0 ? { bg: '#FFF4E6', text: '#8B7355' } :
index === 1 ? { bg: '#E8F5E8', text: '#2D5016' } :
index === 2 ? { bg: '#FEF3C7', text: '#B45309' } :
{ bg: '#FEE2E2', text: '#B91C1C' };
return (
<View key={index} style={[styles.categoryItem, { backgroundColor: colors.bg }]}>
<View style={styles.categoryHeader}>
<Text style={[styles.categoryName, { color: colors.text }]}>
{category.name}
</Text>
<Text style={[styles.categoryRange, { color: colors.text }]}>
{category.range}
</Text>
</View>
<Text style={styles.categoryAdvice}>
{category.advice}
</Text>
</View>
);
})}
<Text style={styles.disclaimer}>
* BMI
</Text>
</ScrollView>
</View>
</Pressable>
</Pressable>
</Modal>
</>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: 22,
padding: 18,
marginBottom: 16,
overflow: 'hidden',
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
},
iconSquare: {
width: 30,
height: 30,
borderRadius: 8,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
cardTitle: {
fontSize: 18,
fontWeight: '800',
color: '#192126',
},
infoButton: {
padding: 4,
},
// 缺少数据时的样式
incompleteContent: {
minHeight: 120,
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 18,
margin: -18,
},
missingDataContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FEF3C7',
borderRadius: 12,
padding: 12,
marginBottom: 12,
},
missingDataText: {
fontSize: 14,
color: '#B45309',
fontWeight: '600',
marginLeft: 8,
flex: 1,
},
completeButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F3F4F6',
borderRadius: 12,
paddingVertical: 12,
paddingHorizontal: 16,
},
completeButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#6B7280',
marginRight: 4,
},
// 有完整数据时的样式
completeContent: {
minHeight: 120,
borderRadius: 22,
padding: 18,
margin: -18,
},
bmiValueContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
bmiValue: {
fontSize: 32,
fontWeight: '800',
marginRight: 12,
},
categoryBadge: {
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
},
categoryText: {
fontSize: 14,
fontWeight: '700',
},
bmiDescription: {
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
},
encouragementText: {
fontSize: 13,
color: '#6B7280',
fontWeight: '500',
lineHeight: 18,
fontStyle: 'italic',
},
// 弹窗样式
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContainer: {
width: '90%',
maxHeight: '85%',
backgroundColor: '#FFFFFF',
borderRadius: 24,
overflow: 'hidden',
elevation: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 20,
},
modalContent: {
maxHeight: '100%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 24,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
backgroundColor: '#FAFAFA',
},
modalTitle: {
fontSize: 22,
fontWeight: '800',
color: '#111827',
letterSpacing: -0.5,
},
closeButton: {
padding: 8,
borderRadius: 20,
backgroundColor: '#F3F4F6',
},
modalBody: {
paddingHorizontal: 24,
paddingVertical: 20,
},
modalDescription: {
fontSize: 16,
color: '#4B5563',
lineHeight: 26,
marginBottom: 28,
textAlign: 'center',
backgroundColor: '#F8FAFC',
padding: 16,
borderRadius: 12,
borderLeftWidth: 4,
borderLeftColor: '#3B82F6',
},
sectionTitle: {
fontSize: 20,
fontWeight: '800',
color: '#111827',
marginBottom: 16,
letterSpacing: -0.3,
},
categoryItem: {
borderRadius: 16,
padding: 20,
marginBottom: 16,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
categoryHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
categoryName: {
fontSize: 18,
fontWeight: '800',
letterSpacing: -0.2,
},
categoryRange: {
fontSize: 15,
fontWeight: '700',
backgroundColor: 'rgba(255,255,255,0.8)',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 20,
},
categoryAdvice: {
fontSize: 15,
color: '#374151',
lineHeight: 22,
fontWeight: '500',
},
disclaimer: {
fontSize: 13,
color: '#6B7280',
fontStyle: 'italic',
marginTop: 24,
textAlign: 'center',
backgroundColor: '#F9FAFB',
padding: 16,
borderRadius: 12,
lineHeight: 20,
},
});

View File

@@ -39,15 +39,15 @@ export function PlanCard({ image, title, subtitle, level, progress }: PlanCardPr
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { card: {
flexDirection: 'row', flexDirection: 'row',
backgroundColor: '#FFFFFF', backgroundColor: '#F0F0F0',
borderRadius: 28, borderRadius: 28,
padding: 20, padding: 20,
marginBottom: 18, marginBottom: 18,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.06, shadowOpacity: 0.25,
shadowRadius: 12, shadowRadius: 30,
elevation: 3, elevation: 10,
}, },
image: { image: {
width: 100, width: 100,

View File

@@ -1705,6 +1705,29 @@ PODS:
- React - React
- RNCAsyncStorage (2.2.0): - RNCAsyncStorage (2.2.0):
- React-Core - React-Core
- RNCMaskedView (0.3.2):
- DoubleConversion
- glog
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsc
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNDateTimePicker (8.4.4): - RNDateTimePicker (8.4.4):
- React-Core - React-Core
- RNGestureHandler (2.24.0): - RNGestureHandler (2.24.0):
@@ -1985,6 +2008,7 @@ DEPENDENCIES:
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- RNAppleHealthKit (from `../node_modules/react-native-health`) - RNAppleHealthKit (from `../node_modules/react-native-health`)
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)"
- "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)"
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNReanimated (from `../node_modules/react-native-reanimated`) - RNReanimated (from `../node_modules/react-native-reanimated`)
@@ -2193,6 +2217,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-health" :path: "../node_modules/react-native-health"
RNCAsyncStorage: RNCAsyncStorage:
:path: "../node_modules/@react-native-async-storage/async-storage" :path: "../node_modules/@react-native-async-storage/async-storage"
RNCMaskedView:
:path: "../node_modules/@react-native-masked-view/masked-view"
RNDateTimePicker: RNDateTimePicker:
:path: "../node_modules/@react-native-community/datetimepicker" :path: "../node_modules/@react-native-community/datetimepicker"
RNGestureHandler: RNGestureHandler:
@@ -2306,6 +2332,7 @@ SPEC CHECKSUMS:
ReactCommon: 7eb76fcd5133313d8c6a138a5c7dd89f80f189d5 ReactCommon: 7eb76fcd5133313d8c6a138a5c7dd89f80f189d5
RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9 RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
RNCMaskedView: d4644e239e65383f96d2f32c40c297f09705ac96
RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c
RNGestureHandler: 6e640921d207f070e4bbcf79f4e6d0eabf323389 RNGestureHandler: 6e640921d207f070e4bbcf79f4e6d0eabf323389
RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb

11
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/datetimepicker": "^8.4.4", "@react-native-community/datetimepicker": "^8.4.4",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/elements": "^2.3.8", "@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.6",
@@ -2893,6 +2894,16 @@
} }
} }
}, },
"node_modules/@react-native-masked-view/masked-view": {
"version": "0.3.2",
"resolved": "https://mirrors.tencent.com/npm/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz",
"integrity": "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16",
"react-native": ">=0.57"
}
},
"node_modules/@react-native/assets-registry": { "node_modules/@react-native/assets-registry": {
"version": "0.79.5", "version": "0.79.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.5.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.5.tgz",

View File

@@ -14,6 +14,7 @@
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/datetimepicker": "^8.4.4", "@react-native-community/datetimepicker": "^8.4.4",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/elements": "^2.3.8", "@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.6",
@@ -62,4 +63,4 @@
"typescript": "~5.8.3" "typescript": "~5.8.3"
}, },
"private": true "private": true
} }

View File

@@ -8,7 +8,7 @@ export type UserProfile = {
name?: string; name?: string;
email?: string; email?: string;
gender?: Gender; gender?: Gender;
age?: number; // 个人中心是字符串展示 birthDate?: string;
weight?: number; weight?: number;
height?: number; height?: number;
avatar?: string | null; avatar?: string | null;

154
utils/bmi.ts Normal file
View File

@@ -0,0 +1,154 @@
/**
* BMI 计算和分类工具函数
*/
export interface BMIResult {
value: number;
category: BMICategory;
color: string;
backgroundColor: string;
description: string;
}
export interface BMICategory {
name: string;
range: string;
description: string;
advice: string;
encouragement: string;
}
// BMI 分类标准(基于中国成人标准)
export const BMI_CATEGORIES: BMICategory[] = [
{
name: '偏瘦',
range: '< 18.5',
description: '体重偏轻',
advice: '建议适当增加营养摄入,进行力量训练增加肌肉量',
encouragement: '每一份营养都是对身体的投资,坚持下去你会更强壮!💪'
},
{
name: '正常',
range: '18.5 - 23.9',
description: '体重正常',
advice: '保持良好的饮食和运动习惯,继续维持健康体重',
encouragement: '太棒了!你的身体状态很健康,继续保持这份活力!✨'
},
{
name: '偏胖',
range: '24.0 - 27.9',
description: '体重偏重',
advice: '建议控制饮食,增加有氧运动,逐步减重',
encouragement: '改变从今天开始,每一次运动都让你更接近理想的自己!🌟'
},
{
name: '肥胖',
range: '≥ 28.0',
description: '肥胖',
advice: '建议咨询专业医生,制定科学的减重计划',
encouragement: '健康之路虽有挑战,但你有勇气迈出第一步就已经很了不起!🚀'
}
];
// BMI 颜色方案(越健康颜色越绿)
const BMI_COLORS = {
underweight: {
color: '#8B7355', // 棕色文字
backgroundColor: '#FFF4E6', // 浅橙色背景
},
normal: {
color: '#2D5016', // 深绿色文字
backgroundColor: '#E8F5E8', // 浅绿色背景
},
overweight: {
color: '#B45309', // 橙色文字
backgroundColor: '#FEF3C7', // 浅黄色背景
},
obese: {
color: '#B91C1C', // 红色文字
backgroundColor: '#FEE2E2', // 浅红色背景
}
};
/**
* 计算 BMI 值
* @param weight 体重kg
* @param height 身高cm
* @returns BMI 值,保留一位小数
*/
export function calculateBMI(weight: number, height: number): number {
if (weight <= 0 || height <= 0) {
throw new Error('体重和身高必须大于0');
}
// 身高转换为米
const heightInMeters = height / 100;
const bmi = weight / (heightInMeters * heightInMeters);
return Math.round(bmi * 10) / 10;
}
/**
* 根据 BMI 值获取分类
* @param bmi BMI 值
* @returns BMI 分类信息
*/
export function getBMICategory(bmi: number): BMICategory {
if (bmi < 18.5) {
return BMI_CATEGORIES[0]; // 偏瘦
} else if (bmi < 24.0) {
return BMI_CATEGORIES[1]; // 正常
} else if (bmi < 28.0) {
return BMI_CATEGORIES[2]; // 偏胖
} else {
return BMI_CATEGORIES[3]; // 肥胖
}
}
/**
* 根据 BMI 值获取颜色
* @param bmi BMI 值
* @returns 颜色配置
*/
export function getBMIColors(bmi: number): { color: string; backgroundColor: string } {
if (bmi < 18.5) {
return BMI_COLORS.underweight;
} else if (bmi < 24.0) {
return BMI_COLORS.normal;
} else if (bmi < 28.0) {
return BMI_COLORS.overweight;
} else {
return BMI_COLORS.obese;
}
}
/**
* 获取完整的 BMI 结果
* @param weight 体重kg
* @param height 身高cm
* @returns 完整的 BMI 分析结果
*/
export function getBMIResult(weight: number, height: number): BMIResult {
const bmi = calculateBMI(weight, height);
const category = getBMICategory(bmi);
const colors = getBMIColors(bmi);
return {
value: bmi,
category,
color: colors.color,
backgroundColor: colors.backgroundColor,
description: category.description
};
}
/**
* 检查是否有足够的数据计算 BMI
* @param weight 体重
* @param height 身高
* @returns 是否可以计算 BMI
*/
export function canCalculateBMI(weight?: number, height?: number): boolean {
return typeof weight === 'number' && weight > 0 &&
typeof height === 'number' && height > 0;
}