From 4c6a0e03992a905a251239202db02592168271f2 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sat, 16 Aug 2025 14:15:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E8=AE=AD=E7=BB=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kiro/steering/chinese-language.md | 15 + .vscode/settings.json | 3 +- CLAUDE.md | 26 +- app/_layout.tsx | 6 +- app/training-plan/schedule/select.tsx | 77 +- app/workout/_layout.tsx | 14 + app/workout/create-session.tsx | 516 ++++++++ app/workout/session/[id].tsx | 1115 ++++++++++++++++++ app/workout/today.tsx | 454 ++++--- components/ui/ActionSheet.tsx | 317 +++++ components/ui/ConfirmDialog.tsx | 251 ++++ components/ui/DialogProvider.tsx | 67 ++ docs/dialog-components.md | 227 ++++ hooks/useDialog.ts | 92 ++ ios/digitalpilates.xcodeproj/project.pbxproj | 10 +- services/workoutsApi.ts | 10 + store/workoutSlice.ts | 45 +- 17 files changed, 3079 insertions(+), 166 deletions(-) create mode 100644 .kiro/steering/chinese-language.md create mode 100644 app/workout/create-session.tsx create mode 100644 app/workout/session/[id].tsx create mode 100644 components/ui/ActionSheet.tsx create mode 100644 components/ui/ConfirmDialog.tsx create mode 100644 components/ui/DialogProvider.tsx create mode 100644 docs/dialog-components.md create mode 100644 hooks/useDialog.ts diff --git a/.kiro/steering/chinese-language.md b/.kiro/steering/chinese-language.md new file mode 100644 index 0000000..61b697f --- /dev/null +++ b/.kiro/steering/chinese-language.md @@ -0,0 +1,15 @@ +--- +inclusion: always +--- + +# 中文回复规则 + +请始终使用中文进行回复和编写文档。包括: + +- 所有对话回复都使用中文 +- 代码注释使用中文 +- 文档和说明使用中文 +- 错误信息和提示使用中文 +- 变量名和函数名可以使用英文,但注释和文档说明必须是中文 + +这个规则适用于所有交互,除非用户明确要求使用其他语言。 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index e2798e4..fdee6f3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,6 @@ "source.fixAll": "explicit", "source.organizeImports": "explicit", "source.sortMembers": "explicit" - } + }, + "kiroAgent.configureMCP": "Enabled" } diff --git a/CLAUDE.md b/CLAUDE.md index 796f028..e438691 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,29 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Architecture - **Framework**: React Native (Expo) with TypeScript. - **Navigation**: Expo Router for file-based routing (`app/` directory). +- **State Management**: Redux Toolkit with slices for different domains (user, training plans, workouts, challenges, etc.). - **UI**: Themed components (`ThemedText`, `ThemedView`) and reusable UI elements (`Collapsible`, `ParallaxScrollView`). +- **API Layer**: Service files for communicating with backend APIs (`services/` directory). +- **Data Persistence**: AsyncStorage for local data storage. - **Platform-Specific**: Android (`android/`) and iOS (`ios/`) configurations with native modules. -- **Hooks**: Custom hooks for color scheme (`useColorScheme`) and theme management (`useThemeColor`). -- **Dependencies**: React Navigation for tab-based navigation, Expo modules for native features (haptics, blur, etc.). \ No newline at end of file +- **Hooks**: Custom hooks for color scheme (`useColorScheme`), theme management (`useThemeColor`), and Redux integration (`useRedux`). +- **Dependencies**: React Navigation for tab-based navigation, Expo modules for native features (haptics, blur, image picking, etc.), and third-party libraries for specific functionality. + +## Key Features +- **Authentication**: Login flow with Apple authentication support. +- **Training Plans**: Creation and management of personalized pilates training plans. +- **Workouts**: Daily workout tracking and session management. +- **AI Features**: AI coach chat and posture assessment capabilities. +- **Health Integration**: Integration with health data tracking. +- **Content Management**: Article reading and educational content. +- **Challenge System**: Challenge participation and progress tracking. +- **User Profiles**: Personal information management and goal setting. + +## Directory Structure +- `app/`: Main application screens and routing +- `components/`: Reusable UI components +- `constants/`: Application constants and configuration +- `hooks/`: Custom React hooks +- `services/`: API service layer +- `store/`: Redux store and slices +- `types/`: TypeScript type definitions \ No newline at end of file diff --git a/app/_layout.tsx b/app/_layout.tsx index 511cbff..38d209d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -14,6 +14,7 @@ import React from 'react'; import RNExitApp from 'react-native-exit-app'; import Toast from 'react-native-toast-message'; +import { DialogProvider } from '@/components/ui/DialogProvider'; import { Provider } from 'react-redux'; function Bootstrapper({ children }: { children: React.ReactNode }) { @@ -50,7 +51,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { }; return ( - <> + {children} - + ); } @@ -82,6 +83,7 @@ export default function RootLayout() { + diff --git a/app/training-plan/schedule/select.tsx b/app/training-plan/schedule/select.tsx index e9bf6a5..a82a4f5 100644 --- a/app/training-plan/schedule/select.tsx +++ b/app/training-plan/schedule/select.tsx @@ -48,12 +48,43 @@ export default function SelectExerciseForScheduleScreen() { const planId = params.planId; const sessionId = params.sessionId; const plan = useMemo(() => plans.find(p => p.id === planId), [plans, planId]); - const session = useMemo(() => sessionId ? currentSession : null, [sessionId, currentSession]); + + // 会话状态管理 + const [session, setSession] = useState(null); + const [sessionLoading, setSessionLoading] = useState(false); // 根据是否有sessionId来确定是训练计划模式还是训练会话模式 const isSessionMode = !!sessionId; + + // 加载会话详情(如果是会话模式) + useEffect(() => { + if (sessionId && !session) { + const loadSession = async () => { + try { + setSessionLoading(true); + // 首先尝试使用 currentSession(如果 sessionId 匹配) + if (currentSession?.id === sessionId) { + setSession(currentSession); + } else { + // 否则从 API 获取会话详情 + const { workoutsApi } = await import('@/services/workoutsApi'); + const sessionDetail = await workoutsApi.getSessionDetail(sessionId); + setSession(sessionDetail); + } + } catch (error) { + console.error('加载会话详情失败:', error); + } finally { + setSessionLoading(false); + } + }; + loadSession(); + } + }, [sessionId, currentSession, session]); + const targetGoal = plan?.goal || session?.trainingPlan?.goal; - const goalConfig = targetGoal ? (GOAL_TEXT[targetGoal] || { title: isSessionMode ? '添加动作' : '训练计划', color: palette.primary, description: isSessionMode ? '选择要添加的动作' : '开始你的训练之旅' }) : null; + const goalConfig = targetGoal + ? (GOAL_TEXT[targetGoal] || { title: isSessionMode ? '添加动作' : '训练计划', color: palette.primary, description: isSessionMode ? '选择要添加的动作' : '开始你的训练之旅' }) + : { title: isSessionMode ? '添加动作' : '训练计划', color: palette.primary, description: isSessionMode ? '选择要添加的动作' : '开始你的训练之旅' }; const [keyword, setKeyword] = useState(''); const [category, setCategory] = useState('全部'); @@ -139,7 +170,7 @@ export default function SelectExerciseForScheduleScreen() { if (isSessionMode && sessionId) { // 训练会话模式:添加到训练会话 await dispatch(addWorkoutExercise({ sessionId, dto: newExerciseDto })).unwrap(); - } else if (plan) { + } else if (plan && planId) { // 训练计划模式:添加到训练计划 const planExerciseDto = { exerciseKey: selected.key, @@ -149,7 +180,7 @@ export default function SelectExerciseForScheduleScreen() { itemType: 'exercise' as const, note: `${selected.category}训练`, }; - await dispatch(addExercise({ planId: plan.id, dto: planExerciseDto })).unwrap(); + await dispatch(addExercise({ planId: planId, dto: planExerciseDto })).unwrap(); } else { throw new Error('缺少必要的参数'); } @@ -191,9 +222,9 @@ export default function SelectExerciseForScheduleScreen() { if (isSessionMode && sessionId) { // 训练会话模式 await dispatch(addWorkoutExercise({ sessionId, dto: restDto })).unwrap(); - } else if (plan) { + } else if (plan && planId) { // 训练计划模式 - await dispatch(addExercise({ planId: plan.id, dto: restDto })).unwrap(); + await dispatch(addExercise({ planId: planId, dto: restDto })).unwrap(); } else { throw new Error('缺少必要的参数'); } @@ -224,9 +255,9 @@ export default function SelectExerciseForScheduleScreen() { if (isSessionMode && sessionId) { // 训练会话模式 await dispatch(addWorkoutExercise({ sessionId, dto: noteDto })).unwrap(); - } else if (plan) { + } else if (plan && planId) { // 训练计划模式 - await dispatch(addExercise({ planId: plan.id, dto: noteDto })).unwrap(); + await dispatch(addExercise({ planId: planId, dto: noteDto })).unwrap(); } else { throw new Error('缺少必要的参数'); } @@ -256,14 +287,36 @@ export default function SelectExerciseForScheduleScreen() { setSelectedKey(key); }; - if (!goalConfig || (!plan && !isSessionMode)) { + // 加载状态 + if (sessionLoading) { return ( router.back()} /> - - {isSessionMode ? '找不到指定的训练会话' : '找不到指定的训练计划'} - + 加载中... + + + ); + } + + // 错误状态检查 + if (isSessionMode && !session) { + return ( + + router.back()} /> + + 找不到指定的训练会话 + + + ); + } + + if (!isSessionMode && !plan) { + return ( + + router.back()} /> + + 找不到指定的训练计划 ); diff --git a/app/workout/_layout.tsx b/app/workout/_layout.tsx index 5e82535..a38de64 100644 --- a/app/workout/_layout.tsx +++ b/app/workout/_layout.tsx @@ -10,6 +10,20 @@ export default function WorkoutLayout() { presentation: 'card', }} /> + + ); } diff --git a/app/workout/create-session.tsx b/app/workout/create-session.tsx new file mode 100644 index 0000000..9a80f4f --- /dev/null +++ b/app/workout/create-session.tsx @@ -0,0 +1,516 @@ +import { Ionicons } from '@expo/vector-icons'; +import dayjs from 'dayjs'; +import * as Haptics from 'expo-haptics'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useRouter } from 'expo-router'; +import React, { useEffect, useState } from 'react'; +import { Alert, FlatList, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import Animated, { FadeInUp } from 'react-native-reanimated'; + +import { ThemedText } from '@/components/ThemedText'; +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { palette } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { loadPlans } from '@/store/trainingPlanSlice'; +import { createWorkoutSession } from '@/store/workoutSlice'; + +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: '舒缓身心,改善睡眠' }, +}; + +// 动态背景组件 +function DynamicBackground({ color }: { color: string }) { + return ( + + + + + + ); +} + +export default function CreateWorkoutSessionScreen() { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { plans, loading: plansLoading } = useAppSelector((s) => s.trainingPlan); + + const [sessionName, setSessionName] = useState(''); + const [selectedPlanId, setSelectedPlanId] = useState(null); + const [creating, setCreating] = useState(false); + + useEffect(() => { + dispatch(loadPlans()); + }, [dispatch]); + + // 自动生成会话名称 + useEffect(() => { + if (!sessionName) { + const today = new Date(); + const dateStr = `${today.getMonth() + 1}月${today.getDate()}日`; + setSessionName(`${dateStr}训练`); + } + }, [sessionName]); + + const selectedPlan = plans.find(p => p.id === selectedPlanId); + const goalConfig = selectedPlan?.goal + ? (GOAL_TEXT[selectedPlan.goal] || { title: '训练', color: palette.primary, description: '开始你的训练之旅' }) + : { title: '新建训练', color: palette.primary, description: '选择创建方式' }; + + // 创建自定义会话 + const handleCreateCustomSession = async () => { + if (creating || !sessionName.trim()) return; + + setCreating(true); + try { + await dispatch(createWorkoutSession({ + name: sessionName.trim(), + scheduledDate: dayjs().format('YYYY-MM-DD') + })).unwrap(); + + // 创建成功后跳转到选择动作页面 + router.replace('/training-plan/schedule/select' as any); + } catch (error) { + console.error('创建训练会话失败:', error); + Alert.alert('创建失败', '创建训练会话时出现错误,请稍后重试'); + } finally { + setCreating(false); + } + }; + + // 从训练计划创建会话 + const handleCreateFromPlan = async () => { + if (creating || !selectedPlan || !sessionName.trim()) return; + + setCreating(true); + try { + await dispatch(createWorkoutSession({ + name: sessionName.trim(), + trainingPlanId: selectedPlan.id, + scheduledDate: dayjs().format('YYYY-MM-DD') + })).unwrap(); + + // 创建成功后返回到训练记录页面 + router.back(); + } catch (error) { + console.error('创建训练会话失败:', error); + Alert.alert('创建失败', '创建训练会话时出现错误,请稍后重试'); + } finally { + setCreating(false); + } + }; + + // 渲染训练计划卡片 + const renderPlanItem = ({ item, index }: { item: any; index: number }) => { + const isSelected = item.id === selectedPlanId; + const planGoalConfig = GOAL_TEXT[item.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' }; + + return ( + + { + setSelectedPlanId(isSelected ? null : item.id); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }} + activeOpacity={0.9} + > + + + {item.name} + + {planGoalConfig.title} + + + {planGoalConfig.description} + + + + + {isSelected ? ( + + ) : ( + + )} + + + + {item.exercises && item.exercises.length > 0 && ( + + + {item.exercises.length} 个动作 + + + )} + + + ); + }; + + return ( + + {/* 动态背景 */} + + + + router.back()} + withSafeTop={false} + transparent={true} + tone="light" + /> + + + {/* 会话信息设置 */} + + + + 训练会话设置 + + {goalConfig.description} + + + + + {/* 会话名称输入 */} + + 会话名称 + + + + {/* 创建方式选择 */} + + 选择创建方式 + + {/* 自定义会话 */} + + + + + + 自定义会话 + + 创建空的训练会话,然后手动添加动作 + + + + + + {/* 从训练计划导入 */} + + 从训练计划导入 + + 选择一个训练计划,将其动作导入到新会话中 + + + {plansLoading ? ( + + 加载训练计划中... + + ) : plans.length === 0 ? ( + + + 暂无训练计划 + router.push('/training-plan/create' as any)} + > + 创建训练计划 + + + ) : ( + <> + item.id} + renderItem={renderPlanItem} + contentContainerStyle={styles.plansList} + showsVerticalScrollIndicator={false} + scrollEnabled={false} + /> + + {selectedPlan && ( + + + {creating ? '创建中...' : `从 "${selectedPlan.name}" 创建会话`} + + + )} + + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + }, + contentWrapper: { + flex: 1, + }, + content: { + flex: 1, + paddingHorizontal: 20, + }, + + // 动态背景 + backgroundOrb: { + position: 'absolute', + width: 300, + height: 300, + borderRadius: 150, + top: -150, + right: -100, + }, + backgroundOrb2: { + position: 'absolute', + width: 400, + height: 400, + borderRadius: 200, + bottom: -200, + left: -150, + }, + + // 会话信息头部 + sessionHeader: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 16, + marginBottom: 20, + }, + sessionColorIndicator: { + width: 4, + height: 40, + borderRadius: 2, + marginRight: 12, + }, + sessionInfo: { + flex: 1, + }, + sessionTitle: { + fontSize: 18, + fontWeight: '800', + color: '#192126', + marginBottom: 4, + }, + sessionDescription: { + fontSize: 13, + color: '#5E6468', + opacity: 0.8, + }, + + // 输入区域 + inputSection: { + marginBottom: 24, + }, + inputLabel: { + fontSize: 14, + fontWeight: '700', + color: '#192126', + marginBottom: 8, + }, + textInput: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 16, + color: '#192126', + borderWidth: 1, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 8, + shadowOffset: { width: 0, height: 2 }, + elevation: 2, + }, + + // 创建方式区域 + methodSection: { + flex: 1, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '800', + color: '#192126', + marginBottom: 16, + }, + + // 方式卡片 + methodCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginBottom: 16, + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 8, + shadowOffset: { width: 0, height: 2 }, + elevation: 2, + }, + methodIcon: { + marginRight: 12, + }, + methodInfo: { + flex: 1, + }, + methodTitle: { + fontSize: 16, + fontWeight: '700', + color: '#192126', + marginBottom: 4, + }, + methodDescription: { + fontSize: 12, + color: '#6B7280', + lineHeight: 16, + }, + + // 训练计划导入区域 + planImportSection: { + marginTop: 8, + }, + + // 训练计划列表 + plansList: { + marginTop: 16, + marginBottom: 20, + }, + planCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + marginBottom: 12, + borderLeftWidth: 4, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 8, + shadowOffset: { width: 0, height: 2 }, + elevation: 2, + }, + planCardContent: { + padding: 16, + }, + planHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 8, + }, + planInfo: { + flex: 1, + }, + planName: { + fontSize: 16, + fontWeight: '800', + color: '#192126', + marginBottom: 4, + }, + planGoal: { + fontSize: 12, + fontWeight: '700', + marginBottom: 2, + }, + planStatus: { + marginLeft: 12, + }, + radioButton: { + width: 24, + height: 24, + borderRadius: 12, + borderWidth: 2, + }, + planStats: { + marginTop: 8, + }, + statsText: { + fontSize: 12, + color: '#6B7280', + }, + + // 空状态 + loadingContainer: { + alignItems: 'center', + paddingVertical: 24, + }, + loadingText: { + fontSize: 14, + color: '#6B7280', + }, + emptyPlansContainer: { + alignItems: 'center', + paddingVertical: 32, + }, + emptyPlansText: { + fontSize: 14, + color: '#6B7280', + marginTop: 8, + marginBottom: 16, + }, + createPlanBtn: { + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 8, + }, + createPlanBtnText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '700', + }, + + // 确认按钮 + confirmBtn: { + paddingVertical: 16, + borderRadius: 12, + alignItems: 'center', + marginTop: 10, + }, + confirmBtnText: { + color: '#FFFFFF', + fontWeight: '800', + fontSize: 16, + }, +}); diff --git a/app/workout/session/[id].tsx b/app/workout/session/[id].tsx new file mode 100644 index 0000000..d270f79 --- /dev/null +++ b/app/workout/session/[id].tsx @@ -0,0 +1,1115 @@ +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import Animated, { FadeInUp } from 'react-native-reanimated'; + +import { CircularRing } from '@/components/CircularRing'; +import { ThemedText } from '@/components/ThemedText'; +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { palette } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import type { WorkoutExercise, WorkoutSession } from '@/services/workoutsApi'; +import { workoutsApi } from '@/services/workoutsApi'; +import { + clearWorkoutError, + completeWorkoutExercise, + deleteWorkoutSession, + skipWorkoutExercise, + startWorkoutExercise, + startWorkoutSession +} from '@/store/workoutSlice'; +import { useFocusEffect } from '@react-navigation/native'; + +// ==================== 工具函数 ==================== + +// 计算两个时间之间的耗时(秒) +const calculateDuration = (startTime: string, endTime: string): number => { + const start = new Date(startTime); + const end = new Date(endTime); + return Math.floor((end.getTime() - start.getTime()) / 1000); +}; + +// 格式化耗时显示(分钟:秒) +const formatDuration = (seconds: number): string => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}分${remainingSeconds.toString().padStart(2, '0')}秒`; +}; + +// 获取动作的耗时信息 +const getExerciseDuration = (exercise: WorkoutExercise): { duration: number; formatted: string } | null => { + if (exercise.status === 'completed' && exercise.startedAt && exercise.completedAt) { + const duration = calculateDuration(exercise.startedAt, exercise.completedAt); + return { + duration, + formatted: formatDuration(duration) + }; + } + return null; +}; + +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: '舒缓身心,改善睡眠' }, +}; + +// 动态背景组件 +function DynamicBackground({ color }: { color: string }) { + return ( + + + + + + ); +} + +export default function WorkoutSessionDetailScreen() { + const router = useRouter(); + const dispatch = useAppDispatch(); + const params = useLocalSearchParams<{ id: string }>(); + const { exerciseLoading, error } = useAppSelector((s) => s.workout); + + const [session, setSession] = useState(null); + const [exercises, setExercises] = useState([]); + const [loading, setLoading] = useState(true); + + // 本地状态 + const [completionModal, setCompletionModal] = useState<{ + visible: boolean; + exercise: WorkoutExercise | null; + sets: number; + reps: number; + }>({ + visible: false, + exercise: null, + sets: 0, + reps: 0, + }); + + const sessionId = params.id; + + // 加载会话详情 - 每次页面展示时都拉取最新数据 + useFocusEffect( + useCallback(() => { + if (!sessionId) return; + + const loadSessionDetail = async () => { + try { + setLoading(true); + const [sessionDetail, sessionExercises] = await Promise.all([ + workoutsApi.getSessionDetail(sessionId), + workoutsApi.getSessionExercises(sessionId) + ]); + setSession(sessionDetail); + setExercises(sessionExercises); + } catch (error) { + console.error('加载会话详情失败:', error); + Alert.alert('加载失败', '无法加载训练会话详情'); + } finally { + setLoading(false); + } + }; + + loadSessionDetail(); + }, [sessionId]) + ); + + const goalConfig = session?.trainingPlan + ? (GOAL_TEXT[session.trainingPlan.goal] || { title: '训练会话', color: palette.primary, description: '开始你的训练之旅' }) + : { title: session?.name, color: palette.primary, description: '开始你的训练之旅' }; + + // 错误处理 + useEffect(() => { + if (error) { + Alert.alert('错误', error, [ + { text: '确定', onPress: () => dispatch(clearWorkoutError()) } + ]); + } + }, [error, dispatch]); + + // 训练状态统计 + const workoutStats = useMemo(() => { + const exerciseItems = exercises.filter(ex => ex.itemType === 'exercise'); + return { + total: exerciseItems.length, + completed: exerciseItems.filter(ex => ex.status === 'completed').length, + inProgress: exerciseItems.filter(ex => ex.status === 'in_progress').length, + pending: exerciseItems.filter(ex => ex.status === 'pending').length, + skipped: exerciseItems.filter(ex => ex.status === 'skipped').length, + }; + }, [exercises]); + + const completionPercentage = workoutStats.total > 0 + ? Math.round((workoutStats.completed / workoutStats.total) * 100) + : 0; + + // 开始训练会话 + const handleStartWorkout = () => { + if (!session) return; + + Alert.alert( + '开始训练', + '准备好开始训练了吗?', + [ + { text: '取消', style: 'cancel' }, + { + text: '开始', + onPress: async () => { + try { + await dispatch(startWorkoutSession({ sessionId: session.id })).unwrap(); + // 重新加载会话详情 + const updatedSession = await workoutsApi.getSessionDetail(session.id); + setSession(updatedSession); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch (error) { + console.error('开始训练失败:', error); + } + } + } + ] + ); + }; + + // 开始动作 + const handleStartExercise = async (exercise: WorkoutExercise) => { + if (!session || exercise.status !== 'pending') return; + + try { + const updatedExercise = await dispatch(startWorkoutExercise({ + sessionId: session.id, + exerciseId: exercise.id + })).unwrap(); + + // 更新本地exercises列表 + setExercises(prev => prev.map(ex => ex.id === exercise.id ? updatedExercise : ex)); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } catch (error) { + console.error('开始动作失败:', error); + } + }; + + // 显示完成动作模态框 + const handleShowCompleteModal = (exercise: WorkoutExercise) => { + setCompletionModal({ + visible: true, + exercise, + sets: exercise.completedSets || exercise.plannedSets || 0, + reps: exercise.completedReps || exercise.plannedReps || 0, + }); + }; + + // 完成动作 + const handleCompleteExercise = async () => { + const { exercise, sets, reps } = completionModal; + if (!session || !exercise) return; + + try { + const result = await dispatch(completeWorkoutExercise({ + sessionId: session.id, + exerciseId: exercise.id, + dto: { + completedSets: sets, + completedReps: reps, + } + })).unwrap(); + + // 更新本地exercises列表和session + setExercises(prev => prev.map(ex => ex.id === exercise.id ? result.exercise : ex)); + setSession(result.updatedSession); + + setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 }); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch (error) { + console.error('完成动作失败:', error); + } + }; + + // 跳过动作 + const handleSkipExercise = (exercise: WorkoutExercise) => { + if (!session) return; + + Alert.alert( + '跳过动作', + `确定要跳过"${exercise.name}"吗?`, + [ + { text: '取消', style: 'cancel' }, + { + text: '跳过', + style: 'destructive', + onPress: async () => { + try { + const result = await dispatch(skipWorkoutExercise({ + sessionId: session.id, + exerciseId: exercise.id + })).unwrap(); + + // 更新本地exercises列表和session + setExercises(prev => prev.map(ex => ex.id === exercise.id ? result.exercise : ex)); + setSession(result.updatedSession); + + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } catch (error) { + console.error('跳过动作失败:', error); + } + } + } + ] + ); + }; + + // 删除训练会话 + const handleDeleteSession = () => { + if (!session) return; + + Alert.alert( + '删除训练会话', + '确定要删除这个训练会话吗?删除后无法恢复。', + [ + { text: '取消', style: 'cancel' }, + { + text: '删除', + style: 'destructive', + onPress: async () => { + try { + await dispatch(deleteWorkoutSession(session.id)).unwrap(); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + router.back(); + } catch (error) { + console.error('删除会话失败:', error); + } + } + } + ] + ); + }; + + // 获取动作状态文本和颜色 + const getExerciseStatusConfig = (exercise: WorkoutExercise) => { + switch (exercise.status) { + case 'completed': + return { text: '已完成', color: '#22C55E', backgroundColor: '#22C55E15' }; + case 'in_progress': + return { text: '进行中', color: '#F59E0B', backgroundColor: '#F59E0B15' }; + case 'skipped': + return { text: '已跳过', color: '#6B7280', backgroundColor: '#6B728015' }; + default: + return { text: '待开始', color: '#6B7280', backgroundColor: '#6B728015' }; + } + }; + + // 渲染动作卡片 + const renderExerciseItem = ({ item, index }: { item: WorkoutExercise; index: number }) => { + const statusConfig = getExerciseStatusConfig(item); + const isLoading = exerciseLoading === item.id; + + if (item.itemType === 'rest') { + return ( + + + + + + {item.name} + {item.restSec}秒 + + + ); + } + + if (item.itemType === 'note') { + return ( + + + + + + {item.name} + {item.note && {item.note}} + + + ); + } + + return ( + + + + {item.name} + {item.exercise && ( + {item.exercise.categoryName} + )} + + + + {statusConfig.text} + + + + + + {item.plannedSets && ( + + {item.plannedSets} 组 × {item.plannedReps || '-'} 次 + + )} + {item.plannedDurationSec && ( + + 持续 {item.plannedDurationSec} 秒 + + )} + + + {item.status === 'completed' && ( + + + + 实际完成: {item.completedSets || '-'} 组 × {item.completedReps || '-'} 次 + + {(() => { + const durationInfo = getExerciseDuration(item); + return durationInfo ? ( + + + + {durationInfo.formatted} + + + ) : null; + })()} + + + )} + + + {item.status === 'pending' && session?.status === 'in_progress' && ( + handleStartExercise(item)} + disabled={isLoading} + > + + {isLoading ? '开始中...' : '开始'} + + + )} + + {item.status === 'in_progress' && ( + <> + handleShowCompleteModal(item)} + disabled={isLoading} + > + + {isLoading ? '完成中...' : '完成'} + + + handleSkipExercise(item)} + disabled={isLoading} + > + 跳过 + + + )} + + {item.status === 'pending' && ( + handleSkipExercise(item)} + disabled={isLoading} + > + 跳过 + + )} + + + ); + }; + + if (loading) { + return ( + + router.back()} /> + + 加载中... + + + ); + } + + if (!session) { + return ( + + router.back()} /> + + + 训练会话不存在 + 该训练会话可能已被删除 + + + ); + } + + return ( + + {/* 动态背景 */} + + + + router.back()} + withSafeTop={false} + transparent={true} + tone="light" + right={ + session?.status === 'planned' ? ( + router.push(`/training-plan/schedule/select?sessionId=${session.id}`)} + disabled={loading} + > + + + ) : null + } + /> + + + {/* 训练计划信息头部 */} + + {/* 删除按钮 - 右上角 */} + + + + + + + {goalConfig.title} + + {session.name} + + {/* 进度统计文字 */} + {session.status !== 'planned' && ( + + {workoutStats.completed}/{workoutStats.total} 个动作已完成 + + )} + + + {/* 右侧区域:圆环进度或开始按钮 */} + {session.status === 'planned' ? ( + + + + ) : ( + + + + + {completionPercentage}% + + + + )} + + + {/* 训练完成提示 */} + {session.status === 'completed' && ( + + + 训练已完成! + + )} + + {/* 动作列表 */} + item.id} + renderItem={renderExerciseItem} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + /> + + + + {/* 完成动作模态框 */} + setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 })} + > + setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 })} + > + e.stopPropagation()} + > + 完成动作 + {completionModal.exercise?.name} + + + + 完成组数 + + setCompletionModal(prev => ({ + ...prev, + sets: Math.max(0, prev.sets - 1) + }))} + > + - + + {completionModal.sets} + setCompletionModal(prev => ({ + ...prev, + sets: Math.min(20, prev.sets + 1) + }))} + > + + + + + + + + 每组次数 + + setCompletionModal(prev => ({ + ...prev, + reps: Math.max(0, prev.reps - 1) + }))} + > + - + + {completionModal.reps} + setCompletionModal(prev => ({ + ...prev, + reps: Math.min(50, prev.reps + 1) + }))} + > + + + + + + + + + 确认完成 + + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + }, + contentWrapper: { + flex: 1, + }, + content: { + flex: 1, + paddingHorizontal: 20, + }, + + // 动态背景 + backgroundOrb: { + position: 'absolute', + width: 300, + height: 300, + borderRadius: 150, + top: -150, + right: -100, + }, + backgroundOrb2: { + position: 'absolute', + width: 400, + height: 400, + borderRadius: 200, + bottom: -200, + left: -150, + }, + + // 计划信息头部 + planHeader: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 16, + marginBottom: 16, + }, + planColorIndicator: { + width: 4, + height: 40, + borderRadius: 2, + marginRight: 12, + }, + planInfo: { + flex: 1, + }, + planTitle: { + fontSize: 18, + fontWeight: '800', + color: '#192126', + marginBottom: 4, + }, + planDescription: { + fontSize: 13, + color: '#5E6468', + opacity: 0.8, + marginBottom: 4, + }, + planProgressStats: { + fontSize: 12, + color: '#6B7280', + marginTop: 4, + }, + + // 圆环进度容器 + circularProgressContainer: { + alignItems: 'center', + justifyContent: 'center', + marginRight: 32, + position: 'relative', + }, + circularProgressText: { + position: 'absolute', + alignItems: 'center', + justifyContent: 'center', + width: 60, + height: 60, + }, + circularProgressPercentage: { + fontSize: 14, + fontWeight: '800', + textAlign: 'center', + }, + + // 删除按钮 + deleteBtn: { + position: 'absolute', + top: 8, + right: 8, + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.9)', + zIndex: 10, + shadowColor: '#000', + shadowOpacity: 0.1, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + elevation: 2, + }, + + // 开始训练按钮 + planStartBtn: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + marginRight: 32, + }, + + // 完成提示 + completedBanner: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#F0FDF4', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 12, + marginBottom: 16, + gap: 8, + }, + completedBannerText: { + color: '#22C55E', + fontSize: 16, + fontWeight: '700', + }, + + // 列表 + listContent: { + paddingBottom: 40, + }, + + // 动作卡片 + exerciseCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginBottom: 12, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 3, + }, + exerciseHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 8, + }, + exerciseInfo: { + flex: 1, + }, + exerciseName: { + fontSize: 16, + fontWeight: '800', + color: '#192126', + marginBottom: 4, + }, + exerciseCategory: { + fontSize: 12, + color: '#888F92', + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + }, + statusText: { + fontSize: 12, + fontWeight: '600', + }, + exerciseDetails: { + marginBottom: 12, + }, + exerciseParams: { + fontSize: 14, + color: '#5E6468', + marginBottom: 2, + }, + completedInfo: { + backgroundColor: '#F0FDF4', + borderRadius: 8, + padding: 12, + marginBottom: 12, + }, + completedRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + completedText: { + fontSize: 12, + color: '#22C55E', + fontWeight: '600', + flex: 1, + }, + durationBadge: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 6, + gap: 3, + }, + durationText: { + fontSize: 11, + fontWeight: '600', + }, + exerciseActions: { + flexDirection: 'row', + gap: 8, + }, + actionBtn: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + alignItems: 'center', + }, + startBtn: { + backgroundColor: '#22C55E', + }, + startBtnText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '700', + }, + completeBtn: { + backgroundColor: '#22C55E', + }, + completeBtnText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '700', + }, + skipBtn: { + backgroundColor: '#F3F4F6', + borderWidth: 1, + borderColor: '#E5E7EB', + }, + skipBtnText: { + color: '#6B7280', + fontSize: 14, + fontWeight: '600', + }, + + // 休息卡片 + restCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginBottom: 12, + borderLeftWidth: 4, + flexDirection: 'row', + alignItems: 'center', + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 2, + }, + restIconContainer: { + marginRight: 12, + }, + restContent: { + flex: 1, + }, + restTitle: { + fontSize: 16, + fontWeight: '700', + color: '#192126', + marginBottom: 4, + }, + restDuration: { + fontSize: 14, + color: '#5E6468', + }, + + // 备注卡片 + noteCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginBottom: 12, + borderLeftWidth: 4, + flexDirection: 'row', + alignItems: 'flex-start', + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 2, + }, + noteIconContainer: { + marginRight: 12, + marginTop: 2, + }, + noteContent: { + flex: 1, + }, + noteTitle: { + fontSize: 16, + fontWeight: '700', + color: '#192126', + marginBottom: 4, + }, + noteText: { + fontSize: 14, + color: '#5E6468', + lineHeight: 20, + }, + + // 空状态 + emptyContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'flex-start', + paddingTop: 40, + padding: 20, + }, + emptyTitle: { + fontSize: 18, + fontWeight: '700', + color: '#192126', + marginTop: 16, + marginBottom: 8, + }, + emptyText: { + fontSize: 14, + color: '#6B7280', + textAlign: 'center', + marginBottom: 24, + }, + + // 加载状态 + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + loadingText: { + fontSize: 16, + color: '#6B7280', + }, + + // 模态框 + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + alignItems: 'center', + justifyContent: 'flex-end', + }, + modalSheet: { + width: '100%', + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 24, + }, + modalTitle: { + fontSize: 18, + fontWeight: '800', + marginBottom: 8, + color: '#192126', + textAlign: 'center', + }, + modalSubtitle: { + fontSize: 14, + color: '#6B7280', + textAlign: 'center', + marginBottom: 24, + }, + inputRow: { + flexDirection: 'row', + gap: 16, + marginBottom: 24, + }, + inputBox: { + flex: 1, + }, + inputLabel: { + fontSize: 14, + fontWeight: '600', + color: '#192126', + marginBottom: 12, + }, + counterRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: '#F3F4F6', + borderRadius: 8, + padding: 4, + }, + counterBtn: { + backgroundColor: '#FFFFFF', + width: 32, + height: 32, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOpacity: 0.05, + shadowRadius: 2, + shadowOffset: { width: 0, height: 1 }, + elevation: 1, + }, + counterBtnText: { + fontWeight: '800', + color: '#192126', + fontSize: 16, + }, + counterValue: { + fontWeight: '700', + color: '#192126', + fontSize: 16, + minWidth: 40, + textAlign: 'center', + }, + confirmBtn: { + paddingVertical: 16, + borderRadius: 12, + alignItems: 'center', + }, + confirmBtnText: { + color: '#FFFFFF', + fontWeight: '800', + fontSize: 16, + }, + + // 添加动作按钮 + addExerciseBtn: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOpacity: 0.1, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + elevation: 2, + }, +}); diff --git a/app/workout/today.tsx b/app/workout/today.tsx index 0769907..b3e3817 100644 --- a/app/workout/today.tsx +++ b/app/workout/today.tsx @@ -6,8 +6,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import Animated, { FadeInUp } from 'react-native-reanimated'; -import { CircularRing } from '@/components/CircularRing'; -import { ThemedText } from '@/components/ThemedText'; +import { useGlobalDialog } from '@/components/ui/DialogProvider'; import { HeaderBar } from '@/components/ui/HeaderBar'; import { palette } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; @@ -15,20 +14,22 @@ import type { WorkoutExercise } from '@/services/workoutsApi'; import { clearWorkoutError, completeWorkoutExercise, + createWorkoutSession, deleteWorkoutSession, - loadTodayWorkout, + loadWorkoutSessions, skipWorkoutExercise, startWorkoutExercise, startWorkoutSession } from '@/store/workoutSlice'; +import dayjs from 'dayjs'; // ==================== 工具函数 ==================== // 计算两个时间之间的耗时(秒) const calculateDuration = (startTime: string, endTime: string): number => { - const start = new Date(startTime); - const end = new Date(endTime); - return Math.floor((end.getTime() - start.getTime()) / 1000); + const start = dayjs(startTime); + const end = dayjs(endTime); + return Math.floor((end.valueOf() - start.valueOf()) / 1000); }; // 格式化耗时显示(分钟:秒) @@ -77,7 +78,11 @@ function DynamicBackground({ color }: { color: string }) { export default function TodayWorkoutScreen() { const router = useRouter(); const dispatch = useAppDispatch(); - const { currentSession, exercises, loading, exerciseLoading, error } = useAppSelector((s) => s.workout); + const { currentSession, exercises, sessions, sessionsPagination, loading, exerciseLoading, error } = useAppSelector((s) => s.workout); + const { showConfirm, showActionSheet } = useGlobalDialog(); + + const [refreshing, setRefreshing] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); // 本地状态 const [completionModal, setCompletionModal] = useState<{ @@ -92,15 +97,52 @@ export default function TodayWorkoutScreen() { reps: 0, }); + + const goalConfig = currentSession?.trainingPlan ? (GOAL_TEXT[currentSession.trainingPlan.goal] || { title: '今日训练', color: palette.primary, description: '开始你的训练之旅' }) : { title: '今日训练', color: palette.primary, description: '开始你的训练之旅' }; - // 加载今日训练数据 + // 加载训练会话列表 useEffect(() => { - dispatch(loadTodayWorkout()); + dispatch(loadWorkoutSessions({ page: 1, limit: 10 })); }, [dispatch]); + // 刷新数据 + const handleRefresh = async () => { + setRefreshing(true); + try { + await dispatch(loadWorkoutSessions({ page: 1, limit: 10 })).unwrap(); + } catch (error) { + console.error('刷新失败:', error); + } finally { + setRefreshing(false); + } + }; + + // 加载更多数据 + const handleLoadMore = async () => { + if (loadingMore || loading || !sessionsPagination) return; + + const currentPage = sessionsPagination.page; + const totalPages = sessionsPagination.totalPages; + + if (currentPage >= totalPages) return; // 已经是最后一页 + + setLoadingMore(true); + try { + await dispatch(loadWorkoutSessions({ + page: currentPage + 1, + limit: 10, + append: true // 追加数据而不是替换 + })).unwrap(); + } catch (error) { + console.error('加载更多失败:', error); + } finally { + setLoadingMore(false); + } + }; + // 错误处理 useEffect(() => { if (error) { @@ -130,19 +172,16 @@ export default function TodayWorkoutScreen() { const handleStartWorkout = () => { if (!currentSession) return; - Alert.alert( - '开始训练', - '准备好开始今日的训练了吗?', - [ - { text: '取消', style: 'cancel' }, - { - text: '开始', - onPress: () => { - dispatch(startWorkoutSession({ sessionId: currentSession.id })); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } - } - ] + showConfirm( + { + title: '开始训练', + message: '准备好开始今日的训练了吗?', + icon: 'fitness-outline', + }, + () => { + dispatch(startWorkoutSession({ sessionId: currentSession.id })); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } ); }; @@ -189,20 +228,55 @@ export default function TodayWorkoutScreen() { const handleSkipExercise = (exercise: WorkoutExercise) => { if (!currentSession) return; - Alert.alert( - '跳过动作', - `确定要跳过"${exercise.name}"吗?`, + showConfirm( + { + title: '跳过动作', + message: `确定要跳过"${exercise.name}"吗?`, + icon: 'play-skip-forward-outline', + destructive: true, + }, + () => { + dispatch(skipWorkoutExercise({ + sessionId: currentSession.id, + exerciseId: exercise.id + })); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + ); + }; + + // 创建新的训练会话 + const handleCreateSession = () => { + showActionSheet( + { + title: '新建训练', + subtitle: '选择创建方式开始你的训练', + }, [ - { text: '取消', style: 'cancel' }, { - text: '跳过', - style: 'destructive', + id: 'custom', + title: '自定义训练', + subtitle: '创建一个空的训练,可以自由添加动作', + icon: 'create-outline', + iconColor: '#3B82F6', onPress: () => { - dispatch(skipWorkoutExercise({ - sessionId: currentSession.id, - exerciseId: exercise.id + // 创建空的自定义会话 + const sessionName = `训练 ${dayjs().format('YYYY年MM月DD日')}`; + dispatch(createWorkoutSession({ + name: sessionName, + scheduledDate: dayjs().format('YYYY-MM-DD') })); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + }, + { + id: 'from-plan', + title: '从训练计划导入', + subtitle: '基于现有训练计划创建训练', + icon: 'library-outline', + iconColor: '#10B981', + onPress: () => { + // 跳转到创建页面选择训练计划 + router.push('/workout/create-session'); } } ] @@ -213,22 +287,19 @@ export default function TodayWorkoutScreen() { const handleDeleteSession = () => { if (!currentSession) return; - Alert.alert( - '删除训练会话', - '确定要删除这个训练会话吗?删除后无法恢复。', - [ - { text: '取消', style: 'cancel' }, - { - text: '删除', - style: 'destructive', - onPress: () => { - dispatch(deleteWorkoutSession(currentSession.id)); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); - // 删除成功后返回上一页 - router.back(); - } - } - ] + showConfirm( + { + title: '删除训练会话', + message: '确定要删除这个训练会话吗?删除后无法恢复。', + icon: 'trash-outline', + destructive: true, + }, + () => { + dispatch(deleteWorkoutSession(currentSession.id)); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + // 删除成功后清空当前会话 + // 不需要返回上一页,因为现在显示的是会话列表 + } ); }; @@ -390,10 +461,85 @@ export default function TodayWorkoutScreen() { ); }; - if (loading && !currentSession) { + // 渲染训练会话卡片 + const renderSessionItem = ({ item, index }: { item: any; index: number }) => { + const goalConfig = item.trainingPlan?.goal + ? (GOAL_TEXT[item.trainingPlan.goal] || { title: '训练会话', color: palette.primary, description: '开始你的训练之旅' }) + : { title: '训练会话', color: palette.primary, description: '开始你的训练之旅' }; + + const exerciseCount = item.exercises?.length || 0; + const completedCount = item.exercises?.filter((ex: any) => ex.status === 'completed').length || 0; + + return ( + + { + router.push(`/workout/session/${item.id}`); + }} + activeOpacity={0.9} + > + + + {item.name} + {item.trainingPlan && ( + {item.trainingPlan.name} + )} + + 创建时间 {dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')} + + + 计划时间 {dayjs(item.scheduledDate ).format('YYYY-MM-DD HH:mm:ss')} + + + + + {item.status === 'completed' ? ( + + 已完成 + + ) : item.status === 'in_progress' ? ( + + 进行中 + + ) : ( + + 待开始 + + )} + + + + {exerciseCount > 0 && ( + + + {completedCount}/{exerciseCount} 个动作已完成 + + + 0 ? (completedCount / exerciseCount) * 100 : 0}%`, + backgroundColor: goalConfig.color + } + ]} + /> + + + )} + + + ); + }; + + if (loading && sessions.length === 0) { return ( - router.back()} /> + router.back()} /> 加载中... @@ -401,19 +547,19 @@ export default function TodayWorkoutScreen() { ); } - if (!currentSession) { + if (sessions.length === 0 && !loading) { return ( - router.back()} /> + router.back()} /> - - 暂无今日训练 - 请先激活一个训练计划 + + 暂无训练记录 + 开始你的第一次训练吧 router.push('/training-plan' as any)} + style={styles.createSessionBtn} + onPress={handleCreateSession} > - 去创建训练计划 + 新建训练会话 @@ -423,101 +569,49 @@ export default function TodayWorkoutScreen() { return ( {/* 动态背景 */} - + router.back()} withSafeTop={false} transparent={true} tone="light" right={ - currentSession?.status === 'in_progress' ? ( - router.push(`/training-plan/schedule/select?sessionId=${currentSession.id}` as any)} - disabled={loading} - > - - - ) : null + + + } /> - {/* 训练计划信息头部 */} - router.push(`/training-plan`)}> - - - {/* 删除按钮 - 右上角 */} - - - - - - - {goalConfig.title} - - {currentSession.trainingPlan?.name || '今日训练'} - - {/* 进度统计文字 */} - {currentSession.status !== 'planned' && ( - - {workoutStats.completed}/{workoutStats.total} 个动作已完成 - - )} - - - {/* 右侧区域:圆环进度或开始按钮 */} - {currentSession.status === 'planned' ? ( - - - - ) : ( - - - - - {completionPercentage}% - - - - )} - - - - {/* 训练完成提示 */} - {currentSession.status === 'completed' && ( - - - 训练已完成! - - )} - - {/* 动作列表 */} + {/* 会话列表 */} item.id} - renderItem={renderExerciseItem} + renderItem={renderSessionItem} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} + refreshing={refreshing} + onRefresh={handleRefresh} + onEndReached={handleLoadMore} + onEndReachedThreshold={0.1} + ListFooterComponent={ + loadingMore ? ( + + 加载更多... + + ) : sessionsPagination && sessionsPagination.page >= sessionsPagination.totalPages ? ( + + 已加载全部数据 + + ) : null + } /> @@ -603,6 +697,7 @@ export default function TodayWorkoutScreen() { + ); } @@ -944,13 +1039,13 @@ const styles = StyleSheet.create({ textAlign: 'center', marginBottom: 24, }, - createPlanBtn: { + createSessionBtn: { backgroundColor: '#22C55E', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 8, }, - createPlanBtnText: { + createSessionBtnText: { color: '#FFFFFF', fontSize: 14, fontWeight: '700', @@ -1054,11 +1149,11 @@ const styles = StyleSheet.create({ fontSize: 16, }, - // 添加动作按钮 - addExerciseBtn: { - width: 28, - height: 28, - borderRadius: 14, + // 添加会话按钮 + addSessionBtn: { + width: 32, + height: 32, + borderRadius: 16, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', @@ -1067,4 +1162,75 @@ const styles = StyleSheet.create({ shadowOffset: { width: 0, height: 2 }, elevation: 2, }, + + // 会话卡片 + sessionCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + marginBottom: 12, + borderLeftWidth: 4, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 3, + }, + sessionCardContent: { + padding: 16, + }, + sessionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 12, + }, + sessionInfo: { + flex: 1, + }, + sessionName: { + fontSize: 16, + fontWeight: '800', + color: '#192126', + marginBottom: 4, + }, + sessionPlan: { + fontSize: 12, + color: '#888F92', + marginBottom: 2, + }, + sessionDate: { + fontSize: 11, + color: '#6B7280', + }, + sessionStats: { + alignItems: 'flex-end', + }, + sessionProgress: { + marginTop: 8, + }, + progressText: { + fontSize: 12, + color: '#6B7280', + marginTop: 6 + }, + progressBar: { + height: 4, + backgroundColor: '#F3F4F6', + borderRadius: 2, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + borderRadius: 2, + }, + + // 加载更多 + loadMoreContainer: { + paddingVertical: 16, + alignItems: 'center', + }, + loadMoreText: { + fontSize: 12, + color: '#6B7280', + }, }); diff --git a/components/ui/ActionSheet.tsx b/components/ui/ActionSheet.tsx new file mode 100644 index 0000000..4863045 --- /dev/null +++ b/components/ui/ActionSheet.tsx @@ -0,0 +1,317 @@ +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import React, { useEffect, useRef } from 'react'; +import { + Animated, + Dimensions, + Modal, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +const { height: screenHeight } = Dimensions.get('window'); + +export interface ActionSheetOption { + id: string; + title: string; + subtitle?: string; + icon?: keyof typeof Ionicons.glyphMap; + iconColor?: string; + destructive?: boolean; + onPress: () => void; +} + +interface ActionSheetProps { + visible: boolean; + onClose: () => void; + title?: string; + subtitle?: string; + options: ActionSheetOption[]; + cancelText?: string; +} + +export function ActionSheet({ + visible, + onClose, + title, + subtitle, + options, + cancelText = '取消' +}: ActionSheetProps) { + const slideAnim = useRef(new Animated.Value(screenHeight)).current; + const opacityAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + // 显示动画 + Animated.parallel([ + Animated.timing(slideAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(opacityAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + ]).start(); + } else { + // 隐藏动画 + Animated.parallel([ + Animated.timing(slideAnim, { + toValue: screenHeight, + duration: 250, + useNativeDriver: true, + }), + Animated.timing(opacityAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + ]).start(); + } + }, [visible]); + + const handleOptionPress = (option: ActionSheetOption) => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onClose(); + // 延迟执行选项回调,让关闭动画先完成 + setTimeout(() => { + option.onPress(); + }, 100); + }; + + const handleCancel = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onClose(); + }; + + if (!visible) return null; + + return ( + + + {/* 背景遮罩 */} + + + + + {/* 弹窗内容 */} + + {/* 拖拽指示器 */} + + + {/* 标题区域 */} + {(title || subtitle) && ( + + {title && {title}} + {subtitle && {subtitle}} + + )} + + {/* 选项列表 */} + + {options.map((option, index) => ( + handleOptionPress(option)} + activeOpacity={0.7} + > + + {option.icon && ( + + + + )} + + + {option.title} + + {option.subtitle && ( + {option.subtitle} + )} + + + + + ))} + + + {/* 取消按钮 */} + + {cancelText} + + + {/* 底部安全区域 */} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'flex-end', + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.4)', + }, + sheet: { + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingTop: 8, + maxHeight: screenHeight * 0.8, + }, + dragIndicator: { + width: 36, + height: 4, + backgroundColor: '#E5E7EB', + borderRadius: 2, + alignSelf: 'center', + marginBottom: 16, + }, + header: { + paddingHorizontal: 20, + paddingBottom: 16, + borderBottomWidth: 1, + borderBottomColor: '#F3F4F6', + }, + title: { + fontSize: 18, + fontWeight: '700', + color: '#111827', + textAlign: 'center', + marginBottom: 4, + }, + subtitle: { + fontSize: 14, + color: '#6B7280', + textAlign: 'center', + lineHeight: 20, + }, + optionsContainer: { + paddingHorizontal: 20, + paddingTop: 8, + }, + option: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 16, + paddingHorizontal: 16, + backgroundColor: '#F9FAFB', + borderWidth: 1, + borderColor: '#E5E7EB', + marginBottom: 1, + }, + firstOption: { + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + }, + lastOption: { + borderBottomLeftRadius: 12, + borderBottomRightRadius: 12, + marginBottom: 0, + }, + optionContent: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + iconContainer: { + width: 32, + height: 32, + borderRadius: 8, + backgroundColor: '#FFFFFF', + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + shadowColor: '#000', + shadowOpacity: 0.05, + shadowRadius: 2, + shadowOffset: { width: 0, height: 1 }, + elevation: 1, + }, + textContainer: { + flex: 1, + }, + optionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#111827', + marginBottom: 2, + }, + optionSubtitle: { + fontSize: 13, + color: '#6B7280', + lineHeight: 18, + }, + destructiveText: { + color: '#EF4444', + }, + cancelButton: { + marginHorizontal: 20, + marginTop: 16, + paddingVertical: 16, + backgroundColor: '#F3F4F6', + borderRadius: 12, + alignItems: 'center', + }, + cancelText: { + fontSize: 16, + fontWeight: '600', + color: '#374151', + }, + safeBottom: { + height: 34, // iPhone底部安全区域高度 + }, +}); \ No newline at end of file diff --git a/components/ui/ConfirmDialog.tsx b/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..d147dcc --- /dev/null +++ b/components/ui/ConfirmDialog.tsx @@ -0,0 +1,251 @@ +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import React, { useEffect, useRef } from 'react'; +import { + Animated, + Dimensions, + Modal, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +const { width: screenWidth } = Dimensions.get('window'); + +interface ConfirmDialogProps { + visible: boolean; + onClose: () => void; + title: string; + message?: string; + confirmText?: string; + cancelText?: string; + onConfirm: () => void; + destructive?: boolean; + icon?: keyof typeof Ionicons.glyphMap; + iconColor?: string; +} + +export function ConfirmDialog({ + visible, + onClose, + title, + message, + confirmText = '确定', + cancelText = '取消', + onConfirm, + destructive = false, + icon, + iconColor, +}: ConfirmDialogProps) { + const scaleAnim = useRef(new Animated.Value(0.8)).current; + const opacityAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + // 显示动画 + Animated.parallel([ + Animated.spring(scaleAnim, { + toValue: 1, + useNativeDriver: true, + tension: 100, + friction: 8, + }), + Animated.timing(opacityAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + ]).start(); + } else { + // 隐藏动画 + Animated.parallel([ + Animated.timing(scaleAnim, { + toValue: 0.8, + duration: 150, + useNativeDriver: true, + }), + Animated.timing(opacityAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + ]).start(); + } + }, [visible]); + + const handleConfirm = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + onClose(); + // 延迟执行确认回调,让关闭动画先完成 + setTimeout(() => { + onConfirm(); + }, 100); + }; + + const handleCancel = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onClose(); + }; + + if (!visible) return null; + + const defaultIconColor = destructive ? '#EF4444' : '#3B82F6'; + const confirmButtonColor = destructive ? '#EF4444' : '#3B82F6'; + + return ( + + + {/* 背景遮罩 */} + + + + + {/* 弹窗内容 */} + + {/* 图标 */} + {icon && ( + + + + )} + + {/* 标题 */} + {title} + + {/* 消息 */} + {message && {message}} + + {/* 按钮组 */} + + + {cancelText} + + + + {confirmText} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 40, + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.4)', + }, + dialog: { + backgroundColor: '#FFFFFF', + borderRadius: 20, + paddingTop: 32, + paddingBottom: 24, + paddingHorizontal: 24, + width: '100%', + maxWidth: screenWidth - 80, + alignItems: 'center', + shadowColor: '#000', + shadowOpacity: 0.15, + shadowRadius: 20, + shadowOffset: { width: 0, height: 10 }, + elevation: 10, + }, + iconContainer: { + width: 64, + height: 64, + borderRadius: 32, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 20, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: '#111827', + textAlign: 'center', + marginBottom: 8, + }, + message: { + fontSize: 15, + color: '#6B7280', + textAlign: 'center', + lineHeight: 22, + marginBottom: 24, + }, + buttonContainer: { + flexDirection: 'row', + gap: 12, + width: '100%', + }, + button: { + flex: 1, + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + }, + cancelButton: { + backgroundColor: '#F3F4F6', + }, + confirmButton: { + backgroundColor: '#3B82F6', + }, + cancelButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#374151', + }, + confirmButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#FFFFFF', + }, +}); \ No newline at end of file diff --git a/components/ui/DialogProvider.tsx b/components/ui/DialogProvider.tsx new file mode 100644 index 0000000..07647db --- /dev/null +++ b/components/ui/DialogProvider.tsx @@ -0,0 +1,67 @@ +import React, { createContext, useContext } from 'react'; + +import { useDialog, type ActionSheetConfig, type ActionSheetOption, type DialogConfig } from '@/hooks/useDialog'; +import { ActionSheet } from './ActionSheet'; +import { ConfirmDialog } from './ConfirmDialog'; + + +interface DialogContextType { + showConfirm: (config: DialogConfig, onConfirm: () => void) => void; + showActionSheet: (config: ActionSheetConfig, options: ActionSheetOption[]) => void; +} + +const DialogContext = createContext(null); + +export function DialogProvider({ children }: { children: React.ReactNode }) { + const { + confirmDialog, + showConfirm, + hideConfirm, + actionSheet, + showActionSheet, + hideActionSheet, + } = useDialog(); + + const contextValue: DialogContextType = { + showConfirm, + showActionSheet, + }; + + return ( + + {children} + + {/* 确认弹窗 */} + + + {/* ActionSheet */} + + + ); +} + +export function useGlobalDialog() { + const context = useContext(DialogContext); + if (!context) { + throw new Error('useGlobalDialog must be used within a DialogProvider'); + } + return context; +} \ No newline at end of file diff --git a/docs/dialog-components.md b/docs/dialog-components.md new file mode 100644 index 0000000..849338a --- /dev/null +++ b/docs/dialog-components.md @@ -0,0 +1,227 @@ +# 弹窗组件使用指南 + +本项目提供了一套优雅的弹窗组件系统,包括确认弹窗(ConfirmDialog)和操作选择弹窗(ActionSheet)。 + +## 全局使用方式 + +### 1. 使用 useGlobalDialog Hook + +```tsx +import { useGlobalDialog } from '@/components/ui/DialogProvider'; + +function MyComponent() { + const { showConfirm, showActionSheet } = useGlobalDialog(); + + // 显示确认弹窗 + const handleDelete = () => { + showConfirm( + { + title: '删除确认', + message: '确定要删除这个项目吗?删除后无法恢复。', + icon: 'trash-outline', + destructive: true, + }, + () => { + // 确认后的操作 + console.log('已删除'); + } + ); + }; + + // 显示操作选择弹窗 + const handleMoreActions = () => { + showActionSheet( + { + title: '更多操作', + subtitle: '选择要执行的操作', + }, + [ + { + id: 'edit', + title: '编辑', + subtitle: '修改项目信息', + icon: 'create-outline', + iconColor: '#3B82F6', + onPress: () => { + console.log('编辑'); + } + }, + { + id: 'share', + title: '分享', + subtitle: '分享给朋友', + icon: 'share-outline', + iconColor: '#10B981', + onPress: () => { + console.log('分享'); + } + }, + { + id: 'delete', + title: '删除', + subtitle: '永久删除项目', + icon: 'trash-outline', + iconColor: '#EF4444', + destructive: true, + onPress: () => { + console.log('删除'); + } + } + ] + ); + }; + + return ( + + + 删除 + + + 更多操作 + + + ); +} +``` + +## 独立使用方式 + +### 1. ConfirmDialog 确认弹窗 + +```tsx +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; + +function MyComponent() { + const [showDialog, setShowDialog] = useState(false); + + return ( + <> + setShowDialog(true)}> + 显示确认弹窗 + + + setShowDialog(false)} + title="确认操作" + message="确定要执行这个操作吗?" + confirmText="确定" + cancelText="取消" + destructive={false} + icon="checkmark-circle-outline" + iconColor="#22C55E" + onConfirm={() => { + console.log('确认操作'); + setShowDialog(false); + }} + /> + + ); +} +``` + +### 2. ActionSheet 操作选择弹窗 + +```tsx +import { ActionSheet } from '@/components/ui/ActionSheet'; + +function MyComponent() { + const [showSheet, setShowSheet] = useState(false); + + const options = [ + { + id: 'camera', + title: '拍照', + subtitle: '使用相机拍摄照片', + icon: 'camera-outline' as const, + iconColor: '#3B82F6', + onPress: () => { + console.log('拍照'); + setShowSheet(false); + } + }, + { + id: 'gallery', + title: '从相册选择', + subtitle: '从手机相册中选择照片', + icon: 'images-outline' as const, + iconColor: '#10B981', + onPress: () => { + console.log('相册'); + setShowSheet(false); + } + } + ]; + + return ( + <> + setShowSheet(true)}> + 选择照片 + + + setShowSheet(false)} + title="选择照片" + subtitle="选择照片来源" + cancelText="取消" + options={options} + /> + + ); +} +``` + +## 配置选项 + +### ConfirmDialog 配置 + +```tsx +interface DialogConfig { + title: string; // 标题 + message?: string; // 消息内容 + confirmText?: string; // 确认按钮文字,默认"确定" + cancelText?: string; // 取消按钮文字,默认"取消" + destructive?: boolean; // 是否为危险操作,影响按钮颜色 + icon?: keyof typeof Ionicons.glyphMap; // 图标名称 + iconColor?: string; // 图标颜色 +} +``` + +### ActionSheet 配置 + +```tsx +interface ActionSheetConfig { + title?: string; // 标题 + subtitle?: string; // 副标题 + cancelText?: string; // 取消按钮文字,默认"取消" +} + +interface ActionSheetOption { + id: string; // 唯一标识 + title: string; // 选项标题 + subtitle?: string; // 选项副标题 + icon?: keyof typeof Ionicons.glyphMap; // 图标名称 + iconColor?: string; // 图标颜色 + destructive?: boolean; // 是否为危险操作 + onPress: () => void; // 点击回调 +} +``` + +## 设计特点 + +1. **优雅的动画效果**:使用原生动画,流畅自然 +2. **触觉反馈**:集成 Haptics 提供触觉反馈 +3. **响应式设计**:适配不同屏幕尺寸 +4. **无障碍支持**:支持屏幕阅读器等无障碍功能 +5. **类型安全**:完整的 TypeScript 类型定义 +6. **全局管理**:通过 Context 实现全局弹窗管理 +7. **易于使用**:简洁的 API 设计,易于集成 + +## 最佳实践 + +1. **使用全局 Hook**:推荐使用 `useGlobalDialog` 而不是独立组件 +2. **合理使用图标**:为不同类型的操作选择合适的图标 +3. **明确的文案**:使用清晰、简洁的标题和描述 +4. **危险操作标识**:对删除等危险操作使用 `destructive: true` +5. **触觉反馈**:重要操作会自动提供触觉反馈 \ No newline at end of file diff --git a/hooks/useDialog.ts b/hooks/useDialog.ts new file mode 100644 index 0000000..8843019 --- /dev/null +++ b/hooks/useDialog.ts @@ -0,0 +1,92 @@ +import { Ionicons } from '@expo/vector-icons'; +import { useState } from 'react'; + +export interface DialogConfig { + title: string; + message?: string; + confirmText?: string; + cancelText?: string; + destructive?: boolean; + icon?: keyof typeof Ionicons.glyphMap; + iconColor?: string; +} + +export interface ActionSheetConfig { + title?: string; + subtitle?: string; + cancelText?: string; +} + +export interface ActionSheetOption { + id: string; + title: string; + subtitle?: string; + icon?: keyof typeof Ionicons.glyphMap; + iconColor?: string; + destructive?: boolean; + onPress: () => void; +} + +export function useDialog() { + // 确认弹窗状态 + const [confirmDialog, setConfirmDialog] = useState<{ + visible: boolean; + config: DialogConfig; + onConfirm: () => void; + }>({ + visible: false, + config: { title: '' }, + onConfirm: () => {}, + }); + + // ActionSheet状态 + const [actionSheet, setActionSheet] = useState<{ + visible: boolean; + config: ActionSheetConfig; + options: ActionSheetOption[]; + }>({ + visible: false, + config: {}, + options: [], + }); + + // 显示确认弹窗 + const showConfirm = (config: DialogConfig, onConfirm: () => void) => { + setConfirmDialog({ + visible: true, + config, + onConfirm, + }); + }; + + // 显示ActionSheet + const showActionSheet = (config: ActionSheetConfig, options: ActionSheetOption[]) => { + setActionSheet({ + visible: true, + config, + options, + }); + }; + + // 关闭确认弹窗 + const hideConfirm = () => { + setConfirmDialog(prev => ({ ...prev, visible: false })); + }; + + // 关闭ActionSheet + const hideActionSheet = () => { + setActionSheet(prev => ({ ...prev, visible: false })); + }; + + return { + // 确认弹窗 + confirmDialog, + showConfirm, + hideConfirm, + + // ActionSheet + actionSheet, + showActionSheet, + hideActionSheet, + }; +} \ No newline at end of file diff --git a/ios/digitalpilates.xcodeproj/project.pbxproj b/ios/digitalpilates.xcodeproj/project.pbxproj index 8779af2..755430f 100644 --- a/ios/digitalpilates.xcodeproj/project.pbxproj +++ b/ios/digitalpilates.xcodeproj/project.pbxproj @@ -447,7 +447,10 @@ LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -502,7 +505,10 @@ ); LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = false; diff --git a/services/workoutsApi.ts b/services/workoutsApi.ts index cddc956..76ad705 100644 --- a/services/workoutsApi.ts +++ b/services/workoutsApi.ts @@ -107,6 +107,12 @@ export interface UpdateWorkoutExerciseDto { itemType?: 'exercise' | 'rest' | 'note'; } +export interface CreateWorkoutSessionDto { + name: string; + trainingPlanId?: string; + scheduledDate?: string; +} + export interface WorkoutSessionListResponse { sessions: WorkoutSession[]; pagination: { @@ -142,6 +148,10 @@ class WorkoutsApi { return api.get(`/workouts/sessions?page=${page}&limit=${limit}`); } + async createSession(dto: CreateWorkoutSessionDto): Promise { + return api.post('/workouts/sessions', dto); + } + async getSessionDetail(sessionId: string): Promise { return api.get(`/workouts/sessions/${sessionId}`); } diff --git a/store/workoutSlice.ts b/store/workoutSlice.ts index e75a107..b22a6de 100644 --- a/store/workoutSlice.ts +++ b/store/workoutSlice.ts @@ -2,6 +2,7 @@ import { workoutsApi, type AddWorkoutExerciseDto, type CompleteWorkoutExerciseDto, + type CreateWorkoutSessionDto, type StartWorkoutDto, type StartWorkoutExerciseDto, type UpdateWorkoutExerciseDto, @@ -159,10 +160,10 @@ export const loadWorkoutStats = createAsyncThunk( // 获取训练会话列表 export const loadWorkoutSessions = createAsyncThunk( 'workout/loadSessions', - async ({ page = 1, limit = 10 }: { page?: number; limit?: number } = {}, { rejectWithValue }) => { + async ({ page = 1, limit = 10, append = false }: { page?: number; limit?: number; append?: boolean } = {}, { rejectWithValue }) => { try { const result = await workoutsApi.getSessions(page, limit); - return result; + return { ...result, append }; } catch (error: any) { return rejectWithValue(error.message || '获取训练列表失败'); } @@ -182,6 +183,20 @@ export const addWorkoutExercise = createAsyncThunk( } ); +// 创建训练会话 +export const createWorkoutSession = createAsyncThunk( + 'workout/createSession', + async (dto: CreateWorkoutSessionDto, { rejectWithValue }) => { + try { + console.log('createWorkoutSession', dto); + const session = await workoutsApi.createSession(dto); + return session; + } catch (error: any) { + return rejectWithValue(error.message || '创建训练会话失败'); + } + } +); + // 删除训练会话 export const deleteWorkoutSession = createAsyncThunk( 'workout/deleteSession', @@ -343,7 +358,13 @@ const workoutSlice = createSlice({ }) .addCase(loadWorkoutSessions.fulfilled, (state, action) => { state.loading = false; - state.sessions = action.payload.sessions; + if (action.payload.append) { + // 追加数据(分页加载更多) + state.sessions = [...state.sessions, ...action.payload.sessions]; + } else { + // 替换数据(刷新或首次加载) + state.sessions = action.payload.sessions; + } state.sessionsPagination = action.payload.pagination; }) .addCase(loadWorkoutSessions.rejected, (state, action) => { @@ -366,6 +387,24 @@ const workoutSlice = createSlice({ state.error = action.payload as string; }) + // createWorkoutSession + .addCase(createWorkoutSession.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(createWorkoutSession.fulfilled, (state, action) => { + state.loading = false; + // 将新创建的会话添加到列表开头 + state.sessions.unshift(action.payload); + // 设置为当前会话 + state.currentSession = action.payload; + state.exercises = action.payload.exercises || []; + }) + .addCase(createWorkoutSession.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + // deleteWorkoutSession .addCase(deleteWorkoutSession.pending, (state) => { state.loading = true;