import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { fetchExerciseConfig, normalizeToLibraryItems } from '@/services/exercises'; import { addExercise } from '@/store/checkinSlice'; 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 { 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'; 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 params = useLocalSearchParams<{ date?: string }>(); const today = useMemo(() => formatDate(new Date()), []); const currentDate = (typeof params?.date === 'string' && params.date) ? params.date : today; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const [keyword, setKeyword] = useState(''); const [category, setCategory] = useState('全部'); const [selectedKey, setSelectedKey] = useState(null); const [sets, setSets] = useState(3); const [reps, setReps] = useState(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(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) return; dispatch(addExercise({ date: currentDate, item: { key: selected.key, name: selected.name, category: selected.category, sets: Math.max(1, sets), reps: reps && reps > 0 ? reps : undefined, }, })); console.log('addExercise', currentDate, selected.key, sets, reps); // 自动同步将由中间件处理,无需手动调用 syncCheckin 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 ( router.back()} withSafeTop={false} transparent /> 从动作库里选择一个动作,设置组数与每组次数 {/* 大分类宫格(无横向滚动) */} {[...mainCategories, '更多'].map((item) => { const active = category === item; const meta: Record = { 全部: { 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 ( {item} ); })} {/* 分类选择弹层(更多) */} setShowCategoryPicker(false)} > setShowCategoryPicker(false)}> e.stopPropagation() as any} > 选择分类 {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 ( { onPressOut(); setCategory(c); setShowCategoryPicker(false); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); }} activeOpacity={0.9} style={[styles.catTile, { backgroundColor: 'rgba(24,24,27,0.06)' }]} > {c} ); })} item.key} contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 40 }} renderItem={({ item }) => { const isSelected = item.key === selectedKey; return ( onSelectItem(item.key)} activeOpacity={0.9} > {item.name} {item.category} {item.description} {isSelected && } {isSelected && ( 组数 setSets(Math.max(1, sets - 1))}>- {sets} setSets(Math.min(20, sets + 1))}>+ 每组次数 {[6, 8, 10, 12, 15, 20, 25, 30].map((v) => { const active = reps === v; return ( { setReps(v); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); }} > {v} ); })} { setShowCustomReps((s) => !s); Haptics.selectionAsync(); }} > 自定义 {showCustomReps && ( { const n = Math.max(1, Math.min(100, parseInt(customRepsInput || '0', 10))); if (!Number.isNaN(n)) { setReps(n); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } }} > 确定 )} 添加到今日打卡 )} ); }} /> ); } 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' }, });