feat: 添加训练计划和打卡功能

- 新增训练计划页面,允许用户制定个性化的训练计划
- 集成打卡功能,用户可以记录每日的训练情况
- 更新 Redux 状态管理,添加训练计划相关的 reducer
- 在首页中添加训练计划卡片,支持用户点击跳转
- 更新样式和布局,以适应新功能的展示和交互
- 添加日期选择器和相关依赖,支持用户选择训练日期
This commit is contained in:
richarjiang
2025-08-13 09:10:00 +08:00
parent e0e000b64f
commit f3e6250505
24 changed files with 1898 additions and 609 deletions

View File

@@ -1,4 +1,6 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { initChallenge } from '@/store/challengeSlice';
import { estimateSessionMinutesWithCustom } from '@/utils/pilatesPlan';
import { Ionicons } from '@expo/vector-icons';
@@ -9,6 +11,7 @@ import { Dimensions, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity,
export default function ChallengeHomeScreen() {
const dispatch = useAppDispatch();
const router = useRouter();
const { ensureLoggedIn } = useAuthGuard();
const challenge = useAppSelector((s) => (s as any).challenge);
useEffect(() => {
@@ -24,16 +27,8 @@ export default function ChallengeHomeScreen() {
return (
<SafeAreaView style={styles.safeArea}>
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.headerRow}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton} accessibilityRole="button">
<Ionicons name="chevron-back" size={24} color="#111827" />
</TouchableOpacity>
<Text style={styles.headerTitle}>30</Text>
<View style={{ width: 32 }} />
</View>
<Text style={styles.subtitle}> · </Text>
</View>
<HeaderBar title="30天普拉提打卡" onBack={() => router.back()} withSafeTop={false} transparent />
<Text style={styles.subtitle}> · </Text>
{/* 进度环与统计 */}
<View style={styles.summaryCard}>
@@ -45,7 +40,7 @@ export default function ChallengeHomeScreen() {
</View>
<View style={styles.summaryRight}>
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{challenge?.streak ?? 0}</Text> </Text>
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{(challenge?.days?.filter((d: any)=>d.status==='completed').length) ?? 0}</Text> / 30 </Text>
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{(challenge?.days?.filter((d: any) => d.status === 'completed').length) ?? 0}</Text> / 30 </Text>
</View>
</View>
@@ -64,7 +59,10 @@ export default function ChallengeHomeScreen() {
return (
<TouchableOpacity
disabled={isLocked}
onPress={() => router.push({ pathname: '/challenge/day', params: { day: String(plan.dayNumber) } })}
onPress={async () => {
if (!(await ensureLoggedIn({ redirectTo: '/challenge', redirectParams: {} }))) return;
router.push({ pathname: '/challenge/day', params: { day: String(plan.dayNumber) } });
}}
style={[styles.dayCell, isLocked && styles.dayCellLocked, isCompleted && styles.dayCellCompleted]}
activeOpacity={0.8}
>
@@ -79,7 +77,10 @@ export default function ChallengeHomeScreen() {
{/* 底部 CTA */}
<View style={styles.bottomBar}>
<TouchableOpacity style={styles.startButton} onPress={() => router.push({ pathname: '/challenge/day', params: { day: String((challenge?.days?.find((d:any)=>d.status==='available')?.plan.dayNumber) || 1) } })}>
<TouchableOpacity style={styles.startButton} onPress={async () => {
if (!(await ensureLoggedIn({ redirectTo: '/challenge' }))) return;
router.push({ pathname: '/challenge/day', params: { day: String((challenge?.days?.find((d: any) => d.status === 'available')?.plan.dayNumber) || 1) } });
}}>
<Text style={styles.startButtonText}></Text>
</TouchableOpacity>
</View>