diff --git a/app.json b/app.json index 7658929..eb0896e 100644 --- a/app.json +++ b/app.json @@ -4,7 +4,7 @@ "slug": "digital-pilates", "version": "1.0.2", "orientation": "portrait", - "icon": "./assets/images/icon.png", + "icon": "./assets/images/logo.jpeg", "scheme": "digitalpilates", "userInterfaceStyle": "automatic", "newArchEnabled": true, @@ -21,7 +21,7 @@ }, "android": { "adaptiveIcon": { - "foregroundImage": "./assets/images/adaptive-icon.png", + "foregroundImage": "./assets/images/logo.jpeg", "backgroundColor": "#ffffff" }, "edgeToEdgeEnabled": true, @@ -30,14 +30,14 @@ "web": { "bundler": "metro", "output": "static", - "favicon": "./assets/images/favicon.png" + "favicon": "./assets/images/logo.jpeg" }, "plugins": [ "expo-router", [ "expo-splash-screen", { - "image": "./assets/images/splash-icon.png", + "image": "./assets/images/logo.jpeg", "imageWidth": 200, "resizeMode": "contain", "backgroundColor": "#ffffff" diff --git a/app/ai-coach-chat.tsx b/app/ai-coach-chat.tsx index 3d25b2c..083c0a1 100644 --- a/app/ai-coach-chat.tsx +++ b/app/ai-coach-chat.tsx @@ -165,6 +165,7 @@ export default function AICoachChatScreen() { setKeyboardOffset(height); } catch { setKeyboardOffset(0); } }); + hideSub = Keyboard.addListener('keyboardWillHide', () => setKeyboardOffset(0)); } else { showSub = Keyboard.addListener('keyboardDidShow', (e: any) => { try { setKeyboardOffset(Math.max(0, e.endCoordinates?.height ?? 0)); } catch { setKeyboardOffset(0); } diff --git a/app/ai-posture-assessment.tsx b/app/ai-posture-assessment.tsx index bbce1e8..d39e368 100644 --- a/app/ai-posture-assessment.tsx +++ b/app/ai-posture-assessment.tsx @@ -4,6 +4,7 @@ import * as ImagePicker from 'expo-image-picker'; import { useRouter } from 'expo-router'; import React, { useEffect, useMemo, useState } from 'react'; import { + ActivityIndicator, Alert, Image, Linking, @@ -12,13 +13,14 @@ import { StyleSheet, Text, TouchableOpacity, - View, + View } from 'react-native'; import ImageViewing from 'react-native-image-viewing'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; +import { useCosUpload } from '@/hooks/useCosUpload'; type PoseView = 'front' | 'side' | 'back'; @@ -59,6 +61,9 @@ export default function AIPostureAssessmentScreen() { [uploadState] ); + const { upload, uploading } = useCosUpload(); + const [uploadingKey, setUploadingKey] = useState(null); + const [cameraPerm, setCameraPerm] = useState(null); const [libraryPerm, setLibraryPerm] = useState(null); const [libraryAccess, setLibraryAccess] = useState<'all' | 'limited' | 'none' | null>(null); @@ -129,7 +134,25 @@ export default function AIPostureAssessmentScreen() { aspect: [3, 4], }); if (!result.canceled) { - setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null })); + // 设置正在上传状态 + setUploadingKey(key); + try { + // 上传到 COS + const { url } = await upload( + { uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' }, + { prefix: 'posture-assessment/' } + ); + // 上传成功,更新状态 + setUploadState((s) => ({ ...s, [key]: url })); + } catch (uploadError) { + console.warn('上传图片失败', uploadError); + Alert.alert('上传失败', '图片上传失败,请重试'); + // 上传失败,清除状态 + setUploadState((s) => ({ ...s, [key]: null })); + } finally { + // 清除上传状态 + setUploadingKey(null); + } } } else { const resp = await ImagePicker.requestMediaLibraryPermissionsAsync(); @@ -158,7 +181,25 @@ export default function AIPostureAssessmentScreen() { aspect: [3, 4], }); if (!result.canceled) { - setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null })); + // 设置正在上传状态 + setUploadingKey(key); + try { + // 上传到 COS + const { url } = await upload( + { uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' }, + { prefix: 'posture-assessment/' } + ); + // 上传成功,更新状态 + setUploadState((s) => ({ ...s, [key]: url })); + } catch (uploadError) { + console.warn('上传图片失败', uploadError); + Alert.alert('上传失败', '图片上传失败,请重试'); + // 上传失败,清除状态 + setUploadState((s) => ({ ...s, [key]: null })); + } finally { + // 清除上传状态 + setUploadingKey(null); + } } } } catch (e) { @@ -219,6 +260,7 @@ export default function AIPostureAssessmentScreen() { onPickCamera={() => requestPermissionAndPick('camera', 'front')} onPickLibrary={() => requestPermissionAndPick('library', 'front')} samples={SAMPLES.front} + uploading={uploading && uploadingKey === 'front'} /> requestPermissionAndPick('camera', 'side')} onPickLibrary={() => requestPermissionAndPick('library', 'side')} samples={SAMPLES.side} + uploading={uploading && uploadingKey === 'side'} /> requestPermissionAndPick('camera', 'back')} onPickLibrary={() => requestPermissionAndPick('library', 'back')} samples={SAMPLES.back} + uploading={uploading && uploadingKey === 'back'} /> @@ -264,12 +308,14 @@ function UploadTile({ onPickCamera, onPickLibrary, samples, + uploading, }: { label: string; value?: string | null; onPickCamera: () => void; onPickLibrary: () => void; samples: Sample[]; + uploading?: boolean; }) { const [viewerVisible, setViewerVisible] = React.useState(false); const [viewerIndex, setViewerIndex] = React.useState(0); @@ -291,8 +337,14 @@ function UploadTile({ onLongPress={onPickLibrary} onPress={onPickCamera} style={styles.uploader} + disabled={uploading} > - {value ? ( + {uploading ? ( + + + 上传中... + + ) : value ? ( ) : ( diff --git a/app/profile/edit.tsx b/app/profile/edit.tsx index 1731334..2bd949b 100644 --- a/app/profile/edit.tsx +++ b/app/profile/edit.tsx @@ -12,6 +12,7 @@ import * as ImagePicker from 'expo-image-picker'; import { router } from 'expo-router'; import React, { useEffect, useMemo, useState } from 'react'; import { + ActivityIndicator, Alert, Image, KeyboardAvoidingView, @@ -23,7 +24,7 @@ import { Text, TextInput, TouchableOpacity, - View, + View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -230,6 +231,7 @@ export default function EditProfileScreen() { { uri: asset.uri, name: asset.fileName || 'avatar.jpg', type: asset.mimeType || 'image/jpeg' }, { prefix: 'avatars/', userId } ); + setProfile((p) => ({ ...p, avatarUri: url, avatarBase64: null })); } catch (e) { console.warn('上传头像失败', e); @@ -250,12 +252,17 @@ export default function EditProfileScreen() { {/* 头像(带相机蒙层,点击从相册选择) */} - + + {uploading && ( + + + + )} @@ -356,12 +363,7 @@ function FieldLabel({ text }: { text: string }) { // 单位切换组件已移除(固定 kg/cm) -// 工具函数 -// 转换函数不再使用,保留 round -function kgToLb(kg: number) { return kg * 2.2046226218; } -function lbToKg(lb: number) { return lb / 2.2046226218; } -function cmToFt(cm: number) { return cm / 30.48; } -function ftToCm(ft: number) { return ft * 30.48; } + function round(n: number, d = 0) { const p = Math.pow(10, d); return Math.round(n * p) / p; } const styles = StyleSheet.create({ @@ -390,6 +392,17 @@ const styles = StyleSheet.create({ justifyContent: 'center', backgroundColor: 'rgba(255,255,255,0.25)', }, + avatarLoadingOverlay: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + borderRadius: 60, + }, inputWrapper: { height: 52, backgroundColor: '#fff', diff --git a/app/training-plan.tsx b/app/training-plan.tsx index 15ce8e2..9cf33cd 100644 --- a/app/training-plan.tsx +++ b/app/training-plan.tsx @@ -1,556 +1,531 @@ -import DateTimePicker from '@react-native-community/datetimepicker'; +import { LinearGradient } from 'expo-linear-gradient'; 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 React, { useEffect } from 'react'; +import { Pressable, SafeAreaView, ScrollView, StyleSheet, View } from 'react-native'; +import Animated, { + FadeInUp, + FadeOut, + interpolate, + Layout, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSpring, + withTiming +} from 'react-native-reanimated'; 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'; +import { clearError, deletePlan, loadPlans, setCurrentPlan, type TrainingPlan } 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(''); - const [datePickerVisible, setDatePickerVisible] = useState(false); - const [pickerDate, setPickerDate] = useState(new Date()); +const GOAL_TEXT: Record = { + postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' }, + fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' }, + posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' }, + core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' }, + flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' }, + rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' }, + stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' }, +}; - useEffect(() => { - dispatch(loadTrainingPlan()); - return () => { - // 离开页面不自动 reset,保留草稿 +// 动态背景组件 +function DynamicBackground() { + const rotate = useSharedValue(0); + const scale = useSharedValue(1); + + React.useEffect(() => { + rotate.value = withRepeat(withTiming(360, { duration: 20000 }), -1); + scale.value = withRepeat(withTiming(1.2, { duration: 8000 }), -1, true); + }, []); + + const backgroundStyle = useAnimatedStyle(() => ({ + transform: [ + { rotate: `${rotate.value}deg` }, + { scale: scale.value } + ], + })); + + return ( + + + + + + ); +} + + +// 简洁的训练计划卡片 +function PlanCard({ plan, onPress, onDelete, isActive, index }: { plan: TrainingPlan; onPress: () => void; onDelete: () => void; isActive?: boolean; index: number }) { + const scale = useSharedValue(1); + const glow = useSharedValue(0); + + React.useEffect(() => { + glow.value = withRepeat(withTiming(1, { duration: 2000 + index * 100 }), -1, true); + }, [index]); + + const goalConfig = GOAL_TEXT[plan.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' }; + + const cardStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + const glowStyle = useAnimatedStyle(() => { + const opacity = isActive ? interpolate(glow.value, [0, 1], [0.3, 0.7]) : interpolate(glow.value, [0, 1], [0.15, 0.4]); + return { + shadowOpacity: opacity, + shadowColor: goalConfig.color, + shadowRadius: isActive ? 24 : 16, + elevation: isActive ? 16 : 12, + borderColor: `${goalConfig.color}${isActive ? '50' : '30'}`, }; - }, [dispatch]); + }); - useEffect(() => { - if (draft.startWeightKg && !weightInput) setWeightInput(String(draft.startWeightKg)); - }, [draft.startWeightKg]); + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return `${date.getMonth() + 1}月${date.getDate()}日`; + }; - 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'); + const getFrequencyText = () => { + if (plan.mode === 'daysOfWeek') { + return `每周${plan.daysOfWeek.length}天`; } - }, [draft.startDate]); - - const handleSave = async () => { - await dispatch(saveTrainingPlan()).unwrap().catch(() => { }); - router.back(); - }; - - 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 `每周${plan.sessionsPerWeek}次`; }; return ( - - - router.back()} withSafeTop={false} transparent /> - - 制定你的训练计划 - 选择你的训练节奏与目标,我们将为你生成合适的普拉提安排。 + + { scale.value = withSpring(0.98); }} + onPressOut={() => { scale.value = withSpring(1); }} + style={styles.cardContent} + > + - - 训练频率 - - dispatch(setMode('daysOfWeek'))} - style={[styles.segmentItem, draft.mode === 'daysOfWeek' && styles.segmentItemActive]} - > - 按星期选择 - - dispatch(setMode('sessionsPerWeek'))} - style={[styles.segmentItem, draft.mode === 'sessionsPerWeek' && styles.segmentItemActive]} - > - 每周次数 - + {/* 左侧色彩指示器 */} + + + {/* 主要内容 */} + + + + {goalConfig.title} + {goalConfig.description} - - {draft.mode === 'daysOfWeek' ? ( - - {WEEK_DAYS.map((d, i) => { - const active = draft.daysOfWeek.includes(i); - return ( - dispatch(toggleDayOfWeek(i))} style={[styles.dayChip, active && styles.dayChipActive]}> - {d} - - ); - })} - - ) : ( - - 每周训练 - - dispatch(setSessionsPerWeek(Math.max(1, draft.sessionsPerWeek - 1)))} style={styles.counterBtn}> - - - - {draft.sessionsPerWeek} - dispatch(setSessionsPerWeek(Math.min(7, draft.sessionsPerWeek + 1)))} style={styles.counterBtn}> - + - - - + {isActive && ( + + 当前 )} - - 已选择:{selectedCount} 次/周 - - 训练目标 - - {GOALS.map((g) => { - const active = draft.goal === g.key; - return ( - dispatch(setGoal(g.key))} style={[styles.goalItem, active && styles.goalItemActive]}> - {g.title} - {g.desc} - - ); - })} + + + 开始时间 + {formatDate(plan.startDate)} - - - - 更多选项 - - 开始日期 - - - 选择日期 - - dispatch(setStartDateNextMonday())} style={[styles.linkBtn, { marginLeft: 8 }]}> - 下周一 - + + 训练频率 + {getFrequencyText()} + + {plan.preferredTimeOfDay && ( + + 时间偏好 + + {plan.preferredTimeOfDay === 'morning' ? '晨练' : + plan.preferredTimeOfDay === 'noon' ? '午间' : '晚间'} + - - {formattedStartDate} - - - 开始体重 (kg) - { - setWeightInput(t); - const v = Number(t); - dispatch(setStartWeight(Number.isFinite(v) ? v : undefined)); - }} - style={styles.input} - /> - - - - 偏好时间段 - - {(['morning', 'noon', 'evening', ''] as const).map((k) => ( - dispatch(setPreferredTime(k))} style={[styles.segmentItemSmall, draft.preferredTimeOfDay === k && styles.segmentItemActiveSmall]}> - {k === 'morning' ? '晨练' : k === 'noon' ? '午间' : k === 'evening' ? '晚间' : '不限'} - - ))} - - + )} + + + + ); +} - - {canSave ? '生成计划' : '请先选择目标/频率'} - +export default function TrainingPlanListScreen() { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { plans, currentId, loading, error } = useAppSelector((s) => s.trainingPlan); - - - - - - - { - if (Platform.OS === 'ios') { - if (date) setPickerDate(date); - } else { - if (event.type === 'set' && date) { - onConfirmDate(date); - } else { - closeDatePicker(); - } - } - }} - /> - {Platform.OS === 'ios' && ( - - - 取消 - - { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}> - 确定 + useEffect(() => { + dispatch(loadPlans()); + }, [dispatch]); + + useEffect(() => { + if (error) { + // 可以在这里显示错误提示,比如使用 Alert 或 Toast + console.error('训练计划错误:', error); + // 3秒后自动清除错误 + const timer = setTimeout(() => { + dispatch(clearError()); + }, 3000); + return () => clearTimeout(timer); + } + }, [error, dispatch]); + + return ( + + {/* 动态背景 */} + + + + router.back()} + withSafeTop={false} + tone='light' + transparent={true} + right={( + router.push('/training-plan/create' as any)} style={styles.createBtn}> + + 新建 + + )} + /> + + + + 我的训练计划 + 点击激活计划,长按删除 + + + {error && ( + + ⚠️ {error} + + )} + + {loading && plans.length === 0 ? ( + + + + + 加载中... + + ) : plans.length === 0 ? ( + + + 📋 + + 还没有训练计划 + 创建你的第一个计划开始训练吧 + router.push('/training-plan/create' as any)} style={styles.primaryBtn}> + 创建计划 + + ) : ( + + {plans.map((p, index) => ( + { + dispatch(setCurrentPlan(p.id)); + }} + onDelete={() => dispatch(deletePlan(p.id))} + /> + ))} + {loading && ( + + 处理中... + + )} )} - - - + + + + + ); } const styles = StyleSheet.create({ safeArea: { flex: 1, - backgroundColor: '#F7F8FA', }, - container: { + contentWrapper: { 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, + paddingTop: 8, + }, + + // 动态背景 + backgroundOrb: { + position: 'absolute', + width: 300, + height: 300, + borderRadius: 150, + backgroundColor: 'rgba(187,242,70,0.15)', + top: -150, + right: -100, + }, + backgroundOrb2: { + position: 'absolute', + width: 400, + height: 400, + borderRadius: 200, + backgroundColor: 'rgba(164,138,237,0.12)', + bottom: -200, + left: -150, + }, + + // 页面标题区域 + headerSection: { + marginBottom: 20, }, title: { fontSize: 28, fontWeight: '800', - color: '#1A1A1A', - lineHeight: 36, + color: '#192126', + marginBottom: 4, }, subtitle: { fontSize: 14, color: '#5E6468', - marginTop: 6, - marginBottom: 16, - lineHeight: 20, + opacity: 0.8, }, - card: { - backgroundColor: '#FFFFFF', + + // 训练计划列表 + plansList: { + gap: 12, + }, + + // 训练计划卡片 + planCard: { borderRadius: 16, - padding: 16, - marginTop: 14, - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 12, + overflow: 'hidden', shadowOffset: { width: 0, height: 6 }, - elevation: 3, + shadowRadius: 16, + elevation: 12, + borderWidth: 1.5, + borderColor: 'rgba(187,242,70,0.3)', + backgroundColor: '#FFFFFF', + shadowColor: '#BBF246', }, - cardTitle: { - fontSize: 18, - fontWeight: '700', - color: '#0F172A', - marginBottom: 12, + cardContent: { + position: 'relative', }, - segment: { + cardGradient: { + ...StyleSheet.absoluteFillObject, + borderRadius: 16, + }, + cardGlow: { + position: 'absolute', + top: -2, + left: -2, + right: -2, + bottom: -2, + borderRadius: 18, + backgroundColor: 'transparent', + }, + colorIndicator: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: 4, + borderTopLeftRadius: 16, + borderBottomLeftRadius: 16, + }, + cardMain: { + padding: 20, + paddingLeft: 24, + }, + cardHeader: { flexDirection: 'row', - backgroundColor: '#F1F5F9', - padding: 4, - borderRadius: 999, + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 16, }, - segmentItem: { + titleSection: { flex: 1, - borderRadius: 999, - paddingVertical: 10, - alignItems: 'center', }, - segmentItemActive: { - backgroundColor: palette.primary, + planTitle: { + fontSize: 18, + fontWeight: '800', + color: '#192126', + marginBottom: 4, }, - segmentText: { - fontSize: 14, - color: '#475569', + planDescription: { + fontSize: 13, + color: '#5E6468', + opacity: 0.8, + }, + activeBadge: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + marginLeft: 12, + }, + activeText: { + fontSize: 11, + fontWeight: '800', + color: palette.ink, + }, + cardInfo: { + flexDirection: 'row', + gap: 20, + }, + infoItem: { + flex: 1, + }, + infoLabel: { + fontSize: 11, + color: '#888F92', + marginBottom: 2, 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: { + infoValue: { 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, + color: '#384046', + fontWeight: '600', }, + + // 按钮样式 primaryBtn: { - marginTop: 18, + marginTop: 20, backgroundColor: palette.primary, paddingVertical: 14, - borderRadius: 14, + paddingHorizontal: 28, + borderRadius: 24, alignItems: 'center', - }, - primaryBtnDisabled: { - opacity: 0.5, + shadowColor: palette.primary, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, }, primaryBtnText: { color: palette.ink, - fontSize: 16, + fontSize: 15, 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: { + createBtn: { backgroundColor: palette.primary, + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 22, + shadowColor: palette.primary, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 4, + minWidth: 44, + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', }, - modalBtnText: { - color: '#334155', - fontWeight: '700', - }, - modalBtnTextPrimary: { + createBtnText: { color: palette.ink, + fontWeight: '800', + fontSize: 14, + }, + + // 空状态 + emptyWrap: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + }, + emptyIcon: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: 'rgba(187,242,70,0.1)', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 16, + }, + emptyIconText: { + fontSize: 32, + }, + emptyText: { + fontSize: 18, + color: '#192126', + fontWeight: '600', + marginBottom: 4, + }, + emptySubtext: { + fontSize: 14, + color: '#5E6468', + textAlign: 'center', + marginBottom: 20, + }, + + // 加载状态 + loadingWrap: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + }, + loadingIcon: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: 'rgba(187,242,70,0.1)', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 16, + }, + loadingIconText: { + fontSize: 32, + }, + loadingText: { + fontSize: 18, + color: '#192126', + fontWeight: '600', + marginBottom: 4, + }, + loadingIndicator: { + alignItems: 'center', + paddingVertical: 20, + }, + loadingIndicatorText: { + fontSize: 14, + color: '#5E6468', + fontWeight: '600', + }, + + // 错误状态 + errorContainer: { + backgroundColor: 'rgba(237,71,71,0.1)', + borderRadius: 12, + padding: 16, + marginBottom: 16, + borderWidth: 1, + borderColor: 'rgba(237,71,71,0.2)', + }, + errorText: { + fontSize: 14, + color: '#ED4747', + fontWeight: '600', + textAlign: 'center', }, }); diff --git a/app/training-plan/create.tsx b/app/training-plan/create.tsx new file mode 100644 index 0000000..ffaa944 --- /dev/null +++ b/app/training-plan/create.tsx @@ -0,0 +1,602 @@ +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(''); + const [datePickerVisible, setDatePickerVisible] = useState(false); + const [pickerDate, setPickerDate] = useState(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 ( + + + router.back()} withSafeTop={false} transparent /> + + 制定你的训练计划 + 选择你的训练节奏与目标,我们将为你生成合适的普拉提安排。 + + {error && ( + + ⚠️ {error} + + )} + + + 计划名称 + dispatch(setName(text))} + style={styles.nameInput} + maxLength={50} + /> + + + + 训练频率 + + dispatch(setMode('daysOfWeek'))} + style={[styles.segmentItem, draft.mode === 'daysOfWeek' && styles.segmentItemActive]} + > + 按星期选择 + + dispatch(setMode('sessionsPerWeek'))} + style={[styles.segmentItem, draft.mode === 'sessionsPerWeek' && styles.segmentItemActive]} + > + 每周次数 + + + + {draft.mode === 'daysOfWeek' ? ( + + {WEEK_DAYS.map((d, i) => { + const active = draft.daysOfWeek.includes(i); + return ( + dispatch(toggleDayOfWeek(i))} style={[styles.dayChip, active && styles.dayChipActive]}> + {d} + + ); + })} + + ) : ( + + 每周训练 + + dispatch(setSessionsPerWeek(Math.max(1, draft.sessionsPerWeek - 1)))} style={styles.counterBtn}> + - + + {draft.sessionsPerWeek} + dispatch(setSessionsPerWeek(Math.min(7, draft.sessionsPerWeek + 1)))} style={styles.counterBtn}> + + + + + + + )} + + 已选择:{selectedCount} 次/周 + + + + 训练目标 + + {GOALS.map((g) => { + const active = draft.goal === g.key; + return ( + dispatch(setGoal(g.key))} style={[styles.goalItem, active && styles.goalItemActive]}> + {g.title} + {g.desc} + + ); + })} + + + + + 更多选项 + + 开始日期 + + + 选择日期 + + dispatch(setStartDateNextMonday())} style={[styles.linkBtn, { marginLeft: 8 }]}> + 下周一 + + + + {formattedStartDate} + + + 开始体重 (kg) + { + setWeightInput(t); + const v = Number(t); + dispatch(setStartWeight(Number.isFinite(v) ? v : undefined)); + }} + style={styles.input} + /> + + + + 偏好时间段 + + {(['morning', 'noon', 'evening', ''] as const).map((k) => ( + dispatch(setPreferredTime(k))} style={[styles.segmentItemSmall, draft.preferredTimeOfDay === k && styles.segmentItemActiveSmall]}> + {k === 'morning' ? '晨练' : k === 'noon' ? '午间' : k === 'evening' ? '晚间' : '不限'} + + ))} + + + + + + + {loading ? '创建中...' : canSave ? '生成计划' : '请先选择目标/频率'} + + + + + + + + + + { + if (Platform.OS === 'ios') { + if (date) setPickerDate(date); + } else { + if (event.type === 'set' && date) { + onConfirmDate(date); + } else { + closeDatePicker(); + } + } + }} + /> + {Platform.OS === 'ios' && ( + + + 取消 + + { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}> + 确定 + + + )} + + + + ); +} + +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', + }, +}); + + diff --git a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png deleted file mode 100644 index 2732229..0000000 Binary files a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/Contents.json b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/Contents.json index 90d8d4c..ca0c7a1 100644 --- a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images": [ { - "filename": "App-Icon-1024x1024@1x.png", + "filename": "logo.jpeg", "idiom": "universal", "platform": "ios", "size": "1024x1024" diff --git a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.jpeg b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.jpeg new file mode 100644 index 0000000..3ad7433 Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.jpeg differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/Contents.json b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/Contents.json index f65c008..8396a3f 100644 --- a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/Contents.json +++ b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/Contents.json @@ -1,23 +1,23 @@ { - "images": [ + "images" : [ { - "idiom": "universal", - "filename": "image.png", - "scale": "1x" + "filename" : "logo.jpeg", + "idiom" : "universal", + "scale" : "1x" }, { - "idiom": "universal", - "filename": "image@2x.png", - "scale": "2x" + "filename" : "logo 1.jpeg", + "idiom" : "universal", + "scale" : "2x" }, { - "idiom": "universal", - "filename": "image@3x.png", - "scale": "3x" + "filename" : "logo 2.jpeg", + "idiom" : "universal", + "scale" : "3x" } ], - "info": { - "version": 1, - "author": "expo" + "info" : { + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/image.png b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/image.png deleted file mode 100644 index 635530a..0000000 Binary files a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/image.png and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/image@2x.png b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/image@2x.png deleted file mode 100644 index d4e41ea..0000000 Binary files a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/image@2x.png and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/image@3x.png b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/image@3x.png deleted file mode 100644 index 67ee127..0000000 Binary files a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/image@3x.png and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.jpeg b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.jpeg new file mode 100644 index 0000000..3ad7433 Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.jpeg differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.jpeg b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.jpeg new file mode 100644 index 0000000..3ad7433 Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.jpeg differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.jpeg b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.jpeg new file mode 100644 index 0000000..3ad7433 Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.jpeg differ diff --git a/services/trainingPlanApi.ts b/services/trainingPlanApi.ts new file mode 100644 index 0000000..be01ebc --- /dev/null +++ b/services/trainingPlanApi.ts @@ -0,0 +1,62 @@ +import { api } from './api'; + +export interface CreateTrainingPlanDto { + startDate: string; + name?: string; + mode: 'daysOfWeek' | 'sessionsPerWeek'; + daysOfWeek: number[]; + sessionsPerWeek: number; + goal: string; + startWeightKg?: number; + preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | ''; +} + +export interface TrainingPlanResponse { + id: string; + userId: string; + name: string; + createdAt: string; + startDate: string; + mode: 'daysOfWeek' | 'sessionsPerWeek'; + daysOfWeek: number[]; + sessionsPerWeek: number; + goal: string; + startWeightKg: number | null; + preferredTimeOfDay: 'morning' | 'noon' | 'evening' | ''; + updatedAt: string; + deleted: boolean; +} + +export interface TrainingPlanSummary { + id: string; + createdAt: string; + startDate: string; + goal: string; +} + +export interface TrainingPlanListResponse { + list: TrainingPlanSummary[]; + total: number; + page: number; + limit: number; +} + +class TrainingPlanApi { + async create(dto: CreateTrainingPlanDto): Promise { + return api.post('/training-plans', dto); + } + + async list(page: number = 1, limit: number = 10): Promise { + return api.get(`/training-plans?page=${page}&limit=${limit}`); + } + + async detail(id: string): Promise { + return api.get(`/training-plans/${id}`); + } + + async delete(id: string): Promise<{ success: boolean }> { + return api.delete<{ success: boolean }>(`/training-plans/${id}`); + } +} + +export const trainingPlanApi = new TrainingPlanApi(); \ No newline at end of file diff --git a/store/trainingPlanSlice.ts b/store/trainingPlanSlice.ts index 9d80344..eee81dd 100644 --- a/store/trainingPlanSlice.ts +++ b/store/trainingPlanSlice.ts @@ -1,3 +1,4 @@ +import { CreateTrainingPlanDto, trainingPlanApi } from '@/services/trainingPlanApi'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; @@ -22,14 +23,19 @@ export type TrainingPlan = { goal: PlanGoal | ''; startWeightKg?: number; preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | ''; + name?: string; }; export type TrainingPlanState = { - current?: TrainingPlan | null; + plans: TrainingPlan[]; + currentId?: string | null; draft: Omit; + loading: boolean; + error: string | null; }; -const STORAGE_KEY = '@training_plan'; +const STORAGE_KEY_LEGACY_SINGLE = '@training_plan'; +const STORAGE_KEY_LIST = '@training_plans'; function nextMondayISO(): string { const now = new Date(); @@ -42,7 +48,10 @@ function nextMondayISO(): string { } const initialState: TrainingPlanState = { - current: null, + plans: [], + currentId: null, + loading: false, + error: null, draft: { startDate: new Date(new Date().setHours(0, 0, 0, 0)).toISOString(), mode: 'daysOfWeek', @@ -51,31 +60,141 @@ const initialState: TrainingPlanState = { goal: '', startWeightKg: undefined, preferredTimeOfDay: '', + name: '', }, }; -export const loadTrainingPlan = createAsyncThunk('trainingPlan/load', async () => { - const str = await AsyncStorage.getItem(STORAGE_KEY); - if (!str) return null; +/** + * 从服务器加载训练计划列表,同时支持本地缓存迁移 + */ +export const loadPlans = createAsyncThunk('trainingPlan/loadPlans', async (_, { rejectWithValue }) => { try { - return JSON.parse(str) as TrainingPlan; - } catch { - return null; + // 尝试从服务器获取数据 + const response = await trainingPlanApi.list(1, 100); // 获取所有计划 + console.log('response', response); + const plans: TrainingPlan[] = response.list.map(summary => ({ + id: summary.id, + createdAt: summary.createdAt, + startDate: summary.startDate, + goal: summary.goal as PlanGoal, + mode: 'daysOfWeek', // 默认值,需要从详情获取 + daysOfWeek: [], + sessionsPerWeek: 3, + preferredTimeOfDay: '', + name: '', + })); + + // 读取最后一次使用的 currentId(从本地存储) + const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null; + + return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null }; + } catch (error: any) { + // 如果API调用失败,回退到本地存储 + console.warn('API调用失败,使用本地存储:', error.message); + + // 新版:列表 + const listStr = await AsyncStorage.getItem(STORAGE_KEY_LIST); + if (listStr) { + try { + const plans = JSON.parse(listStr) as TrainingPlan[]; + const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null; + return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null }; + } catch { + // 解析失败则视为无数据 + } + } + + // 旧版:单计划 + const legacyStr = await AsyncStorage.getItem(STORAGE_KEY_LEGACY_SINGLE); + if (legacyStr) { + try { + const legacy = JSON.parse(legacyStr) as TrainingPlan; + const plans = [legacy]; + const currentId = legacy.id; + return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null }; + } catch { + // ignore + } + } + + return { plans: [], currentId: null } as { plans: TrainingPlan[]; currentId: string | null }; } }); -export const saveTrainingPlan = createAsyncThunk( - 'trainingPlan/save', - async (_: void, { getState }) => { - const s = (getState() as any).trainingPlan as TrainingPlanState; - const draft = s.draft; - const plan: TrainingPlan = { - id: `plan_${Date.now()}`, - createdAt: new Date().toISOString(), - ...draft, - }; - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(plan)); - return plan; +/** + * 将当前 draft 保存为新计划并设为当前计划。 + */ +export const saveDraftAsPlan = createAsyncThunk( + 'trainingPlan/saveDraftAsPlan', + async (_: void, { getState, rejectWithValue }) => { + try { + const s = (getState() as any).trainingPlan as TrainingPlanState; + const draft = s.draft; + + const createDto: CreateTrainingPlanDto = { + startDate: draft.startDate, + name: draft.name, + mode: draft.mode, + daysOfWeek: draft.daysOfWeek, + sessionsPerWeek: draft.sessionsPerWeek, + goal: draft.goal, + startWeightKg: draft.startWeightKg, + preferredTimeOfDay: draft.preferredTimeOfDay, + }; + + const response = await trainingPlanApi.create(createDto); + + const plan: TrainingPlan = { + id: response.id, + createdAt: response.createdAt, + startDate: response.startDate, + mode: response.mode, + daysOfWeek: response.daysOfWeek, + sessionsPerWeek: response.sessionsPerWeek, + goal: response.goal as PlanGoal, + startWeightKg: response.startWeightKg || undefined, + preferredTimeOfDay: response.preferredTimeOfDay, + name: response.name, + }; + + const nextPlans = [...(s.plans || []), plan]; + + // 同时保存到本地存储作为缓存 + await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans)); + await AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, plan.id); + + return { plans: nextPlans, currentId: plan.id } as { plans: TrainingPlan[]; currentId: string }; + } catch (error: any) { + return rejectWithValue(error.message || '创建训练计划失败'); + } + } +); + +/** 删除计划 */ +export const deletePlan = createAsyncThunk( + 'trainingPlan/deletePlan', + async (planId: string, { getState, rejectWithValue }) => { + try { + const s = (getState() as any).trainingPlan as TrainingPlanState; + + // 调用API删除 + await trainingPlanApi.delete(planId); + + // 更新本地状态 + const nextPlans = (s.plans || []).filter((p) => p.id !== planId); + let nextCurrentId = s.currentId || null; + if (nextCurrentId === planId) { + nextCurrentId = nextPlans.length > 0 ? nextPlans[nextPlans.length - 1].id : null; + } + + // 同时更新本地存储 + await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans)); + await AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, nextCurrentId ?? ''); + + return { plans: nextPlans, currentId: nextCurrentId } as { plans: TrainingPlan[]; currentId: string | null }; + } catch (error: any) { + return rejectWithValue(error.message || '删除训练计划失败'); + } } ); @@ -83,6 +202,11 @@ const trainingPlanSlice = createSlice({ name: 'trainingPlan', initialState, reducers: { + setCurrentPlan(state, action: PayloadAction) { + state.currentId = action.payload ?? null; + // 保存到本地存储 + AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, action.payload ?? ''); + }, setMode(state, action: PayloadAction) { state.draft.mode = action.payload; }, @@ -108,30 +232,74 @@ const trainingPlanSlice = createSlice({ setPreferredTime(state, action: PayloadAction) { state.draft.preferredTimeOfDay = action.payload; }, + setName(state, action: PayloadAction) { + state.draft.name = action.payload; + }, setStartDateNextMonday(state) { state.draft.startDate = nextMondayISO(); }, resetDraft(state) { state.draft = initialState.draft; }, + clearError(state) { + state.error = null; + }, }, extraReducers: (builder) => { builder - .addCase(loadTrainingPlan.fulfilled, (state, action) => { - state.current = action.payload; + // loadPlans + .addCase(loadPlans.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(loadPlans.fulfilled, (state, action) => { + state.loading = false; + state.plans = action.payload.plans; + state.currentId = action.payload.currentId; // 若存在历史计划,初始化 draft 基于该计划(便于编辑) - if (action.payload) { - const { id, createdAt, ...rest } = action.payload; + const current = state.plans.find((p) => p.id === state.currentId) || state.plans[state.plans.length - 1]; + if (current) { + const { id, createdAt, ...rest } = current; state.draft = { ...rest }; } }) - .addCase(saveTrainingPlan.fulfilled, (state, action) => { - state.current = action.payload; + .addCase(loadPlans.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string || '加载训练计划失败'; + }) + // saveDraftAsPlan + .addCase(saveDraftAsPlan.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(saveDraftAsPlan.fulfilled, (state, action) => { + state.loading = false; + state.plans = action.payload.plans; + state.currentId = action.payload.currentId; + }) + .addCase(saveDraftAsPlan.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string || '创建训练计划失败'; + }) + // deletePlan + .addCase(deletePlan.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(deletePlan.fulfilled, (state, action) => { + state.loading = false; + state.plans = action.payload.plans; + state.currentId = action.payload.currentId; + }) + .addCase(deletePlan.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string || '删除训练计划失败'; }); }, }); export const { + setCurrentPlan, setMode, toggleDayOfWeek, setSessionsPerWeek, @@ -139,8 +307,10 @@ export const { setStartWeight, setStartDate, setPreferredTime, + setName, setStartDateNextMonday, resetDraft, + clearError, } = trainingPlanSlice.actions; export default trainingPlanSlice.reducer;