Files
digital-pilates/app/training-plan/schedule/index.tsx
richarjiang f95401c1ce feat: 添加 BMI 计算和训练计划排课功能
- 新增 BMI 计算工具,支持用户输入体重和身高计算 BMI 值,并根据结果提供分类和建议
- 在训练计划中集成排课功能,允许用户选择和安排训练动作
- 更新个人信息页面,添加出生日期字段,支持用户完善个人资料
- 优化训练计划卡片样式,提升用户体验
- 更新相关依赖,确保项目兼容性和功能完整性
2025-08-15 10:45:37 +08:00

737 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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