Files
digital-pilates/app/challenge/index.tsx
richarjiang 5f05abc3d5 feat: 添加挑战页面和相关功能
- 在布局中新增挑战页面的导航
- 在首页中添加挑战计划卡片,支持用户点击跳转
- 更新登录页面的标题样式,调整字体粗细
- 集成 Redux 状态管理,新增挑战相关的 reducer
2025-08-12 22:54:23 +08:00

140 lines
6.5 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 { useAppDispatch, useAppSelector } from '@/hooks/redux';
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 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}>
<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>
{/* 进度环与统计 */}
<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={() => 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={() => 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 },
});