- 新增训练计划页面,允许用户制定个性化的训练计划 - 集成打卡功能,用户可以记录每日的训练情况 - 更新 Redux 状态管理,添加训练计划相关的 reducer - 在首页中添加训练计划卡片,支持用户点击跳转 - 更新样式和布局,以适应新功能的展示和交互 - 添加日期选择器和相关依赖,支持用户选择训练日期
364 lines
19 KiB
TypeScript
364 lines
19 KiB
TypeScript
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { useAppDispatch } from '@/hooks/redux';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
import { addExercise } from '@/store/checkinSlice';
|
||
import { EXERCISE_LIBRARY, getCategories, searchExercises } from '@/utils/exerciseLibrary';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import * as Haptics from 'expo-haptics';
|
||
import { useRouter } from 'expo-router';
|
||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native';
|
||
|
||
function formatDate(d: Date) {
|
||
const y = d.getFullYear();
|
||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||
const day = `${d.getDate()}`.padStart(2, '0');
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
export default function SelectExerciseScreen() {
|
||
const dispatch = useAppDispatch();
|
||
const router = useRouter();
|
||
const today = useMemo(() => formatDate(new Date()), []);
|
||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||
const colorTokens = Colors[theme];
|
||
|
||
const [keyword, setKeyword] = useState('');
|
||
const [category, setCategory] = useState<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 controlsOpacity = useRef(new Animated.Value(0)).current;
|
||
|
||
useEffect(() => {
|
||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||
}
|
||
}, []);
|
||
|
||
const categories = useMemo(() => ['全部', ...getCategories()], []);
|
||
const mainCategories = useMemo(() => {
|
||
const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑'];
|
||
const exists = (name: string) => categories.includes(name);
|
||
const picked = preferred.filter(exists);
|
||
// 兜底:若某些偏好分类不存在,补足其他分类
|
||
const rest = categories.filter((c) => !picked.includes(c));
|
||
while (picked.length < 5 && rest.length) picked.push(rest.shift() as string);
|
||
return picked;
|
||
}, [categories]);
|
||
const filtered = useMemo(() => {
|
||
const base = searchExercises(keyword);
|
||
if (category === '全部') return base;
|
||
return base.filter((e) => e.category === category);
|
||
}, [keyword, category]);
|
||
|
||
const selected = useMemo(() => EXERCISE_LIBRARY.find((e) => e.key === selectedKey) || null, [selectedKey]);
|
||
|
||
useEffect(() => {
|
||
Animated.timing(controlsOpacity, {
|
||
toValue: selected ? 1 : 0,
|
||
duration: selected ? 220 : 160,
|
||
useNativeDriver: true,
|
||
}).start();
|
||
}, [selected, controlsOpacity]);
|
||
|
||
const handleAdd = () => {
|
||
if (!selected) return;
|
||
dispatch(addExercise({
|
||
date: today,
|
||
item: {
|
||
key: selected.key,
|
||
name: selected.name,
|
||
category: selected.category,
|
||
sets: Math.max(1, sets),
|
||
reps: reps && reps > 0 ? reps : undefined,
|
||
},
|
||
}));
|
||
router.back();
|
||
};
|
||
|
||
const onSelectItem = (key: string) => {
|
||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||
if (selectedKey === key) {
|
||
setSelectedKey(null);
|
||
return;
|
||
}
|
||
setSets(3);
|
||
setReps(undefined);
|
||
setShowCustomReps(false);
|
||
setCustomRepsInput('');
|
||
setSelectedKey(key);
|
||
};
|
||
|
||
return (
|
||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||
<View pointerEvents="none" style={styles.bgOrnaments}>
|
||
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentPrimary, top: -60, right: -60 }]} />
|
||
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentAccent, bottom: -70, left: -70 }]} />
|
||
</View>
|
||
|
||
<HeaderBar title="选择动作" onBack={() => router.back()} withSafeTop={false} transparent />
|
||
<View style={[styles.hero, { backgroundColor: colorTokens.heroSurfaceTint }]}>
|
||
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}>从动作库里选择一个动作,设置组数与每组次数</Text>
|
||
</View>
|
||
|
||
{/* 大分类宫格(无横向滚动) */}
|
||
<View style={styles.catGrid}>
|
||
{[...mainCategories, '更多'].map((item) => {
|
||
const active = category === item;
|
||
const meta: Record<string, { bg: string }> = {
|
||
全部: { bg: 'rgba(187,242,70,0.22)' },
|
||
核心与腹部: { bg: 'rgba(187,242,70,0.18)' },
|
||
脊柱与后链: { bg: 'rgba(149,204,227,0.20)' },
|
||
侧链与髋: { bg: 'rgba(164,138,237,0.20)' },
|
||
平衡与支撑: { bg: 'rgba(252,196,111,0.22)' },
|
||
进阶控制: { bg: 'rgba(237,71,71,0.18)' },
|
||
柔韧与拉伸: { bg: 'rgba(149,204,227,0.18)' },
|
||
更多: { bg: 'rgba(24,24,27,0.06)' },
|
||
};
|
||
const scale = new Animated.Value(1);
|
||
const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
||
const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
||
const handlePress = () => {
|
||
onPressOut();
|
||
if (item === '更多') {
|
||
setShowCategoryPicker(true);
|
||
Haptics.selectionAsync();
|
||
} else {
|
||
setCategory(item);
|
||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||
}
|
||
};
|
||
return (
|
||
<Animated.View key={item} style={[styles.catTileWrapper, { transform: [{ scale }] }]}>
|
||
<TouchableOpacity
|
||
activeOpacity={0.9}
|
||
onPressIn={onPressIn}
|
||
onPressOut={handlePress}
|
||
style={[styles.catTile, { backgroundColor: meta[item]?.bg ?? colorTokens.surface }, active && styles.catTileActive]}
|
||
>
|
||
<Text style={[styles.catText, { color: active ? colorTokens.onPrimary : colorTokens.text }]}>{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, { backgroundColor: colorTokens.card }]}
|
||
onPress={(e) => e.stopPropagation() as any}
|
||
>
|
||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>选择分类</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={colorTokens.textMuted}
|
||
style={[styles.searchInput, { backgroundColor: colorTokens.card, color: colorTokens.text, borderColor: colorTokens.border }]}
|
||
/>
|
||
</View>
|
||
|
||
<FlatList
|
||
data={filtered}
|
||
keyExtractor={(item) => item.key}
|
||
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 40 }}
|
||
renderItem={({ item }) => {
|
||
const isSelected = item.key === selectedKey;
|
||
return (
|
||
<TouchableOpacity
|
||
style={[
|
||
styles.itemCard,
|
||
{ backgroundColor: colorTokens.card },
|
||
isSelected && { borderWidth: 2, borderColor: colorTokens.primary },
|
||
]}
|
||
onPress={() => onSelectItem(item.key)}
|
||
activeOpacity={0.9}
|
||
>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={[styles.itemTitle, { color: colorTokens.text }]}>{item.name}</Text>
|
||
<Text style={[styles.itemMeta, { color: colorTokens.textMuted }]}>{item.category}</Text>
|
||
<Text style={[styles.itemDesc, { color: colorTokens.textMuted }]}>{item.description}</Text>
|
||
</View>
|
||
{isSelected && <Ionicons name="chevron-down" size={20} color={colorTokens.text} />}
|
||
{isSelected && (
|
||
<Animated.View style={[styles.expandedBox, { opacity: controlsOpacity }]}>
|
||
<View style={styles.controlsRow}>
|
||
<View style={[styles.counterBox, { backgroundColor: colorTokens.surface }]}>
|
||
<Text style={[styles.counterLabel, { color: colorTokens.textMuted }]}>组数</Text>
|
||
<View style={styles.counterRow}>
|
||
<TouchableOpacity style={[styles.counterBtn, { backgroundColor: colorTokens.border }]} onPress={() => setSets(Math.max(1, sets - 1))}><Text style={[styles.counterBtnText, { color: colorTokens.text }]}>-</Text></TouchableOpacity>
|
||
<Text style={[styles.counterValue, { color: colorTokens.text }]}>{sets}</Text>
|
||
<TouchableOpacity style={[styles.counterBtn, { backgroundColor: colorTokens.border }]} onPress={() => setSets(Math.min(20, sets + 1))}><Text style={[styles.counterBtnText, { color: colorTokens.text }]}>+</Text></TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={[styles.counterBox, { backgroundColor: colorTokens.surface }]}>
|
||
<Text style={[styles.counterLabel, { color: colorTokens.textMuted }]}>每组次数</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: colorTokens.primary, borderColor: colorTokens.primary }]}
|
||
onPress={() => {
|
||
setReps(v);
|
||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||
}}
|
||
>
|
||
<Text style={[styles.repChipText, active && { color: colorTokens.onPrimary }]}>{v}</Text>
|
||
</TouchableOpacity>
|
||
);
|
||
})}
|
||
<TouchableOpacity
|
||
style={[styles.repChipGhost, { borderColor: colorTokens.border }]}
|
||
onPress={() => {
|
||
setShowCustomReps((s) => !s);
|
||
Haptics.selectionAsync();
|
||
}}
|
||
>
|
||
<Text style={[styles.repChipGhostText, { color: colorTokens.text }]}>自定义</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
{showCustomReps && (
|
||
<View style={styles.customRepsRow}>
|
||
<TextInput
|
||
keyboardType="number-pad"
|
||
value={customRepsInput}
|
||
onChangeText={setCustomRepsInput}
|
||
placeholder="输入次数 (1-100)"
|
||
placeholderTextColor={colorTokens.textMuted}
|
||
style={[styles.customRepsInput, { borderColor: colorTokens.border, color: colorTokens.text }]}
|
||
/>
|
||
<TouchableOpacity
|
||
style={[styles.customRepsBtn, { backgroundColor: colorTokens.primary }]}
|
||
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, { color: colorTokens.onPrimary }]}>确定</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
<TouchableOpacity
|
||
style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }, (!reps || reps <= 0) && { opacity: 0.5 }]}
|
||
disabled={!reps || reps <= 0}
|
||
onPress={handleAdd}
|
||
>
|
||
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}>添加到今日打卡</Text>
|
||
</TouchableOpacity>
|
||
</Animated.View>
|
||
)}
|
||
</TouchableOpacity>
|
||
);
|
||
}}
|
||
/>
|
||
|
||
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
||
header: { paddingHorizontal: 20, paddingTop: 10, paddingBottom: 10 },
|
||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 },
|
||
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
||
headerTitle: { fontSize: 18, fontWeight: '800', color: '#1A1A1A' },
|
||
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
||
catCard: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 14, flexDirection: 'row', alignItems: 'center' },
|
||
catCardActive: { borderWidth: 2, borderColor: '#BBF246' },
|
||
catEmoji: { fontSize: 16, marginRight: 6 },
|
||
catText: { fontSize: 13, fontWeight: '800' },
|
||
hero: { backgroundColor: 'rgba(187,242,70,0.18)', borderRadius: 16, padding: 14, marginTop: 8 },
|
||
bgOrnaments: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
||
blob: { position: 'absolute', width: 260, height: 260, borderRadius: 999 },
|
||
catGrid: { paddingHorizontal: 16, paddingTop: 10, flexDirection: 'row', flexWrap: 'wrap' },
|
||
catTileWrapper: { width: '33.33%', padding: 6 },
|
||
catTile: { borderRadius: 14, paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', justifyContent: 'center' },
|
||
catTileActive: { borderWidth: 2, borderColor: '#BBF246' },
|
||
searchRow: { paddingHorizontal: 20, marginTop: 8 },
|
||
searchInput: { backgroundColor: '#FFFFFF', borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10, color: '#111827' },
|
||
itemCard: { backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginTop: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 },
|
||
itemCardSelected: { borderWidth: 2, borderColor: '#10B981' },
|
||
itemTitle: { fontSize: 16, fontWeight: '800', color: '#111827' },
|
||
itemMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
||
itemDesc: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
||
expandedBox: { marginTop: 12 },
|
||
controlsRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 10 },
|
||
counterBox: { backgroundColor: '#F3F4F6', borderRadius: 8, padding: 8 },
|
||
counterLabel: { fontSize: 10, color: '#6B7280' },
|
||
counterRow: { flexDirection: 'row', alignItems: 'center' },
|
||
counterBtn: { backgroundColor: '#E5E7EB', width: 28, height: 28, borderRadius: 6, alignItems: 'center', justifyContent: 'center' },
|
||
counterBtnText: { fontWeight: '800', color: '#111827' },
|
||
counterValue: { minWidth: 40, textAlign: 'center', fontWeight: '700', color: '#111827' },
|
||
repsChipsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 6 },
|
||
repChip: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, backgroundColor: '#F3F4F6', borderWidth: 1, borderColor: '#E5E7EB' },
|
||
repChipText: { color: '#111827', fontWeight: '700' },
|
||
repChipGhost: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, borderWidth: 1, backgroundColor: 'transparent' },
|
||
repChipGhostText: { fontWeight: '700' },
|
||
customRepsRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 8 },
|
||
customRepsInput: { flex: 1, height: 40, borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 },
|
||
customRepsBtn: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10 },
|
||
customRepsBtnText: { fontWeight: '800' },
|
||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'flex-end' },
|
||
modalSheet: { width: '100%', borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 24 },
|
||
modalTitle: { fontSize: 16, fontWeight: '800', marginBottom: 8 },
|
||
catGridModal: { flexDirection: 'row', flexWrap: 'wrap' },
|
||
primaryBtn: { backgroundColor: '#111827', paddingVertical: 12, borderRadius: 12, alignItems: 'center' },
|
||
primaryBtnText: { color: '#FFFFFF', fontWeight: '800' },
|
||
});
|
||
|
||
|