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

619 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 DateTimePicker from '@react-native-community/datetimepicker';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Platform, Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native';
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 {
clearError,
loadPlans,
saveDraftAsPlan,
setGoal,
setMode,
setName,
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 TrainingPlanCreateScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
const { draft, loading, error, editingId } = useAppSelector((s) => s.trainingPlan);
const { id } = useLocalSearchParams<{ id?: string }>();
const [weightInput, setWeightInput] = useState<string>('');
const [datePickerVisible, setDatePickerVisible] = useState(false);
const [pickerDate, setPickerDate] = useState<Date>(new Date());
useEffect(() => {
dispatch(loadPlans());
}, [dispatch]);
// 如果带有 id加载详情并进入编辑模式
useEffect(() => {
if (id) {
dispatch({ type: 'trainingPlan/clearError' } as any);
dispatch((require('@/store/trainingPlanSlice') as any).loadPlanForEdit(id as string));
} else {
// 离开编辑模式
dispatch((require('@/store/trainingPlanSlice') as any).setEditingId(null));
}
}, [id, 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 formattedStartDate = useMemo(() => {
const d = new Date(draft.startDate);
try {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short',
}).format(d);
} catch {
return d.toLocaleDateString('zh-CN');
}
}, [draft.startDate]);
const handleSave = async () => {
try {
if (editingId) {
await dispatch((require('@/store/trainingPlanSlice') as any).updatePlanFromDraft()).unwrap();
} else {
await dispatch(saveDraftAsPlan()).unwrap();
}
router.back();
} catch (error) {
// 错误已经在Redux中处理这里可以显示额外的用户反馈
console.error('保存训练计划失败:', error);
}
};
useEffect(() => {
if (error) {
// 3秒后自动清除错误
const timer = setTimeout(() => {
dispatch(clearError());
}, 3000);
return () => clearTimeout(timer);
}
}, [error, dispatch]);
const openDatePicker = () => {
const base = draft.startDate ? new Date(draft.startDate) : new Date();
base.setHours(0, 0, 0, 0);
setPickerDate(base);
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={editingId ? '编辑训练计划' : '新建训练计划'} onBack={() => router.back()} withSafeTop={false} transparent />
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={styles.subtitle}></ThemedText>
{error && (
<View style={styles.errorContainer}>
<ThemedText style={styles.errorText}> {error}</ThemedText>
</View>
)}
<View style={styles.card}>
<ThemedText style={styles.cardTitle}></ThemedText>
<TextInput
placeholder="为你的训练计划起个名字(可选)"
value={draft.name || ''}
onChangeText={(text) => dispatch(setName(text))}
style={styles.nameInput}
maxLength={50}
/>
</View>
<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}>{formattedStartDate}</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 || loading} onPress={handleSave} style={[styles.primaryBtn, (!canSave || loading) && styles.primaryBtnDisabled]}>
<ThemedText style={styles.primaryBtnText}>
{loading ? (editingId ? '更新中...' : '创建中...') : canSave ? (editingId ? '更新计划' : '生成计划') : '请先选择目标/频率'}
</ThemedText>
</Pressable>
<View style={{ height: 32 }} />
</ScrollView>
</ThemedView>
<Modal
visible={datePickerVisible}
transparent
animationType="fade"
onRequestClose={closeDatePicker}
>
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
<View style={styles.modalSheet}>
<DateTimePicker
value={pickerDate}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={new Date()}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
} else {
if (event.type === 'set' && date) {
onConfirmDate(date);
} else {
closeDatePicker();
}
}
}}
/>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
<ThemedText style={styles.modalBtnText}></ThemedText>
</Pressable>
<Pressable onPress={() => { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<ThemedText style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></ThemedText>
</Pressable>
</View>
)}
</View>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F7F8FA',
},
container: {
flex: 1,
backgroundColor: '#F7F8FA',
},
content: {
paddingHorizontal: 20,
paddingTop: 16,
},
title: {
fontSize: 28,
fontWeight: '800',
color: '#1A1A1A',
lineHeight: 36,
},
subtitle: {
fontSize: 14,
color: '#5E6468',
marginTop: 6,
marginBottom: 16,
lineHeight: 20,
},
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',
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.35)',
},
modalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 8,
gap: 12,
},
modalBtn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: '#F1F5F9',
},
modalBtnPrimary: {
backgroundColor: palette.primary,
},
modalBtnText: {
color: '#334155',
fontWeight: '700',
},
modalBtnTextPrimary: {
color: palette.ink,
},
// 计划名称输入框
nameInput: {
backgroundColor: '#F1F5F9',
paddingHorizontal: 12,
paddingVertical: 12,
borderRadius: 8,
fontSize: 16,
color: '#0F172A',
marginTop: 8,
},
// 错误状态
errorContainer: {
backgroundColor: 'rgba(237,71,71,0.1)',
borderRadius: 12,
padding: 16,
marginTop: 16,
borderWidth: 1,
borderColor: 'rgba(237,71,71,0.2)',
},
errorText: {
fontSize: 14,
color: '#ED4747',
fontWeight: '600',
textAlign: 'center',
},
});