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