Files
digital-pilates/app/checkin/select.tsx
richarjiang 5d09cc05dc feat: 更新文章功能和相关依赖
- 新增文章详情页面,支持根据文章 ID 加载和展示文章内容
- 添加文章卡片组件,展示推荐文章的标题、封面和阅读量
- 更新文章服务,支持获取文章列表和根据 ID 获取文章详情
- 集成腾讯云 COS SDK,支持文件上传功能
- 优化打卡功能,支持按日期加载和展示打卡记录
- 更新相关依赖,确保项目兼容性和功能完整性
- 调整样式以适应新功能的展示和交互
2025-08-14 16:03:19 +08:00

381 lines
20 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, syncCheckin } from '@/store/checkinSlice';
import { EXERCISE_LIBRARY, getCategories, searchExercises } from '@/utils/exerciseLibrary';
import { Ionicons } from '@expo/vector-icons';
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 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: 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);
// 同步到后端(读取最新 store 需要在返回后由首页触发 load或此处直接上报
// 简单做法:直接上报新增项(其余项由后端合并/覆盖)
dispatch(syncCheckin({
date: currentDate,
items: [
{
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' },
});