feat: 添加挑战页面和相关功能
- 在布局中新增挑战页面的导航 - 在首页中添加挑战计划卡片,支持用户点击跳转 - 更新登录页面的标题样式,调整字体粗细 - 集成 Redux 状态管理,新增挑战相关的 reducer
This commit is contained in:
@@ -75,6 +75,7 @@ export default function HomeScreen() {
|
|||||||
level="初学者"
|
level="初学者"
|
||||||
progress={0}
|
progress={0}
|
||||||
/>
|
/>
|
||||||
|
<Pressable onPress={() => router.push('/challenge')}>
|
||||||
<PlanCard
|
<PlanCard
|
||||||
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
|
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
|
||||||
title="30日训练打卡"
|
title="30日训练打卡"
|
||||||
@@ -82,6 +83,7 @@ export default function HomeScreen() {
|
|||||||
level="初学者"
|
level="初学者"
|
||||||
progress={0.75}
|
progress={0.75}
|
||||||
/>
|
/>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default function RootLayout() {
|
|||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="onboarding" />
|
<Stack.Screen name="onboarding" />
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
|
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="profile/edit" />
|
<Stack.Screen name="profile/edit" />
|
||||||
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="ai-posture-assessment" />
|
<Stack.Screen name="ai-posture-assessment" />
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: '800',
|
fontWeight: '500',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
|
|||||||
13
app/challenge/_layout.tsx
Normal file
13
app/challenge/_layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function ChallengeLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen name="day" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
182
app/challenge/day.tsx
Normal file
182
app/challenge/day.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { completeDay, setCustom } from '@/store/challengeSlice';
|
||||||
|
import type { Exercise, ExerciseCustomConfig } from '@/utils/pilatesPlan';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
export default function ChallengeDayScreen() {
|
||||||
|
const { day } = useLocalSearchParams<{ day: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const challenge = useAppSelector((s) => (s as any).challenge);
|
||||||
|
const dayNumber = Math.max(1, Math.min(30, parseInt(String(day || '1'), 10)));
|
||||||
|
const dayState = challenge?.days?.[dayNumber - 1];
|
||||||
|
const [currentSetIndexByExercise, setCurrentSetIndexByExercise] = useState<Record<string, number>>({});
|
||||||
|
const [custom, setCustomLocal] = useState<ExerciseCustomConfig[]>(dayState?.custom || []);
|
||||||
|
|
||||||
|
const isLocked = dayState?.status === 'locked';
|
||||||
|
const isCompleted = dayState?.status === 'completed';
|
||||||
|
const plan = dayState?.plan;
|
||||||
|
|
||||||
|
// 不再强制所有动作完成,始终允许完成
|
||||||
|
const canFinish = true;
|
||||||
|
|
||||||
|
const handleNextSet = (ex: Exercise) => {
|
||||||
|
const curr = currentSetIndexByExercise[ex.key] ?? 0;
|
||||||
|
if (curr < ex.sets.length) {
|
||||||
|
setCurrentSetIndexByExercise((prev) => ({ ...prev, [ex.key]: curr + 1 }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
// 持久化自定义配置
|
||||||
|
await dispatch(setCustom({ dayNumber, custom: custom }));
|
||||||
|
await dispatch(completeDay(dayNumber));
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCustom = (key: string, partial: Partial<ExerciseCustomConfig>) => {
|
||||||
|
setCustomLocal((prev) => {
|
||||||
|
const next = prev.map((c) => (c.key === key ? { ...c, ...partial } : c));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<View style={styles.container}><Text>加载中...</Text></View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}>第{plan.dayNumber}天</Text>
|
||||||
|
<View style={{ width: 32 }} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>{plan.title}</Text>
|
||||||
|
<Text style={styles.subtitle}>{plan.focus}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={plan.exercises}
|
||||||
|
keyExtractor={(item) => item.key}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 120 }}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const doneSets = currentSetIndexByExercise[item.key] ?? 0;
|
||||||
|
const conf = custom.find((c) => c.key === item.key);
|
||||||
|
const targetSets = conf?.sets ?? item.sets.length;
|
||||||
|
const perSetDuration = conf?.durationSec ?? item.sets[0]?.durationSec ?? 40;
|
||||||
|
return (
|
||||||
|
<View style={styles.exerciseCard}>
|
||||||
|
<View style={styles.exerciseHeader}>
|
||||||
|
<Text style={styles.exerciseName}>{item.name}</Text>
|
||||||
|
<Text style={styles.exerciseDesc}>{item.description}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.controlsRow}>
|
||||||
|
<TouchableOpacity style={[styles.toggleBtn, conf?.enabled === false && styles.toggleBtnOff]} onPress={() => updateCustom(item.key, { enabled: !(conf?.enabled ?? true) })}>
|
||||||
|
<Text style={styles.toggleBtnText}>{conf?.enabled === false ? '已关闭' : '已启用'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={styles.counterBox}>
|
||||||
|
<Text style={styles.counterLabel}>组数</Text>
|
||||||
|
<View style={styles.counterRow}>
|
||||||
|
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { sets: Math.max(1, (conf?.sets ?? targetSets) - 1) })}><Text style={styles.counterBtnText}>-</Text></TouchableOpacity>
|
||||||
|
<Text style={styles.counterValue}>{conf?.sets ?? targetSets}</Text>
|
||||||
|
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { sets: Math.min(10, (conf?.sets ?? targetSets) + 1) })}><Text style={styles.counterBtnText}>+</Text></TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.counterBox}>
|
||||||
|
<Text style={styles.counterLabel}>时长/组</Text>
|
||||||
|
<View style={styles.counterRow}>
|
||||||
|
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { durationSec: Math.max(10, (conf?.durationSec ?? perSetDuration) - 5) })}><Text style={styles.counterBtnText}>-</Text></TouchableOpacity>
|
||||||
|
<Text style={styles.counterValue}>{conf?.durationSec ?? perSetDuration}s</Text>
|
||||||
|
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { durationSec: Math.min(180, (conf?.durationSec ?? perSetDuration) + 5) })}><Text style={styles.counterBtnText}>+</Text></TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.setsRow}>
|
||||||
|
{Array.from({ length: targetSets }).map((_, idx) => (
|
||||||
|
<View key={idx} style={[styles.setPill, idx < doneSets ? styles.setPillDone : styles.setPillTodo]}>
|
||||||
|
<Text style={[styles.setPillText, idx < doneSets ? styles.setPillTextDone : styles.setPillTextTodo]}>
|
||||||
|
{perSetDuration}s
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.nextSetBtn} onPress={() => handleNextSet(item)} disabled={doneSets >= targetSets || conf?.enabled === false}>
|
||||||
|
<Text style={styles.nextSetText}>{doneSets >= item.sets.length ? '本动作完成' : '完成一组'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{item.tips && (
|
||||||
|
<View style={styles.tipsBox}>
|
||||||
|
{item.tips.map((t: string, i: number) => (
|
||||||
|
<Text key={i} style={styles.tipText}>• {t}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.bottomBar}>
|
||||||
|
<TouchableOpacity style={[styles.finishBtn, !canFinish && { opacity: 0.5 }]} disabled={!canFinish || isLocked || isCompleted} onPress={handleComplete}>
|
||||||
|
<Text style={styles.finishBtnText}>{isCompleted ? '已完成' : '完成今日训练'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||||
|
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||||
|
header: { paddingHorizontal: 20, paddingTop: 10, paddingBottom: 10 },
|
||||||
|
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
|
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
||||||
|
headerTitle: { fontSize: 18, fontWeight: '800', color: '#1A1A1A' },
|
||||||
|
title: { marginTop: 6, fontSize: 20, fontWeight: '800', color: '#1A1A1A' },
|
||||||
|
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
||||||
|
exerciseCard: {
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginTop: 12,
|
||||||
|
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
||||||
|
},
|
||||||
|
exerciseHeader: { marginBottom: 8 },
|
||||||
|
exerciseName: { fontSize: 16, fontWeight: '800', color: '#111827' },
|
||||||
|
exerciseDesc: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
||||||
|
setsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 },
|
||||||
|
controlsRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginTop: 8 },
|
||||||
|
toggleBtn: { backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
|
||||||
|
toggleBtnOff: { backgroundColor: '#9CA3AF' },
|
||||||
|
toggleBtnText: { color: '#FFFFFF', fontWeight: '700' },
|
||||||
|
counterBox: { backgroundColor: '#F3F4F6', borderRadius: 8, padding: 8 },
|
||||||
|
counterLabel: { fontSize: 10, color: '#6B7280' },
|
||||||
|
counterRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
|
counterBtn: { backgroundColor: '#E5E7EB', width: 28, height: 28, borderRadius: 6, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
counterBtnText: { fontWeight: '800', color: '#111827' },
|
||||||
|
counterValue: { minWidth: 40, textAlign: 'center', fontWeight: '700', color: '#111827' },
|
||||||
|
setPill: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 999 },
|
||||||
|
setPillTodo: { backgroundColor: '#F3F4F6' },
|
||||||
|
setPillDone: { backgroundColor: '#BBF246' },
|
||||||
|
setPillText: { fontSize: 12, fontWeight: '700' },
|
||||||
|
setPillTextTodo: { color: '#6B7280' },
|
||||||
|
setPillTextDone: { color: '#192126' },
|
||||||
|
nextSetBtn: { marginTop: 10, alignSelf: 'flex-start', backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
|
||||||
|
nextSetText: { color: '#FFFFFF', fontWeight: '700' },
|
||||||
|
tipsBox: { marginTop: 10, backgroundColor: '#F9FAFB', borderRadius: 8, padding: 10 },
|
||||||
|
tipText: { fontSize: 12, color: '#6B7280', lineHeight: 18 },
|
||||||
|
bottomBar: { position: 'absolute', left: 0, right: 0, bottom: 0, padding: 20, backgroundColor: 'transparent' },
|
||||||
|
finishBtn: { backgroundColor: '#BBF246', paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
|
||||||
|
finishBtnText: { color: '#192126', fontWeight: '800', fontSize: 16 },
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
139
app/challenge/index.tsx
Normal file
139
app/challenge/index.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
129
store/challengeSlice.ts
Normal file
129
store/challengeSlice.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { buildDefaultCustomFromPlan, DayPlan, ExerciseCustomConfig, generatePilates30DayPlan, PilatesLevel } from '@/utils/pilatesPlan';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export type DayStatus = 'locked' | 'available' | 'completed';
|
||||||
|
|
||||||
|
export type ChallengeDayState = {
|
||||||
|
plan: DayPlan;
|
||||||
|
status: DayStatus;
|
||||||
|
completedAt?: string | null; // ISO
|
||||||
|
notes?: string;
|
||||||
|
custom?: ExerciseCustomConfig[]; // 用户自定义:启用/禁用、组数、时长
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChallengeState = {
|
||||||
|
startedAt?: string | null;
|
||||||
|
level: PilatesLevel;
|
||||||
|
days: ChallengeDayState[]; // 1..30
|
||||||
|
streak: number; // 连续天数
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = '@pilates_challenge_30d';
|
||||||
|
|
||||||
|
const initialState: ChallengeState = {
|
||||||
|
startedAt: null,
|
||||||
|
level: 'beginner',
|
||||||
|
days: [],
|
||||||
|
streak: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function computeStreak(days: ChallengeDayState[]): number {
|
||||||
|
// 连续从第1天开始的已完成天数
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < days.length; i += 1) {
|
||||||
|
if (days[i].status === 'completed') s += 1; else break;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initChallenge = createAsyncThunk(
|
||||||
|
'challenge/init',
|
||||||
|
async (_: void, { getState }) => {
|
||||||
|
const persisted = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
|
if (persisted) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(persisted) as ChallengeState;
|
||||||
|
return parsed;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// 默认生成
|
||||||
|
const level: PilatesLevel = 'beginner';
|
||||||
|
const plans = generatePilates30DayPlan(level);
|
||||||
|
const days: ChallengeDayState[] = plans.map((p, idx) => ({
|
||||||
|
plan: p,
|
||||||
|
status: idx === 0 ? 'available' : 'locked',
|
||||||
|
custom: buildDefaultCustomFromPlan(p),
|
||||||
|
}));
|
||||||
|
const state: ChallengeState = {
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
days,
|
||||||
|
streak: 0,
|
||||||
|
};
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const persistChallenge = createAsyncThunk(
|
||||||
|
'challenge/persist',
|
||||||
|
async (_: void, { getState }) => {
|
||||||
|
const s = (getState() as any).challenge as ChallengeState;
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const completeDay = createAsyncThunk(
|
||||||
|
'challenge/completeDay',
|
||||||
|
async (dayNumber: number, { getState, dispatch }) => {
|
||||||
|
const state = (getState() as any).challenge as ChallengeState;
|
||||||
|
const idx = dayNumber - 1;
|
||||||
|
const days = [...state.days];
|
||||||
|
if (!days[idx] || days[idx].status === 'completed') return state;
|
||||||
|
days[idx] = { ...days[idx], status: 'completed', completedAt: new Date().toISOString() };
|
||||||
|
if (days[idx + 1]) {
|
||||||
|
days[idx + 1] = { ...days[idx + 1], status: 'available' };
|
||||||
|
}
|
||||||
|
const next: ChallengeState = {
|
||||||
|
...state,
|
||||||
|
days,
|
||||||
|
streak: computeStreak(days),
|
||||||
|
};
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const challengeSlice = createSlice({
|
||||||
|
name: 'challenge',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setLevel(state, action: PayloadAction<PilatesLevel>) {
|
||||||
|
state.level = action.payload;
|
||||||
|
},
|
||||||
|
setNote(state, action: PayloadAction<{ dayNumber: number; notes: string }>) {
|
||||||
|
const idx = action.payload.dayNumber - 1;
|
||||||
|
if (state.days[idx]) state.days[idx].notes = action.payload.notes;
|
||||||
|
},
|
||||||
|
setCustom(state, action: PayloadAction<{ dayNumber: number; custom: ExerciseCustomConfig[] }>) {
|
||||||
|
const idx = action.payload.dayNumber - 1;
|
||||||
|
if (state.days[idx]) state.days[idx].custom = action.payload.custom;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(initChallenge.fulfilled, (_state, action) => {
|
||||||
|
return action.payload as ChallengeState;
|
||||||
|
})
|
||||||
|
.addCase(completeDay.fulfilled, (_state, action) => {
|
||||||
|
return action.payload as ChallengeState;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setLevel, setNote, setCustom } = challengeSlice.actions;
|
||||||
|
export default challengeSlice.reducer;
|
||||||
|
|
||||||
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import challengeReducer from './challengeSlice';
|
||||||
import userReducer from './userSlice';
|
import userReducer from './userSlice';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
user: userReducer,
|
user: userReducer,
|
||||||
|
challenge: challengeReducer,
|
||||||
},
|
},
|
||||||
// React Native 环境默认即可
|
// React Native 环境默认即可
|
||||||
});
|
});
|
||||||
|
|||||||
190
utils/pilatesPlan.ts
Normal file
190
utils/pilatesPlan.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
export type ExerciseSet = {
|
||||||
|
durationSec?: number; // 计时型(秒)
|
||||||
|
reps?: number; // 次数型(可选)
|
||||||
|
restSecAfter?: number; // 组间休息
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Exercise = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
sets: ExerciseSet[];
|
||||||
|
tips?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DayPlan = {
|
||||||
|
dayNumber: number; // 1..30
|
||||||
|
type: 'training' | 'recovery' | 'rest';
|
||||||
|
title: string;
|
||||||
|
focus: string; // 训练重点
|
||||||
|
exercises: Exercise[]; // rest/recovery 可为空数组
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PilatesLevel = 'beginner' | 'intermediate' | 'advanced';
|
||||||
|
|
||||||
|
const BASE_EXERCISES: Omit<Exercise, 'sets'>[] = [
|
||||||
|
{
|
||||||
|
key: 'hundred',
|
||||||
|
name: '百次拍击 (The Hundred)',
|
||||||
|
description: '仰卧,抬腿至桌面位,核心收紧,小幅快速摆动手臂并配合呼吸。',
|
||||||
|
videoUrl: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'single_leg_stretch',
|
||||||
|
name: '单腿伸展 (Single Leg Stretch)',
|
||||||
|
description: '仰卧,交替伸直一条腿,另一条腿屈膝抱向胸口,核心稳定。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'double_leg_stretch',
|
||||||
|
name: '双腿伸展 (Double Leg Stretch)',
|
||||||
|
description: '仰卧同时伸直双腿与双臂,呼气收回环抱膝盖。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roll_up',
|
||||||
|
name: '卷起 (Roll Up)',
|
||||||
|
description: '由仰卧卷起至坐并前屈,再控制还原,强调脊柱分节活动。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'spine_twist',
|
||||||
|
name: '脊柱扭转 (Spine Twist)',
|
||||||
|
description: '坐姿,躯干直立,左右控制旋转,强调轴向延展与核心稳定。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bridge',
|
||||||
|
name: '桥式 (Bridge)',
|
||||||
|
description: '仰卧,卷尾抬起骨盆至肩桥位,感受臀腿后侧发力,控制还原。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'side_leg_lift',
|
||||||
|
name: '侧抬腿 (Side Leg Lift)',
|
||||||
|
description: '侧卧,髋稳定,抬高上侧腿,控制下放。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'swimming',
|
||||||
|
name: '游泳式 (Swimming)',
|
||||||
|
description: '俯卧,交替抬起对侧手臂与腿,保持脊柱中立并延展。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cat_cow',
|
||||||
|
name: '猫牛式 (Cat-Cow)',
|
||||||
|
description: '四点支撑,呼吸带动脊柱屈伸,作为热身或整理放松。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'saw',
|
||||||
|
name: '锯式 (Saw)',
|
||||||
|
description: '坐姿分腿,旋转并前屈,斜向触碰对侧脚尖。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'plank',
|
||||||
|
name: '平板支撑 (Plank)',
|
||||||
|
description: '前臂/掌支撑,身体成直线,保持核心紧致。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildSets(level: PilatesLevel, baseSec: number, setCount: number): ExerciseSet[] {
|
||||||
|
const effort = baseSec + (level === 'intermediate' ? 10 : level === 'advanced' ? 20 : 0);
|
||||||
|
const rest = 15;
|
||||||
|
return Array.from({ length: setCount }).map(() => ({ durationSec: effort, restSecAfter: rest }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickExercises(keys: string[], level: PilatesLevel, baseSec: number, setCount: number): Exercise[] {
|
||||||
|
return keys.map((k) => {
|
||||||
|
const base = BASE_EXERCISES.find((e) => e.key === k)!;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
sets: buildSets(level, baseSec, setCount),
|
||||||
|
tips: [
|
||||||
|
'保持呼吸与动作节奏一致',
|
||||||
|
'核心始终收紧,避免腰椎塌陷',
|
||||||
|
'以控制优先于速度,专注动作质量',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePilates30DayPlan(level: PilatesLevel = 'beginner'): DayPlan[] {
|
||||||
|
// 周期化:每7天安排恢复/拉伸;逐周增加时长或组数
|
||||||
|
const plan: DayPlan[] = [];
|
||||||
|
for (let d = 1; d <= 30; d += 1) {
|
||||||
|
const weekIndex = Math.ceil(d / 7); // 1..5
|
||||||
|
const isRecovery = d % 7 === 0 || d === 14 || d === 21 || d === 28; // 每周末恢复
|
||||||
|
|
||||||
|
if (isRecovery) {
|
||||||
|
plan.push({
|
||||||
|
dayNumber: d,
|
||||||
|
type: 'recovery',
|
||||||
|
title: '恢复与拉伸日',
|
||||||
|
focus: '呼吸、脊柱分节、柔韧性',
|
||||||
|
exercises: pickExercises(['cat_cow', 'saw'], level, 40, 2),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 训练参数随周数递增
|
||||||
|
const baseSec = 35 + (weekIndex - 1) * 5; // 35,40,45,50,55
|
||||||
|
const setCount = 2 + (weekIndex > 3 ? 1 : 0); // 第4周开始 3 组
|
||||||
|
|
||||||
|
// 交替不同侧重点
|
||||||
|
let title = '核心激活与稳定';
|
||||||
|
let focus = '腹横肌、骨盆稳定、呼吸控制';
|
||||||
|
let keys: string[] = ['hundred', 'single_leg_stretch', 'double_leg_stretch', 'roll_up', 'spine_twist'];
|
||||||
|
|
||||||
|
if (d % 3 === 0) {
|
||||||
|
title = '后链力量与体态';
|
||||||
|
focus = '臀腿后侧、胸椎伸展、姿态矫正';
|
||||||
|
keys = ['bridge', 'swimming', 'plank', 'spine_twist', 'roll_up'];
|
||||||
|
} else if (d % 3 === 2) {
|
||||||
|
title = '髋稳定与侧链';
|
||||||
|
focus = '中臀肌、侧链控制、骨盆稳定';
|
||||||
|
keys = ['side_leg_lift', 'single_leg_stretch', 'bridge', 'saw', 'plank'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const exercises = pickExercises(keys, level, baseSec, setCount);
|
||||||
|
plan.push({ dayNumber: d, type: 'training', title, focus, exercises });
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateSessionMinutes(day: DayPlan): number {
|
||||||
|
if (day.type !== 'training' && day.type !== 'recovery') return 0;
|
||||||
|
const totalSec = day.exercises.reduce((sum, ex) => {
|
||||||
|
return (
|
||||||
|
sum +
|
||||||
|
ex.sets.reduce((s, st) => s + (st.durationSec ?? 0) + (st.restSecAfter ?? 0), 0)
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
return Math.max(10, Math.round(totalSec / 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户自定义配置(用于挑战页可调节)
|
||||||
|
export type ExerciseCustomConfig = {
|
||||||
|
key: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sets: number; // 目标组数(计时型)
|
||||||
|
durationSec?: number; // 每组计时秒数(若为空可按默认)
|
||||||
|
reps?: number; // 每组次数(可选)
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildDefaultCustomFromPlan(day: DayPlan): ExerciseCustomConfig[] {
|
||||||
|
return (day.exercises || []).map((ex) => ({
|
||||||
|
key: ex.key,
|
||||||
|
enabled: true,
|
||||||
|
sets: Math.max(1, ex.sets?.length || 2),
|
||||||
|
durationSec: ex.sets?.[0]?.durationSec ?? 40,
|
||||||
|
reps: ex.sets?.[0]?.reps,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateSessionMinutesWithCustom(day: DayPlan, custom: ExerciseCustomConfig[] | undefined): number {
|
||||||
|
if (!custom || custom.length === 0) return estimateSessionMinutes(day);
|
||||||
|
const restSec = 15; // 估算默认休息
|
||||||
|
const totalSec = custom.reduce((sum, c) => {
|
||||||
|
if (!c.enabled) return sum;
|
||||||
|
const perSet = (c.durationSec ?? 40) + restSec;
|
||||||
|
return sum + perSet * Math.max(1, c.sets);
|
||||||
|
}, 0);
|
||||||
|
return Math.max(5, Math.round(totalSec / 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user