- 新增训练计划页面,允许用户制定个性化的训练计划 - 集成打卡功能,用户可以记录每日的训练情况 - 更新 Redux 状态管理,添加训练计划相关的 reducer - 在首页中添加训练计划卡片,支持用户点击跳转 - 更新样式和布局,以适应新功能的展示和交互 - 添加日期选择器和相关依赖,支持用户选择训练日期
141 lines
6.6 KiB
TypeScript
141 lines
6.6 KiB
TypeScript
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';
|
||
import { useRouter } from 'expo-router';
|
||
import React, { useEffect, useMemo } from 'react';
|
||
import { Dimensions, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||
|
||
export default function ChallengeHomeScreen() {
|
||
const dispatch = useAppDispatch();
|
||
const router = useRouter();
|
||
const { ensureLoggedIn } = useAuthGuard();
|
||
const challenge = useAppSelector((s) => (s as any).challenge);
|
||
|
||
useEffect(() => {
|
||
dispatch(initChallenge());
|
||
}, [dispatch]);
|
||
|
||
const progress = useMemo(() => {
|
||
const total = challenge?.days?.length || 30;
|
||
const done = challenge?.days?.filter((d: any) => d.status === 'completed').length || 0;
|
||
return total ? done / total : 0;
|
||
}, [challenge?.days]);
|
||
|
||
return (
|
||
<SafeAreaView style={styles.safeArea}>
|
||
<View style={styles.container}>
|
||
<HeaderBar title="30天普拉提打卡" onBack={() => router.back()} withSafeTop={false} transparent />
|
||
<Text style={styles.subtitle}>专注核心、体态与柔韧 · 连续完成解锁徽章</Text>
|
||
|
||
{/* 进度环与统计 */}
|
||
<View style={styles.summaryCard}>
|
||
<View style={styles.summaryLeft}>
|
||
<View style={styles.progressPill}>
|
||
<View style={[styles.progressFill, { width: `${Math.round((progress || 0) * 100)}%` }]} />
|
||
</View>
|
||
<Text style={styles.progressText}>{Math.round((progress || 0) * 100)}%</Text>
|
||
</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>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 日历格子(简单 6x5 网格) */}
|
||
<FlatList
|
||
data={challenge?.days || []}
|
||
keyExtractor={(item) => String(item.plan.dayNumber)}
|
||
numColumns={5}
|
||
columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 12 }}
|
||
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 10, paddingBottom: 40 }}
|
||
renderItem={({ item }) => {
|
||
const { plan, status } = item;
|
||
const isLocked = status === 'locked';
|
||
const isCompleted = status === 'completed';
|
||
const minutes = estimateSessionMinutesWithCustom(plan, item.custom);
|
||
return (
|
||
<TouchableOpacity
|
||
disabled={isLocked}
|
||
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}
|
||
>
|
||
<Text style={[styles.dayNumber, isLocked && styles.dayNumberLocked]}>{plan.dayNumber}</Text>
|
||
<Text style={styles.dayMinutes}>{minutes}′</Text>
|
||
{isCompleted && <Ionicons name="checkmark-circle" size={18} color="#10B981" style={{ position: 'absolute', top: 6, right: 6 }} />}
|
||
{isLocked && <Ionicons name="lock-closed" size={16} color="#9CA3AF" style={{ position: 'absolute', top: 6, right: 6 }} />}
|
||
</TouchableOpacity>
|
||
);
|
||
}}
|
||
/>
|
||
|
||
{/* 底部 CTA */}
|
||
<View style={styles.bottomBar}>
|
||
<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>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const { width } = Dimensions.get('window');
|
||
const cellSize = (width - 40 - 4 * 12) / 5; // 20 padding *2, 12 spacing *4
|
||
|
||
const styles = StyleSheet.create({
|
||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
||
header: { paddingHorizontal: 20, paddingTop: 10 },
|
||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
||
headerTitle: { fontSize: 22, fontWeight: '800', color: '#1A1A1A' },
|
||
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
||
summaryCard: {
|
||
marginTop: 16,
|
||
marginHorizontal: 20,
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: 16,
|
||
padding: 16,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
||
},
|
||
summaryLeft: { flexDirection: 'row', alignItems: 'center' },
|
||
progressPill: { width: 120, height: 10, borderRadius: 999, backgroundColor: '#E5E7EB', overflow: 'hidden' },
|
||
progressFill: { height: '100%', backgroundColor: '#BBF246' },
|
||
progressText: { marginLeft: 12, fontWeight: '700', color: '#111827' },
|
||
summaryRight: {},
|
||
summaryItem: { fontSize: 12, color: '#6B7280' },
|
||
summaryItemValue: { fontWeight: '800', color: '#111827' },
|
||
dayCell: {
|
||
width: cellSize,
|
||
height: cellSize,
|
||
borderRadius: 16,
|
||
backgroundColor: '#FFFFFF',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
||
},
|
||
dayCellLocked: { backgroundColor: '#F3F4F6' },
|
||
dayCellCompleted: { backgroundColor: '#ECFDF5', borderWidth: 1, borderColor: '#A7F3D0' },
|
||
dayNumber: { fontWeight: '800', color: '#111827', fontSize: 16 },
|
||
dayNumberLocked: { color: '#9CA3AF' },
|
||
dayMinutes: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
||
bottomBar: { padding: 20 },
|
||
startButton: { backgroundColor: '#BBF246', paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
|
||
startButtonText: { color: '#192126', fontWeight: '800', fontSize: 16 },
|
||
});
|
||
|
||
|