1116 lines
36 KiB
TypeScript
1116 lines
36 KiB
TypeScript
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||
import { palette } from '@/constants/Colors';
|
||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||
import { loadExerciseLibrary } from '@/store/exerciseLibrarySlice';
|
||
import { EXERCISE_LIBRARY, getCategories } from '@/utils/exerciseLibrary';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
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 { Alert, Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native';
|
||
|
||
import { ThemedText } from '@/components/ThemedText';
|
||
import { addExercise } from '@/store/scheduleExerciseSlice';
|
||
import { addWorkoutExercise } from '@/store/workoutSlice';
|
||
|
||
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 dispatch = useAppDispatch();
|
||
const params = useLocalSearchParams<{ planId?: string; sessionId?: string }>();
|
||
const { plans } = useAppSelector((s) => s.trainingPlan);
|
||
const { currentSession } = useAppSelector((s) => s.workout);
|
||
|
||
const planId = params.planId;
|
||
const sessionId = params.sessionId;
|
||
const plan = useMemo(() => plans.find(p => p.id === planId), [plans, planId]);
|
||
|
||
// 会话状态管理
|
||
const [session, setSession] = useState<any>(null);
|
||
const [sessionLoading, setSessionLoading] = useState(false);
|
||
|
||
// 根据是否有sessionId来确定是训练计划模式还是训练会话模式
|
||
const isSessionMode = !!sessionId;
|
||
|
||
// 加载会话详情(如果是会话模式)
|
||
useEffect(() => {
|
||
if (sessionId && !session) {
|
||
const loadSession = async () => {
|
||
try {
|
||
setSessionLoading(true);
|
||
// 首先尝试使用 currentSession(如果 sessionId 匹配)
|
||
if (currentSession?.id === sessionId) {
|
||
setSession(currentSession);
|
||
} else {
|
||
// 否则从 API 获取会话详情
|
||
const { workoutsApi } = await import('@/services/workoutsApi');
|
||
const sessionDetail = await workoutsApi.getSessionDetail(sessionId);
|
||
setSession(sessionDetail);
|
||
}
|
||
} catch (error) {
|
||
console.error('加载会话详情失败:', error);
|
||
} finally {
|
||
setSessionLoading(false);
|
||
}
|
||
};
|
||
loadSession();
|
||
}
|
||
}, [sessionId, currentSession, session]);
|
||
|
||
const targetGoal = plan?.goal || session?.trainingPlan?.goal;
|
||
const goalConfig = targetGoal
|
||
? (GOAL_TEXT[targetGoal] || { title: isSessionMode ? '添加动作' : '训练计划', color: palette.primary, description: isSessionMode ? '选择要添加的动作' : '开始你的训练之旅' })
|
||
: { title: isSessionMode ? '添加动作' : '训练计划', color: palette.primary, description: isSessionMode ? '选择要添加的动作' : '开始你的训练之旅' };
|
||
|
||
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 [showRestModal, setShowRestModal] = useState(false);
|
||
const [showNoteModal, setShowNoteModal] = useState(false);
|
||
const [restDuration, setRestDuration] = useState(30);
|
||
const [noteContent, setNoteContent] = useState('');
|
||
const { categories: serverCategoryDtos, exercises: serverExercises } = useAppSelector((s) => s.exerciseLibrary);
|
||
const [adding, setAdding] = useState(false);
|
||
|
||
const controlsOpacity = useRef(new Animated.Value(0)).current;
|
||
|
||
useEffect(() => {
|
||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
dispatch(loadExerciseLibrary());
|
||
}, [dispatch]);
|
||
|
||
const categories = useMemo(() => {
|
||
const base = serverCategoryDtos && serverCategoryDtos.length
|
||
? serverCategoryDtos.map((c) => c.name)
|
||
: getCategories();
|
||
const unique = Array.from(new Set(base));
|
||
return ['全部', ...unique];
|
||
}, [serverCategoryDtos]);
|
||
|
||
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(() => (serverExercises && serverExercises.length ? serverExercises : EXERCISE_LIBRARY), [serverExercises]);
|
||
|
||
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 = async () => {
|
||
if (!selected || adding) return;
|
||
|
||
console.log('选择动作:', selected);
|
||
|
||
const newExerciseDto = {
|
||
exerciseKey: selected.key,
|
||
name: selected.name,
|
||
plannedSets: sets,
|
||
plannedReps: reps,
|
||
itemType: 'exercise' as const,
|
||
note: `${selected.category}训练`,
|
||
};
|
||
|
||
setAdding(true);
|
||
try {
|
||
if (isSessionMode && sessionId) {
|
||
// 训练会话模式:添加到训练会话
|
||
await dispatch(addWorkoutExercise({ sessionId, dto: newExerciseDto })).unwrap();
|
||
} else if (plan && planId) {
|
||
// 训练计划模式:添加到训练计划
|
||
const planExerciseDto = {
|
||
exerciseKey: selected.key,
|
||
name: selected.name,
|
||
sets: sets,
|
||
reps: reps,
|
||
itemType: 'exercise' as const,
|
||
note: `${selected.category}训练`,
|
||
};
|
||
await dispatch(addExercise({ planId: planId, dto: planExerciseDto })).unwrap();
|
||
} else {
|
||
throw new Error('缺少必要的参数');
|
||
}
|
||
|
||
// 返回到上一页
|
||
router.back();
|
||
} catch (error) {
|
||
console.error('添加动作失败:', error);
|
||
Alert.alert('添加失败', '添加动作时出现错误,请稍后重试');
|
||
} finally {
|
||
setAdding(false);
|
||
}
|
||
};
|
||
|
||
// 添加休息项目
|
||
const handleAddRest = () => {
|
||
setShowRestModal(true);
|
||
Haptics.selectionAsync();
|
||
};
|
||
|
||
// 添加备注项目
|
||
const handleAddNote = () => {
|
||
setShowNoteModal(true);
|
||
Haptics.selectionAsync();
|
||
};
|
||
|
||
// 确认添加休息
|
||
const confirmAddRest = async () => {
|
||
if (adding) return;
|
||
|
||
const restDto = {
|
||
name: `间隔休息 ${restDuration}s`,
|
||
restSec: restDuration,
|
||
itemType: 'rest' as const,
|
||
};
|
||
|
||
setAdding(true);
|
||
try {
|
||
if (isSessionMode && sessionId) {
|
||
// 训练会话模式
|
||
await dispatch(addWorkoutExercise({ sessionId, dto: restDto })).unwrap();
|
||
} else if (plan && planId) {
|
||
// 训练计划模式
|
||
await dispatch(addExercise({ planId: planId, dto: restDto })).unwrap();
|
||
} else {
|
||
throw new Error('缺少必要的参数');
|
||
}
|
||
|
||
setShowRestModal(false);
|
||
setRestDuration(30);
|
||
router.back();
|
||
} catch (error) {
|
||
console.error('添加休息失败:', error);
|
||
Alert.alert('添加失败', '添加休息时出现错误,请稍后重试');
|
||
} finally {
|
||
setAdding(false);
|
||
}
|
||
};
|
||
|
||
// 确认添加备注
|
||
const confirmAddNote = async () => {
|
||
if (adding || !noteContent.trim()) return;
|
||
|
||
const noteDto = {
|
||
name: '训练提示',
|
||
note: noteContent.trim(),
|
||
itemType: 'note' as const,
|
||
};
|
||
|
||
setAdding(true);
|
||
try {
|
||
if (isSessionMode && sessionId) {
|
||
// 训练会话模式
|
||
await dispatch(addWorkoutExercise({ sessionId, dto: noteDto })).unwrap();
|
||
} else if (plan && planId) {
|
||
// 训练计划模式
|
||
await dispatch(addExercise({ planId: planId, dto: noteDto })).unwrap();
|
||
} else {
|
||
throw new Error('缺少必要的参数');
|
||
}
|
||
|
||
setShowNoteModal(false);
|
||
setNoteContent('');
|
||
router.back();
|
||
} catch (error) {
|
||
console.error('添加备注失败:', error);
|
||
Alert.alert('添加失败', '添加备注时出现错误,请稍后重试');
|
||
} finally {
|
||
setAdding(false);
|
||
}
|
||
};
|
||
|
||
const onSelectItem = (key: string) => {
|
||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||
if (selectedKey === key) {
|
||
setSelectedKey(null);
|
||
return;
|
||
}
|
||
const sel = library.find((e) => e.key === key) as any;
|
||
setSets(sel?.beginnerSets ?? 3);
|
||
setReps(sel?.beginnerReps);
|
||
setShowCustomReps(false);
|
||
setCustomRepsInput('');
|
||
setSelectedKey(key);
|
||
};
|
||
|
||
// 加载状态
|
||
if (sessionLoading) {
|
||
return (
|
||
<SafeAreaView style={styles.safeArea}>
|
||
<HeaderBar title="选择动作" onBack={() => router.back()} />
|
||
<View style={styles.errorContainer}>
|
||
<ThemedText style={styles.errorText}>加载中...</ThemedText>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
// 错误状态检查
|
||
if (isSessionMode && !session) {
|
||
return (
|
||
<SafeAreaView style={styles.safeArea}>
|
||
<HeaderBar title="选择动作" onBack={() => router.back()} />
|
||
<View style={styles.errorContainer}>
|
||
<ThemedText style={styles.errorText}>找不到指定的训练会话</ThemedText>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
if (!isSessionMode && !plan) {
|
||
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={isSessionMode ? "添加动作" : "选择动作"}
|
||
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}>
|
||
{isSessionMode ? '为当前训练会话添加动作' : '选择动作或添加休息、备注项目'}
|
||
</ThemedText>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 快捷添加区域 */}
|
||
<View style={styles.quickAddSection}>
|
||
<TouchableOpacity
|
||
style={[styles.quickAddBtn, { backgroundColor: `${goalConfig.color}15`, borderColor: goalConfig.color }]}
|
||
onPress={handleAddRest}
|
||
>
|
||
<Ionicons name="time-outline" size={20} color={goalConfig.color} />
|
||
<Text style={[styles.quickAddText, { color: goalConfig.color }]}>添加休息</Text>
|
||
</TouchableOpacity>
|
||
|
||
<TouchableOpacity
|
||
style={[styles.quickAddBtn, { backgroundColor: `${goalConfig.color}15`, borderColor: goalConfig.color }]}
|
||
onPress={handleAddNote}
|
||
>
|
||
<Ionicons name="document-text-outline" size={20} color={goalConfig.color} />
|
||
<Text style={[styles.quickAddText, { color: goalConfig.color }]}>添加备注</Text>
|
||
</TouchableOpacity>
|
||
</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>
|
||
{((item as any).targetMuscleGroups || (item as any).equipmentName) && (
|
||
<Text style={styles.itemMeta}>
|
||
{[(item as any).targetMuscleGroups, (item as any).equipmentName].filter(Boolean).join(' · ')}
|
||
</Text>
|
||
)}
|
||
{(((item as any).beginnerSets || (item as any).beginnerReps)) && (
|
||
<Text style={styles.itemMeta}>
|
||
建议 {(item as any).beginnerSets ?? '-'} 组 × {(item as any).beginnerReps ?? '-'} 次
|
||
</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) || adding) && { opacity: 0.5 }
|
||
]}
|
||
disabled={!reps || reps <= 0 || adding}
|
||
onPress={handleAdd}
|
||
>
|
||
<Text style={styles.addBtnText}>
|
||
{adding ? '添加中...' : (isSessionMode ? '添加到训练会话' : '添加到训练计划')}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</Animated.View>
|
||
)}
|
||
</TouchableOpacity>
|
||
);
|
||
}}
|
||
/>
|
||
</View>
|
||
</SafeAreaView>
|
||
|
||
{/* 休息时间配置模态框 */}
|
||
<Modal visible={showRestModal} transparent animationType="fade" onRequestClose={() => setShowRestModal(false)}>
|
||
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setShowRestModal(false)}>
|
||
<TouchableOpacity activeOpacity={1} style={styles.modalSheet} onPress={(e) => e.stopPropagation() as any}>
|
||
<Text style={styles.modalTitle}>设置休息时间</Text>
|
||
|
||
<View style={styles.restTimeRow}>
|
||
{[15, 30, 45, 60, 90, 120].map((v) => {
|
||
const active = restDuration === v;
|
||
return (
|
||
<TouchableOpacity
|
||
key={v}
|
||
style={[
|
||
styles.restChip,
|
||
active && { backgroundColor: goalConfig.color, borderColor: goalConfig.color }
|
||
]}
|
||
onPress={() => {
|
||
setRestDuration(v);
|
||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||
}}
|
||
>
|
||
<Text style={[styles.restChipText, active && { color: '#FFFFFF' }]}>{v}s</Text>
|
||
</TouchableOpacity>
|
||
);
|
||
})}
|
||
</View>
|
||
|
||
<View style={styles.customRestSection}>
|
||
<Text style={styles.sectionLabel}>自定义时间</Text>
|
||
<View style={styles.customRestRow}>
|
||
<TextInput
|
||
value={restDuration.toString()}
|
||
onChangeText={(text) => {
|
||
const num = parseInt(text) || 30;
|
||
setRestDuration(Math.max(10, Math.min(300, num)));
|
||
}}
|
||
keyboardType="number-pad"
|
||
style={styles.customRestInput}
|
||
/>
|
||
<Text style={styles.customRestUnit}>秒</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<TouchableOpacity
|
||
style={[styles.confirmBtn, { backgroundColor: goalConfig.color }]}
|
||
onPress={confirmAddRest}
|
||
disabled={adding}
|
||
>
|
||
<Text style={styles.confirmBtnText}>{adding ? '添加中...' : '确认添加'}</Text>
|
||
</TouchableOpacity>
|
||
</TouchableOpacity>
|
||
</TouchableOpacity>
|
||
</Modal>
|
||
|
||
{/* 备注配置模态框 */}
|
||
<Modal visible={showNoteModal} transparent animationType="fade" onRequestClose={() => setShowNoteModal(false)}>
|
||
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setShowNoteModal(false)}>
|
||
<TouchableOpacity activeOpacity={1} style={styles.modalSheet} onPress={(e) => e.stopPropagation() as any}>
|
||
<Text style={styles.modalTitle}>添加训练提示</Text>
|
||
|
||
<TextInput
|
||
value={noteContent}
|
||
onChangeText={setNoteContent}
|
||
placeholder="输入训练提醒或注意事项..."
|
||
placeholderTextColor="#888F92"
|
||
style={styles.noteModalInput}
|
||
multiline
|
||
maxLength={100}
|
||
autoFocus
|
||
/>
|
||
|
||
<View style={styles.noteModalInfo}>
|
||
<Text style={styles.noteCounter}>{noteContent.length}/100</Text>
|
||
{noteContent.length > 0 && (
|
||
<TouchableOpacity
|
||
onPress={() => setNoteContent('')}
|
||
style={styles.noteClearBtn}
|
||
>
|
||
<Ionicons name="close-circle" size={20} color="#888F92" />
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
|
||
<TouchableOpacity
|
||
style={[
|
||
styles.confirmBtn,
|
||
{ backgroundColor: goalConfig.color },
|
||
(!noteContent.trim() || adding) && { opacity: 0.5 }
|
||
]}
|
||
onPress={confirmAddNote}
|
||
disabled={!noteContent.trim() || adding}
|
||
>
|
||
<Text style={styles.confirmBtnText}>{adding ? '添加中...' : '确认添加'}</Text>
|
||
</TouchableOpacity>
|
||
</TouchableOpacity>
|
||
</TouchableOpacity>
|
||
</Modal>
|
||
</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,
|
||
},
|
||
|
||
// 快捷添加区域
|
||
quickAddSection: {
|
||
flexDirection: 'row',
|
||
gap: 12,
|
||
marginBottom: 16,
|
||
},
|
||
quickAddBtn: {
|
||
flex: 1,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingVertical: 12,
|
||
paddingHorizontal: 16,
|
||
borderRadius: 12,
|
||
borderWidth: 1,
|
||
gap: 8,
|
||
},
|
||
quickAddText: {
|
||
fontSize: 14,
|
||
fontWeight: '700',
|
||
},
|
||
|
||
// 分类网格
|
||
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,
|
||
},
|
||
|
||
// 休息时间配置
|
||
restTimeRow: {
|
||
flexDirection: 'row',
|
||
flexWrap: 'wrap',
|
||
gap: 8,
|
||
marginBottom: 16,
|
||
},
|
||
restChip: {
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
borderRadius: 16,
|
||
borderWidth: 1,
|
||
borderColor: '#E5E7EB',
|
||
backgroundColor: '#FFFFFF',
|
||
},
|
||
restChipText: {
|
||
fontSize: 12,
|
||
fontWeight: '700',
|
||
color: '#384046',
|
||
},
|
||
|
||
// 模态框自定义休息时间
|
||
customRestSection: {
|
||
marginBottom: 20,
|
||
},
|
||
sectionLabel: {
|
||
fontSize: 14,
|
||
fontWeight: '700',
|
||
color: '#384046',
|
||
marginBottom: 10,
|
||
},
|
||
customRestRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 10,
|
||
},
|
||
customRestInput: {
|
||
flex: 1,
|
||
height: 40,
|
||
borderWidth: 1,
|
||
borderColor: '#E5E7EB',
|
||
borderRadius: 10,
|
||
paddingHorizontal: 12,
|
||
color: '#384046',
|
||
fontSize: 16,
|
||
textAlign: 'center',
|
||
},
|
||
customRestUnit: {
|
||
fontSize: 14,
|
||
color: '#384046',
|
||
fontWeight: '600',
|
||
},
|
||
|
||
// 备注模态框
|
||
noteModalInput: {
|
||
minHeight: 100,
|
||
maxHeight: 150,
|
||
borderWidth: 1,
|
||
borderColor: '#E5E7EB',
|
||
borderRadius: 12,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 10,
|
||
color: '#384046',
|
||
fontSize: 14,
|
||
textAlignVertical: 'top',
|
||
backgroundColor: '#FFFFFF',
|
||
marginBottom: 10,
|
||
},
|
||
noteModalInfo: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: 20,
|
||
},
|
||
noteCounter: {
|
||
fontSize: 11,
|
||
color: '#888F92',
|
||
},
|
||
noteClearBtn: {
|
||
padding: 4,
|
||
},
|
||
|
||
// 确认按钮
|
||
confirmBtn: {
|
||
paddingVertical: 12,
|
||
borderRadius: 12,
|
||
alignItems: 'center',
|
||
marginTop: 10,
|
||
},
|
||
confirmBtnText: {
|
||
color: '#FFFFFF',
|
||
fontWeight: '800',
|
||
fontSize: 14,
|
||
},
|
||
|
||
addBtn: {
|
||
marginTop: 20,
|
||
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',
|
||
},
|
||
}); |