feat: 添加训练计划和打卡功能
- 新增训练计划页面,允许用户制定个性化的训练计划 - 集成打卡功能,用户可以记录每日的训练情况 - 更新 Redux 状态管理,添加训练计划相关的 reducer - 在首页中添加训练计划卡片,支持用户点击跳转 - 更新样式和布局,以适应新功能的展示和交互 - 添加日期选择器和相关依赖,支持用户选择训练日期
This commit is contained in:
363
app/checkin/select.tsx
Normal file
363
app/checkin/select.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
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' },
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user