From f95401c1ce5e1394db6852d71914652826e85a59 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 15 Aug 2025 10:45:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20BMI=20=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E5=92=8C=E8=AE=AD=E7=BB=83=E8=AE=A1=E5=88=92=E6=8E=92?= =?UTF-8?q?=E8=AF=BE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 BMI 计算工具,支持用户输入体重和身高计算 BMI 值,并根据结果提供分类和建议 - 在训练计划中集成排课功能,允许用户选择和安排训练动作 - 更新个人信息页面,添加出生日期字段,支持用户完善个人资料 - 优化训练计划卡片样式,提升用户体验 - 更新相关依赖,确保项目兼容性和功能完整性 --- app/(tabs)/explore.tsx | 8 + app/(tabs)/personal.tsx | 311 +++---- app/index.tsx | 23 +- app/profile/edit.tsx | 15 +- app/training-plan.tsx | 1219 ++++++++++++++++++++++--- app/training-plan/schedule/index.tsx | 737 +++++++++++++++ app/training-plan/schedule/select.tsx | 724 +++++++++++++++ components/BMICard.tsx | 439 +++++++++ components/PlanCard.tsx | 10 +- ios/Podfile.lock | 27 + package-lock.json | 11 + package.json | 3 +- store/userSlice.ts | 2 +- utils/bmi.ts | 154 ++++ 14 files changed, 3309 insertions(+), 374 deletions(-) create mode 100644 app/training-plan/schedule/index.tsx create mode 100644 app/training-plan/schedule/select.tsx create mode 100644 components/BMICard.tsx create mode 100644 utils/bmi.ts diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index fdb9ce6..67474a6 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -1,4 +1,5 @@ import { AnimatedNumber } from '@/components/AnimatedNumber'; +import { BMICard } from '@/components/BMICard'; import { CircularRing } from '@/components/CircularRing'; import { ProgressBar } from '@/components/ProgressBar'; import { Colors } from '@/constants/Colors'; @@ -27,6 +28,7 @@ export default function ExploreScreen() { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000; + const userProfile = useAppSelector((s) => s.user.profile); // 使用 dayjs:当月日期与默认选中“今天” const days = getMonthDaysZh(); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); @@ -225,6 +227,12 @@ export default function ExploreScreen() { + + {/* BMI 指数卡片 */} + diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 50b2582..f2f595b 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -10,106 +10,63 @@ import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; import type { Href } from 'expo-router'; import { router } from 'expo-router'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Alert, Image, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg'; + export default function PersonalScreen() { const dispatch = useAppDispatch(); const insets = useSafeAreaInsets(); const tabBarHeight = useBottomTabBarHeight(); + const colorScheme = useColorScheme(); + const [notificationEnabled, setNotificationEnabled] = useState(true); + + // 计算底部间距 const bottomPadding = useMemo(() => { - // 统一的页面底部留白:TabBar 高度 + TabBar 与底部的额外间距 + 安全区底部 return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0); }, [tabBarHeight, insets?.bottom]); - const [notificationEnabled, setNotificationEnabled] = useState(true); - const colorScheme = useColorScheme(); + + // 颜色主题 const colors = Colors[colorScheme ?? 'light']; const theme = (colorScheme ?? 'light') as 'light' | 'dark'; - const colorTokens = Colors[theme]; - const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg'; - type UserProfile = { - name?: string; - email?: string; - gender?: 'male' | 'female' | ''; - age?: string; - weightKg?: number; - heightCm?: number; - avatarUri?: string | null; - }; + // 直接使用 Redux 中的用户信息,避免重复状态管理 + const userProfile = useAppSelector((state) => state.user.profile); - const userProfileFromRedux = useAppSelector((s) => s.user.profile); - const [profile, setProfile] = useState({}); - - const load = async () => { - try { - const [p, o] = await Promise.all([ - AsyncStorage.getItem('@user_profile'), - AsyncStorage.getItem('@user_personal_info'), - ]); - let next: UserProfile = {}; - if (o) { - try { - const parsed = JSON.parse(o); - next = { - ...next, - age: parsed?.age ? String(parsed.age) : undefined, - gender: parsed?.gender || '', - heightCm: parsed?.height ? parseFloat(parsed.height) : undefined, - weightKg: parsed?.weight ? parseFloat(parsed.weight) : undefined, - }; - } catch { } - } - if (p) { - try { next = { ...next, ...JSON.parse(p) }; } catch { } - } - setProfile(next); - } catch (e) { - console.warn('加载用户资料失败', e); - } - }; - - useEffect(() => { load(); }, []); - useFocusEffect(React.useCallback(() => { - // 聚焦时只拉后端,避免与本地 load 循环触发 - dispatch(fetchMyProfile()); - return () => { }; - }, [dispatch])); - useEffect(() => { - const r = userProfileFromRedux as any; - if (!r) return; - setProfile((prev) => { - const next = { ...prev } as any; - const nameNext = (r.name && String(r.name)) || prev.name; - const genderNext = (r.gender === 'male' || r.gender === 'female') ? r.gender : (prev.gender ?? ''); - const avatarUriNext = typeof r.avatar === 'string' && (r.avatar.startsWith('http') || r.avatar.startsWith('data:')) - ? r.avatar - : prev.avatarUri; - let changed = false; - if (next.name !== nameNext) { next.name = nameNext; changed = true; } - if (next.gender !== genderNext) { next.gender = genderNext; changed = true; } - if (next.avatarUri !== avatarUriNext) { next.avatarUri = avatarUriNext; changed = true; } - return changed ? next : prev; - }); - }, [userProfileFromRedux]); + // 页面聚焦时获取最新用户信息 + useFocusEffect( + React.useCallback(() => { + dispatch(fetchMyProfile()); + }, [dispatch]) + ); + // 数据格式化函数 const formatHeight = () => { - if (profile.heightCm == null) return '--'; - return `${Math.round(profile.heightCm)}cm`; + if (userProfile.height == null) return '--'; + return `${Math.round(userProfile.height)}cm`; }; const formatWeight = () => { - if (profile.weightKg == null) return '--'; - return `${round(profile.weightKg, 1)}kg`; + if (userProfile.weight == null) return '--'; + return `${Math.round(userProfile.weight * 10) / 10}kg`; }; - const formatAge = () => (profile.age ? `${profile.age}岁` : '--'); - - const round = (n: number, d = 0) => { - const p = Math.pow(10, d); return Math.round(n * p) / p; + const formatAge = () => { + if (!userProfile.birthDate) return '--'; + const birthDate = new Date(userProfile.birthDate); + const today = new Date(); + const age = today.getFullYear() - birthDate.getFullYear(); + return `${age}岁`; }; + // 显示名称 + const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME; + + // 颜色令牌 + const colorTokens = colors; + const handleResetOnboarding = () => { Alert.alert( '重置引导', @@ -137,7 +94,6 @@ export default function PersonalScreen() { }; - const displayName = (profile.name && profile.name.trim()) ? profile.name : DEFAULT_MEMBER_NAME; const handleDeleteAccount = () => { Alert.alert( @@ -172,7 +128,7 @@ export default function PersonalScreen() { {/* 头像 */} - + @@ -206,6 +162,7 @@ export default function PersonalScreen() { ); + // 菜单项组件 const MenuSection = ({ title, items }: { title: string; items: any[] }) => ( {title} @@ -216,8 +173,15 @@ export default function PersonalScreen() { onPress={item.onPress} > - - + + {item.title} @@ -237,8 +201,8 @@ export default function PersonalScreen() { ); - // 动态创建样式 - const dynamicStyles = { + // 动态样式 + const dynamicStyles = StyleSheet.create({ editButton: { backgroundColor: colors.primary, paddingHorizontal: 20, @@ -246,13 +210,13 @@ export default function PersonalScreen() { borderRadius: 20, }, editButtonText: { - color: '#192126', + color: colors.onPrimary, fontSize: 14, - fontWeight: '600' as const, + fontWeight: '600', }, statValue: { fontSize: 18, - fontWeight: 'bold' as const, + fontWeight: 'bold', color: colors.primary, marginBottom: 4, }, @@ -261,83 +225,80 @@ export default function PersonalScreen() { height: 56, borderRadius: 28, backgroundColor: colors.primary, - alignItems: 'center' as const, - justifyContent: 'center' as const, + alignItems: 'center', + justifyContent: 'center', shadowColor: colors.primary, - shadowOffset: { - width: 0, - height: 4, - }, + shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 8, }, - }; + }); - const accountItems = [ + // 菜单项配置 + const menuSections = [ { - icon: 'flag-outline', - iconBg: '#E8F5E8', - iconColor: '#4ADE80', - title: '目标管理', - onPress: () => router.push('/profile/goals' as Href), + title: '账户', + items: [ + { + icon: 'flag-outline' as const, + title: '目标管理', + onPress: () => router.push('/profile/goals' as Href), + }, + { + icon: 'stats-chart-outline' as const, + title: '训练进度', + }, + ], }, { - icon: 'stats-chart-outline', - iconBg: '#E8F5E8', - iconColor: '#4ADE80', - title: '训练进度', - }, - ]; - - const notificationItems = [ - { - icon: 'notifications-outline', - iconBg: '#E8F5E8', - iconColor: '#4ADE80', - title: '消息推送', - type: 'switch', - }, - ]; - - const otherItems = [ - { - icon: 'mail-outline', - iconBg: '#E8F5E8', - iconColor: '#4ADE80', - title: '联系我们', + title: '通知', + items: [ + { + icon: 'notifications-outline' as const, + title: '消息推送', + type: 'switch' as const, + }, + ], }, { - icon: 'shield-checkmark-outline', - iconBg: '#E8F5E8', - iconColor: '#4ADE80', - title: '隐私政策', + title: '其他', + items: [ + { + icon: 'mail-outline' as const, + title: '联系我们', + }, + { + icon: 'shield-checkmark-outline' as const, + title: '隐私政策', + }, + { + icon: 'settings-outline' as const, + title: '设置', + }, + ], }, { - icon: 'settings-outline', - iconBg: '#E8F5E8', - iconColor: '#4ADE80', - title: '设置', + title: '账号与安全', + items: [ + { + icon: 'trash-outline' as const, + title: '注销帐号', + onPress: handleDeleteAccount, + isDanger: true, + }, + ], }, - ]; - - const securityItems = [ { - icon: 'trash-outline', - iconBg: '#FFE8E8', - iconColor: '#FF4444', - title: '注销帐号', - onPress: handleDeleteAccount, - }, - ]; - - const developerItems = [ - { - icon: 'refresh-outline', - iconBg: '#FFE8E8', - iconColor: '#FF4444', - title: '重置引导流程', - onPress: handleResetOnboarding, + title: '开发者', + items: [ + { + icon: 'refresh-outline' as const, + title: '重置引导流程', + onPress: handleResetOnboarding, + isDanger: true, + }, + ], }, ]; @@ -352,11 +313,9 @@ export default function PersonalScreen() { > - - - - - + {menuSections.map((section, index) => ( + + ))} {/* 底部浮动按钮 */} @@ -373,7 +332,6 @@ export default function PersonalScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#F5F5F5', // 浅灰色背景 }, safeArea: { flex: 1, @@ -381,18 +339,13 @@ const styles = StyleSheet.create({ scrollView: { flex: 1, paddingHorizontal: 20, - backgroundColor: '#F5F5F5', }, // 用户信息区域 userInfoCard: { borderRadius: 16, marginBottom: 20, - backgroundColor: '#FFFFFF', shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, + shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 6, elevation: 3, @@ -409,57 +362,26 @@ const styles = StyleSheet.create({ width: 80, height: 80, borderRadius: 40, - backgroundColor: '#E8D4F0', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }, - avatarContent: { - width: '100%', - height: '100%', - alignItems: 'center', - justifyContent: 'center', - }, - avatarIcon: { - alignItems: 'center', - justifyContent: 'center', - }, - avatarFace: { - width: 25, - height: 25, - borderRadius: 12.5, - backgroundColor: '#D4A574', - marginBottom: 5, - }, - avatarBody: { - width: 30, - height: 20, - borderRadius: 15, - backgroundColor: '#F4C842', - }, userDetails: { flex: 1, }, userName: { fontSize: 18, fontWeight: 'bold', - color: '#192126', marginBottom: 4, }, - - // 统计信息区域 statsContainer: { flexDirection: 'row', justifyContent: 'space-between', - backgroundColor: '#FFFFFF', borderRadius: 16, padding: 20, marginBottom: 20, shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, + shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 3, @@ -471,19 +393,15 @@ const styles = StyleSheet.create({ statLabel: { fontSize: 12, - color: '#687076', }, - // 菜单区域 menuSection: { marginBottom: 20, - backgroundColor: '#FFFFFF', padding: 16, borderRadius: 16, }, sectionTitle: { fontSize: 20, fontWeight: '800', - color: '#192126', marginBottom: 12, paddingHorizontal: 4, }, @@ -511,7 +429,6 @@ const styles = StyleSheet.create({ }, menuItemText: { fontSize: 16, - color: '#192126', flex: 1, }, switch: { diff --git a/app/index.tsx b/app/index.tsx index a15e621..c20c79e 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -3,7 +3,7 @@ import { useThemeColor } from '@/hooks/useThemeColor'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { router } from 'expo-router'; import React, { useEffect, useState } from 'react'; -import { ActivityIndicator, Text, View } from 'react-native'; +import { ActivityIndicator, View } from 'react-native'; const ONBOARDING_COMPLETED_KEY = '@onboarding_completed'; @@ -20,15 +20,12 @@ export default function SplashScreen() { try { const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY); - // 添加一个短暂的延迟以显示启动画面 - setTimeout(() => { - if (onboardingCompleted === 'true') { - router.replace('/(tabs)'); - } else { - router.replace('/onboarding'); - } - setIsLoading(false); - }, 1000); + if (onboardingCompleted === 'true') { + router.replace('/(tabs)'); + } else { + router.replace('/onboarding'); + } + setIsLoading(false); } catch (error) { console.error('检查引导状态失败:', error); // 如果出现错误,默认显示引导页面 @@ -59,11 +56,7 @@ export default function SplashScreen() { alignItems: 'center', marginBottom: 20, }}> - - 🧘‍♀️ - + diff --git a/app/profile/edit.tsx b/app/profile/edit.tsx index 1ced0d7..9efae71 100644 --- a/app/profile/edit.tsx +++ b/app/profile/edit.tsx @@ -195,6 +195,7 @@ export default function EditProfileScreen() { avatar: next.avatarUri || undefined, weight: next.weight || undefined, height: next.height || undefined, + birthDate: next.birthDate ? new Date(next.birthDate).getTime() / 1000 : undefined, }); // 拉取最新用户信息,刷新全局状态 await dispatch(fetchMyProfile() as any); @@ -363,13 +364,13 @@ export default function EditProfileScreen() { {profile.birthDate ? (() => { - try { - const d = new Date(profile.birthDate); - return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }).format(d); - } catch { - return profile.birthDate; - } - })() + try { + const d = new Date(profile.birthDate); + return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }).format(d); + } catch { + return profile.birthDate; + } + })() : '选择出生日期(可选)'} diff --git a/app/training-plan.tsx b/app/training-plan.tsx index 5211f87..d2d9ea0 100644 --- a/app/training-plan.tsx +++ b/app/training-plan.tsx @@ -1,7 +1,9 @@ +import { Ionicons } from '@expo/vector-icons'; +import MaskedView from '@react-native-masked-view/masked-view'; import { LinearGradient } from 'expo-linear-gradient'; -import { useRouter } from 'expo-router'; -import React, { useEffect } from 'react'; -import { Pressable, SafeAreaView, ScrollView, StyleSheet, View } from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, FlatList, Modal, Pressable, SafeAreaView, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native'; import Animated, { FadeInUp, FadeOut, @@ -16,9 +18,36 @@ import Animated, { import { ThemedText } from '@/components/ThemedText'; import { HeaderBar } from '@/components/ui/HeaderBar'; -import { palette } from '@/constants/Colors'; +import { Colors, palette } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; import { activatePlan, clearError, deletePlan, loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice'; +import { buildClassicalSession } from '@/utils/classicalSession'; + +// 训练计划排课项目类型 +export interface ScheduleExercise { + key: string; + name: string; + category: string; + sets: number; + reps?: number; + durationSec?: number; + restSec?: number; + note?: string; + itemType?: 'exercise' | 'rest' | 'note'; + completed?: boolean; +} + +// 训练计划排课数据 +export interface PlanSchedule { + planId: string; + exercises: ScheduleExercise[]; + note?: string; + lastModified: string; +} + +// Tab 类型定义 +type TabType = 'list' | 'schedule'; const GOAL_TEXT: Record = { @@ -61,8 +90,23 @@ function DynamicBackground() { } -// 简洁的训练计划卡片 -function PlanCard({ plan, onPress, onDelete, isActive, index }: { plan: TrainingPlan; onPress: () => void; onDelete: () => void; isActive?: boolean; index: number }) { +// 渐变文字 +function GradientText({ children }: { children: string }) { + return ( + {children}}> + + {children} + + + ); +} + +// 新视觉训练计划卡片 +function PlanCard({ plan, onPress, onDelete, onActivate, onSchedule, isActive, index }: { plan: TrainingPlan; onPress: () => void; onDelete: () => void; onActivate: () => void; onSchedule: () => void; isActive?: boolean; index: number }) { const scale = useSharedValue(1); const glow = useSharedValue(0); @@ -77,13 +121,13 @@ function PlanCard({ plan, onPress, onDelete, isActive, index }: { plan: Training })); const glowStyle = useAnimatedStyle(() => { - const opacity = isActive ? interpolate(glow.value, [0, 1], [0.3, 0.7]) : interpolate(glow.value, [0, 1], [0.15, 0.4]); + const opacity = isActive ? interpolate(glow.value, [0, 1], [0.25, 0.55]) : interpolate(glow.value, [0, 1], [0.1, 0.3]); return { shadowOpacity: opacity, - shadowColor: goalConfig.color, - shadowRadius: isActive ? 24 : 16, - elevation: isActive ? 16 : 12, - borderColor: `${goalConfig.color}${isActive ? '50' : '30'}`, + shadowColor: '#000', + shadowRadius: isActive ? 28 : 18, + elevation: isActive ? 18 : 10, + borderColor: isActive ? `${goalConfig.color}55` : '#1B262B', }; }); @@ -99,6 +143,11 @@ function PlanCard({ plan, onPress, onDelete, isActive, index }: { plan: Training return `每周${plan.sessionsPerWeek}次`; }; + const displayTitle = plan.name?.trim() ? plan.name : goalConfig.title; + const frequencyCount = plan.mode === 'daysOfWeek' ? plan.daysOfWeek.length : plan.sessionsPerWeek; + const sinceCreatedDays = Math.max(0, Math.floor((Date.now() - new Date(plan.createdAt).getTime()) / (24 * 3600 * 1000))); + const startDeltaDays = Math.floor((Date.now() - new Date(plan.startDate).getTime()) / (24 * 3600 * 1000)); + return ( { + Alert.alert('操作', '选择要执行的操作', [ + { text: '排课', onPress: onSchedule }, + { text: isActive ? '已激活' : '激活', onPress: onActivate }, + { text: '删除', style: 'destructive', onPress: onDelete }, + { text: '取消', style: 'cancel' }, + ]); + }} onPressIn={() => { scale.value = withSpring(0.98); }} onPressOut={() => { scale.value = withSpring(1); }} - style={styles.cardContent} + style={styles.darkCard} > + {displayTitle} + + {`${goalConfig.description} · 开始于 ${formatDate(plan.startDate)} · ${getFrequencyText()}${plan.preferredTimeOfDay ? ` · ${plan.preferredTimeOfDay === 'morning' ? '晨练' : plan.preferredTimeOfDay === 'noon' ? '午间' : '晚间'}` : ''}`} + - {/* 左侧色彩指示器 */} - - - {/* 主要内容 */} - - - - {goalConfig.title} - {goalConfig.description} - - {isActive && ( - - 当前 - - )} - - - - - 开始时间 - {formatDate(plan.startDate)} - - - 训练频率 - {getFrequencyText()} - - {plan.preferredTimeOfDay && ( - - 时间偏好 - - {plan.preferredTimeOfDay === 'morning' ? '晨练' : - plan.preferredTimeOfDay === 'noon' ? '午间' : '晚间'} - - - )} - + + + + 排课 + + + + {isActive ? '已激活' : '激活'} + + { + Alert.alert('确认删除', '确定要删除这个训练计划吗?此操作无法撤销。', [ + { text: '取消', style: 'cancel' }, + { text: '删除', style: 'destructive', onPress: onDelete }, + ]); + }} hitSlop={8}> + + 删除 + ); } -export default function TrainingPlanListScreen() { +// 底部 Tab 组件 +function BottomTabs({ activeTab, onTabChange, selectedPlan }: { + activeTab: TabType; + onTabChange: (tab: TabType) => void; + selectedPlan?: TrainingPlan; +}) { + const theme = useColorScheme() ?? 'light'; + const colorTokens = Colors[theme]; + + return ( + + + onTabChange('list')} + > + + {activeTab === 'list' && ( + 训练计划 + )} + + + onTabChange('schedule')} + > + + {activeTab === 'schedule' && ( + 锻炼排期 + )} + + + + ); +} + +export default function TrainingPlanScreen() { const router = useRouter(); const dispatch = useAppDispatch(); + const params = useLocalSearchParams<{ planId?: string; newExercise?: string }>(); const { plans, currentId, loading, error } = useAppSelector((s) => s.trainingPlan); + // Tab 状态管理 + const [activeTab, setActiveTab] = useState('list'); + const [selectedPlanId, setSelectedPlanId] = useState(params.planId || currentId || null); + + // 排课相关状态 + const [exercises, setExercises] = useState([]); + const [scheduleNote, setScheduleNote] = useState(''); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + // 一键排课配置 + const [genVisible, setGenVisible] = useState(false); + const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner'); + const [genWithRests, setGenWithRests] = useState(true); + const [genWithNotes, setGenWithNotes] = useState(true); + const [genRest, setGenRest] = useState('30'); + + const selectedPlan = useMemo(() => plans.find(p => p.id === selectedPlanId), [plans, selectedPlanId]); + + // 模拟加载排课数据的函数 + const loadScheduleData = async (planId: string): Promise => { + // 模拟 API 调用延迟 + await new Promise(resolve => setTimeout(resolve, 300)); + + // 模拟数据 - 在实际应用中,这里应该从后端或本地存储获取数据 + const mockData: Record = { + // 示例数据结构,实际应用中应从服务器或本地存储获取 + // 'plan1': { + // planId: 'plan1', + // exercises: [...], + // note: '示例备注', + // lastModified: new Date().toISOString() + // } + }; + + return mockData[planId] || null; + }; + + // 监听 selectedPlan 变化,加载对应的排课数据 + useEffect(() => { + const loadSchedule = async () => { + if (selectedPlan) { + try { + const scheduleData = await loadScheduleData(selectedPlan.id); + if (scheduleData) { + setExercises(scheduleData.exercises); + setScheduleNote(scheduleData.note || ''); + } else { + // 如果没有保存的排课数据,重置为默认空状态 + setExercises([]); + setScheduleNote(''); + } + } catch (error) { + console.error('加载排课数据失败:', error); + // 出错时重置为默认空状态 + setExercises([]); + setScheduleNote(''); + } + } else { + // 没有选中计划时,重置为默认空状态 + setExercises([]); + setScheduleNote(''); + } + }; + + loadSchedule(); + }, [selectedPlan]); + useEffect(() => { dispatch(loadPlans()); }, [dispatch]); useEffect(() => { if (error) { - // 可以在这里显示错误提示,比如使用 Alert 或 Toast console.error('训练计划错误:', error); - // 3秒后自动清除错误 const timer = setTimeout(() => { dispatch(clearError()); }, 3000); return () => clearTimeout(timer); } }, [error, dispatch]); - + + // 处理从选择页面传回的新动作 + useEffect(() => { + if (params.newExercise) { + try { + const newExercise: ScheduleExercise = JSON.parse(params.newExercise); + setExercises(prev => [...prev, newExercise]); + setHasUnsavedChanges(true); + router.setParams({ newExercise: undefined } as any); + } catch (error) { + console.error('解析新动作数据失败:', error); + } + } + }, [params.newExercise]); const handleActivate = async (planId: string) => { try { @@ -192,7 +362,304 @@ export default function TrainingPlanListScreen() { } catch (error) { console.error('激活训练计划失败:', error); } - } + } + + const handlePlanSelect = (plan: TrainingPlan) => { + setSelectedPlanId(plan.id); + setActiveTab('schedule'); + // TODO: 加载该计划的排课数据 + } + + const handleTabChange = (tab: TabType) => { + if (tab === 'schedule' && !selectedPlanId && plans.length > 0) { + // 如果没有选中计划但要切换到排课页面,自动选择当前激活的计划或第一个计划 + const targetPlan = plans.find(p => p.id === currentId) || plans[0]; + setSelectedPlanId(targetPlan.id); + } + setActiveTab(tab); + } + + // 排课相关方法 + const handleSave = async () => { + if (!selectedPlan) return; + + try { + const scheduleData: PlanSchedule = { + planId: selectedPlan.id, + exercises, + note: scheduleNote, + lastModified: new Date().toISOString(), + }; + + console.log('保存排课数据:', scheduleData); + setHasUnsavedChanges(false); + Alert.alert('保存成功', '训练计划排课已保存'); + } catch (error) { + console.error('保存排课失败:', error); + Alert.alert('保存失败', '请稍后重试'); + } + }; + + const handleAddExercise = () => { + router.push(`/training-plan/schedule/select?planId=${selectedPlanId}` as any); + }; + + const handleRemoveExercise = (key: string) => { + Alert.alert('确认移除', '确定要移除该动作吗?', [ + { text: '取消', style: 'cancel' }, + { + text: '移除', + style: 'destructive', + onPress: () => { + setExercises(prev => prev.filter(ex => ex.key !== key)); + setHasUnsavedChanges(true); + }, + }, + ]); + }; + + const handleToggleCompleted = (key: string) => { + setExercises(prev => prev.map(ex => + ex.key === key ? { ...ex, completed: !ex.completed } : ex + )); + setHasUnsavedChanges(true); + }; + + const onGenerate = () => { + const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10))); + const { items, note } = buildClassicalSession({ + withSectionRests: genWithRests, + restSeconds: restSec, + withNotes: genWithNotes, + level: genLevel + }); + + const scheduleItems: ScheduleExercise[] = items.map((item, index) => ({ + key: `generated_${Date.now()}_${index}`, + name: item.name, + category: item.category, + sets: item.sets, + reps: item.reps, + durationSec: item.durationSec, + restSec: item.restSec, + note: item.note, + itemType: item.itemType, + completed: false, + })); + + setExercises(scheduleItems); + setScheduleNote(note || ''); + setHasUnsavedChanges(true); + setGenVisible(false); + Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。'); + }; + + // 渲染训练计划列表 + const renderPlansList = () => ( + + + 我的训练计划 + 点击计划卡片进入排课模式,或使用底部切换 + + + {error && ( + + ⚠️ {error} + + )} + + {loading && plans.length === 0 ? ( + + + + + 加载中... + + ) : plans.length === 0 ? ( + + + 📋 + + 还没有训练计划 + 创建你的第一个计划开始训练吧 + router.push('/training-plan/create' as any)} style={styles.primaryBtn}> + 创建计划 + + + ) : ( + + {plans.map((p, index) => ( + handlePlanSelect(p)} + onDelete={() => dispatch(deletePlan(p.id))} + onActivate={() => handleActivate(p.id)} + onSchedule={() => handlePlanSelect(p)} + /> + ))} + {loading && ( + + 处理中... + + )} + + )} + + + + ); + + // 渲染排课页面 + const renderSchedulePage = () => { + if (!selectedPlan) { + return ( + + + 📅 + + 请先选择一个训练计划 + 切换到训练计划页面选择一个计划,或点击下方按钮 + setActiveTab('list')} + > + 选择计划 + + + ); + } + + const goalConfig = GOAL_TEXT[selectedPlan.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' }; + + return ( + + {/* 计划信息头部 */} + + + + {selectedPlan.name || goalConfig.title} + {goalConfig.description} + + + + {/* 操作按钮区域 */} + + + + 添加动作 + + + setGenVisible(true)} + > + + 一键排课 + + + + {/* 动作列表 */} + item.key} + contentContainerStyle={styles.scheduleListContent} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + 💪 + + 还没有添加任何动作 + 点击"添加动作"开始排课,或使用"一键排课"快速生成 + + } + renderItem={({ item, index }) => { + const isRest = item.itemType === 'rest'; + const isNote = item.itemType === 'note'; + + if (isRest || isNote) { + return ( + + + + + {isRest ? `间隔休息 ${item.restSec ?? 30}s` : (item.note || '提示')} + + + handleRemoveExercise(item.key)} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + > + + + + ); + } + + return ( + + + + {item.name} + {item.category} + + 组数 {item.sets} + {item.reps ? ` · 每组 ${item.reps} 次` : ''} + {item.durationSec ? ` · 每组 ${item.durationSec}s` : ''} + + + + + handleToggleCompleted(item.key)} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + > + + + + handleRemoveExercise(item.key)} + > + 移除 + + + + + ); + }} + /> + + ); + }; return ( @@ -201,72 +668,93 @@ export default function TrainingPlanListScreen() { router.back()} withSafeTop={false} tone='light' transparent={true} - right={( - router.push('/training-plan/create' as any)} style={styles.createBtn}> - + 新建 - - )} + right={ + activeTab === 'list' ? ( + router.push('/training-plan/create' as any)} style={styles.headerRightBtn}> + + 新建 + + ) : hasUnsavedChanges ? ( + + 保存 + + ) : undefined + } /> - - - 我的训练计划 - 点击激活计划,长按删除 - + + {activeTab === 'list' ? renderPlansList() : renderSchedulePage()} + - {error && ( - - ⚠️ {error} - - )} + {/* 底部 Tab */} + - {loading && plans.length === 0 ? ( - - - - - 加载中... - - ) : plans.length === 0 ? ( - - - 📋 - - 还没有训练计划 - 创建你的第一个计划开始训练吧 - router.push('/training-plan/create' as any)} style={styles.primaryBtn}> - 创建计划 - - - ) : ( - - {plans.map((p, index) => ( - { - router.push(`/training-plan/create?id=${p.id}` as any); - }} - onDelete={() => dispatch(deletePlan(p.id))} - /> - ))} - {loading && ( - - 处理中... - - )} - - )} + {/* 一键排课配置弹窗 */} + {selectedPlan && ( + setGenVisible(false)}> + setGenVisible(false)}> + e.stopPropagation() as any}> + 一键排课配置 - - + 强度水平 + + {(['beginner', 'intermediate', 'advanced'] as const).map((lv) => ( + setGenLevel(lv)} + > + + {lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'} + + + ))} + + + + 段间休息 + + + + + 插入操作提示 + + + + + 休息秒数 + + + + + 生成训练计划 + + + + + )} ); @@ -323,27 +811,36 @@ const styles = StyleSheet.create({ // 训练计划列表 plansList: { - gap: 12, + gap: 10, }, // 训练计划卡片 planCard: { - borderRadius: 16, + borderRadius: 28, overflow: 'hidden', - shadowOffset: { width: 0, height: 6 }, - shadowRadius: 16, - elevation: 12, - borderWidth: 1.5, - borderColor: 'rgba(187,242,70,0.3)', - backgroundColor: '#FFFFFF', - shadowColor: '#BBF246', + shadowOffset: { width: 0, height: 10 }, + shadowRadius: 20, + elevation: 8, + borderWidth: 1, + borderColor: '#3A261B', + backgroundColor: '#1F1410', + shadowColor: '#000', }, cardContent: { position: 'relative', }, + darkCard: { + backgroundColor: '#1F1410', + padding: 24, + borderRadius: 28, + }, + cardTintGradient: { + ...StyleSheet.absoluteFillObject, + borderRadius: 28, + }, cardGradient: { ...StyleSheet.absoluteFillObject, - borderRadius: 16, + borderRadius: 14, }, cardGlow: { position: 'absolute', @@ -364,57 +861,144 @@ const styles = StyleSheet.create({ borderBottomLeftRadius: 16, }, cardMain: { - padding: 20, - paddingLeft: 24, + padding: 16, + paddingLeft: 20, }, cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', - marginBottom: 16, + marginBottom: 12, }, titleSection: { flex: 1, }, planTitle: { - fontSize: 18, + fontSize: 17, fontWeight: '800', - color: '#192126', - marginBottom: 4, + color: '#1A1E23', + marginBottom: 2, + }, + gradientTitle: { + fontSize: 34, + fontWeight: '800', + lineHeight: 40, + color: '#FFFFFF', }, planDescription: { - fontSize: 13, - color: '#5E6468', - opacity: 0.8, + fontSize: 12, + color: '#6A5E58', + opacity: 0.9, + }, + darkSubtitle: { + marginTop: 16, + fontSize: 16, + color: '#E0D2C9', + lineHeight: 24, }, activeBadge: { - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 12, - marginLeft: 12, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 10, + marginLeft: 8, }, activeText: { - fontSize: 11, + fontSize: 10, fontWeight: '800', color: palette.ink, }, cardInfo: { flexDirection: 'row', - gap: 20, + gap: 16, }, infoItem: { flex: 1, }, infoLabel: { - fontSize: 11, - color: '#888F92', - marginBottom: 2, + fontSize: 10, + color: '#8A7F78', + marginBottom: 1, fontWeight: '600', }, infoValue: { - fontSize: 14, - color: '#384046', - fontWeight: '600', + fontSize: 13, + color: '#2F2A26', + fontWeight: '700', + }, + metricsRow: { + marginTop: 28, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + metricItem: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 8, + borderRadius: 20, + }, + metricActive: { + backgroundColor: 'rgba(255,255,255,0.06)', + }, + metricText: { + marginLeft: 8, + color: '#E6EEF2', + fontSize: 16, + fontWeight: '700', + }, + + // 操作按钮区域 + actionButtons: { + flexDirection: 'row', + marginTop: 10, + gap: 6, + }, + actionButton: { + flex: 1, + paddingVertical: 6, + paddingHorizontal: 8, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + scheduleButton: { + backgroundColor: 'transparent', + borderWidth: 1, + }, + activateButton: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.12, + shadowRadius: 3, + elevation: 3, + }, + activeIndicator: { + borderWidth: 1.5, + }, + actionButtonText: { + fontSize: 11, + fontWeight: '700', + }, + activateButtonText: { + fontSize: 11, + fontWeight: '700', + color: '#FFFFFF', + }, + activeIndicatorText: { + fontSize: 11, + fontWeight: '700', + }, + deleteButton: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: '#ED4747', + }, + deleteButtonText: { + fontSize: 11, + fontWeight: '700', + color: '#ED4747', }, // 按钮样式 @@ -537,6 +1121,345 @@ const styles = StyleSheet.create({ fontWeight: '600', textAlign: 'center', }, + // 底部 Tab 样式(与主页一致) + bottomTabContainer: { + position: 'absolute', + bottom: 20, // TAB_BAR_BOTTOM_OFFSET + left: 0, + right: 0, + paddingHorizontal: 20, + }, + bottomTabBar: { + flexDirection: 'row', + height: 68, // TAB_BAR_HEIGHT + borderRadius: 34, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 10, + elevation: 5, + paddingHorizontal: 10, + paddingTop: 0, + paddingBottom: 0, + marginHorizontal: 0, + width: '100%', + alignSelf: 'center', + }, + tabButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginHorizontal: 6, + marginVertical: 10, + borderRadius: 25, + paddingHorizontal: 16, + paddingVertical: 8, + }, + tabText: { + fontSize: 12, + fontWeight: '600', + marginLeft: 6, + }, + + // 主内容区域 + mainContent: { + flex: 1, + paddingBottom: 100, // 为底部 tab 留出空间 + }, + + // 排课页面样式 + scheduleContent: { + flex: 1, + paddingHorizontal: 20, + }, + planHeader: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 16, + marginBottom: 16, + }, + planColorIndicator: { + width: 4, + height: 40, + borderRadius: 2, + marginRight: 12, + }, + planInfo: { + flex: 1, + }, + + // 排课操作按钮 + actionRow: { + flexDirection: 'row', + gap: 12, + marginBottom: 20, + }, + scheduleActionBtn: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + borderRadius: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 4, + }, + scheduleActionBtnText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '700', + }, + scheduleSecondaryBtn: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + borderRadius: 12, + borderWidth: 1.5, + backgroundColor: '#FFFFFF', + }, + scheduleSecondaryBtnText: { + fontSize: 14, + fontWeight: '700', + }, + + // 排课列表 + scheduleListContent: { + paddingBottom: 40, + }, + + // 动作卡片 + exerciseCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginBottom: 12, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 3, + }, + exerciseContent: { + flexDirection: 'row', + alignItems: 'center', + }, + exerciseInfo: { + flex: 1, + }, + exerciseName: { + fontSize: 16, + fontWeight: '800', + color: '#192126', + marginBottom: 4, + }, + exerciseCategory: { + fontSize: 12, + color: '#888F92', + marginBottom: 4, + }, + exerciseMeta: { + fontSize: 12, + color: '#5E6468', + }, + exerciseActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + completeBtn: { + padding: 4, + }, + removeBtn: { + backgroundColor: '#F3F4F6', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + }, + removeBtnText: { + color: '#384046', + fontWeight: '700', + fontSize: 12, + }, + + // 内联项目(休息、提示) + inlineRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + inlineBadge: { + marginLeft: 6, + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 999, + paddingVertical: 6, + paddingHorizontal: 10, + flex: 1, + }, + inlineBadgeRest: { + backgroundColor: '#F8FAFC', + }, + inlineBadgeNote: { + backgroundColor: '#F9FAFB', + }, + inlineText: { + fontSize: 12, + fontWeight: '700', + }, + inlineTextItalic: { + fontSize: 12, + fontStyle: 'italic', + }, + inlineRemoveBtn: { + marginLeft: 6, + padding: 4, + borderRadius: 999, + }, + + // 空状态(排课页面) + emptyContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + }, + + // 未选择计划状态 + noSelectionContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + paddingHorizontal: 20, + }, + noSelectionIcon: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: 'rgba(187,242,70,0.1)', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 16, + }, + noSelectionIconText: { + fontSize: 32, + }, + noSelectionText: { + fontSize: 18, + color: '#192126', + fontWeight: '600', + marginBottom: 4, + textAlign: 'center', + }, + noSelectionSubtext: { + fontSize: 14, + color: '#5E6468', + textAlign: 'center', + marginBottom: 20, + }, + + + // 弹窗样式 + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + alignItems: 'center', + justifyContent: 'flex-end', + }, + modalSheet: { + width: '100%', + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 24, + }, + modalTitle: { + fontSize: 16, + fontWeight: '800', + marginBottom: 16, + color: '#192126', + }, + modalLabel: { + fontSize: 12, + color: '#888F92', + marginBottom: 8, + fontWeight: '600', + }, + segmentedRow: { + flexDirection: 'row', + gap: 8, + marginBottom: 16, + }, + segment: { + flex: 1, + borderRadius: 999, + borderWidth: 1, + borderColor: '#E5E7EB', + paddingVertical: 8, + alignItems: 'center', + }, + segmentText: { + fontWeight: '700', + color: '#384046', + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 12, + }, + switchLabel: { + fontWeight: '700', + color: '#384046', + }, + inputRow: { + marginBottom: 20, + }, + inputLabel: { + fontSize: 12, + color: '#888F92', + marginBottom: 8, + fontWeight: '600', + }, + input: { + height: 40, + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 10, + paddingHorizontal: 12, + color: '#384046', + }, + generateBtn: { + paddingVertical: 12, + borderRadius: 12, + alignItems: 'center', + }, + generateBtnText: { + color: '#FFFFFF', + fontWeight: '800', + fontSize: 14, + }, + + // 顶部导航右侧按钮(与 HeaderBar 标准尺寸一致,使用 tab 配色) + headerRightBtn: { + width: 52, + height: 32, + backgroundColor: palette.primary, // 使用 tab 的主色 + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + headerRightBtnText: { + color: palette.ink, + fontWeight: '800', + fontSize: 10, + }, }); - - diff --git a/app/training-plan/schedule/index.tsx b/app/training-plan/schedule/index.tsx new file mode 100644 index 0000000..c83cdb4 --- /dev/null +++ b/app/training-plan/schedule/index.tsx @@ -0,0 +1,737 @@ +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import Animated, { FadeInUp } from 'react-native-reanimated'; + +import { ThemedText } from '@/components/ThemedText'; +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { palette } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { buildClassicalSession } from '@/utils/classicalSession'; + +// 训练计划排课项目类型 +export interface ScheduleExercise { + key: string; + name: string; + category: string; + sets: number; + reps?: number; + durationSec?: number; + restSec?: number; + note?: string; + itemType?: 'exercise' | 'rest' | 'note'; + completed?: boolean; +} + +// 训练计划排课数据 +export interface PlanSchedule { + planId: string; + exercises: ScheduleExercise[]; + note?: string; + lastModified: string; +} + +const GOAL_TEXT: Record = { + postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' }, + fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' }, + posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' }, + core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' }, + flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' }, + rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' }, + stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' }, +}; + +// 动态背景组件 +function DynamicBackground({ color }: { color: string }) { + return ( + + + + + + ); +} + +export default function PlanScheduleScreen() { + const router = useRouter(); + const dispatch = useAppDispatch(); + const params = useLocalSearchParams<{ planId?: string; newExercise?: string }>(); + const { plans } = useAppSelector((s) => s.trainingPlan); + + const planId = params.planId; + const plan = useMemo(() => plans.find(p => p.id === planId), [plans, planId]); + + // 排课数据状态 + const [exercises, setExercises] = useState([]); + const [scheduleNote, setScheduleNote] = useState(''); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + // 一键排课配置 + const [genVisible, setGenVisible] = useState(false); + const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner'); + const [genWithRests, setGenWithRests] = useState(true); + const [genWithNotes, setGenWithNotes] = useState(true); + const [genRest, setGenRest] = useState('30'); + + const goalConfig = plan ? (GOAL_TEXT[plan.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' }) : null; + + useEffect(() => { + if (!plan) { + Alert.alert('错误', '找不到指定的训练计划', [ + { text: '确定', onPress: () => router.back() } + ]); + return; + } + + // TODO: 从存储中加载已有的排课数据 + // loadPlanSchedule(planId); + }, [plan, planId]); + + // 处理从选择页面传回的新动作 + useEffect(() => { + if (params.newExercise) { + try { + const newExercise: ScheduleExercise = JSON.parse(params.newExercise); + setExercises(prev => [...prev, newExercise]); + setHasUnsavedChanges(true); + + // 清除路由参数,避免重复添加 + router.setParams({ newExercise: undefined } as any); + } catch (error) { + console.error('解析新动作数据失败:', error); + } + } + }, [params.newExercise]); + + const handleSave = async () => { + if (!plan) return; + + try { + // TODO: 保存排课数据到存储 + const scheduleData: PlanSchedule = { + planId: plan.id, + exercises, + note: scheduleNote, + lastModified: new Date().toISOString(), + }; + + console.log('保存排课数据:', scheduleData); + setHasUnsavedChanges(false); + Alert.alert('保存成功', '训练计划排课已保存'); + } catch (error) { + console.error('保存排课失败:', error); + Alert.alert('保存失败', '请稍后重试'); + } + }; + + const handleAddExercise = () => { + router.push(`/training-plan/schedule/select?planId=${planId}` as any); + }; + + const handleRemoveExercise = (key: string) => { + Alert.alert('确认移除', '确定要移除该动作吗?', [ + { text: '取消', style: 'cancel' }, + { + text: '移除', + style: 'destructive', + onPress: () => { + setExercises(prev => prev.filter(ex => ex.key !== key)); + setHasUnsavedChanges(true); + }, + }, + ]); + }; + + const handleToggleCompleted = (key: string) => { + setExercises(prev => prev.map(ex => + ex.key === key ? { ...ex, completed: !ex.completed } : ex + )); + setHasUnsavedChanges(true); + }; + + const onGenerate = () => { + const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10))); + const { items, note } = buildClassicalSession({ + withSectionRests: genWithRests, + restSeconds: restSec, + withNotes: genWithNotes, + level: genLevel + }); + + // 转换为排课格式 + const scheduleItems: ScheduleExercise[] = items.map((item, index) => ({ + key: `generated_${Date.now()}_${index}`, + name: item.name, + category: item.category, + sets: item.sets, + reps: item.reps, + durationSec: item.durationSec, + restSec: item.restSec, + note: item.note, + itemType: item.itemType, + completed: false, + })); + + setExercises(scheduleItems); + setScheduleNote(note || ''); + setHasUnsavedChanges(true); + setGenVisible(false); + Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。'); + }; + + if (!plan || !goalConfig) { + return ( + + router.back()} /> + + 找不到指定的训练计划 + + + ); + } + + return ( + + {/* 动态背景 */} + + + + router.back()} + withSafeTop={false} + tone='light' + transparent={true} + right={hasUnsavedChanges ? ( + + 保存 + + ) : undefined} + /> + + + {/* 计划信息头部 */} + + + + {goalConfig.title} + {goalConfig.description} + + + + {/* 操作按钮区域 */} + + + + 添加动作 + + + setGenVisible(true)} + > + + 一键排课 + + + + {/* 动作列表 */} + item.key} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + 💪 + + 还没有添加任何动作 + 点击"添加动作"开始排课,或使用"一键排课"快速生成 + + } + renderItem={({ item, index }) => { + const isRest = item.itemType === 'rest'; + const isNote = item.itemType === 'note'; + + if (isRest || isNote) { + return ( + + + + + {isRest ? `间隔休息 ${item.restSec ?? 30}s` : (item.note || '提示')} + + + handleRemoveExercise(item.key)} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + > + + + + ); + } + + return ( + + + + {item.name} + {item.category} + + 组数 {item.sets} + {item.reps ? ` · 每组 ${item.reps} 次` : ''} + {item.durationSec ? ` · 每组 ${item.durationSec}s` : ''} + + + + + handleToggleCompleted(item.key)} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + > + + + + handleRemoveExercise(item.key)} + > + 移除 + + + + + ); + }} + /> + + + {/* 一键排课配置弹窗 */} + setGenVisible(false)}> + setGenVisible(false)}> + e.stopPropagation() as any}> + 一键排课配置 + + 强度水平 + + {(['beginner', 'intermediate', 'advanced'] as const).map((lv) => ( + setGenLevel(lv)} + > + + {lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'} + + + ))} + + + + 段间休息 + + + + + 插入操作提示 + + + + + 休息秒数 + + + + + 生成训练计划 + + + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + }, + contentWrapper: { + flex: 1, + }, + content: { + flex: 1, + paddingHorizontal: 20, + }, + + // 动态背景 + backgroundOrb: { + position: 'absolute', + width: 300, + height: 300, + borderRadius: 150, + top: -150, + right: -100, + }, + backgroundOrb2: { + position: 'absolute', + width: 400, + height: 400, + borderRadius: 200, + bottom: -200, + left: -150, + }, + + // 计划信息头部 + planHeader: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 16, + marginBottom: 16, + }, + planColorIndicator: { + width: 4, + height: 40, + borderRadius: 2, + marginRight: 12, + }, + planInfo: { + flex: 1, + }, + planTitle: { + fontSize: 18, + fontWeight: '800', + color: '#192126', + marginBottom: 4, + }, + planDescription: { + fontSize: 13, + color: '#5E6468', + opacity: 0.8, + }, + + // 操作按钮 + actionRow: { + flexDirection: 'row', + gap: 12, + marginBottom: 20, + }, + primaryBtn: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + borderRadius: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 4, + }, + primaryBtnText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '700', + }, + secondaryBtn: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + borderRadius: 12, + borderWidth: 1.5, + backgroundColor: '#FFFFFF', + }, + secondaryBtnText: { + fontSize: 14, + fontWeight: '700', + }, + + // 保存按钮 + saveBtn: { + backgroundColor: palette.primary, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + shadowColor: palette.primary, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 4, + }, + saveBtnText: { + color: palette.ink, + fontWeight: '800', + fontSize: 14, + }, + + // 列表 + listContent: { + paddingBottom: 40, + }, + + // 空状态 + emptyContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + }, + emptyIcon: { + width: 80, + height: 80, + borderRadius: 40, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 16, + }, + emptyIconText: { + fontSize: 32, + }, + emptyText: { + fontSize: 18, + color: '#192126', + fontWeight: '600', + marginBottom: 4, + }, + emptySubtext: { + fontSize: 14, + color: '#5E6468', + textAlign: 'center', + lineHeight: 20, + }, + + // 动作卡片 + exerciseCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginBottom: 12, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 3, + }, + exerciseContent: { + flexDirection: 'row', + alignItems: 'center', + }, + exerciseInfo: { + flex: 1, + }, + exerciseName: { + fontSize: 16, + fontWeight: '800', + color: '#192126', + marginBottom: 4, + }, + exerciseCategory: { + fontSize: 12, + color: '#888F92', + marginBottom: 4, + }, + exerciseMeta: { + fontSize: 12, + color: '#5E6468', + }, + exerciseActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + completeBtn: { + padding: 4, + }, + removeBtn: { + backgroundColor: '#F3F4F6', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + }, + removeBtnText: { + color: '#384046', + fontWeight: '700', + fontSize: 12, + }, + + // 内联项目(休息、提示) + inlineRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + inlineBadge: { + marginLeft: 6, + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 999, + paddingVertical: 6, + paddingHorizontal: 10, + flex: 1, + }, + inlineBadgeRest: { + backgroundColor: '#F8FAFC', + }, + inlineBadgeNote: { + backgroundColor: '#F9FAFB', + }, + inlineText: { + fontSize: 12, + fontWeight: '700', + }, + inlineTextItalic: { + fontSize: 12, + fontStyle: 'italic', + }, + inlineRemoveBtn: { + marginLeft: 6, + padding: 4, + borderRadius: 999, + }, + + // 错误状态 + errorContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + errorText: { + fontSize: 16, + color: '#ED4747', + fontWeight: '600', + }, + + // 弹窗样式 + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + alignItems: 'center', + justifyContent: 'flex-end', + }, + modalSheet: { + width: '100%', + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 24, + }, + modalTitle: { + fontSize: 16, + fontWeight: '800', + marginBottom: 16, + color: '#192126', + }, + modalLabel: { + fontSize: 12, + color: '#888F92', + marginBottom: 8, + fontWeight: '600', + }, + segmentedRow: { + flexDirection: 'row', + gap: 8, + marginBottom: 16, + }, + segment: { + flex: 1, + borderRadius: 999, + borderWidth: 1, + borderColor: '#E5E7EB', + paddingVertical: 8, + alignItems: 'center', + }, + segmentText: { + fontWeight: '700', + color: '#384046', + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 12, + }, + switchLabel: { + fontWeight: '700', + color: '#384046', + }, + inputRow: { + marginBottom: 20, + }, + inputLabel: { + fontSize: 12, + color: '#888F92', + marginBottom: 8, + fontWeight: '600', + }, + input: { + height: 40, + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 10, + paddingHorizontal: 12, + color: '#384046', + }, + generateBtn: { + paddingVertical: 12, + borderRadius: 12, + alignItems: 'center', + }, + generateBtnText: { + color: '#FFFFFF', + fontWeight: '800', + fontSize: 14, + }, +}); \ No newline at end of file diff --git a/app/training-plan/schedule/select.tsx b/app/training-plan/schedule/select.tsx new file mode 100644 index 0000000..5ce8fa1 --- /dev/null +++ b/app/training-plan/schedule/select.tsx @@ -0,0 +1,724 @@ +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { palette } from '@/constants/Colors'; +import { useAppSelector } from '@/hooks/redux'; +import { fetchExerciseConfig, normalizeToLibraryItems } from '@/services/exercises'; +import { EXERCISE_LIBRARY, getCategories } from '@/utils/exerciseLibrary'; +import { Ionicons } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Haptics from 'expo-haptics'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useLocalSearchParams, 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'; + +import { ThemedText } from '@/components/ThemedText'; +import type { ScheduleExercise } from './index'; + +const GOAL_TEXT: Record = { + postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' }, + fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' }, + posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' }, + core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' }, + flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' }, + rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' }, + stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' }, +}; + +// 动态背景组件 +function DynamicBackground({ color }: { color: string }) { + return ( + + + + + + ); +} + +export default function SelectExerciseForScheduleScreen() { + const router = useRouter(); + const params = useLocalSearchParams<{ planId?: string }>(); + const { plans } = useAppSelector((s) => s.trainingPlan); + + const planId = params.planId; + const plan = useMemo(() => plans.find(p => p.id === planId), [plans, planId]); + const goalConfig = plan ? (GOAL_TEXT[plan.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' }) : null; + + 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 [serverLibrary, setServerLibrary] = useState<{ key: string; name: string; description: string; category: string }[] | null>(null); + const [serverCategories, setServerCategories] = useState(null); + + const controlsOpacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); + } + }, []); + + useEffect(() => { + let aborted = false; + const CACHE_KEY = '@exercise_config_v1'; + (async () => { + try { + const cached = await AsyncStorage.getItem(CACHE_KEY); + if (cached && !aborted) { + const parsed = JSON.parse(cached); + const items = normalizeToLibraryItems(parsed); + if (items.length) { + setServerLibrary(items); + const cats = Array.from(new Set(items.map((i) => i.category))); + setServerCategories(cats); + } + } + } catch { } + try { + const resp = await fetchExerciseConfig(); + console.log('fetchExerciseConfig', resp); + if (aborted) return; + const items = normalizeToLibraryItems(resp); + setServerLibrary(items); + const cats = Array.from(new Set(items.map((i) => i.category))); + setServerCategories(cats); + try { await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(resp)); } catch { } + } catch (err) { } + })(); + return () => { aborted = true; }; + }, []); + + const categories = useMemo(() => { + const base = serverCategories ?? getCategories(); + return ['全部', ...base]; + }, [serverCategories]); + + 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 library = useMemo(() => serverLibrary ?? EXERCISE_LIBRARY, [serverLibrary]); + + const filtered = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + const base = kw + ? library.filter((e) => e.name.toLowerCase().includes(kw) || (e.description || '').toLowerCase().includes(kw)) + : library; + if (category === '全部') return base; + return base.filter((e) => e.category === category); + }, [keyword, category, library]); + + const selected = useMemo(() => library.find((e) => e.key === selectedKey) || null, [selectedKey, library]); + + useEffect(() => { + Animated.timing(controlsOpacity, { + toValue: selected ? 1 : 0, + duration: selected ? 220 : 160, + useNativeDriver: true, + }).start(); + }, [selected, controlsOpacity]); + + const handleAdd = () => { + if (!selected || !plan) return; + + const exerciseData: ScheduleExercise = { + key: `${selected.key}_${Date.now()}`, + name: selected.name, + category: selected.category, + sets: Math.max(1, sets), + reps: reps && reps > 0 ? reps : undefined, + itemType: 'exercise', + completed: false, + }; + + console.log('添加动作到排课:', exerciseData); + + // 通过路由参数传递数据回到排课页面 + router.push({ + pathname: '/training-plan/schedule', + params: { + planId: planId, + newExercise: JSON.stringify(exerciseData) + } + } as any); + }; + + const onSelectItem = (key: string) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + if (selectedKey === key) { + setSelectedKey(null); + return; + } + setSets(3); + setReps(undefined); + setShowCustomReps(false); + setCustomRepsInput(''); + setSelectedKey(key); + }; + + if (!plan || !goalConfig) { + return ( + + router.back()} /> + + 找不到指定的训练计划 + + + ); + } + + return ( + + {/* 动态背景 */} + + + + router.back()} + withSafeTop={false} + transparent={true} + tone="light" + /> + + + {/* 计划信息头部 */} + + + + {goalConfig.title} + 从动作库里选择一个动作,设置组数与每组次数 + + + + {/* 大分类宫格 */} + + {[...mainCategories, '更多'].map((item) => { + const active = category === item; + const meta: Record = { + 全部: { bg: `${goalConfig.color}22` }, + 核心与腹部: { bg: `${goalConfig.color}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={styles.listContent} + showsVerticalScrollIndicator={false} + 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, + }, + contentWrapper: { + flex: 1, + }, + content: { + flex: 1, + paddingHorizontal: 20, + }, + + // 动态背景 + backgroundOrb: { + position: 'absolute', + width: 300, + height: 300, + borderRadius: 150, + top: -150, + right: -100, + }, + backgroundOrb2: { + position: 'absolute', + width: 400, + height: 400, + borderRadius: 200, + bottom: -200, + left: -150, + }, + + // 计划信息头部 + planHeader: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 16, + marginBottom: 16, + }, + planColorIndicator: { + width: 4, + height: 40, + borderRadius: 2, + marginRight: 12, + }, + planInfo: { + flex: 1, + }, + planTitle: { + fontSize: 18, + fontWeight: '800', + color: '#192126', + marginBottom: 4, + }, + planDescription: { + fontSize: 13, + color: '#5E6468', + opacity: 0.8, + }, + + // 分类网格 + catGrid: { + paddingTop: 10, + flexDirection: 'row', + flexWrap: 'wrap', + marginBottom: 16, + }, + catTileWrapper: { + width: '33.33%', + padding: 6, + }, + catTile: { + borderRadius: 14, + paddingVertical: 16, + paddingHorizontal: 8, + alignItems: 'center', + justifyContent: 'center', + }, + catText: { + fontSize: 13, + fontWeight: '700', + color: '#384046', + }, + + // 搜索框 + searchRow: { + marginBottom: 16, + }, + searchInput: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + color: '#384046', + borderWidth: 1, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 8, + shadowOffset: { width: 0, height: 2 }, + elevation: 2, + }, + + // 列表 + listContent: { + paddingBottom: 40, + }, + + // 动作卡片 + itemCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginBottom: 12, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 3, + }, + itemTitle: { + fontSize: 16, + fontWeight: '800', + color: '#192126', + marginBottom: 4, + }, + itemMeta: { + fontSize: 12, + color: '#888F92', + marginBottom: 4, + }, + itemDesc: { + fontSize: 12, + color: '#5E6468', + lineHeight: 16, + }, + + // 展开的控制区域 + expandedBox: { + marginTop: 12, + }, + controlsRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 12, + flexWrap: 'wrap', + marginBottom: 16, + }, + counterBox: { + backgroundColor: '#F8F9FA', + borderRadius: 8, + padding: 12, + minWidth: 120, + }, + counterLabel: { + fontSize: 10, + color: '#888F92', + marginBottom: 8, + fontWeight: '600', + }, + counterRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + counterBtn: { + backgroundColor: '#E5E7EB', + width: 28, + height: 28, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + }, + counterBtnText: { + fontWeight: '800', + color: '#384046', + }, + counterValue: { + minWidth: 40, + textAlign: 'center', + fontWeight: '700', + color: '#384046', + }, + repsChipsRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginTop: 6, + }, + repChip: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 999, + backgroundColor: '#F3F4F6', + borderWidth: 1, + borderColor: '#E5E7EB', + }, + repChipText: { + color: '#384046', + fontWeight: '700', + fontSize: 12, + }, + repChipGhost: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 1, + backgroundColor: 'transparent', + borderColor: '#E5E7EB', + }, + repChipGhostText: { + fontWeight: '700', + color: '#384046', + fontSize: 12, + }, + customRepsRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + marginTop: 8, + }, + customRepsInput: { + flex: 1, + height: 40, + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 10, + paddingHorizontal: 12, + color: '#384046', + }, + customRepsBtn: { + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 10, + }, + customRepsBtnText: { + fontWeight: '800', + color: '#FFFFFF', + fontSize: 12, + }, + addBtn: { + paddingVertical: 12, + borderRadius: 12, + alignItems: 'center', + }, + addBtnText: { + color: '#FFFFFF', + fontWeight: '800', + fontSize: 14, + }, + + // 错误状态 + errorContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + errorText: { + fontSize: 16, + color: '#ED4747', + fontWeight: '600', + }, + + // 弹窗样式 + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + alignItems: 'center', + justifyContent: 'flex-end', + }, + modalSheet: { + width: '100%', + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 24, + }, + modalTitle: { + fontSize: 16, + fontWeight: '800', + marginBottom: 16, + color: '#192126', + }, + catGridModal: { + flexDirection: 'row', + flexWrap: 'wrap', + }, +}); \ No newline at end of file diff --git a/components/BMICard.tsx b/components/BMICard.tsx new file mode 100644 index 0000000..7fccbbf --- /dev/null +++ b/components/BMICard.tsx @@ -0,0 +1,439 @@ +import { + BMI_CATEGORIES, + canCalculateBMI, + getBMIResult, + type BMIResult +} from '@/utils/bmi'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import React, { useState } from 'react'; +import { + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +interface BMICardProps { + weight?: number; + height?: number; + style?: any; +} + +export function BMICard({ weight, height, style }: BMICardProps) { + const router = useRouter(); + const [showInfoModal, setShowInfoModal] = useState(false); + + const canCalculate = canCalculateBMI(weight, height); + let bmiResult: BMIResult | null = null; + + + + if (canCalculate && weight && height) { + try { + bmiResult = getBMIResult(weight, height); + } catch (error) { + console.warn('BMI 计算错误:', error); + } + } + + const handleGoToProfile = () => { + router.push('/profile/edit'); + }; + + const handleShowInfoModal = () => { + setShowInfoModal(true); + }; + + const handleHideInfoModal = () => { + setShowInfoModal(false); + }; + + const renderContent = () => { + if (!canCalculate) { + // 缺少数据的情况 + return ( + + + + + + + BMI 指数 + + + + + + + + + + {!weight && !height ? '请完善身高和体重信息' : + !weight ? '请完善体重信息' : '请完善身高信息'} + + + + + 前往完善 + + + + ); + } + + // 有完整数据的情况 + return ( + + + + + + + BMI 指数 + + + + + + + + + {bmiResult?.value} + + + + {bmiResult?.category.name} + + + + + + {bmiResult?.description} + + + + {bmiResult?.category.encouragement} + + + ); + }; + + return ( + <> + + {renderContent()} + + + {/* BMI 信息弹窗 */} + + + e.stopPropagation()}> + + + BMI 指数说明 + + + + + + + + BMI(身体质量指数)是评估体重与身高关系的常用指标,计算公式为:体重(kg) ÷ 身高²(m) + + + BMI 分类标准 + {BMI_CATEGORIES.map((category, index) => { + const colors = index === 0 ? { bg: '#FFF4E6', text: '#8B7355' } : + index === 1 ? { bg: '#E8F5E8', text: '#2D5016' } : + index === 2 ? { bg: '#FEF3C7', text: '#B45309' } : + { bg: '#FEE2E2', text: '#B91C1C' }; + + return ( + + + + {category.name} + + + {category.range} + + + + {category.advice} + + + ); + })} + + + * BMI 仅供参考,不能完全反映身体健康状况。如有疑问,请咨询专业医生。 + + + + + + + + ); +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 22, + padding: 18, + marginBottom: 16, + overflow: 'hidden', + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 12, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + }, + iconSquare: { + width: 30, + height: 30, + borderRadius: 8, + backgroundColor: '#FFFFFF', + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + }, + cardTitle: { + fontSize: 18, + fontWeight: '800', + color: '#192126', + }, + infoButton: { + padding: 4, + }, + + // 缺少数据时的样式 + incompleteContent: { + minHeight: 120, + backgroundColor: '#FFFFFF', + borderRadius: 22, + padding: 18, + margin: -18, + }, + missingDataContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FEF3C7', + borderRadius: 12, + padding: 12, + marginBottom: 12, + }, + missingDataText: { + fontSize: 14, + color: '#B45309', + fontWeight: '600', + marginLeft: 8, + flex: 1, + }, + completeButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#F3F4F6', + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 16, + }, + completeButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#6B7280', + marginRight: 4, + }, + + // 有完整数据时的样式 + completeContent: { + minHeight: 120, + borderRadius: 22, + padding: 18, + margin: -18, + }, + bmiValueContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + bmiValue: { + fontSize: 32, + fontWeight: '800', + marginRight: 12, + }, + categoryBadge: { + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 12, + }, + categoryText: { + fontSize: 14, + fontWeight: '700', + }, + bmiDescription: { + fontSize: 14, + fontWeight: '600', + marginBottom: 8, + }, + encouragementText: { + fontSize: 13, + color: '#6B7280', + fontWeight: '500', + lineHeight: 18, + fontStyle: 'italic', + }, + + // 弹窗样式 + modalBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + modalContainer: { + width: '90%', + maxHeight: '85%', + backgroundColor: '#FFFFFF', + borderRadius: 24, + overflow: 'hidden', + elevation: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.3, + shadowRadius: 20, + }, + modalContent: { + maxHeight: '100%', + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 24, + borderBottomWidth: 1, + borderBottomColor: '#F3F4F6', + backgroundColor: '#FAFAFA', + }, + modalTitle: { + fontSize: 22, + fontWeight: '800', + color: '#111827', + letterSpacing: -0.5, + }, + closeButton: { + padding: 8, + borderRadius: 20, + backgroundColor: '#F3F4F6', + }, + modalBody: { + paddingHorizontal: 24, + paddingVertical: 20, + }, + modalDescription: { + fontSize: 16, + color: '#4B5563', + lineHeight: 26, + marginBottom: 28, + textAlign: 'center', + backgroundColor: '#F8FAFC', + padding: 16, + borderRadius: 12, + borderLeftWidth: 4, + borderLeftColor: '#3B82F6', + }, + sectionTitle: { + fontSize: 20, + fontWeight: '800', + color: '#111827', + marginBottom: 16, + letterSpacing: -0.3, + }, + categoryItem: { + borderRadius: 16, + padding: 20, + marginBottom: 16, + borderWidth: 1, + borderColor: 'rgba(0,0,0,0.05)', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 2, + }, + categoryHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + categoryName: { + fontSize: 18, + fontWeight: '800', + letterSpacing: -0.2, + }, + categoryRange: { + fontSize: 15, + fontWeight: '700', + backgroundColor: 'rgba(255,255,255,0.8)', + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 20, + }, + categoryAdvice: { + fontSize: 15, + color: '#374151', + lineHeight: 22, + fontWeight: '500', + }, + disclaimer: { + fontSize: 13, + color: '#6B7280', + fontStyle: 'italic', + marginTop: 24, + textAlign: 'center', + backgroundColor: '#F9FAFB', + padding: 16, + borderRadius: 12, + lineHeight: 20, + }, +}); \ No newline at end of file diff --git a/components/PlanCard.tsx b/components/PlanCard.tsx index 5265a8e..fc0b9db 100644 --- a/components/PlanCard.tsx +++ b/components/PlanCard.tsx @@ -39,15 +39,15 @@ export function PlanCard({ image, title, subtitle, level, progress }: PlanCardPr const styles = StyleSheet.create({ card: { flexDirection: 'row', - backgroundColor: '#FFFFFF', + backgroundColor: '#F0F0F0', borderRadius: 28, padding: 20, marginBottom: 18, shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.06, - shadowRadius: 12, - elevation: 3, + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.25, + shadowRadius: 30, + elevation: 10, }, image: { width: 100, diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 20234f2..e3039da 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1705,6 +1705,29 @@ PODS: - React - RNCAsyncStorage (2.2.0): - React-Core + - RNCMaskedView (0.3.2): + - DoubleConversion + - glog + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsc + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - RNDateTimePicker (8.4.4): - React-Core - RNGestureHandler (2.24.0): @@ -1985,6 +2008,7 @@ DEPENDENCIES: - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - RNAppleHealthKit (from `../node_modules/react-native-health`) - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" + - "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNReanimated (from `../node_modules/react-native-reanimated`) @@ -2193,6 +2217,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-health" RNCAsyncStorage: :path: "../node_modules/@react-native-async-storage/async-storage" + RNCMaskedView: + :path: "../node_modules/@react-native-masked-view/masked-view" RNDateTimePicker: :path: "../node_modules/@react-native-community/datetimepicker" RNGestureHandler: @@ -2306,6 +2332,7 @@ SPEC CHECKSUMS: ReactCommon: 7eb76fcd5133313d8c6a138a5c7dd89f80f189d5 RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9 RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f + RNCMaskedView: d4644e239e65383f96d2f32c40c297f09705ac96 RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c RNGestureHandler: 6e640921d207f070e4bbcf79f4e6d0eabf323389 RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb diff --git a/package-lock.json b/package-lock.json index 83756a0..88ff173 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@expo/vector-icons": "^14.1.0", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/datetimepicker": "^8.4.4", + "@react-native-masked-view/masked-view": "^0.3.2", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", @@ -2893,6 +2894,16 @@ } } }, + "node_modules/@react-native-masked-view/masked-view": { + "version": "0.3.2", + "resolved": "https://mirrors.tencent.com/npm/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", + "integrity": "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16", + "react-native": ">=0.57" + } + }, "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", diff --git a/package.json b/package.json index 5ea52c7..59008ae 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@expo/vector-icons": "^14.1.0", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/datetimepicker": "^8.4.4", + "@react-native-masked-view/masked-view": "^0.3.2", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", @@ -62,4 +63,4 @@ "typescript": "~5.8.3" }, "private": true -} \ No newline at end of file +} diff --git a/store/userSlice.ts b/store/userSlice.ts index dfbe2a4..44aabb2 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -8,7 +8,7 @@ export type UserProfile = { name?: string; email?: string; gender?: Gender; - age?: number; // 个人中心是字符串展示 + birthDate?: string; weight?: number; height?: number; avatar?: string | null; diff --git a/utils/bmi.ts b/utils/bmi.ts new file mode 100644 index 0000000..a21977b --- /dev/null +++ b/utils/bmi.ts @@ -0,0 +1,154 @@ +/** + * BMI 计算和分类工具函数 + */ + +export interface BMIResult { + value: number; + category: BMICategory; + color: string; + backgroundColor: string; + description: string; +} + +export interface BMICategory { + name: string; + range: string; + description: string; + advice: string; + encouragement: string; +} + +// BMI 分类标准(基于中国成人标准) +export const BMI_CATEGORIES: BMICategory[] = [ + { + name: '偏瘦', + range: '< 18.5', + description: '体重偏轻', + advice: '建议适当增加营养摄入,进行力量训练增加肌肉量', + encouragement: '每一份营养都是对身体的投资,坚持下去你会更强壮!💪' + }, + { + name: '正常', + range: '18.5 - 23.9', + description: '体重正常', + advice: '保持良好的饮食和运动习惯,继续维持健康体重', + encouragement: '太棒了!你的身体状态很健康,继续保持这份活力!✨' + }, + { + name: '偏胖', + range: '24.0 - 27.9', + description: '体重偏重', + advice: '建议控制饮食,增加有氧运动,逐步减重', + encouragement: '改变从今天开始,每一次运动都让你更接近理想的自己!🌟' + }, + { + name: '肥胖', + range: '≥ 28.0', + description: '肥胖', + advice: '建议咨询专业医生,制定科学的减重计划', + encouragement: '健康之路虽有挑战,但你有勇气迈出第一步就已经很了不起!🚀' + } +]; + +// BMI 颜色方案(越健康颜色越绿) +const BMI_COLORS = { + underweight: { + color: '#8B7355', // 棕色文字 + backgroundColor: '#FFF4E6', // 浅橙色背景 + }, + normal: { + color: '#2D5016', // 深绿色文字 + backgroundColor: '#E8F5E8', // 浅绿色背景 + }, + overweight: { + color: '#B45309', // 橙色文字 + backgroundColor: '#FEF3C7', // 浅黄色背景 + }, + obese: { + color: '#B91C1C', // 红色文字 + backgroundColor: '#FEE2E2', // 浅红色背景 + } +}; + +/** + * 计算 BMI 值 + * @param weight 体重(kg) + * @param height 身高(cm) + * @returns BMI 值,保留一位小数 + */ +export function calculateBMI(weight: number, height: number): number { + if (weight <= 0 || height <= 0) { + throw new Error('体重和身高必须大于0'); + } + + // 身高转换为米 + const heightInMeters = height / 100; + const bmi = weight / (heightInMeters * heightInMeters); + + return Math.round(bmi * 10) / 10; +} + +/** + * 根据 BMI 值获取分类 + * @param bmi BMI 值 + * @returns BMI 分类信息 + */ +export function getBMICategory(bmi: number): BMICategory { + if (bmi < 18.5) { + return BMI_CATEGORIES[0]; // 偏瘦 + } else if (bmi < 24.0) { + return BMI_CATEGORIES[1]; // 正常 + } else if (bmi < 28.0) { + return BMI_CATEGORIES[2]; // 偏胖 + } else { + return BMI_CATEGORIES[3]; // 肥胖 + } +} + +/** + * 根据 BMI 值获取颜色 + * @param bmi BMI 值 + * @returns 颜色配置 + */ +export function getBMIColors(bmi: number): { color: string; backgroundColor: string } { + if (bmi < 18.5) { + return BMI_COLORS.underweight; + } else if (bmi < 24.0) { + return BMI_COLORS.normal; + } else if (bmi < 28.0) { + return BMI_COLORS.overweight; + } else { + return BMI_COLORS.obese; + } +} + +/** + * 获取完整的 BMI 结果 + * @param weight 体重(kg) + * @param height 身高(cm) + * @returns 完整的 BMI 分析结果 + */ +export function getBMIResult(weight: number, height: number): BMIResult { + const bmi = calculateBMI(weight, height); + const category = getBMICategory(bmi); + const colors = getBMIColors(bmi); + + return { + value: bmi, + category, + color: colors.color, + backgroundColor: colors.backgroundColor, + description: category.description + }; +} + +/** + * 检查是否有足够的数据计算 BMI + * @param weight 体重 + * @param height 身高 + * @returns 是否可以计算 BMI + */ +export function canCalculateBMI(weight?: number, height?: number): boolean { + return typeof weight === 'number' && weight > 0 && + typeof height === 'number' && height > 0; +} \ No newline at end of file