diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 2e541bd..d92774f 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -75,6 +75,7 @@ export default function HomeScreen() { level="初学者" progress={0} /> + router.push('/challenge')}> + diff --git a/app/_layout.tsx b/app/_layout.tsx index bd921ee..3100a47 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -37,6 +37,7 @@ export default function RootLayout() { + diff --git a/app/auth/login.tsx b/app/auth/login.tsx index f814a13..bb672e1 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -168,7 +168,7 @@ const styles = StyleSheet.create({ }, title: { fontSize: 32, - fontWeight: '800', + fontWeight: '500', letterSpacing: 0.5, }, subtitle: { diff --git a/app/challenge/_layout.tsx b/app/challenge/_layout.tsx new file mode 100644 index 0000000..b86fc0d --- /dev/null +++ b/app/challenge/_layout.tsx @@ -0,0 +1,13 @@ +import { Stack } from 'expo-router'; +import React from 'react'; + +export default function ChallengeLayout() { + return ( + + + + + ); +} + + diff --git a/app/challenge/day.tsx b/app/challenge/day.tsx new file mode 100644 index 0000000..dd36ae3 --- /dev/null +++ b/app/challenge/day.tsx @@ -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>({}); + const [custom, setCustomLocal] = useState(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) => { + setCustomLocal((prev) => { + const next = prev.map((c) => (c.key === key ? { ...c, ...partial } : c)); + return next; + }); + }; + + if (!plan) { + return ( + + 加载中... + + ); + } + + return ( + + + + + router.back()} style={styles.backButton} accessibilityRole="button"> + + + 第{plan.dayNumber}天 + + + {plan.title} + {plan.focus} + + + 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 ( + + + {item.name} + {item.description} + + + updateCustom(item.key, { enabled: !(conf?.enabled ?? true) })}> + {conf?.enabled === false ? '已关闭' : '已启用'} + + + 组数 + + updateCustom(item.key, { sets: Math.max(1, (conf?.sets ?? targetSets) - 1) })}>- + {conf?.sets ?? targetSets} + updateCustom(item.key, { sets: Math.min(10, (conf?.sets ?? targetSets) + 1) })}>+ + + + + 时长/组 + + updateCustom(item.key, { durationSec: Math.max(10, (conf?.durationSec ?? perSetDuration) - 5) })}>- + {conf?.durationSec ?? perSetDuration}s + updateCustom(item.key, { durationSec: Math.min(180, (conf?.durationSec ?? perSetDuration) + 5) })}>+ + + + + + {Array.from({ length: targetSets }).map((_, idx) => ( + + + {perSetDuration}s + + + ))} + + handleNextSet(item)} disabled={doneSets >= targetSets || conf?.enabled === false}> + {doneSets >= item.sets.length ? '本动作完成' : '完成一组'} + + {item.tips && ( + + {item.tips.map((t: string, i: number) => ( + • {t} + ))} + + )} + + ); + }} + /> + + + + {isCompleted ? '已完成' : '完成今日训练'} + + + + + ); +} + +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 }, +}); + + diff --git a/app/challenge/index.tsx b/app/challenge/index.tsx new file mode 100644 index 0000000..6bfb602 --- /dev/null +++ b/app/challenge/index.tsx @@ -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 ( + + + + + router.back()} style={styles.backButton} accessibilityRole="button"> + + + 30天普拉提打卡 + + + 专注核心、体态与柔韧 · 连续完成解锁徽章 + + + {/* 进度环与统计 */} + + + + + + {Math.round((progress || 0) * 100)}% + + + {challenge?.streak ?? 0} 天连续 + {(challenge?.days?.filter((d: any)=>d.status==='completed').length) ?? 0} / 30 完成 + + + + {/* 日历格子(简单 6x5 网格) */} + 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 ( + router.push({ pathname: '/challenge/day', params: { day: String(plan.dayNumber) } })} + style={[styles.dayCell, isLocked && styles.dayCellLocked, isCompleted && styles.dayCellCompleted]} + activeOpacity={0.8} + > + {plan.dayNumber} + {minutes}′ + {isCompleted && } + {isLocked && } + + ); + }} + /> + + {/* 底部 CTA */} + + router.push({ pathname: '/challenge/day', params: { day: String((challenge?.days?.find((d:any)=>d.status==='available')?.plan.dayNumber) || 1) } })}> + 开始今日训练 + + + + + ); +} + +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 }, +}); + + diff --git a/store/challengeSlice.ts b/store/challengeSlice.ts new file mode 100644 index 0000000..8ddc8df --- /dev/null +++ b/store/challengeSlice.ts @@ -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) { + 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; + + diff --git a/store/index.ts b/store/index.ts index 81df940..96bee61 100644 --- a/store/index.ts +++ b/store/index.ts @@ -1,9 +1,11 @@ import { configureStore } from '@reduxjs/toolkit'; +import challengeReducer from './challengeSlice'; import userReducer from './userSlice'; export const store = configureStore({ reducer: { user: userReducer, + challenge: challengeReducer, }, // React Native 环境默认即可 }); diff --git a/utils/pilatesPlan.ts b/utils/pilatesPlan.ts new file mode 100644 index 0000000..38aa416 --- /dev/null +++ b/utils/pilatesPlan.ts @@ -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[] = [ + { + 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)); +} + +