Files
digital-pilates/app/checkin/select.tsx
richarjiang f3e6250505 feat: 添加训练计划和打卡功能
- 新增训练计划页面,允许用户制定个性化的训练计划
- 集成打卡功能,用户可以记录每日的训练情况
- 更新 Redux 状态管理,添加训练计划相关的 reducer
- 在首页中添加训练计划卡片,支持用户点击跳转
- 更新样式和布局,以适应新功能的展示和交互
- 添加日期选择器和相关依赖,支持用户选择训练日期
2025-08-13 09:10:00 +08:00

364 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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' },
});