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;