feat: 添加 BMI 计算和训练计划排课功能
- 新增 BMI 计算工具,支持用户输入体重和身高计算 BMI 值,并根据结果提供分类和建议 - 在训练计划中集成排课功能,允许用户选择和安排训练动作 - 更新个人信息页面,添加出生日期字段,支持用户完善个人资料 - 优化训练计划卡片样式,提升用户体验 - 更新相关依赖,确保项目兼容性和功能完整性
This commit is contained in:
@@ -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() {
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* BMI 指数卡片 */}
|
||||
<BMICard
|
||||
weight={userProfile?.weight}
|
||||
height={userProfile?.height}
|
||||
/>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
|
||||
@@ -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<UserProfile>({});
|
||||
|
||||
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() {
|
||||
{/* 头像 */}
|
||||
<View style={styles.avatarContainer}>
|
||||
<View style={[styles.avatar, { backgroundColor: colorTokens.ornamentAccent }]}>
|
||||
<Image source={{ uri: profile.avatarUri || DEFAULT_AVATAR_URL }} style={{ width: '100%', height: '100%' }} />
|
||||
<Image source={{ uri: userProfile.avatar || DEFAULT_AVATAR_URL }} style={{ width: '100%', height: '100%' }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -206,6 +162,7 @@ export default function PersonalScreen() {
|
||||
</View>
|
||||
);
|
||||
|
||||
// 菜单项组件
|
||||
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
|
||||
<View style={[styles.menuSection, { backgroundColor: colorTokens.card }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>{title}</Text>
|
||||
@@ -216,8 +173,15 @@ export default function PersonalScreen() {
|
||||
onPress={item.onPress}
|
||||
>
|
||||
<View style={styles.menuItemLeft}>
|
||||
<View style={[styles.menuIcon, { backgroundColor: 'rgba(187,242,70,0.12)' }]}>
|
||||
<Ionicons name={item.icon} size={20} color={'#192126'} />
|
||||
<View style={[
|
||||
styles.menuIcon,
|
||||
{ backgroundColor: item.isDanger ? 'rgba(255,68,68,0.12)' : 'rgba(187,242,70,0.12)' }
|
||||
]}>
|
||||
<Ionicons
|
||||
name={item.icon}
|
||||
size={20}
|
||||
color={item.isDanger ? colors.danger : colors.onPrimary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.menuItemText, { color: colorTokens.text }]}>{item.title}</Text>
|
||||
</View>
|
||||
@@ -237,8 +201,8 @@ export default function PersonalScreen() {
|
||||
</View>
|
||||
);
|
||||
|
||||
// 动态创建样式
|
||||
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() {
|
||||
>
|
||||
<UserInfoSection />
|
||||
<StatsSection />
|
||||
<MenuSection title="账户" items={accountItems} />
|
||||
<MenuSection title="通知" items={notificationItems} />
|
||||
<MenuSection title="其他" items={otherItems} />
|
||||
<MenuSection title="账号与安全" items={securityItems} />
|
||||
<MenuSection title="开发者" items={developerItems} />
|
||||
{menuSections.map((section, index) => (
|
||||
<MenuSection key={index} title={section.title} items={section.items} />
|
||||
))}
|
||||
|
||||
{/* 底部浮动按钮 */}
|
||||
<View style={[styles.floatingButtonContainer, { bottom: Math.max(30, tabBarHeight / 2) + (insets?.bottom ?? 0) }]}>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: 32,
|
||||
}}>
|
||||
🧘♀️
|
||||
</Text>
|
||||
|
||||
</View>
|
||||
<ActivityIndicator size="large" color={primaryColor} />
|
||||
</ThemedView>
|
||||
|
||||
@@ -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() {
|
||||
<Text style={[styles.textInput, { color: profile.birthDate ? textColor : placeholderColor }]}>
|
||||
{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;
|
||||
}
|
||||
})()
|
||||
: '选择出生日期(可选)'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
737
app/training-plan/schedule/index.tsx
Normal file
737
app/training-plan/schedule/index.tsx
Normal file
@@ -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<string, { title: string; color: string; description: string }> = {
|
||||
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 (
|
||||
<View style={StyleSheet.absoluteFillObject}>
|
||||
<LinearGradient
|
||||
colors={['#F9FBF2', '#FFFFFF', '#F5F9F0']}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
<View style={[styles.backgroundOrb, { backgroundColor: `${color}15` }]} />
|
||||
<View style={[styles.backgroundOrb2, { backgroundColor: `${color}10` }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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<ScheduleExercise[]>([]);
|
||||
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 (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<HeaderBar title="训练排课" onBack={() => router.back()} />
|
||||
<View style={styles.errorContainer}>
|
||||
<ThemedText style={styles.errorText}>找不到指定的训练计划</ThemedText>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.safeArea}>
|
||||
{/* 动态背景 */}
|
||||
<DynamicBackground color={goalConfig.color} />
|
||||
|
||||
<SafeAreaView style={styles.contentWrapper}>
|
||||
<HeaderBar
|
||||
title="训练排课"
|
||||
onBack={() => router.back()}
|
||||
withSafeTop={false}
|
||||
tone='light'
|
||||
transparent={true}
|
||||
right={hasUnsavedChanges ? (
|
||||
<TouchableOpacity onPress={handleSave} style={styles.saveBtn}>
|
||||
<ThemedText style={styles.saveBtnText}>保存</ThemedText>
|
||||
</TouchableOpacity>
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* 计划信息头部 */}
|
||||
<Animated.View entering={FadeInUp.duration(600)} style={[styles.planHeader, { backgroundColor: `${goalConfig.color}20` }]}>
|
||||
<View style={[styles.planColorIndicator, { backgroundColor: goalConfig.color }]} />
|
||||
<View style={styles.planInfo}>
|
||||
<ThemedText style={styles.planTitle}>{goalConfig.title}</ThemedText>
|
||||
<ThemedText style={styles.planDescription}>{goalConfig.description}</ThemedText>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<View style={styles.actionRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, { backgroundColor: goalConfig.color }]}
|
||||
onPress={handleAddExercise}
|
||||
>
|
||||
<Ionicons name="add" size={16} color="#FFFFFF" style={{ marginRight: 4 }} />
|
||||
<Text style={styles.primaryBtnText}>添加动作</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryBtn, { borderColor: goalConfig.color }]}
|
||||
onPress={() => setGenVisible(true)}
|
||||
>
|
||||
<Ionicons name="flash" size={16} color={goalConfig.color} style={{ marginRight: 4 }} />
|
||||
<Text style={[styles.secondaryBtnText, { color: goalConfig.color }]}>一键排课</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 动作列表 */}
|
||||
<FlatList
|
||||
data={exercises}
|
||||
keyExtractor={(item) => item.key}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<Animated.View entering={FadeInUp.delay(200).duration(600)} style={styles.emptyContainer}>
|
||||
<View style={[styles.emptyIcon, { backgroundColor: `${goalConfig.color}20` }]}>
|
||||
<ThemedText style={styles.emptyIconText}>💪</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.emptyText}>还没有添加任何动作</ThemedText>
|
||||
<ThemedText style={styles.emptySubtext}>点击"添加动作"开始排课,或使用"一键排课"快速生成</ThemedText>
|
||||
</Animated.View>
|
||||
}
|
||||
renderItem={({ item, index }) => {
|
||||
const isRest = item.itemType === 'rest';
|
||||
const isNote = item.itemType === 'note';
|
||||
|
||||
if (isRest || isNote) {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInUp.delay(index * 50).duration(400)}
|
||||
style={styles.inlineRow}
|
||||
>
|
||||
<Ionicons
|
||||
name={isRest ? 'time-outline' : 'information-circle-outline'}
|
||||
size={14}
|
||||
color="#888F92"
|
||||
/>
|
||||
<View style={[
|
||||
styles.inlineBadge,
|
||||
isRest ? styles.inlineBadgeRest : styles.inlineBadgeNote
|
||||
]}>
|
||||
<Text style={[
|
||||
isNote ? styles.inlineTextItalic : styles.inlineText,
|
||||
{ color: '#888F92' }
|
||||
]}>
|
||||
{isRest ? `间隔休息 ${item.restSec ?? 30}s` : (item.note || '提示')}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.inlineRemoveBtn}
|
||||
onPress={() => handleRemoveExercise(item.key)}
|
||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||||
>
|
||||
<Ionicons name="close-outline" size={16} color="#888F92" />
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInUp.delay(index * 50).duration(400)}
|
||||
style={styles.exerciseCard}
|
||||
>
|
||||
<View style={styles.exerciseContent}>
|
||||
<View style={styles.exerciseInfo}>
|
||||
<ThemedText style={styles.exerciseName}>{item.name}</ThemedText>
|
||||
<ThemedText style={styles.exerciseCategory}>{item.category}</ThemedText>
|
||||
<ThemedText style={styles.exerciseMeta}>
|
||||
组数 {item.sets}
|
||||
{item.reps ? ` · 每组 ${item.reps} 次` : ''}
|
||||
{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.exerciseActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.completeBtn}
|
||||
onPress={() => handleToggleCompleted(item.key)}
|
||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={item.completed ? 'checkmark-circle' : 'checkmark-circle-outline'}
|
||||
size={24}
|
||||
color={item.completed ? goalConfig.color : '#888F92'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.removeBtn}
|
||||
onPress={() => handleRemoveExercise(item.key)}
|
||||
>
|
||||
<Text style={styles.removeBtnText}>移除</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 一键排课配置弹窗 */}
|
||||
<Modal visible={genVisible} transparent animationType="fade" onRequestClose={() => setGenVisible(false)}>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setGenVisible(false)}>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.modalSheet} onPress={(e) => e.stopPropagation() as any}>
|
||||
<Text style={styles.modalTitle}>一键排课配置</Text>
|
||||
|
||||
<Text style={styles.modalLabel}>强度水平</Text>
|
||||
<View style={styles.segmentedRow}>
|
||||
{(['beginner', 'intermediate', 'advanced'] as const).map((lv) => (
|
||||
<TouchableOpacity
|
||||
key={lv}
|
||||
style={[
|
||||
styles.segment,
|
||||
genLevel === lv && { backgroundColor: goalConfig.color }
|
||||
]}
|
||||
onPress={() => setGenLevel(lv)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.segmentText,
|
||||
genLevel === lv && { color: '#FFFFFF' }
|
||||
]}>
|
||||
{lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>段间休息</Text>
|
||||
<Switch value={genWithRests} onValueChange={setGenWithRests} />
|
||||
</View>
|
||||
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>插入操作提示</Text>
|
||||
<Switch value={genWithNotes} onValueChange={setGenWithNotes} />
|
||||
</View>
|
||||
|
||||
<View style={styles.inputRow}>
|
||||
<Text style={styles.inputLabel}>休息秒数</Text>
|
||||
<TextInput
|
||||
value={genRest}
|
||||
onChangeText={setGenRest}
|
||||
keyboardType="number-pad"
|
||||
style={styles.input}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.generateBtn, { backgroundColor: goalConfig.color }]}
|
||||
onPress={onGenerate}
|
||||
>
|
||||
<Text style={styles.generateBtnText}>生成训练计划</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
724
app/training-plan/schedule/select.tsx
Normal file
724
app/training-plan/schedule/select.tsx
Normal file
@@ -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<string, { title: string; color: string; description: string }> = {
|
||||
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 (
|
||||
<View style={StyleSheet.absoluteFillObject}>
|
||||
<LinearGradient
|
||||
colors={['#F9FBF2', '#FFFFFF', '#F5F9F0']}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
<View style={[styles.backgroundOrb, { backgroundColor: `${color}15` }]} />
|
||||
<View style={[styles.backgroundOrb2, { backgroundColor: `${color}10` }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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<string>('全部');
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [sets, setSets] = useState(3);
|
||||
const [reps, setReps] = useState<number | undefined>(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<string[] | null>(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 (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<HeaderBar title="选择动作" onBack={() => router.back()} />
|
||||
<View style={styles.errorContainer}>
|
||||
<ThemedText style={styles.errorText}>找不到指定的训练计划</ThemedText>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.safeArea}>
|
||||
{/* 动态背景 */}
|
||||
<DynamicBackground color={goalConfig.color} />
|
||||
|
||||
<SafeAreaView style={styles.contentWrapper}>
|
||||
<HeaderBar
|
||||
title="选择动作"
|
||||
onBack={() => router.back()}
|
||||
withSafeTop={false}
|
||||
transparent={true}
|
||||
tone="light"
|
||||
/>
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* 计划信息头部 */}
|
||||
<View style={[styles.planHeader, { backgroundColor: `${goalConfig.color}20` }]}>
|
||||
<View style={[styles.planColorIndicator, { backgroundColor: goalConfig.color }]} />
|
||||
<View style={styles.planInfo}>
|
||||
<ThemedText style={styles.planTitle}>{goalConfig.title}</ThemedText>
|
||||
<ThemedText style={styles.planDescription}>从动作库里选择一个动作,设置组数与每组次数</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 大分类宫格 */}
|
||||
<View style={styles.catGrid}>
|
||||
{[...mainCategories, '更多'].map((item) => {
|
||||
const active = category === item;
|
||||
const meta: Record<string, { bg: string }> = {
|
||||
全部: { 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 (
|
||||
<Animated.View key={item} style={[styles.catTileWrapper, { transform: [{ scale }] }]}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={handlePress}
|
||||
style={[
|
||||
styles.catTile,
|
||||
{ backgroundColor: meta[item]?.bg ?? 'rgba(24,24,27,0.06)' },
|
||||
active && { borderWidth: 2, borderColor: goalConfig.color }
|
||||
]}
|
||||
>
|
||||
<Text style={[
|
||||
styles.catText,
|
||||
{ color: active ? goalConfig.color : '#384046' },
|
||||
active && { fontWeight: '800' }
|
||||
]}>
|
||||
{item}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 分类选择弹层 */}
|
||||
<Modal
|
||||
visible={showCategoryPicker}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={() => setShowCategoryPicker(false)}
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setShowCategoryPicker(false)}>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.modalSheet}
|
||||
onPress={(e) => e.stopPropagation() as any}
|
||||
>
|
||||
<Text style={styles.modalTitle}>选择分类</Text>
|
||||
<View style={styles.catGridModal}>
|
||||
{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 (
|
||||
<Animated.View key={c} style={[styles.catTileWrapper, { transform: [{ scale }] }]}>
|
||||
<TouchableOpacity
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={() => {
|
||||
onPressOut();
|
||||
setCategory(c);
|
||||
setShowCategoryPicker(false);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
activeOpacity={0.9}
|
||||
style={[styles.catTile, { backgroundColor: 'rgba(24,24,27,0.06)' }]}
|
||||
>
|
||||
<Text style={styles.catText}>{c}</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<View style={styles.searchRow}>
|
||||
<TextInput
|
||||
value={keyword}
|
||||
onChangeText={setKeyword}
|
||||
placeholder="搜索动作名称/要点"
|
||||
placeholderTextColor="#888F92"
|
||||
style={[styles.searchInput, { borderColor: `${goalConfig.color}30` }]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 动作列表 */}
|
||||
<FlatList
|
||||
data={filtered}
|
||||
keyExtractor={(item) => item.key}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item }) => {
|
||||
const isSelected = item.key === selectedKey;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.itemCard,
|
||||
isSelected && { borderWidth: 2, borderColor: goalConfig.color },
|
||||
]}
|
||||
onPress={() => onSelectItem(item.key)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.itemTitle}>{item.name}</Text>
|
||||
<Text style={styles.itemMeta}>{item.category}</Text>
|
||||
<Text style={styles.itemDesc}>{item.description}</Text>
|
||||
</View>
|
||||
{isSelected && <Ionicons name="chevron-down" size={20} color={goalConfig.color} />}
|
||||
{isSelected && (
|
||||
<Animated.View style={[styles.expandedBox, { opacity: controlsOpacity }]}>
|
||||
<View style={styles.controlsRow}>
|
||||
<View style={styles.counterBox}>
|
||||
<Text style={styles.counterLabel}>组数</Text>
|
||||
<View style={styles.counterRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.counterBtn}
|
||||
onPress={() => setSets(Math.max(1, sets - 1))}
|
||||
>
|
||||
<Text style={styles.counterBtnText}>-</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.counterValue}>{sets}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.counterBtn}
|
||||
onPress={() => setSets(Math.min(20, sets + 1))}
|
||||
>
|
||||
<Text style={styles.counterBtnText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.counterBox}>
|
||||
<Text style={styles.counterLabel}>每组次数</Text>
|
||||
<View style={styles.repsChipsRow}>
|
||||
{[6, 8, 10, 12, 15, 20, 25, 30].map((v) => {
|
||||
const active = reps === v;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={v}
|
||||
style={[
|
||||
styles.repChip,
|
||||
active && { backgroundColor: goalConfig.color, borderColor: goalConfig.color }
|
||||
]}
|
||||
onPress={() => {
|
||||
setReps(v);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.repChipText, active && { color: '#FFFFFF' }]}>{v}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
<TouchableOpacity
|
||||
style={styles.repChipGhost}
|
||||
onPress={() => {
|
||||
setShowCustomReps((s) => !s);
|
||||
Haptics.selectionAsync();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.repChipGhostText}>自定义</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{showCustomReps && (
|
||||
<View style={styles.customRepsRow}>
|
||||
<TextInput
|
||||
keyboardType="number-pad"
|
||||
value={customRepsInput}
|
||||
onChangeText={setCustomRepsInput}
|
||||
placeholder="输入次数 (1-100)"
|
||||
placeholderTextColor="#888F92"
|
||||
style={styles.customRepsInput}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.customRepsBtn, { backgroundColor: goalConfig.color }]}
|
||||
onPress={() => {
|
||||
const n = Math.max(1, Math.min(100, parseInt(customRepsInput || '0', 10)));
|
||||
if (!Number.isNaN(n)) {
|
||||
setReps(n);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.customRepsBtnText}>确定</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.addBtn,
|
||||
{ backgroundColor: goalConfig.color },
|
||||
(!reps || reps <= 0) && { opacity: 0.5 }
|
||||
]}
|
||||
disabled={!reps || reps <= 0}
|
||||
onPress={handleAdd}
|
||||
>
|
||||
<Text style={styles.addBtnText}>添加到训练计划</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user