From f3e625050588459cc4f4f9f9c9ad712eb2a3c2fd Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 13 Aug 2025 09:10:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AE=AD=E7=BB=83?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E5=92=8C=E6=89=93=E5=8D=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增训练计划页面,允许用户制定个性化的训练计划 - 集成打卡功能,用户可以记录每日的训练情况 - 更新 Redux 状态管理,添加训练计划相关的 reducer - 在首页中添加训练计划卡片,支持用户点击跳转 - 更新样式和布局,以适应新功能的展示和交互 - 添加日期选择器和相关依赖,支持用户选择训练日期 --- app/(tabs)/explore.tsx | 7 +- app/(tabs)/index.tsx | 59 +++-- app/(tabs)/personal.tsx | 8 +- app/_layout.tsx | 2 + app/ai-coach-chat.tsx | 307 ++++++++++++++++++++++ app/ai-posture-assessment.tsx | 14 +- app/auth/login.tsx | 34 ++- app/challenge/day.tsx | 16 +- app/challenge/index.tsx | 27 +- app/checkin/index.tsx | 110 ++++++++ app/checkin/select.tsx | 363 +++++++++++++++++++++++++ app/health-consultation.tsx | 481 ---------------------------------- app/profile/edit.tsx | 10 +- app/profile/goals.tsx | 86 +++--- app/training-plan.tsx | 468 +++++++++++++++++++++++++++++++++ components/ui/HeaderBar.tsx | 86 ++++++ constants/Colors.ts | 12 + hooks/useAuthGuard.ts | 63 +++++ package-lock.json | 40 ++- package.json | 2 + store/checkinSlice.ts | 78 ++++++ store/index.ts | 4 + store/trainingPlanSlice.ts | 148 +++++++++++ utils/exerciseLibrary.ts | 82 ++++++ 24 files changed, 1898 insertions(+), 609 deletions(-) create mode 100644 app/ai-coach-chat.tsx create mode 100644 app/checkin/index.tsx create mode 100644 app/checkin/select.tsx delete mode 100644 app/health-consultation.tsx create mode 100644 app/training-plan.tsx create mode 100644 components/ui/HeaderBar.tsx create mode 100644 hooks/useAuthGuard.ts create mode 100644 store/checkinSlice.ts create mode 100644 store/trainingPlanSlice.ts create mode 100644 utils/exerciseLibrary.ts diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 2fcb4a3..a436208 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -3,6 +3,7 @@ import { CircularRing } from '@/components/CircularRing'; import { ProgressBar } from '@/components/ProgressBar'; import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; +import { useColorScheme } from '@/hooks/useColorScheme'; import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { ensureHealthPermissions, fetchHealthDataForDate, fetchTodayHealthData } from '@/utils/health'; import { Ionicons } from '@expo/vector-icons'; @@ -20,6 +21,8 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function ExploreScreen() { + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; // 使用 dayjs:当月日期与默认选中“今天” const days = getMonthDaysZh(); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); @@ -104,8 +107,8 @@ export default function ExploreScreen() { return ( - - + + { - // 仅在本次会话首次进入首页时打开登录页,可返回关闭 - if (!hasOpenedLoginRef.current) { - hasOpenedLoginRef.current = true; - router.push('/auth/login'); - } - }, [router]); + const { pushIfAuthedElseLogin } = useAuthGuard(); + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; return ( - - + + {/* Header Section */} @@ -52,7 +49,7 @@ export default function HomeScreen() { router.push('/health-consultation' as any)} + onPress={() => router.push('/ai-coach-chat?name=Sarah' as any)} > 在线教练 认证教练 · 1对1即时解答 @@ -75,15 +72,33 @@ export default function HomeScreen() { level="初学者" progress={0} /> - router.push('/challenge')}> - - + pushIfAuthedElseLogin('/challenge')}> + + + pushIfAuthedElseLogin('/training-plan')}> + + + pushIfAuthedElseLogin('/checkin')}> + + diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index ce76590..b3f5e2d 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -22,6 +22,8 @@ export default function PersonalScreen() { const [notificationEnabled, setNotificationEnabled] = useState(true); const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; + const theme = (colorScheme ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; type UserProfile = { fullName?: string; @@ -287,11 +289,11 @@ export default function PersonalScreen() { ]; return ( - + - + diff --git a/app/_layout.tsx b/app/_layout.tsx index 3100a47..5438663 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -38,8 +38,10 @@ export default function RootLayout() { + + diff --git a/app/ai-coach-chat.tsx b/app/ai-coach-chat.tsx new file mode 100644 index 0000000..a2992cb --- /dev/null +++ b/app/ai-coach-chat.tsx @@ -0,0 +1,307 @@ +import { Ionicons } from '@expo/vector-icons'; +import { BlurView } from 'expo-blur'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + KeyboardAvoidingView, + Platform, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; + +type Role = 'user' | 'assistant'; + +type ChatMessage = { + id: string; + role: Role; + content: string; +}; + +export default function AICoachChatScreen() { + const router = useRouter(); + const params = useLocalSearchParams<{ name?: string }>(); + const insets = useSafeAreaInsets(); + const colorScheme = useColorScheme() ?? 'light'; + // 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色 + const theme = Colors.light; + const coachName = (params?.name || 'Sarah').toString(); + const [input, setInput] = useState(''); + const [isSending, setIsSending] = useState(false); + const [messages, setMessages] = useState([{ + id: 'm_welcome', + role: 'assistant', + content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`, + }]); + const listRef = useRef>(null); + + const planDraft = useAppSelector((s) => s.trainingPlan?.draft); + + const chips = useMemo(() => [ + { key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') }, + { key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() }, + ], [router, planDraft]); + + function scrollToEnd() { + requestAnimationFrame(() => { + listRef.current?.scrollToEnd({ animated: true }); + }); + } + + async function fakeStreamResponse(prompt: string): Promise { + // 占位实现:模拟AI逐字输出(可替换为真实后端流式接口) + const canned = + prompt.includes('训练计划') || prompt.includes('制定') + ? '好的,我将基于你的目标与时间安排制定一周普拉提计划:\n\n- 周一:核心激活与呼吸(20-25分钟)\n- 周三:下肢稳定与髋部灵活(25-30分钟)\n- 周五:全身整合与平衡(30分钟)\n\n每次训练前后各进行5分钟呼吸与拉伸。若有不适请降低强度或暂停。' + : '已收到,我会根据你的问题给出建议:保持规律练习与充分恢复,注意呼吸控制与动作节奏。若感到疼痛请及时调整或咨询专业教练。'; + await new Promise((r) => setTimeout(r, 500)); + return canned; + } + + async function send(text: string) { + if (!text.trim() || isSending) return; + const userMsg: ChatMessage = { id: `u_${Date.now()}`, role: 'user', content: text.trim() }; + setMessages((m) => [...m, userMsg]); + setInput(''); + setIsSending(true); + scrollToEnd(); + + try { + const replyText = await fakeStreamResponse(text.trim()); + const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: replyText }; + setMessages((m) => [...m, aiMsg]); + scrollToEnd(); + } catch (e) { + const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: '抱歉,请求失败,请稍后再试。' }; + setMessages((m) => [...m, aiMsg]); + } finally { + setIsSending(false); + } + } + + function handleQuickPlan() { + const goalMap: Record = { + postpartum_recovery: '产后恢复', + fat_loss: '减脂塑形', + posture_correction: '体态矫正', + core_strength: '核心力量', + flexibility: '柔韧灵活', + rehab: '康复保健', + stress_relief: '释压放松', + }; + const goalText = planDraft?.goal ? goalMap[planDraft.goal] : '整体提升'; + const freq = planDraft?.mode === 'sessionsPerWeek' + ? `${planDraft?.sessionsPerWeek ?? 3}次/周` + : (planDraft?.daysOfWeek?.length ? `${planDraft.daysOfWeek.length}次/周` : '3次/周'); + const prefer = planDraft?.preferredTimeOfDay ? `偏好${planDraft.preferredTimeOfDay}` : '时间灵活'; + const prompt = `请根据我的目标“${goalText}”、频率“${freq}”、${prefer},制定1周的普拉提训练计划,包含每次训练主题、时长、主要动作与注意事项,并给出恢复建议。`; + send(prompt); + } + + function renderItem({ item }: { item: ChatMessage }) { + const isUser = item.role === 'user'; + return ( + + {!isUser && ( + + AI + + )} + + {item.content} + + + ); + } + + return ( + + router.back()} + tone="light" + transparent + /> + + m.id} + renderItem={renderItem} + contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8, paddingBottom: insets.bottom + 140 }} + onContentSizeChange={scrollToEnd} + showsVerticalScrollIndicator={false} + /> + + + + + {chips.map((c) => ( + + {c.label} + + ))} + + + + send(input)} + blurOnSubmit={false} + /> + send(input)} + style={[ + styles.sendBtn, + { backgroundColor: theme.primary, opacity: input.trim() && !isSending ? 1 : 0.5 } + ]} + > + {isSending ? ( + + ) : ( + + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingBottom: 10, + }, + backButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.06)' + }, + headerTitle: { + fontSize: 20, + fontWeight: '800', + }, + row: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: 8, + marginVertical: 6, + }, + avatar: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + }, + avatarText: { + color: '#192126', + fontSize: 12, + fontWeight: '800', + }, + bubble: { + maxWidth: '82%', + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 16, + }, + bubbleText: { + fontSize: 15, + lineHeight: 22, + }, + composerWrap: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingTop: 8, + paddingHorizontal: 10, + borderTopWidth: 0, + }, + chipsRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + paddingHorizontal: 6, + marginBottom: 8, + }, + chip: { + paddingHorizontal: 10, + height: 34, + borderRadius: 18, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'transparent', + }, + chipText: { + fontSize: 13, + fontWeight: '600', + }, + inputRow: { + flexDirection: 'row', + alignItems: 'flex-end', + padding: 8, + borderWidth: 1, + borderRadius: 16, + backgroundColor: 'rgba(0,0,0,0.04)' + }, + input: { + flex: 1, + fontSize: 15, + maxHeight: 120, + paddingHorizontal: 8, + paddingVertical: 6, + }, + sendBtn: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, +}); + + diff --git a/app/ai-posture-assessment.tsx b/app/ai-posture-assessment.tsx index d888d98..2918c90 100644 --- a/app/ai-posture-assessment.tsx +++ b/app/ai-posture-assessment.tsx @@ -16,6 +16,7 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; type PoseView = 'front' | 'side' | 'back'; @@ -172,18 +173,7 @@ export default function AIPostureAssessmentScreen() { return ( - {/* Header */} - - router.back()} - style={styles.backButton} - > - - - AI体态测评 - - + router.back()} tone="dark" transparent /> (); const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const color = Colors[scheme]; const dispatch = useAppDispatch(); @@ -46,7 +47,18 @@ export default function LoginScreen() { }); const identityToken = (credential as any)?.identityToken; await dispatch(login({ appleIdentityToken: identityToken })).unwrap(); - router.back(); + // 登录成功后处理重定向 + const to = searchParams?.redirectTo as string | undefined; + const paramsJson = searchParams?.redirectParams as string | undefined; + let parsedParams: Record | undefined; + if (paramsJson) { + try { parsedParams = JSON.parse(paramsJson); } catch { } + } + if (to) { + router.replace({ pathname: to, params: parsedParams } as any); + } else { + router.back(); + } } catch (err: any) { if (err?.code === 'ERR_CANCELED') return; const message = err?.message || '登录失败,请稍后再试'; @@ -54,12 +66,22 @@ export default function LoginScreen() { } finally { setLoading(false); } - }, [appleAvailable, router]); + }, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]); const onGuestLogin = useCallback(() => { - // TODO: 标记为游客身份,可在此写入本地状态/上报统计 - router.back(); - }, [router]); + // 游客继续:若有 redirect 则前往,无则返回 + const to = searchParams?.redirectTo as string | undefined; + const paramsJson = searchParams?.redirectParams as string | undefined; + let parsedParams: Record | undefined; + if (paramsJson) { + try { parsedParams = JSON.parse(paramsJson); } catch { } + } + if (to) { + router.replace({ pathname: to, params: parsedParams } as any); + } else { + router.back(); + } + }, [router, searchParams?.redirectParams, searchParams?.redirectTo]); const disabledStyle = useMemo(() => ({ opacity: hasAgreed ? 1 : 0.5 }), [hasAgreed]); diff --git a/app/challenge/day.tsx b/app/challenge/day.tsx index dd36ae3..7a672d6 100644 --- a/app/challenge/day.tsx +++ b/app/challenge/day.tsx @@ -1,7 +1,7 @@ +import { HeaderBar } from '@/components/ui/HeaderBar'; 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'; @@ -55,17 +55,9 @@ export default function ChallengeDayScreen() { return ( - - - router.back()} style={styles.backButton} accessibilityRole="button"> - - - 第{plan.dayNumber}天 - - - {plan.title} - {plan.focus} - + router.back()} withSafeTop={false} transparent /> + {plan.title} + {plan.focus} (s as any).challenge); useEffect(() => { @@ -24,16 +27,8 @@ export default function ChallengeHomeScreen() { return ( - - - router.back()} style={styles.backButton} accessibilityRole="button"> - - - 30天普拉提打卡 - - - 专注核心、体态与柔韧 · 连续完成解锁徽章 - + router.back()} withSafeTop={false} transparent /> + 专注核心、体态与柔韧 · 连续完成解锁徽章 {/* 进度环与统计 */} @@ -45,7 +40,7 @@ export default function ChallengeHomeScreen() { {challenge?.streak ?? 0} 天连续 - {(challenge?.days?.filter((d: any)=>d.status==='completed').length) ?? 0} / 30 完成 + {(challenge?.days?.filter((d: any) => d.status === 'completed').length) ?? 0} / 30 完成 @@ -64,7 +59,10 @@ export default function ChallengeHomeScreen() { return ( 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 */} - router.push({ pathname: '/challenge/day', params: { day: String((challenge?.days?.find((d:any)=>d.status==='available')?.plan.dayNumber) || 1) } })}> + { + 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) } }); + }}> 开始今日训练 diff --git a/app/checkin/index.tsx b/app/checkin/index.tsx new file mode 100644 index 0000000..d4cbb08 --- /dev/null +++ b/app/checkin/index.tsx @@ -0,0 +1,110 @@ +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { removeExercise, setCurrentDate, toggleExerciseCompleted } from '@/store/checkinSlice'; +import { useRouter } from 'expo-router'; +import React, { useEffect, useMemo } from 'react'; +import { FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +function formatDate(d: Date) { + const y = d.getFullYear(); + const m = `${d.getMonth() + 1}`.padStart(2, '0'); + const day = `${d.getDate()}`.padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +export default function CheckinHome() { + const dispatch = useAppDispatch(); + const router = useRouter(); + const today = useMemo(() => formatDate(new Date()), []); + const checkin = useAppSelector((s) => (s as any).checkin); + const record = checkin?.byDate?.[today]; + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + + useEffect(() => { + dispatch(setCurrentDate(today)); + }, [dispatch, today]); + + return ( + + + + + + + + router.back()} withSafeTop={false} transparent /> + + {today} + 请选择动作并记录完成情况 + + + + router.push('/checkin/select')}> + 新增动作 + + + + item.key} + contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 20 }} + ListEmptyComponent={ + + 还没有选择任何动作,点击“新增动作”开始吧。 + + } + renderItem={({ item }) => ( + + + {item.name} + {item.category} + 组数 {item.sets}{item.reps ? ` · 每组 ${item.reps} 次` : ''}{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''} + + dispatch(toggleExerciseCompleted({ date: today, key: item.key }))}> + {item.completed ? '已完成' : '完成'} + + dispatch(removeExercise({ date: today, key: item.key }))}> + 移除 + + + )} + /> + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: '#F7F8FA' }, + container: { flex: 1, backgroundColor: '#F7F8FA' }, + header: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8 }, + headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 }, + backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' }, + hero: { backgroundColor: 'rgba(187,242,70,0.18)', borderRadius: 16, padding: 14 }, + title: { fontSize: 24, fontWeight: '800', color: '#111827' }, + subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' }, + bgOrnaments: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }, + blob: { position: 'absolute', width: 260, height: 260, borderRadius: 999 }, + blobPrimary: { backgroundColor: '#00000000' }, + blobPurple: { backgroundColor: '#00000000' }, + actionRow: { paddingHorizontal: 20, marginTop: 8 }, + primaryBtn: { backgroundColor: '#111827', paddingVertical: 10, borderRadius: 10, alignItems: 'center' }, + primaryBtnText: { color: '#FFFFFF', fontWeight: '800' }, + emptyBox: { marginTop: 16, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginHorizontal: 20 }, + emptyText: { color: '#6B7280' }, + card: { marginTop: 12, marginHorizontal: 20, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 }, + cardTitle: { fontSize: 16, fontWeight: '800', color: '#111827' }, + cardMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' }, + removeBtn: { backgroundColor: '#F3F4F6', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8 }, + removeBtnText: { color: '#111827', fontWeight: '700' }, + doneBtn: { backgroundColor: '#E5E7EB', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8, marginRight: 8 }, + doneBtnActive: { backgroundColor: '#10B981' }, + doneBtnText: { color: '#111827', fontWeight: '700' }, + doneBtnTextActive: { color: '#FFFFFF', fontWeight: '800' }, + +}); + + diff --git a/app/checkin/select.tsx b/app/checkin/select.tsx new file mode 100644 index 0000000..c36707f --- /dev/null +++ b/app/checkin/select.tsx @@ -0,0 +1,363 @@ +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { useAppDispatch } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { addExercise } from '@/store/checkinSlice'; +import { EXERCISE_LIBRARY, getCategories, searchExercises } from '@/utils/exerciseLibrary'; +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import { useRouter } from 'expo-router'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native'; + +function formatDate(d: Date) { + const y = d.getFullYear(); + const m = `${d.getMonth() + 1}`.padStart(2, '0'); + const day = `${d.getDate()}`.padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +export default function SelectExerciseScreen() { + const dispatch = useAppDispatch(); + const router = useRouter(); + const today = useMemo(() => formatDate(new Date()), []); + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + + const [keyword, setKeyword] = useState(''); + const [category, setCategory] = useState('全部'); + const [selectedKey, setSelectedKey] = useState(null); + const [sets, setSets] = useState(3); + const [reps, setReps] = useState(undefined); + const [showCustomReps, setShowCustomReps] = useState(false); + const [customRepsInput, setCustomRepsInput] = useState(''); + const [showCategoryPicker, setShowCategoryPicker] = useState(false); + + const controlsOpacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); + } + }, []); + + const categories = useMemo(() => ['全部', ...getCategories()], []); + const mainCategories = useMemo(() => { + const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑']; + const exists = (name: string) => categories.includes(name); + const picked = preferred.filter(exists); + // 兜底:若某些偏好分类不存在,补足其他分类 + const rest = categories.filter((c) => !picked.includes(c)); + while (picked.length < 5 && rest.length) picked.push(rest.shift() as string); + return picked; + }, [categories]); + const filtered = useMemo(() => { + const base = searchExercises(keyword); + if (category === '全部') return base; + return base.filter((e) => e.category === category); + }, [keyword, category]); + + const selected = useMemo(() => EXERCISE_LIBRARY.find((e) => e.key === selectedKey) || null, [selectedKey]); + + useEffect(() => { + Animated.timing(controlsOpacity, { + toValue: selected ? 1 : 0, + duration: selected ? 220 : 160, + useNativeDriver: true, + }).start(); + }, [selected, controlsOpacity]); + + const handleAdd = () => { + if (!selected) return; + dispatch(addExercise({ + date: today, + item: { + key: selected.key, + name: selected.name, + category: selected.category, + sets: Math.max(1, sets), + reps: reps && reps > 0 ? reps : undefined, + }, + })); + router.back(); + }; + + const onSelectItem = (key: string) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + if (selectedKey === key) { + setSelectedKey(null); + return; + } + setSets(3); + setReps(undefined); + setShowCustomReps(false); + setCustomRepsInput(''); + setSelectedKey(key); + }; + + return ( + + + + + + + + router.back()} withSafeTop={false} transparent /> + + 从动作库里选择一个动作,设置组数与每组次数 + + + {/* 大分类宫格(无横向滚动) */} + + {[...mainCategories, '更多'].map((item) => { + const active = category === item; + const meta: Record = { + 全部: { bg: 'rgba(187,242,70,0.22)' }, + 核心与腹部: { bg: 'rgba(187,242,70,0.18)' }, + 脊柱与后链: { bg: 'rgba(149,204,227,0.20)' }, + 侧链与髋: { bg: 'rgba(164,138,237,0.20)' }, + 平衡与支撑: { bg: 'rgba(252,196,111,0.22)' }, + 进阶控制: { bg: 'rgba(237,71,71,0.18)' }, + 柔韧与拉伸: { bg: 'rgba(149,204,227,0.18)' }, + 更多: { bg: 'rgba(24,24,27,0.06)' }, + }; + const scale = new Animated.Value(1); + const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); + const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); + const handlePress = () => { + onPressOut(); + if (item === '更多') { + setShowCategoryPicker(true); + Haptics.selectionAsync(); + } else { + setCategory(item); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + }; + return ( + + + {item} + + + ); + })} + + + {/* 分类选择弹层(更多) */} + setShowCategoryPicker(false)} + > + setShowCategoryPicker(false)}> + e.stopPropagation() as any} + > + 选择分类 + + {categories.filter((c) => c !== '全部').map((c) => { + const scale = new Animated.Value(1); + const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); + const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); + return ( + + { + onPressOut(); + setCategory(c); + setShowCategoryPicker(false); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }} + activeOpacity={0.9} + style={[styles.catTile, { backgroundColor: 'rgba(24,24,27,0.06)' }]} + > + {c} + + + ); + })} + + + + + + + + + + item.key} + contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 40 }} + renderItem={({ item }) => { + const isSelected = item.key === selectedKey; + return ( + onSelectItem(item.key)} + activeOpacity={0.9} + > + + {item.name} + {item.category} + {item.description} + + {isSelected && } + {isSelected && ( + + + + 组数 + + setSets(Math.max(1, sets - 1))}>- + {sets} + setSets(Math.min(20, sets + 1))}>+ + + + + + 每组次数 + + {[6, 8, 10, 12, 15, 20, 25, 30].map((v) => { + const active = reps === v; + return ( + { + setReps(v); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }} + > + {v} + + ); + })} + { + setShowCustomReps((s) => !s); + Haptics.selectionAsync(); + }} + > + 自定义 + + + {showCustomReps && ( + + + { + const n = Math.max(1, Math.min(100, parseInt(customRepsInput || '0', 10))); + if (!Number.isNaN(n)) { + setReps(n); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + }} + > + 确定 + + + )} + + + + + 添加到今日打卡 + + + )} + + ); + }} + /> + + + + + ); +} + +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', zIndex: 2 }, + backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' }, + headerTitle: { fontSize: 18, fontWeight: '800', color: '#1A1A1A' }, + subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' }, + catCard: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 14, flexDirection: 'row', alignItems: 'center' }, + catCardActive: { borderWidth: 2, borderColor: '#BBF246' }, + catEmoji: { fontSize: 16, marginRight: 6 }, + catText: { fontSize: 13, fontWeight: '800' }, + hero: { backgroundColor: 'rgba(187,242,70,0.18)', borderRadius: 16, padding: 14, marginTop: 8 }, + bgOrnaments: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }, + blob: { position: 'absolute', width: 260, height: 260, borderRadius: 999 }, + catGrid: { paddingHorizontal: 16, paddingTop: 10, flexDirection: 'row', flexWrap: 'wrap' }, + catTileWrapper: { width: '33.33%', padding: 6 }, + catTile: { borderRadius: 14, paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', justifyContent: 'center' }, + catTileActive: { borderWidth: 2, borderColor: '#BBF246' }, + searchRow: { paddingHorizontal: 20, marginTop: 8 }, + searchInput: { backgroundColor: '#FFFFFF', borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10, color: '#111827' }, + itemCard: { backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginTop: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 }, + itemCardSelected: { borderWidth: 2, borderColor: '#10B981' }, + itemTitle: { fontSize: 16, fontWeight: '800', color: '#111827' }, + itemMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' }, + itemDesc: { marginTop: 6, fontSize: 12, color: '#6B7280' }, + expandedBox: { marginTop: 12 }, + controlsRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 10 }, + 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' }, + repsChipsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 6 }, + repChip: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, backgroundColor: '#F3F4F6', borderWidth: 1, borderColor: '#E5E7EB' }, + repChipText: { color: '#111827', fontWeight: '700' }, + repChipGhost: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, borderWidth: 1, backgroundColor: 'transparent' }, + repChipGhostText: { fontWeight: '700' }, + customRepsRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 8 }, + customRepsInput: { flex: 1, height: 40, borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 }, + customRepsBtn: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10 }, + customRepsBtnText: { fontWeight: '800' }, + modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'flex-end' }, + modalSheet: { width: '100%', borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 24 }, + modalTitle: { fontSize: 16, fontWeight: '800', marginBottom: 8 }, + catGridModal: { flexDirection: 'row', flexWrap: 'wrap' }, + primaryBtn: { backgroundColor: '#111827', paddingVertical: 12, borderRadius: 12, alignItems: 'center' }, + primaryBtnText: { color: '#FFFFFF', fontWeight: '800' }, +}); + + diff --git a/app/health-consultation.tsx b/app/health-consultation.tsx deleted file mode 100644 index 3b974a3..0000000 --- a/app/health-consultation.tsx +++ /dev/null @@ -1,481 +0,0 @@ -import { ThemedText } from '@/components/ThemedText'; -import { ThemedView } from '@/components/ThemedView'; -import { useThemeColor } from '@/hooks/useThemeColor'; -import { Ionicons } from '@expo/vector-icons'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useRouter } from 'expo-router'; -import React, { useEffect, useState } from 'react'; -import { - Dimensions, - Pressable, - SafeAreaView, - ScrollView, - StyleSheet, - Text, - View -} from 'react-native'; - -const { width: screenWidth } = Dimensions.get('window'); - -// 健康数据项类型 -interface HealthItem { - id: string; - title: string; - subtitle: string; - status: 'warning' | 'good' | 'info'; - icon: string; - recommendation: string; - value?: string; - color: string; - bgColor: string; -} - -// 健康数据 -const healthData: HealthItem[] = [ - { - id: '1', - title: '运动状态', - subtitle: '本周运动不足', - status: 'warning', - icon: '🏃‍♀️', - recommendation: '建议每天进行30分钟普拉提训练', - value: '2天/周', - color: '#FF6B6B', - bgColor: '#FFE5E5', - }, - { - id: '2', - title: '体态评估', - subtitle: '需要进行评估', - status: 'info', - icon: '🧘‍♀️', - recommendation: '进行AI体态评估,了解身体状况', - color: '#4ECDC4', - bgColor: '#E5F9F7', - }, - { - id: '3', - title: '核心力量', - subtitle: '待加强', - status: 'warning', - icon: '💪', - recommendation: '推荐核心训练课程', - value: '初级', - color: '#FFB84D', - bgColor: '#FFF4E5', - }, - { - id: '4', - title: '柔韧性', - subtitle: '良好', - status: 'good', - icon: '🤸‍♀️', - recommendation: '保持每日拉伸习惯', - value: '良好', - color: '#95E1D3', - bgColor: '#E5F9F5', - }, - { - id: '5', - title: '平衡能力', - subtitle: '需要提升', - status: 'info', - icon: '⚖️', - recommendation: '尝试单腿站立训练', - color: '#A8E6CF', - bgColor: '#E8F8F0', - }, - { - id: '6', - title: '呼吸质量', - subtitle: '待改善', - status: 'warning', - icon: '🌬️', - recommendation: '学习普拉提呼吸法', - color: '#C7CEEA', - bgColor: '#F0F1F8', - }, -]; - -export default function HealthConsultationScreen() { - const router = useRouter(); - const primaryColor = useThemeColor({}, 'primary'); - const backgroundColor = useThemeColor({}, 'background'); - const textColor = useThemeColor({}, 'text'); - const [greeting, setGreeting] = useState(''); - - useEffect(() => { - const hour = new Date().getHours(); - if (hour < 12) { - setGreeting('早上好'); - } else if (hour < 18) { - setGreeting('下午好'); - } else { - setGreeting('晚上好'); - } - }, []); - - const handleHealthItemPress = (item: HealthItem) => { - // 根据不同的健康项导航到相应页面 - if (item.title === '体态评估') { - router.push('/ai-posture-assessment'); - } else { - console.log(`点击了 ${item.title}`); - // 可以添加更多导航逻辑 - } - }; - - return ( - - - - {/* 顶部导航栏 */} - - router.back()} style={styles.backButton}> - - - 健康咨询 - - - - - - {/* 教练问候卡片 */} - - - - - 👩‍⚕️ - - - {greeting}, - 我是您的普拉提教练 Sarah - - - 今天感觉怎么样? - 让我们一起了解您的身体状况 - - - - {/* 快速操作按钮 */} - - - - 体态检测 - - - - 咨询教练 - - - - 预约课程 - - - - {/* 健康状况标题 */} - - 您的健康状况 - - 需要关注 - - - - {/* 健康数据网格 */} - - {healthData.map((item) => ( - handleHealthItemPress(item)} - > - - {item.icon} - {item.value && ( - - {item.value} - - )} - - {item.title} - {item.subtitle} - - - - {item.recommendation} - - - - - ))} - - - {/* 今日建议 */} - - 今日建议 - - - 💡 - - - - 根据您的身体状况,建议今天进行轻度核心训练 - - - 配合呼吸练习,效果更佳 - - - 开始训练 - - - - - - - {/* 底部间距 */} - - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { - flex: 1, - }, - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 20, - paddingVertical: 12, - }, - backButton: { - padding: 8, - }, - headerTitle: { - fontSize: 18, - fontWeight: '600', - }, - notificationButton: { - padding: 8, - }, - coachCard: { - marginHorizontal: 20, - marginTop: 12, - borderRadius: 20, - padding: 24, - elevation: 5, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 8, - }, - coachContent: { - gap: 16, - }, - coachInfo: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - }, - coachAvatar: { - width: 50, - height: 50, - borderRadius: 25, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, - coachAvatarEmoji: { - fontSize: 30, - }, - coachTextContainer: { - flex: 1, - }, - coachGreeting: { - fontSize: 14, - color: '#192126', - opacity: 0.8, - }, - coachName: { - fontSize: 16, - fontWeight: '600', - color: '#192126', - }, - coachQuestion: { - fontSize: 28, - fontWeight: 'bold', - color: '#192126', - marginTop: 8, - }, - coachSubtext: { - fontSize: 14, - color: '#192126', - opacity: 0.7, - }, - quickActions: { - flexDirection: 'row', - paddingHorizontal: 20, - marginTop: 20, - gap: 12, - }, - actionButton: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - borderRadius: 12, - gap: 6, - }, - actionButtonOutline: { - borderWidth: 1, - borderColor: '#E0E0E0', - }, - actionButtonText: { - fontSize: 14, - fontWeight: '500', - }, - sectionHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 20, - marginTop: 32, - marginBottom: 16, - }, - sectionTitle: { - fontSize: 22, - fontWeight: 'bold', - }, - healthBadge: { - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 12, - }, - healthBadgeText: { - fontSize: 12, - fontWeight: '600', - color: '#192126', - }, - healthGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - paddingHorizontal: 16, - gap: 12, - }, - healthCard: { - width: (screenWidth - 44) / 2, - padding: 16, - borderRadius: 16, - position: 'relative', - }, - healthCardHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - healthIcon: { - fontSize: 28, - }, - healthValue: { - fontSize: 12, - fontWeight: '600', - }, - healthTitle: { - fontSize: 16, - fontWeight: '600', - color: '#192126', - marginBottom: 4, - }, - healthSubtitle: { - fontSize: 13, - color: '#666', - marginBottom: 12, - }, - healthRecommendation: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: 6, - paddingRight: 20, - }, - recommendationText: { - fontSize: 11, - flex: 1, - }, - cardArrow: { - position: 'absolute', - bottom: 12, - right: 12, - }, - suggestionSection: { - paddingHorizontal: 20, - marginTop: 32, - }, - suggestionTitle: { - fontSize: 22, - fontWeight: 'bold', - marginBottom: 16, - }, - suggestionCard: { - flexDirection: 'row', - padding: 20, - borderRadius: 16, - gap: 16, - }, - suggestionIcon: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, - suggestionContent: { - flex: 1, - gap: 8, - }, - suggestionMainText: { - fontSize: 15, - fontWeight: '500', - color: '#192126', - }, - suggestionSubText: { - fontSize: 13, - color: '#666', - }, - startButton: { - flexDirection: 'row', - alignItems: 'center', - alignSelf: 'flex-start', - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - marginTop: 8, - gap: 6, - }, - startButtonText: { - fontSize: 14, - fontWeight: '600', - color: '#192126', - }, - bottomSpacing: { - height: 100, - }, -}); \ No newline at end of file diff --git a/app/profile/edit.tsx b/app/profile/edit.tsx index 8dc9e10..9304482 100644 --- a/app/profile/edit.tsx +++ b/app/profile/edit.tsx @@ -1,3 +1,4 @@ +import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; import { Ionicons } from '@expo/vector-icons'; @@ -157,14 +158,7 @@ export default function EditProfileScreen() { - {/* 统一头部 */} - - router.back()} style={styles.backButton}> - - - 编辑资料 - - + router.back()} withSafeTop={false} transparent /> {/* 头像(带相机蒙层,点击从相册选择) */} diff --git a/app/profile/goals.tsx b/app/profile/goals.tsx index 05f80ac..8b7d895 100644 --- a/app/profile/goals.tsx +++ b/app/profile/goals.tsx @@ -1,3 +1,4 @@ +import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; import { Ionicons } from '@expo/vector-icons'; @@ -6,12 +7,12 @@ import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; import React, { useEffect, useMemo, useState } from 'react'; import { - SafeAreaView, - ScrollView, - StyleSheet, - Text, - TouchableOpacity, - View, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -56,34 +57,34 @@ export default function GoalsScreen() { try { const parsed = JSON.parse(p); if (Array.isArray(parsed)) setPurposes(parsed.filter((x) => typeof x === 'string')); - } catch {} + } catch { } } - } catch {} + } catch { } }; load(); }, []); useEffect(() => { - AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => {}); + AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => { }); }, [calories]); useEffect(() => { - AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => {}); + AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => { }); }, [steps]); useEffect(() => { - AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => {}); + AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => { }); }, [purposes]); const caloriesPercent = useMemo(() => (Math.min(CALORIES_RANGE.max, Math.max(CALORIES_RANGE.min, calories)) - CALORIES_RANGE.min) / (CALORIES_RANGE.max - CALORIES_RANGE.min), - [calories]); + [calories]); const stepsPercent = useMemo(() => (Math.min(STEPS_RANGE.max, Math.max(STEPS_RANGE.min, steps)) - STEPS_RANGE.min) / (STEPS_RANGE.max - STEPS_RANGE.min), - [steps]); + [steps]); const changeWithHaptics = (next: number, setter: (v: number) => void) => { if (process.env.EXPO_OS === 'ios') { @@ -101,34 +102,34 @@ export default function GoalsScreen() { const SectionCard: React.FC<{ title: string; subtitle?: string; children: React.ReactNode }> = ({ title, subtitle, children }) => ( - - {title} - {subtitle ? {subtitle} : null} - {children} - - ); + + {title} + {subtitle ? {subtitle} : null} + {children} + + ); const PresetChip: React.FC<{ label: string; active?: boolean; onPress: () => void }> = ({ label, active, onPress }) => ( - - {label} - - ); + + {label} + + ); const Stepper: React.FC<{ onDec: () => void; onInc: () => void }> = ({ onDec, onInc }) => ( - - - - - - - + - - - ); + + + - + + + + + + + ); const PURPOSE_OPTIONS: { id: string; label: string; icon: any }[] = [ { id: 'core', label: '增强核心力量', icon: 'barbell-outline' }, @@ -152,19 +153,8 @@ export default function GoalsScreen() { }; return ( - - {/* Header(参照 AI 体态测评页面的实现) */} - - router.back()} - style={[styles.backButton, { backgroundColor: theme === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)' }]} - > - - - 目标管理 - - + + router.back()} withSafeTop={false} tone={theme} transparent /> s.trainingPlan); + const [weightInput, setWeightInput] = useState(''); + const [datePickerVisible, setDatePickerVisible] = useState(false); + + useEffect(() => { + dispatch(loadTrainingPlan()); + return () => { + // 离开页面不自动 reset,保留草稿 + }; + }, [dispatch]); + + useEffect(() => { + if (draft.startWeightKg && !weightInput) setWeightInput(String(draft.startWeightKg)); + }, [draft.startWeightKg]); + + const selectedCount = draft.mode === 'daysOfWeek' ? draft.daysOfWeek.length : draft.sessionsPerWeek; + + const canSave = useMemo(() => { + if (!draft.goal) return false; + if (draft.mode === 'daysOfWeek' && draft.daysOfWeek.length === 0) return false; + if (draft.mode === 'sessionsPerWeek' && draft.sessionsPerWeek <= 0) return false; + return true; + }, [draft]); + + const handleSave = async () => { + await dispatch(saveTrainingPlan()).unwrap().catch(() => { }); + router.back(); + }; + + const openDatePicker = () => setDatePickerVisible(true); + const closeDatePicker = () => setDatePickerVisible(false); + const onConfirmDate = (date: Date) => { + // 只允许今天之后(含今天)的日期 + const today = new Date(); + today.setHours(0, 0, 0, 0); + const picked = new Date(date); + picked.setHours(0, 0, 0, 0); + const finalDate = picked < today ? today : picked; + dispatch(setStartDate(finalDate.toISOString())); + closeDatePicker(); + }; + + return ( + + + router.back()} withSafeTop={false} transparent /> + + 制定你的训练计划 + 选择你的训练节奏与目标,我们将为你生成合适的普拉提安排。 + + + 训练频率 + + dispatch(setMode('daysOfWeek'))} + style={[styles.segmentItem, draft.mode === 'daysOfWeek' && styles.segmentItemActive]} + > + 按星期选择 + + dispatch(setMode('sessionsPerWeek'))} + style={[styles.segmentItem, draft.mode === 'sessionsPerWeek' && styles.segmentItemActive]} + > + 每周次数 + + + + {draft.mode === 'daysOfWeek' ? ( + + {WEEK_DAYS.map((d, i) => { + const active = draft.daysOfWeek.includes(i); + return ( + dispatch(toggleDayOfWeek(i))} style={[styles.dayChip, active && styles.dayChipActive]}> + {d} + + ); + })} + + ) : ( + + 每周训练 + + dispatch(setSessionsPerWeek(Math.max(1, draft.sessionsPerWeek - 1)))} style={styles.counterBtn}> + - + + {draft.sessionsPerWeek} + dispatch(setSessionsPerWeek(Math.min(7, draft.sessionsPerWeek + 1)))} style={styles.counterBtn}> + + + + + + + )} + + 已选择:{selectedCount} 次/周 + + + + 训练目标 + + {GOALS.map((g) => { + const active = draft.goal === g.key; + return ( + dispatch(setGoal(g.key))} style={[styles.goalItem, active && styles.goalItemActive]}> + {g.title} + {g.desc} + + ); + })} + + + + + 更多选项 + + 开始日期 + + + 选择日期 + + dispatch(setStartDateNextMonday())} style={[styles.linkBtn, { marginLeft: 8 }]}> + 下周一 + + + + {new Date(draft.startDate).toLocaleDateString()} + + + 开始体重 (kg) + { + setWeightInput(t); + const v = Number(t); + dispatch(setStartWeight(Number.isFinite(v) ? v : undefined)); + }} + style={styles.input} + /> + + + + 偏好时间段 + + {(['morning', 'noon', 'evening', ''] as const).map((k) => ( + dispatch(setPreferredTime(k))} style={[styles.segmentItemSmall, draft.preferredTimeOfDay === k && styles.segmentItemActiveSmall]}> + {k === 'morning' ? '晨练' : k === 'noon' ? '午间' : k === 'evening' ? '晚间' : '不限'} + + ))} + + + + + + {canSave ? '生成计划' : '请先选择目标/频率'} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#F7F8FA', + }, + container: { + flex: 1, + backgroundColor: '#F7F8FA', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingVertical: 12, + }, + backButton: { + padding: 8, + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + }, + content: { + paddingHorizontal: 20, + paddingTop: 16, + }, + title: { + fontSize: 28, + fontWeight: '800', + color: '#1A1A1A', + }, + subtitle: { + fontSize: 14, + color: '#5E6468', + marginTop: 6, + marginBottom: 16, + }, + card: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginTop: 14, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 3, + }, + cardTitle: { + fontSize: 18, + fontWeight: '700', + color: '#0F172A', + marginBottom: 12, + }, + segment: { + flexDirection: 'row', + backgroundColor: '#F1F5F9', + padding: 4, + borderRadius: 999, + }, + segmentItem: { + flex: 1, + borderRadius: 999, + paddingVertical: 10, + alignItems: 'center', + }, + segmentItemActive: { + backgroundColor: palette.primary, + }, + segmentText: { + fontSize: 14, + color: '#475569', + fontWeight: '600', + }, + segmentTextActive: { + color: palette.ink, + }, + weekRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 14, + }, + dayChip: { + width: 44, + height: 44, + borderRadius: 12, + backgroundColor: '#F1F5F9', + alignItems: 'center', + justifyContent: 'center', + }, + dayChipActive: { + backgroundColor: '#E0F8A2', + borderWidth: 2, + borderColor: palette.primary, + }, + dayChipText: { + fontSize: 16, + color: '#334155', + fontWeight: '700', + }, + dayChipTextActive: { + color: '#0F172A', + }, + sliderRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 16, + }, + sliderLabel: { + fontSize: 16, + color: '#334155', + fontWeight: '700', + }, + counter: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 12, + }, + counterBtn: { + width: 36, + height: 36, + borderRadius: 999, + backgroundColor: '#F1F5F9', + alignItems: 'center', + justifyContent: 'center', + }, + counterBtnText: { + fontSize: 18, + fontWeight: '800', + color: '#0F172A', + }, + counterValue: { + width: 44, + textAlign: 'center', + fontSize: 18, + fontWeight: '800', + color: '#0F172A', + }, + sliderSuffix: { + marginLeft: 8, + color: '#475569', + }, + helper: { + marginTop: 10, + color: '#5E6468', + }, + goalGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + goalItem: { + width: '48%', + backgroundColor: '#F8FAFC', + borderRadius: 14, + padding: 12, + marginBottom: 12, + }, + goalItemActive: { + backgroundColor: '#E0F8A2', + borderColor: palette.primary, + borderWidth: 2, + }, + goalTitle: { + fontSize: 16, + fontWeight: '800', + color: '#0F172A', + }, + goalTitleActive: { + color: '#0F172A', + }, + goalDesc: { + marginTop: 6, + fontSize: 12, + color: '#5E6468', + lineHeight: 16, + }, + rowBetween: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 6, + }, + rowRight: { + flexDirection: 'row', + alignItems: 'center', + }, + label: { + fontSize: 14, + color: '#0F172A', + fontWeight: '700', + }, + linkBtn: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 999, + backgroundColor: '#F1F5F9', + }, + linkText: { + color: '#334155', + fontWeight: '700', + }, + dateHint: { + marginTop: 6, + color: '#5E6468', + }, + input: { + marginLeft: 12, + backgroundColor: '#F1F5F9', + paddingHorizontal: 10, + paddingVertical: 8, + borderRadius: 8, + minWidth: 88, + textAlign: 'right', + color: '#0F172A', + }, + segmentSmall: { + flexDirection: 'row', + backgroundColor: '#F1F5F9', + padding: 3, + borderRadius: 999, + }, + segmentItemSmall: { + borderRadius: 999, + paddingVertical: 6, + paddingHorizontal: 10, + marginHorizontal: 3, + }, + segmentItemActiveSmall: { + backgroundColor: palette.primary, + }, + segmentTextSmall: { + fontSize: 12, + color: '#475569', + fontWeight: '700', + }, + segmentTextActiveSmall: { + color: palette.ink, + }, + primaryBtn: { + marginTop: 18, + backgroundColor: palette.primary, + paddingVertical: 14, + borderRadius: 14, + alignItems: 'center', + }, + primaryBtnDisabled: { + opacity: 0.5, + }, + primaryBtnText: { + color: palette.ink, + fontSize: 16, + fontWeight: '800', + }, +}); + + diff --git a/components/ui/HeaderBar.tsx b/components/ui/HeaderBar.tsx new file mode 100644 index 0000000..ee0e06e --- /dev/null +++ b/components/ui/HeaderBar.tsx @@ -0,0 +1,86 @@ +import { Ionicons } from '@expo/vector-icons'; +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; + +export type HeaderBarProps = { + title: string | React.ReactNode; + onBack?: () => void; + right?: React.ReactNode; + tone?: 'light' | 'dark'; + showBottomBorder?: boolean; + withSafeTop?: boolean; + transparent?: boolean; +}; + +export function HeaderBar({ + title, + onBack, + right, + tone, + showBottomBorder = false, + withSafeTop = true, + transparent = true, +}: HeaderBarProps) { + const insets = useSafeAreaInsets(); + const colorScheme = useColorScheme() ?? 'light'; + const theme = Colors[tone ?? colorScheme]; + + return ( + + {onBack ? ( + + + + ) : ( + + )} + + + {typeof title === 'string' ? ( + {title} + ) : ( + title + )} + + + {right ?? } + + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingBottom: 10, + }, + backButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 20, + fontWeight: '800', + }, +}); + + diff --git a/constants/Colors.ts b/constants/Colors.ts index 5dd93b8..4d55d51 100644 --- a/constants/Colors.ts +++ b/constants/Colors.ts @@ -62,6 +62,12 @@ export const Colors = { tabIconSelected: palette.ink, // tab 激活时的文字/图标颜色(深色,在亮色背景上显示) tabBarBackground: palette.ink, // tab 栏背景色 tabBarActiveBackground: primaryColor, // tab 激活时的背景色 + + // 页面氛围与装饰(新) + pageBackgroundEmphasis: '#F9FBF2', + heroSurfaceTint: 'rgba(187,242,70,0.18)', + ornamentPrimary: 'rgba(187,242,70,0.22)', + ornamentAccent: 'rgba(164,138,237,0.16)', }, dark: { // 基础文本/背景 @@ -99,5 +105,11 @@ export const Colors = { tabIconSelected: palette.ink, // 在亮色背景上使用深色文字 tabBarBackground: palette.ink, tabBarActiveBackground: primaryColor, + + // 页面氛围与装饰(新) + pageBackgroundEmphasis: '#151718', + heroSurfaceTint: 'rgba(187,242,70,0.12)', + ornamentPrimary: 'rgba(187,242,70,0.18)', + ornamentAccent: 'rgba(164,138,237,0.14)', }, } as const; diff --git a/hooks/useAuthGuard.ts b/hooks/useAuthGuard.ts new file mode 100644 index 0000000..d3d41a2 --- /dev/null +++ b/hooks/useAuthGuard.ts @@ -0,0 +1,63 @@ +import { usePathname, useRouter } from 'expo-router'; +import { useCallback } from 'react'; + +import { useAppSelector } from '@/hooks/redux'; + +type RedirectParams = Record; + +type EnsureOptions = { + redirectTo?: string; + redirectParams?: RedirectParams; +}; + +export function useAuthGuard() { + const router = useRouter(); + const currentPath = usePathname(); + const token = useAppSelector((s) => (s as any)?.user?.token as string | null); + const isLoggedIn = !!token; + + const ensureLoggedIn = useCallback(async (options?: EnsureOptions): Promise => { + if (isLoggedIn) return true; + + const redirectTo = options?.redirectTo ?? currentPath ?? '/(tabs)'; + const paramsJson = options?.redirectParams ? JSON.stringify(options.redirectParams) : undefined; + + router.push({ + pathname: '/auth/login', + params: { + redirectTo, + ...(paramsJson ? { redirectParams: paramsJson } : {}), + }, + } as any); + return false; + }, [isLoggedIn, router, currentPath]); + + const pushIfAuthedElseLogin = useCallback((pathname: string, params?: RedirectParams) => { + if (isLoggedIn) { + router.push({ pathname, params } as any); + return; + } + const paramsJson = params ? JSON.stringify(params) : undefined; + router.push({ pathname: '/auth/login', params: { redirectTo: pathname, ...(paramsJson ? { redirectParams: paramsJson } : {}) } } as any); + }, [isLoggedIn, router]); + + const guardHandler = useCallback( + (fn: (...args: T) => any | Promise, options?: EnsureOptions) => { + return async (...args: T) => { + const ok = await ensureLoggedIn(options); + if (!ok) return; + return fn(...args); + }; + }, + [ensureLoggedIn] + ); + + return { + isLoggedIn, + ensureLoggedIn, + pushIfAuthedElseLogin, + guardHandler, + } as const; +} + + diff --git a/package-lock.json b/package-lock.json index 26e3417..73d726e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@expo/vector-icons": "^14.1.0", "@react-native-async-storage/async-storage": "^2.2.0", + "@react-native-community/datetimepicker": "^8.4.4", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", @@ -36,6 +37,7 @@ "react-native": "0.79.5", "react-native-gesture-handler": "~2.24.0", "react-native-health": "^1.19.0", + "react-native-modal-datetime-picker": "^18.0.0", "react-native-reanimated": "~3.17.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", @@ -2764,6 +2766,29 @@ "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, + "node_modules/@react-native-community/datetimepicker": { + "version": "8.4.4", + "resolved": "https://mirrors.tencent.com/npm/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz", + "integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": ">=52.0.0", + "react": "*", + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + } + }, "node_modules/@react-native/assets-registry": { "version": "0.79.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.5.tgz", @@ -10225,7 +10250,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -10237,7 +10261,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/punycode": { @@ -10678,6 +10701,19 @@ "react-native": "*" } }, + "node_modules/react-native-modal-datetime-picker": { + "version": "18.0.0", + "resolved": "https://mirrors.tencent.com/npm/react-native-modal-datetime-picker/-/react-native-modal-datetime-picker-18.0.0.tgz", + "integrity": "sha512-0jdvhhraZQlRACwr7pM6vmZ2kxgzJ4CpnmV6J3TVA6MrXMXK6Zo/upRBKkRp0+fTOiKuNblzesA2U59rYo6SGA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@react-native-community/datetimepicker": ">=6.7.0", + "react-native": ">=0.65.0" + } + }, "node_modules/react-native-reanimated": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz", diff --git a/package.json b/package.json index 033432b..a25ff2d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@expo/vector-icons": "^14.1.0", "@react-native-async-storage/async-storage": "^2.2.0", + "@react-native-community/datetimepicker": "^8.4.4", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", @@ -39,6 +40,7 @@ "react-native": "0.79.5", "react-native-gesture-handler": "~2.24.0", "react-native-health": "^1.19.0", + "react-native-modal-datetime-picker": "^18.0.0", "react-native-reanimated": "~3.17.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", diff --git a/store/checkinSlice.ts b/store/checkinSlice.ts new file mode 100644 index 0000000..d50f2f1 --- /dev/null +++ b/store/checkinSlice.ts @@ -0,0 +1,78 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export type CheckinExercise = { + key: string; + name: string; + category: string; + sets: number; // 组数 + reps?: number; // 每组重复(计次型) + durationSec?: number; // 每组时长(计时型) + completed?: boolean; // 是否已完成该动作 +}; + +export type CheckinRecord = { + id: string; + date: string; // YYYY-MM-DD + items: CheckinExercise[]; + note?: string; +}; + +export type CheckinState = { + byDate: Record; + currentDate: string | null; +}; + +const initialState: CheckinState = { + byDate: {}, + currentDate: null, +}; + +function ensureRecord(state: CheckinState, date: string): CheckinRecord { + if (!state.byDate[date]) { + state.byDate[date] = { + id: `rec_${date}`, + date, + items: [], + }; + } + return state.byDate[date]; +} + +const checkinSlice = createSlice({ + name: 'checkin', + initialState, + reducers: { + setCurrentDate(state, action: PayloadAction) { + state.currentDate = action.payload; // 期望格式 YYYY-MM-DD + ensureRecord(state, action.payload); + }, + addExercise(state, action: PayloadAction<{ date: string; item: CheckinExercise }>) { + const rec = ensureRecord(state, action.payload.date); + // 若同 key 已存在则覆盖参数(更接近用户“重新选择/编辑”的心智) + const idx = rec.items.findIndex((it) => it.key === action.payload.item.key); + const normalized: CheckinExercise = { ...action.payload.item, completed: false }; + if (idx >= 0) rec.items[idx] = normalized; else rec.items.push(normalized); + }, + removeExercise(state, action: PayloadAction<{ date: string; key: string }>) { + const rec = ensureRecord(state, action.payload.date); + rec.items = rec.items.filter((it) => it.key !== action.payload.key); + }, + toggleExerciseCompleted(state, action: PayloadAction<{ date: string; key: string }>) { + const rec = ensureRecord(state, action.payload.date); + const idx = rec.items.findIndex((it) => it.key === action.payload.key); + if (idx >= 0) rec.items[idx].completed = !rec.items[idx].completed; + }, + setNote(state, action: PayloadAction<{ date: string; note: string }>) { + const rec = ensureRecord(state, action.payload.date); + rec.note = action.payload.note; + }, + resetDate(state, action: PayloadAction) { + delete state.byDate[action.payload]; + }, + }, +}); + +export const { setCurrentDate, addExercise, removeExercise, toggleExerciseCompleted, setNote, resetDate } = checkinSlice.actions; +export default checkinSlice.reducer; + + diff --git a/store/index.ts b/store/index.ts index 96bee61..06c48aa 100644 --- a/store/index.ts +++ b/store/index.ts @@ -1,11 +1,15 @@ import { configureStore } from '@reduxjs/toolkit'; import challengeReducer from './challengeSlice'; +import checkinReducer from './checkinSlice'; +import trainingPlanReducer from './trainingPlanSlice'; import userReducer from './userSlice'; export const store = configureStore({ reducer: { user: userReducer, challenge: challengeReducer, + checkin: checkinReducer, + trainingPlan: trainingPlanReducer, }, // React Native 环境默认即可 }); diff --git a/store/trainingPlanSlice.ts b/store/trainingPlanSlice.ts new file mode 100644 index 0000000..9d80344 --- /dev/null +++ b/store/trainingPlanSlice.ts @@ -0,0 +1,148 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export type PlanMode = 'daysOfWeek' | 'sessionsPerWeek'; + +export type PlanGoal = + | 'postpartum_recovery' // 产后恢复 + | 'fat_loss' // 减脂塑形 + | 'posture_correction' // 体态矫正 + | 'core_strength' // 核心力量 + | 'flexibility' // 柔韧灵活 + | 'rehab' // 康复保健 + | 'stress_relief'; // 释压放松 + +export type TrainingPlan = { + id: string; + createdAt: string; // ISO + startDate: string; // ISO (当天或下周一) + mode: PlanMode; + daysOfWeek: number[]; // 0(日) - 6(六) + sessionsPerWeek: number; // 1..7 + goal: PlanGoal | ''; + startWeightKg?: number; + preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | ''; +}; + +export type TrainingPlanState = { + current?: TrainingPlan | null; + draft: Omit; +}; + +const STORAGE_KEY = '@training_plan'; + +function nextMondayISO(): string { + const now = new Date(); + const day = now.getDay(); + const diff = (8 - day) % 7 || 7; // 距下周一的天数 + const next = new Date(now); + next.setDate(now.getDate() + diff); + next.setHours(0, 0, 0, 0); + return next.toISOString(); +} + +const initialState: TrainingPlanState = { + current: null, + draft: { + startDate: new Date(new Date().setHours(0, 0, 0, 0)).toISOString(), + mode: 'daysOfWeek', + daysOfWeek: [1, 3, 5], + sessionsPerWeek: 3, + goal: '', + startWeightKg: undefined, + preferredTimeOfDay: '', + }, +}; + +export const loadTrainingPlan = createAsyncThunk('trainingPlan/load', async () => { + const str = await AsyncStorage.getItem(STORAGE_KEY); + if (!str) return null; + try { + return JSON.parse(str) as TrainingPlan; + } catch { + return null; + } +}); + +export const saveTrainingPlan = createAsyncThunk( + 'trainingPlan/save', + async (_: void, { getState }) => { + const s = (getState() as any).trainingPlan as TrainingPlanState; + const draft = s.draft; + const plan: TrainingPlan = { + id: `plan_${Date.now()}`, + createdAt: new Date().toISOString(), + ...draft, + }; + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(plan)); + return plan; + } +); + +const trainingPlanSlice = createSlice({ + name: 'trainingPlan', + initialState, + reducers: { + setMode(state, action: PayloadAction) { + state.draft.mode = action.payload; + }, + toggleDayOfWeek(state, action: PayloadAction) { + const d = action.payload; + const set = new Set(state.draft.daysOfWeek); + if (set.has(d)) set.delete(d); else set.add(d); + state.draft.daysOfWeek = Array.from(set).sort(); + }, + setSessionsPerWeek(state, action: PayloadAction) { + const n = Math.min(7, Math.max(1, action.payload)); + state.draft.sessionsPerWeek = n; + }, + setGoal(state, action: PayloadAction) { + state.draft.goal = action.payload; + }, + setStartWeight(state, action: PayloadAction) { + state.draft.startWeightKg = action.payload; + }, + setStartDate(state, action: PayloadAction) { + state.draft.startDate = action.payload; + }, + setPreferredTime(state, action: PayloadAction) { + state.draft.preferredTimeOfDay = action.payload; + }, + setStartDateNextMonday(state) { + state.draft.startDate = nextMondayISO(); + }, + resetDraft(state) { + state.draft = initialState.draft; + }, + }, + extraReducers: (builder) => { + builder + .addCase(loadTrainingPlan.fulfilled, (state, action) => { + state.current = action.payload; + // 若存在历史计划,初始化 draft 基于该计划(便于编辑) + if (action.payload) { + const { id, createdAt, ...rest } = action.payload; + state.draft = { ...rest }; + } + }) + .addCase(saveTrainingPlan.fulfilled, (state, action) => { + state.current = action.payload; + }); + }, +}); + +export const { + setMode, + toggleDayOfWeek, + setSessionsPerWeek, + setGoal, + setStartWeight, + setStartDate, + setPreferredTime, + setStartDateNextMonday, + resetDraft, +} = trainingPlanSlice.actions; + +export default trainingPlanSlice.reducer; + + diff --git a/utils/exerciseLibrary.ts b/utils/exerciseLibrary.ts new file mode 100644 index 0000000..2efd82a --- /dev/null +++ b/utils/exerciseLibrary.ts @@ -0,0 +1,82 @@ +export type ExerciseCategory = + | '核心与腹部' + | '脊柱与后链' + | '侧链与髋' + | '平衡与支撑' + | '进阶控制' + | '柔韧与拉伸'; + +export type ExerciseLibraryItem = { + key: string; + name: string; + description: string; + category: ExerciseCategory; +}; + +export const EXERCISE_LIBRARY: ExerciseLibraryItem[] = [ + // 核心与腹部 + { key: 'hundred', name: '百次拍击 (The Hundred)', description: '仰卧桌面位,小幅摆臂协同吸呼,核心激活。', category: '核心与腹部' }, + { key: 'single_leg_stretch', name: '单腿伸展 (Single Leg Stretch)', description: '交替伸直一条腿,另一腿屈膝抱向胸口,稳定骨盆。', category: '核心与腹部' }, + { key: 'double_leg_stretch', name: '双腿伸展 (Double Leg Stretch)', description: '双臂双腿同时伸直/收回,呼吸控制,核心稳定。', category: '核心与腹部' }, + { key: 'criss_cross', name: '扭转卷腹 (Criss Cross)', description: '肘碰对侧膝,腹斜肌发力与呼吸配合。', category: '核心与腹部' }, + { key: 'single_straight_leg', name: '单腿直剪 (Scissors)', description: '交替拉长上抬直腿,控制下放,避免耸肩。', category: '核心与腹部' }, + { key: 'double_straight_leg', name: '双腿抬降 (Double Straight Leg Lower Lift)', description: '双腿并拢抬降,腹直肌抗伸展。', category: '核心与腹部' }, + { key: 'spine_stretch_forward', name: '脊柱前伸 (Spine Stretch Forward)', description: '坐姿脊柱分节前伸,强调轴向延展与呼吸。', category: '核心与腹部' }, + { key: 'roll_up', name: '卷起 (Roll Up)', description: '仰卧分节卷起到坐,前屈后还原,控制节律。', category: '核心与腹部' }, + { key: 'rolling_like_a_ball', name: '小球滚动 (Rolling Like a Ball)', description: '抱膝蜷成球,控制滚动回正,核心稳定。', category: '核心与腹部' }, + { key: 'teaser', name: '船式 (Teaser)', description: 'V字平衡,腿躯干对抗重力与摆动。', category: '核心与腹部' }, + + // 脊柱与后链 + { key: 'swan', name: '天鹅式 (Swan)', description: '俯卧伸展胸椎,后链发力,延展不挤压。', category: '脊柱与后链' }, + { key: 'swan_dive', name: '天鹅下潜 (Swan Dive)', description: '在Swan基础上前后摆动,弹性控制。', category: '脊柱与后链' }, + { key: 'swimming', name: '游泳式 (Swimming)', description: '俯卧交替抬对侧上肢与下肢,脊柱中立。', category: '脊柱与后链' }, + { key: 'shoulder_bridge', name: '肩桥 (Shoulder Bridge)', description: '仰卧卷尾抬盆,臀腿后侧力量与脊柱分节。', category: '脊柱与后链' }, + { key: 'spine_twist', name: '脊柱扭转 (Spine Twist)', description: '坐姿轴向延展上的控制旋转。', category: '脊柱与后链' }, + { key: 'saw', name: '锯式 (Saw)', description: '坐姿分腿,旋转前屈触对侧脚尖。', category: '脊柱与后链' }, + + // 侧链与髋 + { key: 'side_kick_front_back', name: '侧踢腿 前后摆 (Side Kick Front/Back)', description: '侧卧,髋稳定,腿前后摆动。', category: '侧链与髋' }, + { key: 'side_kick_up_down', name: '侧踢腿 上下 (Side Kick Up/Down)', description: '侧卧,上侧腿上抬下放,控制不耸肩。', category: '侧链与髋' }, + { key: 'side_leg_lift', name: '侧抬腿 (Side Leg Lift)', description: '侧卧抬腿,髋稳定,中臀肌激活。', category: '侧链与髋' }, + { key: 'clam', name: '蛤蜊式 (Clam)', description: '侧卧屈髋屈膝,抬起上侧膝,臀中刺激。', category: '侧链与髋' }, + { key: 'mermaid', name: '美人鱼 (Mermaid)', description: '坐姿侧屈拉伸,改善侧链柔韧。', category: '侧链与髋' }, + + // 平衡与支撑 + { key: 'plank', name: '平板支撑 (Plank)', description: '掌/前臂支撑,身体成一直线,核心稳定。', category: '平衡与支撑' }, + { key: 'side_plank', name: '侧板支撑 (Side Plank)', description: '单侧支撑,侧链与肩带稳定。', category: '平衡与支撑' }, + { key: 'push_up', name: '俯卧撑 (Push Up)', description: '胸肩臂与核心协同的推举支撑。', category: '平衡与支撑' }, + { key: 'leg_pull_front', name: '前拉腿 (Leg Pull Front)', description: '俯撑抬腿,后链与肩带控制。', category: '平衡与支撑' }, + { key: 'leg_pull_back', name: '后拉腿 (Leg Pull Back)', description: '仰撑抬腿,后链与肩带控制。', category: '平衡与支撑' }, + + // 进阶控制 + { key: 'jackknife', name: '折刀 (Jackknife)', description: '仰卧抬腿过头后折刀上推,核心与控制。', category: '进阶控制' }, + { key: 'open_leg_rocker', name: '开腿摇滚 (Open Leg Rocker)', description: '开腿V坐平衡,来回滚动。', category: '进阶控制' }, + { key: 'corkscrew', name: '开瓶器 (Corkscrew)', description: '躯干稳定下的双腿画圈,控制髋与核心。', category: '进阶控制' }, + { key: 'boomerang', name: '回旋镖 (Boomerang)', description: '融合Roll Over/Teaser的串联流畅控制。', category: '进阶控制' }, + { key: 'control_balance', name: '控制平衡 (Control Balance)', description: '过头位下的单腿抬降与控制。', category: '进阶控制' }, + { key: 'neck_pull', name: '抱颈卷起 (Neck Pull)', description: '更具挑战的卷起,背侧链参与更高。', category: '进阶控制' }, + { key: 'roll_over', name: '翻滚过头 (Roll Over)', description: '仰卧双腿过头落地,脊柱分节控制。', category: '进阶控制' }, + + // 柔韧与拉伸 + { key: 'cat_cow', name: '猫牛 (Cat-Cow)', description: '四点支撑的屈伸热身/整理放松。', category: '柔韧与拉伸' }, + { key: 'hamstring_stretch', name: '腘绳肌拉伸', description: '仰卧或坐姿,拉伸大腿后侧。', category: '柔韧与拉伸' }, + { key: 'hip_flexor_stretch', name: '髋屈肌拉伸', description: '弓步位,前髋前侧拉伸。', category: '柔韧与拉伸' }, + { key: 'thoracic_extension', name: '胸椎伸展', description: '泡沫轴/垫上胸椎延展放松。', category: '柔韧与拉伸' }, +]; + +export function getCategories(): ExerciseCategory[] { + const set = new Set(); + EXERCISE_LIBRARY.forEach((e) => set.add(e.category)); + return Array.from(set); +} + +export function searchExercises(keyword: string): ExerciseLibraryItem[] { + const kw = keyword.trim().toLowerCase(); + if (!kw) return EXERCISE_LIBRARY; + return EXERCISE_LIBRARY.filter((e) => + e.name.toLowerCase().includes(kw) || + e.description.toLowerCase().includes(kw) + ); +} + +