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

386 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, 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 },
});