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

@@ -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',
},
});