import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import type { CheckinExercise } from '@/store/checkinSlice'; import { getDailyCheckins, removeExercise, replaceExercises, setCurrentDate, toggleExerciseCompleted } from '@/store/checkinSlice'; import { loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice'; import { buildClassicalSession } from '@/utils/classicalSession'; import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, 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 CheckinHome() { const dispatch = useAppDispatch(); const router = useRouter(); const params = useLocalSearchParams<{ date?: string }>(); const today = useMemo(() => formatDate(new Date()), []); const checkin = useAppSelector((s) => (s as any).checkin); const training = useAppSelector((s) => (s as any).trainingPlan); const routeDateParam = typeof params?.date === 'string' && params.date ? params.date : undefined; const currentDate: string = routeDateParam || (checkin?.currentDate as string) || today; const record = checkin?.byDate?.[currentDate] as (undefined | { items?: CheckinExercise[]; note?: string; raw?: any[] }); const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; console.log('CheckinHome render', { currentDate, routeDateParam, itemsCount: record?.items?.length || 0, rawCount: (record as any)?.raw?.length || 0, }); const lastFetchedRef = useRef(null); useEffect(() => { // 初始化当前日期:路由参数优先,其次 store,最后今天 if (currentDate && checkin?.currentDate !== currentDate) { dispatch(setCurrentDate(currentDate)); } // 仅当切换日期时获取一次,避免重复请求 if (currentDate && lastFetchedRef.current !== currentDate) { lastFetchedRef.current = currentDate; dispatch(getDailyCheckins(currentDate)).unwrap().catch((err: any) => { Alert.alert('获取打卡失败', err?.message || '请稍后重试'); }); } }, [dispatch, currentDate]); // 加载训练计划列表:仅在页面挂载时尝试一次,避免因失败导致的重复请求 const hasLoadedPlansRef = useRef(false); useEffect(() => { if (hasLoadedPlansRef.current) return; hasLoadedPlansRef.current = true; dispatch(loadPlans()); }, [dispatch]); // 同步触发逻辑改为显式操作处调用,避免页面渲染期间的副作用 const [genVisible, setGenVisible] = useState(false); const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner'); const [genWithRests, setGenWithRests] = useState(true); const [genWithNotes, setGenWithNotes] = useState(true); const [genRest, setGenRest] = useState('30'); // 计算“进行中的训练计划”(startDate <= 当前日期)。若 currentId 存在,优先该计划。 const activePlan: TrainingPlan | null = useMemo(() => { const plans: TrainingPlan[] = training?.plans || []; if (!plans.length) return null; const current = training?.currentId ? plans.find((p) => p.id === training.currentId) : null; const dateObj = new Date(`${currentDate}T00:00:00`); if (current && new Date(current.startDate) <= dateObj) return current; const ongoing = plans .filter((p) => new Date(p.startDate) <= dateObj) .sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()); return ongoing[0] ?? null; }, [training?.plans, training?.currentId, currentDate]); const planStartText = useMemo(() => { if (!activePlan?.startDate) return ''; const d = new Date(activePlan.startDate); const y = d.getFullYear(); const m = `${d.getMonth() + 1}`.padStart(2, '0'); const day = `${d.getDate()}`.padStart(2, '0'); return `${y}-${m}-${day}`; }, [activePlan?.startDate]); const onGenerate = () => { const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10))); const { items, note } = buildClassicalSession({ withSectionRests: genWithRests, restSeconds: restSec, withNotes: genWithNotes, level: genLevel }); dispatch(replaceExercises({ date: currentDate, items, note })); // 自动同步将由中间件处理 setGenVisible(false); Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。'); }; return ( router.back()} withSafeTop={false} transparent /> {currentDate} 请选择动作并记录完成情况 {/* 训练计划提示(非强制) */} {activePlan ? ( 已有训练计划进行中 {!!planStartText && ( 开始于 {planStartText} )} router.push('/training-plan' as any)} accessibilityRole="button" accessibilityLabel="查看训练计划" > 查看训练计划 ) : ( 你还没有训练计划 创建计划可明确每周节奏与目标(可跳过) router.push('/training-plan/create' as any)} accessibilityRole="button" accessibilityLabel="创建训练计划" > 创建训练计划 )} router.push({ pathname: '/checkin/select', params: { date: currentDate } })} > 新增动作 setGenVisible(true)} > 一键排课(经典序列) 0) ? record.items : ((record as any)?.raw || [])} keyExtractor={(item, index) => (item?.key || item?.id || `${currentDate}_${index}`)} contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 20 }} ListEmptyComponent={ 还没有选择任何动作,点击“新增动作”开始吧。 } renderItem={({ item }) => { // 若为后端原始项(无 key),以标题/时间为卡片,禁用交互 const isRaw = !item?.key; if (isRaw) { const title = item?.title || '每日训练打卡'; const status = item?.status || ''; const startedAt = item?.startedAt ? new Date(item.startedAt).toLocaleString() : ''; return ( {title} {!!status && {status}} {!!startedAt && {startedAt}} ); } const exercise = item as CheckinExercise; const type = exercise.itemType ?? 'exercise'; const isRest = type === 'rest'; const isNote = type === 'note'; const cardStyle = [styles.card, { backgroundColor: colorTokens.card }]; if (isRest || isNote) { return ( {isRest ? `间隔休息 ${exercise.restSec ?? 30}s` : (exercise.note || '提示')} Alert.alert('确认移除', '确定要移除该条目吗?', [ { text: '取消', style: 'cancel' }, { text: '移除', style: 'destructive', onPress: () => { dispatch(removeExercise({ date: currentDate, key: exercise.key })); // 自动同步将由中间件处理 }, }, ]) } hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} > ); } return ( {exercise.name} {exercise.category} {isNote && ( {exercise.note || '提示'} )} {!isNote && ( {isRest ? `建议休息 ${exercise.restSec ?? 30}s` : `组数 ${exercise.sets}${exercise.reps ? ` · 每组 ${exercise.reps} 次` : ''}${exercise.durationSec ? ` · 每组 ${exercise.durationSec}s` : ''}`} )} {type === 'exercise' && ( { dispatch(toggleExerciseCompleted({ date: currentDate, key: exercise.key })); // 自动同步将由中间件处理 }} hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} > )} Alert.alert('确认移除', '确定要移除该动作吗?', [ { text: '取消', style: 'cancel' }, { text: '移除', style: 'destructive', onPress: () => { dispatch(removeExercise({ date: currentDate, key: exercise.key })); // 自动同步将由中间件处理 }, }, ]) } > 移除 ); }} /> {/* 生成配置弹窗 */} setGenVisible(false)}> setGenVisible(false)}> e.stopPropagation() as any}> 经典排课配置 强度水平 {(['beginner', 'intermediate', 'advanced'] as const).map((lv) => ( setGenLevel(lv)}> {lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'} ))} 段间休息 插入操作提示 休息秒数 生成今日计划 ); } const styles = StyleSheet.create({ safeArea: { flex: 1, backgroundColor: '#F7F8FA' }, container: { flex: 1, backgroundColor: '#F7F8FA' }, header: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8 }, headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 }, backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' }, hero: { backgroundColor: 'rgba(187,242,70,0.18)', borderRadius: 16, padding: 14 }, title: { fontSize: 24, fontWeight: '800', color: '#111827' }, subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' }, bgOrnaments: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }, blob: { position: 'absolute', width: 260, height: 260, borderRadius: 999 }, blobPrimary: { backgroundColor: '#00000000' }, blobPurple: { backgroundColor: '#00000000' }, actionRow: { paddingHorizontal: 20, marginTop: 8 }, primaryBtn: { backgroundColor: '#111827', paddingVertical: 10, borderRadius: 10, alignItems: 'center' }, primaryBtnText: { color: '#FFFFFF', fontWeight: '800' }, secondaryBtn: { borderWidth: 2, paddingVertical: 10, borderRadius: 10, alignItems: 'center' }, secondaryBtnText: { fontWeight: '800' }, // 训练计划提示卡片 planHintCard: { flexDirection: 'row', alignItems: 'center', gap: 10, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 12, borderWidth: 1 }, planHintTitle: { fontSize: 14, fontWeight: '800' }, planHintSub: { marginTop: 4, fontSize: 12 }, hintBtn: { paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, borderWidth: 1 }, hintBtnText: { fontWeight: '800' }, hintPrimaryBtn: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10 }, hintPrimaryBtnText: { fontWeight: '800' }, emptyBox: { marginTop: 16, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginHorizontal: 0 }, emptyText: { color: '#6B7280' }, card: { marginTop: 12, marginHorizontal: 0, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 }, cardTitle: { fontSize: 16, fontWeight: '800', color: '#111827' }, cardMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' }, cardMetaItalic: { marginTop: 4, fontSize: 12, color: '#6B7280', fontStyle: 'italic' }, removeBtn: { backgroundColor: '#F3F4F6', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8 }, removeBtnText: { color: '#111827', fontWeight: '700' }, doneIconBtn: { paddingHorizontal: 4, paddingVertical: 4, borderRadius: 16, marginRight: 8 }, inlineRow: { marginTop: 10, marginHorizontal: 20, flexDirection: 'row', alignItems: 'center' }, inlineBadge: { marginLeft: 6, borderWidth: 1, borderRadius: 999, paddingVertical: 6, paddingHorizontal: 10 }, inlineBadgeRest: { backgroundColor: '#F8FAFC' }, inlineBadgeNote: { backgroundColor: '#F9FAFB' }, inlineText: { fontSize: 12, fontWeight: '700' }, inlineTextItalic: { fontSize: 12, fontStyle: 'italic' }, inlineRemoveBtn: { marginLeft: 6, padding: 4, borderRadius: 999 }, 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 }, modalLabel: { fontSize: 12, marginBottom: 6 }, segmentedRow: { flexDirection: 'row', gap: 8, marginBottom: 8 }, segment: { flex: 1, borderRadius: 999, borderWidth: 1, borderColor: '#E5E7EB', paddingVertical: 8, alignItems: 'center' }, segmentText: { fontWeight: '700' }, switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }, switchLabel: { fontWeight: '700' }, inputRow: { marginTop: 8 }, input: { height: 40, borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 }, });