- 将应用版本更新至 1.0.3,修改相关配置文件 - 强制全局使用浅色主题,确保一致的用户体验 - 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划 - 优化打卡功能,支持自动同步打卡记录至服务器 - 更新样式以适应新功能的展示和交互
386 lines
21 KiB
TypeScript
386 lines
21 KiB
TypeScript
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<string | null>(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 (
|
||
<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.title, { color: colorTokens.text }]}>{currentDate}</Text>
|
||
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}>请选择动作并记录完成情况</Text>
|
||
</View>
|
||
|
||
{/* 训练计划提示(非强制) */}
|
||
<View style={{ paddingHorizontal: 20, marginTop: 8 }}>
|
||
{activePlan ? (
|
||
<View style={[styles.planHintCard, { backgroundColor: colorTokens.card, borderColor: colorTokens.border }]}>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={[styles.planHintTitle, { color: colorTokens.text }]}>已有训练计划进行中</Text>
|
||
{!!planStartText && (
|
||
<Text style={[styles.planHintSub, { color: colorTokens.textMuted }]}>开始于 {planStartText}</Text>
|
||
)}
|
||
</View>
|
||
<TouchableOpacity
|
||
style={[styles.hintBtn, { borderColor: colorTokens.primary }]}
|
||
onPress={() => router.push('/training-plan' as any)}
|
||
accessibilityRole="button"
|
||
accessibilityLabel="查看训练计划"
|
||
>
|
||
<Text style={[styles.hintBtnText, { color: colorTokens.primary }]}>查看训练计划</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
) : (
|
||
<View style={[styles.planHintCard, { backgroundColor: colorTokens.card, borderColor: colorTokens.border }]}>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={[styles.planHintTitle, { color: colorTokens.text }]}>你还没有训练计划</Text>
|
||
<Text style={[styles.planHintSub, { color: colorTokens.textMuted }]}>创建计划可明确每周节奏与目标(可跳过)</Text>
|
||
</View>
|
||
<TouchableOpacity
|
||
style={[styles.hintPrimaryBtn, { backgroundColor: colorTokens.primary }]}
|
||
onPress={() => router.push('/training-plan/create' as any)}
|
||
accessibilityRole="button"
|
||
accessibilityLabel="创建训练计划"
|
||
>
|
||
<Text style={[styles.hintPrimaryBtnText, { color: colorTokens.onPrimary }]}>创建训练计划</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
<View style={styles.actionRow}>
|
||
<TouchableOpacity
|
||
style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]}
|
||
onPress={() => router.push({ pathname: '/checkin/select', params: { date: currentDate } })}
|
||
>
|
||
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}>新增动作</Text>
|
||
</TouchableOpacity>
|
||
<View style={{ height: 10 }} />
|
||
<TouchableOpacity
|
||
style={[styles.secondaryBtn, { borderColor: colorTokens.primary }]}
|
||
onPress={() => setGenVisible(true)}
|
||
>
|
||
<Text style={[styles.secondaryBtnText, { color: colorTokens.primary }]}>一键排课(经典序列)</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<FlatList
|
||
data={(record?.items && record.items.length > 0)
|
||
? record.items
|
||
: ((record as any)?.raw || [])}
|
||
keyExtractor={(item, index) => (item?.key || item?.id || `${currentDate}_${index}`)}
|
||
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 20 }}
|
||
ListEmptyComponent={
|
||
<View style={[styles.emptyBox, { backgroundColor: colorTokens.card }]}>
|
||
<Text style={[styles.emptyText, { color: colorTokens.textMuted }]}>还没有选择任何动作,点击“新增动作”开始吧。</Text>
|
||
</View>
|
||
}
|
||
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 (
|
||
<View style={[styles.card, { backgroundColor: colorTokens.card }]}>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={[styles.cardTitle, { color: colorTokens.text }]}>{title}</Text>
|
||
{!!status && <Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{status}</Text>}
|
||
{!!startedAt && <Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{startedAt}</Text>}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
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 (
|
||
<View style={styles.inlineRow}>
|
||
<Ionicons name={isRest ? 'time-outline' : 'information-circle-outline'} size={14} color={colorTokens.textMuted} />
|
||
<View style={[styles.inlineBadge, isRest ? styles.inlineBadgeRest : styles.inlineBadgeNote, { borderColor: colorTokens.border }]}>
|
||
<Text style={[isNote ? styles.inlineTextItalic : styles.inlineText, { color: colorTokens.textMuted }]}>
|
||
{isRest ? `间隔休息 ${exercise.restSec ?? 30}s` : (exercise.note || '提示')}
|
||
</Text>
|
||
</View>
|
||
<TouchableOpacity
|
||
style={styles.inlineRemoveBtn}
|
||
onPress={() =>
|
||
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 }}
|
||
>
|
||
<Ionicons name="close-outline" size={16} color={colorTokens.textMuted} />
|
||
</TouchableOpacity>
|
||
</View>
|
||
);
|
||
}
|
||
return (
|
||
<View style={cardStyle as any}>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={[styles.cardTitle, { color: colorTokens.text }]}>{exercise.name}</Text>
|
||
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{exercise.category}</Text>
|
||
{isNote && (
|
||
<Text style={[styles.cardMetaItalic, { color: colorTokens.textMuted }]}>{exercise.note || '提示'}</Text>
|
||
)}
|
||
{!isNote && (
|
||
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>
|
||
{isRest
|
||
? `建议休息 ${exercise.restSec ?? 30}s`
|
||
: `组数 ${exercise.sets}${exercise.reps ? ` · 每组 ${exercise.reps} 次` : ''}${exercise.durationSec ? ` · 每组 ${exercise.durationSec}s` : ''}`}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
{type === 'exercise' && (
|
||
<TouchableOpacity
|
||
accessibilityRole="button"
|
||
accessibilityLabel={exercise.completed ? '已完成,点击取消完成' : '未完成,点击标记完成'}
|
||
style={styles.doneIconBtn}
|
||
onPress={() => {
|
||
dispatch(toggleExerciseCompleted({ date: currentDate, key: exercise.key }));
|
||
// 自动同步将由中间件处理
|
||
}}
|
||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||
>
|
||
<Ionicons
|
||
name={exercise.completed ? 'checkmark-circle' : 'checkmark-circle-outline'}
|
||
size={24}
|
||
color={exercise.completed ? colorTokens.primary : colorTokens.textMuted}
|
||
/>
|
||
</TouchableOpacity>
|
||
)}
|
||
<TouchableOpacity
|
||
style={[styles.removeBtn, { backgroundColor: colorTokens.border }]}
|
||
onPress={() =>
|
||
Alert.alert('确认移除', '确定要移除该动作吗?', [
|
||
{ text: '取消', style: 'cancel' },
|
||
{
|
||
text: '移除',
|
||
style: 'destructive',
|
||
onPress: () => {
|
||
dispatch(removeExercise({ date: currentDate, key: exercise.key }));
|
||
// 自动同步将由中间件处理
|
||
},
|
||
},
|
||
])
|
||
}
|
||
>
|
||
<Text style={[styles.removeBtnText, { color: colorTokens.text }]}>移除</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
);
|
||
}}
|
||
/>
|
||
{/* 生成配置弹窗 */}
|
||
<Modal visible={genVisible} transparent animationType="fade" onRequestClose={() => setGenVisible(false)}>
|
||
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setGenVisible(false)}>
|
||
<TouchableOpacity activeOpacity={1} style={[styles.modalSheet, { backgroundColor: colorTokens.card }]} onPress={(e) => e.stopPropagation() as any}>
|
||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>经典排课配置</Text>
|
||
<Text style={[styles.modalLabel, { color: colorTokens.textMuted }]}>强度水平</Text>
|
||
<View style={styles.segmentedRow}>
|
||
{(['beginner', 'intermediate', 'advanced'] as const).map((lv) => (
|
||
<TouchableOpacity key={lv} style={[styles.segment, genLevel === lv && { backgroundColor: colorTokens.primary }]} onPress={() => setGenLevel(lv)}>
|
||
<Text style={[styles.segmentText, genLevel === lv && { color: colorTokens.onPrimary }]}>
|
||
{lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
<View style={styles.switchRow}>
|
||
<Text style={[styles.switchLabel, { color: colorTokens.text }]}>段间休息</Text>
|
||
<Switch value={genWithRests} onValueChange={setGenWithRests} />
|
||
</View>
|
||
<View style={styles.switchRow}>
|
||
<Text style={[styles.switchLabel, { color: colorTokens.text }]}>插入操作提示</Text>
|
||
<Switch value={genWithNotes} onValueChange={setGenWithNotes} />
|
||
</View>
|
||
<View style={styles.inputRow}>
|
||
<Text style={[styles.switchLabel, { color: colorTokens.textMuted }]}>休息秒数</Text>
|
||
<TextInput value={genRest} onChangeText={setGenRest} keyboardType="number-pad" style={[styles.input, { borderColor: colorTokens.border, color: colorTokens.text }]} />
|
||
</View>
|
||
<View style={{ height: 8 }} />
|
||
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]} onPress={onGenerate}>
|
||
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}>生成今日计划</Text>
|
||
</TouchableOpacity>
|
||
</TouchableOpacity>
|
||
</TouchableOpacity>
|
||
</Modal>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
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 },
|
||
|
||
});
|
||
|
||
|