Files
digital-pilates/app/training-plan.tsx
richarjiang 807e185761 feat: 更新应用版本和主题设置
- 将应用版本更新至 1.0.3,修改相关配置文件
- 强制全局使用浅色主题,确保一致的用户体验
- 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划
- 优化打卡功能,支持自动同步打卡记录至服务器
- 更新样式以适应新功能的展示和交互
2025-08-14 22:23:45 +08:00

543 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useEffect } from 'react';
import { Pressable, SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
import Animated, {
FadeInUp,
FadeOut,
interpolate,
Layout,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSpring,
withTiming
} 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 { activatePlan, clearError, deletePlan, loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice';
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() {
const rotate = useSharedValue(0);
const scale = useSharedValue(1);
React.useEffect(() => {
rotate.value = withRepeat(withTiming(360, { duration: 20000 }), -1);
scale.value = withRepeat(withTiming(1.2, { duration: 8000 }), -1, true);
}, []);
const backgroundStyle = useAnimatedStyle(() => ({
transform: [
{ rotate: `${rotate.value}deg` },
{ scale: scale.value }
],
}));
return (
<View style={StyleSheet.absoluteFillObject}>
<LinearGradient
colors={['#F9FBF2', '#FFFFFF', '#F5F9F0']}
style={StyleSheet.absoluteFillObject}
/>
<Animated.View style={[styles.backgroundOrb, backgroundStyle]} />
<Animated.View style={[styles.backgroundOrb2, backgroundStyle]} />
</View>
);
}
// 简洁的训练计划卡片
function PlanCard({ plan, onPress, onDelete, isActive, index }: { plan: TrainingPlan; onPress: () => void; onDelete: () => void; isActive?: boolean; index: number }) {
const scale = useSharedValue(1);
const glow = useSharedValue(0);
React.useEffect(() => {
glow.value = withRepeat(withTiming(1, { duration: 2000 + index * 100 }), -1, true);
}, [index]);
const goalConfig = GOAL_TEXT[plan.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' };
const cardStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const glowStyle = useAnimatedStyle(() => {
const opacity = isActive ? interpolate(glow.value, [0, 1], [0.3, 0.7]) : interpolate(glow.value, [0, 1], [0.15, 0.4]);
return {
shadowOpacity: opacity,
shadowColor: goalConfig.color,
shadowRadius: isActive ? 24 : 16,
elevation: isActive ? 16 : 12,
borderColor: `${goalConfig.color}${isActive ? '50' : '30'}`,
};
});
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return `${date.getMonth() + 1}${date.getDate()}`;
};
const getFrequencyText = () => {
if (plan.mode === 'daysOfWeek') {
return `每周${plan.daysOfWeek.length}`;
}
return `每周${plan.sessionsPerWeek}`;
};
return (
<Animated.View
entering={FadeInUp.delay(index * 100).duration(400)}
exiting={FadeOut}
layout={Layout.springify()}
style={[styles.planCard, cardStyle, glowStyle]}
>
<Pressable
onPress={onPress}
onLongPress={onDelete}
onPressIn={() => { scale.value = withSpring(0.98); }}
onPressOut={() => { scale.value = withSpring(1); }}
style={styles.cardContent}
>
<LinearGradient
colors={isActive ?
[`${goalConfig.color}50`, `${goalConfig.color}20`] :
['rgba(255,255,255,0.95)', 'rgba(255,255,255,0.85)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.cardGradient}
/>
{/* 左侧色彩指示器 */}
<View style={[styles.colorIndicator, { backgroundColor: goalConfig.color }]} />
{/* 主要内容 */}
<View style={styles.cardMain}>
<View style={styles.cardHeader}>
<View style={styles.titleSection}>
<ThemedText style={styles.planTitle}>{goalConfig.title}</ThemedText>
<ThemedText style={styles.planDescription}>{goalConfig.description}</ThemedText>
</View>
{isActive && (
<View style={[styles.activeBadge, { backgroundColor: goalConfig.color }]}>
<ThemedText style={styles.activeText}></ThemedText>
</View>
)}
</View>
<View style={styles.cardInfo}>
<View style={styles.infoItem}>
<ThemedText style={styles.infoLabel}></ThemedText>
<ThemedText style={styles.infoValue}>{formatDate(plan.startDate)}</ThemedText>
</View>
<View style={styles.infoItem}>
<ThemedText style={styles.infoLabel}></ThemedText>
<ThemedText style={styles.infoValue}>{getFrequencyText()}</ThemedText>
</View>
{plan.preferredTimeOfDay && (
<View style={styles.infoItem}>
<ThemedText style={styles.infoLabel}></ThemedText>
<ThemedText style={styles.infoValue}>
{plan.preferredTimeOfDay === 'morning' ? '晨练' :
plan.preferredTimeOfDay === 'noon' ? '午间' : '晚间'}
</ThemedText>
</View>
)}
</View>
</View>
</Pressable>
</Animated.View>
);
}
export default function TrainingPlanListScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
const { plans, currentId, loading, error } = useAppSelector((s) => s.trainingPlan);
useEffect(() => {
dispatch(loadPlans());
}, [dispatch]);
useEffect(() => {
if (error) {
// 可以在这里显示错误提示,比如使用 Alert 或 Toast
console.error('训练计划错误:', error);
// 3秒后自动清除错误
const timer = setTimeout(() => {
dispatch(clearError());
}, 3000);
return () => clearTimeout(timer);
}
}, [error, dispatch]);
const handleActivate = async (planId: string) => {
try {
await dispatch(activatePlan(planId));
} catch (error) {
console.error('激活训练计划失败:', error);
}
}
return (
<View style={styles.safeArea}>
{/* 动态背景 */}
<DynamicBackground />
<SafeAreaView style={styles.contentWrapper}>
<HeaderBar
title="训练计划"
onBack={() => router.back()}
withSafeTop={false}
tone='light'
transparent={true}
right={(
<Pressable onPress={() => router.push('/training-plan/create' as any)} style={styles.createBtn}>
<ThemedText style={styles.createBtnText}>+ </ThemedText>
</Pressable>
)}
/>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<Animated.View entering={FadeInUp.duration(600)} style={styles.headerSection}>
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={styles.subtitle}></ThemedText>
</Animated.View>
{error && (
<Animated.View entering={FadeInUp.duration(400)} style={styles.errorContainer}>
<ThemedText style={styles.errorText}> {error}</ThemedText>
</Animated.View>
)}
{loading && plans.length === 0 ? (
<Animated.View entering={FadeInUp.delay(200).duration(600)} style={styles.loadingWrap}>
<View style={styles.loadingIcon}>
<ThemedText style={styles.loadingIconText}></ThemedText>
</View>
<ThemedText style={styles.loadingText}>...</ThemedText>
</Animated.View>
) : plans.length === 0 ? (
<Animated.View entering={FadeInUp.delay(200).duration(600)} style={styles.emptyWrap}>
<View style={styles.emptyIcon}>
<ThemedText style={styles.emptyIconText}>📋</ThemedText>
</View>
<ThemedText style={styles.emptyText}></ThemedText>
<ThemedText style={styles.emptySubtext}></ThemedText>
<Pressable onPress={() => router.push('/training-plan/create' as any)} style={styles.primaryBtn}>
<ThemedText style={styles.primaryBtnText}></ThemedText>
</Pressable>
</Animated.View>
) : (
<View style={styles.plansList}>
{plans.map((p, index) => (
<PlanCard
key={p.id}
plan={p}
index={index}
isActive={p.id === currentId}
onPress={() => {
router.push(`/training-plan/create?id=${p.id}` as any);
}}
onDelete={() => dispatch(deletePlan(p.id))}
/>
))}
{loading && (
<Animated.View entering={FadeInUp.duration(400)} style={styles.loadingIndicator}>
<ThemedText style={styles.loadingIndicatorText}>...</ThemedText>
</Animated.View>
)}
</View>
)}
<View style={{ height: 40 }} />
</ScrollView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
contentWrapper: {
flex: 1,
},
content: {
paddingHorizontal: 20,
paddingTop: 8,
},
// 动态背景
backgroundOrb: {
position: 'absolute',
width: 300,
height: 300,
borderRadius: 150,
backgroundColor: 'rgba(187,242,70,0.15)',
top: -150,
right: -100,
},
backgroundOrb2: {
position: 'absolute',
width: 400,
height: 400,
borderRadius: 200,
backgroundColor: 'rgba(164,138,237,0.12)',
bottom: -200,
left: -150,
},
// 页面标题区域
headerSection: {
marginBottom: 20,
},
title: {
fontSize: 28,
fontWeight: '800',
color: '#192126',
lineHeight: 34,
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#5E6468',
opacity: 0.8,
},
// 训练计划列表
plansList: {
gap: 12,
},
// 训练计划卡片
planCard: {
borderRadius: 16,
overflow: 'hidden',
shadowOffset: { width: 0, height: 6 },
shadowRadius: 16,
elevation: 12,
borderWidth: 1.5,
borderColor: 'rgba(187,242,70,0.3)',
backgroundColor: '#FFFFFF',
shadowColor: '#BBF246',
},
cardContent: {
position: 'relative',
},
cardGradient: {
...StyleSheet.absoluteFillObject,
borderRadius: 16,
},
cardGlow: {
position: 'absolute',
top: -2,
left: -2,
right: -2,
bottom: -2,
borderRadius: 18,
backgroundColor: 'transparent',
},
colorIndicator: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
borderTopLeftRadius: 16,
borderBottomLeftRadius: 16,
},
cardMain: {
padding: 20,
paddingLeft: 24,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 16,
},
titleSection: {
flex: 1,
},
planTitle: {
fontSize: 18,
fontWeight: '800',
color: '#192126',
marginBottom: 4,
},
planDescription: {
fontSize: 13,
color: '#5E6468',
opacity: 0.8,
},
activeBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
marginLeft: 12,
},
activeText: {
fontSize: 11,
fontWeight: '800',
color: palette.ink,
},
cardInfo: {
flexDirection: 'row',
gap: 20,
},
infoItem: {
flex: 1,
},
infoLabel: {
fontSize: 11,
color: '#888F92',
marginBottom: 2,
fontWeight: '600',
},
infoValue: {
fontSize: 14,
color: '#384046',
fontWeight: '600',
},
// 按钮样式
primaryBtn: {
marginTop: 20,
backgroundColor: palette.primary,
paddingVertical: 14,
paddingHorizontal: 28,
borderRadius: 24,
alignItems: 'center',
shadowColor: palette.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
primaryBtnText: {
color: palette.ink,
fontSize: 15,
fontWeight: '800',
},
createBtn: {
backgroundColor: palette.primary,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 22,
shadowColor: palette.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 4,
minWidth: 44,
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
},
createBtnText: {
color: palette.ink,
fontWeight: '800',
fontSize: 14,
},
// 空状态
emptyWrap: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: 'rgba(187,242,70,0.1)',
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',
marginBottom: 20,
},
// 加载状态
loadingWrap: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
loadingIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: 'rgba(187,242,70,0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
loadingIconText: {
fontSize: 32,
},
loadingText: {
fontSize: 18,
color: '#192126',
fontWeight: '600',
marginBottom: 4,
},
loadingIndicator: {
alignItems: 'center',
paddingVertical: 20,
},
loadingIndicatorText: {
fontSize: 14,
color: '#5E6468',
fontWeight: '600',
},
// 错误状态
errorContainer: {
backgroundColor: 'rgba(237,71,71,0.1)',
borderRadius: 12,
padding: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: 'rgba(237,71,71,0.2)',
},
errorText: {
fontSize: 14,
color: '#ED4747',
fontWeight: '600',
textAlign: 'center',
},
});