- 将应用版本更新至 1.0.3,修改相关配置文件 - 强制全局使用浅色主题,确保一致的用户体验 - 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划 - 优化打卡功能,支持自动同步打卡记录至服务器 - 更新样式以适应新功能的展示和交互
543 lines
15 KiB
TypeScript
543 lines
15 KiB
TypeScript
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',
|
||
},
|
||
});
|
||
|
||
|