Files
digital-pilates/app/training-plan.tsx
richarjiang f3e6250505 feat: 添加训练计划和打卡功能
- 新增训练计划页面,允许用户制定个性化的训练计划
- 集成打卡功能,用户可以记录每日的训练情况
- 更新 Redux 状态管理,添加训练计划相关的 reducer
- 在首页中添加训练计划卡片,支持用户点击跳转
- 更新样式和布局,以适应新功能的展示和交互
- 添加日期选择器和相关依赖,支持用户选择训练日期
2025-08-13 09:10:00 +08:00

469 lines
14 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 { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import { Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native';
import DateTimePickerModal from 'react-native-modal-datetime-picker';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { palette } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import {
loadTrainingPlan,
saveTrainingPlan,
setGoal,
setMode,
setPreferredTime,
setSessionsPerWeek,
setStartDate,
setStartDateNextMonday,
setStartWeight,
toggleDayOfWeek,
type PlanGoal
} from '@/store/trainingPlanSlice';
const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六'];
const GOALS: { key: PlanGoal; title: string; desc: string }[] = [
{ key: 'postpartum_recovery', title: '产后恢复', desc: '温和激活,核心重建' },
{ key: 'posture_correction', title: '体态矫正', desc: '打开胸肩,改善圆肩驼背' },
{ key: 'fat_loss', title: '减脂塑形', desc: '全身燃脂,线条雕刻' },
{ key: 'core_strength', title: '核心力量', desc: '核心稳定,提升运动表现' },
{ key: 'flexibility', title: '柔韧灵活', desc: '拉伸延展,释放紧张' },
{ key: 'rehab', title: '康复保健', desc: '循序渐进,科学修复' },
{ key: 'stress_relief', title: '释压放松', desc: '舒缓身心,改善睡眠' },
];
export default function TrainingPlanScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
const { draft, current } = useAppSelector((s) => s.trainingPlan);
const [weightInput, setWeightInput] = useState<string>('');
const [datePickerVisible, setDatePickerVisible] = useState(false);
useEffect(() => {
dispatch(loadTrainingPlan());
return () => {
// 离开页面不自动 reset保留草稿
};
}, [dispatch]);
useEffect(() => {
if (draft.startWeightKg && !weightInput) setWeightInput(String(draft.startWeightKg));
}, [draft.startWeightKg]);
const selectedCount = draft.mode === 'daysOfWeek' ? draft.daysOfWeek.length : draft.sessionsPerWeek;
const canSave = useMemo(() => {
if (!draft.goal) return false;
if (draft.mode === 'daysOfWeek' && draft.daysOfWeek.length === 0) return false;
if (draft.mode === 'sessionsPerWeek' && draft.sessionsPerWeek <= 0) return false;
return true;
}, [draft]);
const handleSave = async () => {
await dispatch(saveTrainingPlan()).unwrap().catch(() => { });
router.back();
};
const openDatePicker = () => setDatePickerVisible(true);
const closeDatePicker = () => setDatePickerVisible(false);
const onConfirmDate = (date: Date) => {
// 只允许今天之后(含今天)的日期
const today = new Date();
today.setHours(0, 0, 0, 0);
const picked = new Date(date);
picked.setHours(0, 0, 0, 0);
const finalDate = picked < today ? today : picked;
dispatch(setStartDate(finalDate.toISOString()));
closeDatePicker();
};
return (
<SafeAreaView style={styles.safeArea}>
<ThemedView style={styles.container}>
<HeaderBar title="训练计划" onBack={() => router.back()} withSafeTop={false} transparent />
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={styles.subtitle}></ThemedText>
<View style={styles.card}>
<ThemedText style={styles.cardTitle}></ThemedText>
<View style={styles.segment}>
<Pressable
onPress={() => dispatch(setMode('daysOfWeek'))}
style={[styles.segmentItem, draft.mode === 'daysOfWeek' && styles.segmentItemActive]}
>
<ThemedText style={[styles.segmentText, draft.mode === 'daysOfWeek' && styles.segmentTextActive]}></ThemedText>
</Pressable>
<Pressable
onPress={() => dispatch(setMode('sessionsPerWeek'))}
style={[styles.segmentItem, draft.mode === 'sessionsPerWeek' && styles.segmentItemActive]}
>
<ThemedText style={[styles.segmentText, draft.mode === 'sessionsPerWeek' && styles.segmentTextActive]}></ThemedText>
</Pressable>
</View>
{draft.mode === 'daysOfWeek' ? (
<View style={styles.weekRow}>
{WEEK_DAYS.map((d, i) => {
const active = draft.daysOfWeek.includes(i);
return (
<Pressable key={i} onPress={() => dispatch(toggleDayOfWeek(i))} style={[styles.dayChip, active && styles.dayChipActive]}>
<ThemedText style={[styles.dayChipText, active && styles.dayChipTextActive]}>{d}</ThemedText>
</Pressable>
);
})}
</View>
) : (
<View style={styles.sliderRow}>
<ThemedText style={styles.sliderLabel}></ThemedText>
<View style={styles.counter}>
<Pressable onPress={() => dispatch(setSessionsPerWeek(Math.max(1, draft.sessionsPerWeek - 1)))} style={styles.counterBtn}>
<ThemedText style={styles.counterBtnText}>-</ThemedText>
</Pressable>
<ThemedText style={styles.counterValue}>{draft.sessionsPerWeek}</ThemedText>
<Pressable onPress={() => dispatch(setSessionsPerWeek(Math.min(7, draft.sessionsPerWeek + 1)))} style={styles.counterBtn}>
<ThemedText style={styles.counterBtnText}>+</ThemedText>
</Pressable>
</View>
<ThemedText style={styles.sliderSuffix}></ThemedText>
</View>
)}
<ThemedText style={styles.helper}>{selectedCount} /</ThemedText>
</View>
<View style={styles.card}>
<ThemedText style={styles.cardTitle}></ThemedText>
<View style={styles.goalGrid}>
{GOALS.map((g) => {
const active = draft.goal === g.key;
return (
<Pressable key={g.key} onPress={() => dispatch(setGoal(g.key))} style={[styles.goalItem, active && styles.goalItemActive]}>
<ThemedText style={[styles.goalTitle, active && styles.goalTitleActive]}>{g.title}</ThemedText>
<ThemedText style={styles.goalDesc}>{g.desc}</ThemedText>
</Pressable>
);
})}
</View>
</View>
<View style={styles.card}>
<ThemedText style={styles.cardTitle}></ThemedText>
<View style={styles.rowBetween}>
<ThemedText style={styles.label}></ThemedText>
<View style={styles.rowRight}>
<Pressable onPress={openDatePicker} style={styles.linkBtn}>
<ThemedText style={styles.linkText}></ThemedText>
</Pressable>
<Pressable onPress={() => dispatch(setStartDateNextMonday())} style={[styles.linkBtn, { marginLeft: 8 }]}>
<ThemedText style={styles.linkText}></ThemedText>
</Pressable>
</View>
</View>
<ThemedText style={styles.dateHint}>{new Date(draft.startDate).toLocaleDateString()}</ThemedText>
<View style={styles.rowBetween}>
<ThemedText style={styles.label}> (kg)</ThemedText>
<TextInput
keyboardType="numeric"
placeholder="可选"
value={weightInput}
onChangeText={(t) => {
setWeightInput(t);
const v = Number(t);
dispatch(setStartWeight(Number.isFinite(v) ? v : undefined));
}}
style={styles.input}
/>
</View>
<View style={[styles.rowBetween, { marginTop: 12 }]}>
<ThemedText style={styles.label}></ThemedText>
<View style={styles.segmentSmall}>
{(['morning', 'noon', 'evening', ''] as const).map((k) => (
<Pressable key={k || 'none'} onPress={() => dispatch(setPreferredTime(k))} style={[styles.segmentItemSmall, draft.preferredTimeOfDay === k && styles.segmentItemActiveSmall]}>
<ThemedText style={[styles.segmentTextSmall, draft.preferredTimeOfDay === k && styles.segmentTextActiveSmall]}>{k === 'morning' ? '晨练' : k === 'noon' ? '午间' : k === 'evening' ? '晚间' : '不限'}</ThemedText>
</Pressable>
))}
</View>
</View>
</View>
<Pressable disabled={!canSave} onPress={handleSave} style={[styles.primaryBtn, !canSave && styles.primaryBtnDisabled]}>
<ThemedText style={styles.primaryBtnText}>{canSave ? '生成计划' : '请先选择目标/频率'}</ThemedText>
</Pressable>
<View style={{ height: 32 }} />
</ScrollView>
</ThemedView>
<DateTimePickerModal
isVisible={datePickerVisible}
mode="date"
minimumDate={new Date()}
onConfirm={onConfirmDate}
onCancel={closeDatePicker}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F7F8FA',
},
container: {
flex: 1,
backgroundColor: '#F7F8FA',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 12,
},
backButton: {
padding: 8,
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
},
content: {
paddingHorizontal: 20,
paddingTop: 16,
},
title: {
fontSize: 28,
fontWeight: '800',
color: '#1A1A1A',
},
subtitle: {
fontSize: 14,
color: '#5E6468',
marginTop: 6,
marginBottom: 16,
},
card: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
marginTop: 14,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 3,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
marginBottom: 12,
},
segment: {
flexDirection: 'row',
backgroundColor: '#F1F5F9',
padding: 4,
borderRadius: 999,
},
segmentItem: {
flex: 1,
borderRadius: 999,
paddingVertical: 10,
alignItems: 'center',
},
segmentItemActive: {
backgroundColor: palette.primary,
},
segmentText: {
fontSize: 14,
color: '#475569',
fontWeight: '600',
},
segmentTextActive: {
color: palette.ink,
},
weekRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 14,
},
dayChip: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: '#F1F5F9',
alignItems: 'center',
justifyContent: 'center',
},
dayChipActive: {
backgroundColor: '#E0F8A2',
borderWidth: 2,
borderColor: palette.primary,
},
dayChipText: {
fontSize: 16,
color: '#334155',
fontWeight: '700',
},
dayChipTextActive: {
color: '#0F172A',
},
sliderRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 16,
},
sliderLabel: {
fontSize: 16,
color: '#334155',
fontWeight: '700',
},
counter: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: 12,
},
counterBtn: {
width: 36,
height: 36,
borderRadius: 999,
backgroundColor: '#F1F5F9',
alignItems: 'center',
justifyContent: 'center',
},
counterBtnText: {
fontSize: 18,
fontWeight: '800',
color: '#0F172A',
},
counterValue: {
width: 44,
textAlign: 'center',
fontSize: 18,
fontWeight: '800',
color: '#0F172A',
},
sliderSuffix: {
marginLeft: 8,
color: '#475569',
},
helper: {
marginTop: 10,
color: '#5E6468',
},
goalGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
goalItem: {
width: '48%',
backgroundColor: '#F8FAFC',
borderRadius: 14,
padding: 12,
marginBottom: 12,
},
goalItemActive: {
backgroundColor: '#E0F8A2',
borderColor: palette.primary,
borderWidth: 2,
},
goalTitle: {
fontSize: 16,
fontWeight: '800',
color: '#0F172A',
},
goalTitleActive: {
color: '#0F172A',
},
goalDesc: {
marginTop: 6,
fontSize: 12,
color: '#5E6468',
lineHeight: 16,
},
rowBetween: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 6,
},
rowRight: {
flexDirection: 'row',
alignItems: 'center',
},
label: {
fontSize: 14,
color: '#0F172A',
fontWeight: '700',
},
linkBtn: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: '#F1F5F9',
},
linkText: {
color: '#334155',
fontWeight: '700',
},
dateHint: {
marginTop: 6,
color: '#5E6468',
},
input: {
marginLeft: 12,
backgroundColor: '#F1F5F9',
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 8,
minWidth: 88,
textAlign: 'right',
color: '#0F172A',
},
segmentSmall: {
flexDirection: 'row',
backgroundColor: '#F1F5F9',
padding: 3,
borderRadius: 999,
},
segmentItemSmall: {
borderRadius: 999,
paddingVertical: 6,
paddingHorizontal: 10,
marginHorizontal: 3,
},
segmentItemActiveSmall: {
backgroundColor: palette.primary,
},
segmentTextSmall: {
fontSize: 12,
color: '#475569',
fontWeight: '700',
},
segmentTextActiveSmall: {
color: palette.ink,
},
primaryBtn: {
marginTop: 18,
backgroundColor: palette.primary,
paddingVertical: 14,
borderRadius: 14,
alignItems: 'center',
},
primaryBtnDisabled: {
opacity: 0.5,
},
primaryBtnText: {
color: palette.ink,
fontSize: 16,
fontWeight: '800',
},
});