Files
digital-pilates/app/checkin/select.tsx
richarjiang 807e185761 feat: 更新应用版本和主题设置
- 将应用版本更新至 1.0.3,修改相关配置文件
- 强制全局使用浅色主题,确保一致的用户体验
- 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划
- 优化打卡功能,支持自动同步打卡记录至服务器
- 更新样式以适应新功能的展示和交互
2025-08-14 22:23:45 +08:00

408 lines
21 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 { 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<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 [serverLibrary, setServerLibrary] = useState<{ key: string; name: string; description: string; category: string }[] | null>(null);
const [serverCategories, setServerCategories] = useState<string[] | null>(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 (
<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' },
});