- 将应用图标更改为 logo.jpeg,更新相关配置文件 - 删除旧的图标文件,确保资源整洁 - 更新启动画面使用新的 logo 图片,提升视觉一致性 - 在训练计划相关功能中集成新的 API 接口,支持训练计划的创建和管理 - 优化 Redux 状态管理,支持训练计划的加载和删除功能 - 更新样式以适应新图标和功能的展示
603 lines
18 KiB
TypeScript
603 lines
18 KiB
TypeScript
import DateTimePicker from '@react-native-community/datetimepicker';
|
||
import { 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 } = useAppSelector((s) => s.trainingPlan);
|
||
const [weightInput, setWeightInput] = useState<string>('');
|
||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||
|
||
useEffect(() => {
|
||
dispatch(loadPlans());
|
||
}, [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 {
|
||
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="新建训练计划" 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 ? '创建中...' : canSave ? '生成计划' : '请先选择目标/频率'}
|
||
</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',
|
||
},
|
||
});
|
||
|
||
|