feat: 完善训练

This commit is contained in:
2025-08-16 14:15:11 +08:00
parent 5a4d86ff7d
commit 4c6a0e0399
17 changed files with 3079 additions and 166 deletions

View File

@@ -0,0 +1,15 @@
---
inclusion: always
---
# 中文回复规则
请始终使用中文进行回复和编写文档。包括:
- 所有对话回复都使用中文
- 代码注释使用中文
- 文档和说明使用中文
- 错误信息和提示使用中文
- 变量名和函数名可以使用英文,但注释和文档说明必须是中文
这个规则适用于所有交互,除非用户明确要求使用其他语言。

View File

@@ -3,5 +3,6 @@
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
},
"kiroAgent.configureMCP": "Enabled"
}

View File

@@ -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.).
- **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

View File

@@ -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 (
<>
<DialogProvider>
{children}
<PrivacyConsentModal
visible={showPrivacyModal}
@@ -58,7 +59,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
onDisagree={handlePrivacyDisagree}
/>
<Toast />
</>
</DialogProvider>
);
}
@@ -82,6 +83,7 @@ export default function RootLayout() {
<Stack.Screen name="(tabs)" />
<Stack.Screen name="challenge" options={{ headerShown: false }} />
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
<Stack.Screen name="workout" options={{ headerShown: false }} />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
<Stack.Screen name="ai-coach-chat" options={{ headerShown: false }} />

View File

@@ -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<any>(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<string>('全部');
@@ -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 (
<SafeAreaView style={styles.safeArea}>
<HeaderBar title="选择动作" onBack={() => router.back()} />
<View style={styles.errorContainer}>
<ThemedText style={styles.errorText}>
{isSessionMode ? '找不到指定的训练会话' : '找不到指定的训练计划'}
</ThemedText>
<ThemedText style={styles.errorText}>...</ThemedText>
</View>
</SafeAreaView>
);
}
// 错误状态检查
if (isSessionMode && !session) {
return (
<SafeAreaView style={styles.safeArea}>
<HeaderBar title="选择动作" onBack={() => router.back()} />
<View style={styles.errorContainer}>
<ThemedText style={styles.errorText}></ThemedText>
</View>
</SafeAreaView>
);
}
if (!isSessionMode && !plan) {
return (
<SafeAreaView style={styles.safeArea}>
<HeaderBar title="选择动作" onBack={() => router.back()} />
<View style={styles.errorContainer}>
<ThemedText style={styles.errorText}></ThemedText>
</View>
</SafeAreaView>
);

View File

@@ -10,6 +10,20 @@ export default function WorkoutLayout() {
presentation: 'card',
}}
/>
<Stack.Screen
name="create-session"
options={{
headerShown: false,
presentation: 'modal',
}}
/>
<Stack.Screen
name="session/[id]"
options={{
headerShown: false,
presentation: 'card',
}}
/>
</Stack>
);
}

View File

@@ -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<string, { title: string; color: string; description: string }> = {
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 (
<View style={StyleSheet.absoluteFillObject}>
<LinearGradient
colors={['#F9FBF2', '#FFFFFF', '#F5F9F0']}
style={StyleSheet.absoluteFillObject}
/>
<View style={[styles.backgroundOrb, { backgroundColor: `${color}15` }]} />
<View style={[styles.backgroundOrb2, { backgroundColor: `${color}10` }]} />
</View>
);
}
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<string | null>(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 (
<Animated.View
entering={FadeInUp.delay(index * 100)}
style={[
styles.planCard,
{ borderLeftColor: planGoalConfig.color },
isSelected && { borderWidth: 2, borderColor: planGoalConfig.color }
]}
>
<TouchableOpacity
style={styles.planCardContent}
onPress={() => {
setSelectedPlanId(isSelected ? null : item.id);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}}
activeOpacity={0.9}
>
<View style={styles.planHeader}>
<View style={styles.planInfo}>
<Text style={styles.planName}>{item.name}</Text>
<Text style={[styles.planGoal, { color: planGoalConfig.color }]}>
{planGoalConfig.title}
</Text>
<Text style={styles.methodDescription}>
{planGoalConfig.description}
</Text>
</View>
<View style={styles.planStatus}>
{isSelected ? (
<Ionicons name="checkmark-circle" size={24} color={planGoalConfig.color} />
) : (
<View style={[styles.radioButton, { borderColor: planGoalConfig.color }]} />
)}
</View>
</View>
{item.exercises && item.exercises.length > 0 && (
<View style={styles.planStats}>
<Text style={styles.statsText}>
{item.exercises.length}
</Text>
</View>
)}
</TouchableOpacity>
</Animated.View>
);
};
return (
<View style={styles.safeArea}>
{/* 动态背景 */}
<DynamicBackground color={goalConfig.color} />
<SafeAreaView style={styles.contentWrapper}>
<HeaderBar
title="新建训练"
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
tone="light"
/>
<View style={styles.content}>
{/* 会话信息设置 */}
<View style={[styles.sessionHeader, { backgroundColor: `${goalConfig.color}20` }]}>
<View style={[styles.sessionColorIndicator, { backgroundColor: goalConfig.color }]} />
<View style={styles.sessionInfo}>
<ThemedText style={styles.sessionTitle}></ThemedText>
<ThemedText style={styles.sessionDescription}>
{goalConfig.description}
</ThemedText>
</View>
</View>
{/* 会话名称输入 */}
<View style={styles.inputSection}>
<Text style={styles.inputLabel}></Text>
<TextInput
value={sessionName}
onChangeText={setSessionName}
placeholder="输入会话名称"
placeholderTextColor="#888F92"
style={[styles.textInput, { borderColor: `${goalConfig.color}30` }]}
maxLength={50}
/>
</View>
{/* 创建方式选择 */}
<View style={styles.methodSection}>
<Text style={styles.sectionTitle}></Text>
{/* 自定义会话 */}
<TouchableOpacity
style={[styles.methodCard, { borderColor: `${goalConfig.color}30` }]}
onPress={handleCreateCustomSession}
disabled={creating || !sessionName.trim()}
activeOpacity={0.9}
>
<View style={styles.methodIcon}>
<Ionicons name="create-outline" size={24} color={goalConfig.color} />
</View>
<View style={styles.methodInfo}>
<Text style={styles.methodTitle}></Text>
<Text style={styles.methodDescription}>
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#9CA3AF" />
</TouchableOpacity>
{/* 从训练计划导入 */}
<View style={styles.planImportSection}>
<Text style={styles.methodTitle}></Text>
<Text style={styles.methodDescription}>
</Text>
{plansLoading ? (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text>
</View>
) : plans.length === 0 ? (
<View style={styles.emptyPlansContainer}>
<Ionicons name="document-outline" size={32} color="#9CA3AF" />
<Text style={styles.emptyPlansText}></Text>
<TouchableOpacity
style={[styles.createPlanBtn, { backgroundColor: goalConfig.color }]}
onPress={() => router.push('/training-plan/create' as any)}
>
<Text style={styles.createPlanBtnText}></Text>
</TouchableOpacity>
</View>
) : (
<>
<FlatList
data={plans}
keyExtractor={(item) => item.id}
renderItem={renderPlanItem}
contentContainerStyle={styles.plansList}
showsVerticalScrollIndicator={false}
scrollEnabled={false}
/>
{selectedPlan && (
<TouchableOpacity
style={[
styles.confirmBtn,
{ backgroundColor: goalConfig.color },
(!sessionName.trim() || creating) && { opacity: 0.5 }
]}
onPress={handleCreateFromPlan}
disabled={!sessionName.trim() || creating}
>
<Text style={styles.confirmBtnText}>
{creating ? '创建中...' : `从 "${selectedPlan.name}" 创建会话`}
</Text>
</TouchableOpacity>
)}
</>
)}
</View>
</View>
</View>
</SafeAreaView>
</View>
);
}
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,
},
});

1115
app/workout/session/[id].tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<Animated.View
entering={FadeInUp.delay(index * 100)}
style={[styles.sessionCard, { borderLeftColor: goalConfig.color }]}
>
<TouchableOpacity
style={styles.sessionCardContent}
onPress={() => {
router.push(`/workout/session/${item.id}`);
}}
activeOpacity={0.9}
>
<View style={styles.sessionHeader}>
<View style={styles.sessionInfo}>
<Text style={styles.sessionName}>{item.name}</Text>
{item.trainingPlan && (
<Text style={styles.sessionPlan}>{item.trainingPlan.name}</Text>
)}
<Text style={styles.sessionDate}>
{dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Text>
<Text style={styles.sessionDate}>
{dayjs(item.scheduledDate ).format('YYYY-MM-DD HH:mm:ss')}
</Text>
</View>
<View style={styles.sessionStats}>
{item.status === 'completed' ? (
<View style={[styles.statusBadge, { backgroundColor: '#22C55E15' }]}>
<Text style={[styles.statusText, { color: '#22C55E' }]}></Text>
</View>
) : item.status === 'in_progress' ? (
<View style={[styles.statusBadge, { backgroundColor: '#F59E0B15' }]}>
<Text style={[styles.statusText, { color: '#F59E0B' }]}></Text>
</View>
) : (
<View style={[styles.statusBadge, { backgroundColor: '#6B728015' }]}>
<Text style={[styles.statusText, { color: '#6B7280' }]}></Text>
</View>
)}
</View>
</View>
{exerciseCount > 0 && (
<View style={styles.sessionProgress}>
<Text style={styles.progressText}>
{completedCount}/{exerciseCount}
</Text>
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{
width: `${exerciseCount > 0 ? (completedCount / exerciseCount) * 100 : 0}%`,
backgroundColor: goalConfig.color
}
]}
/>
</View>
</View>
)}
</TouchableOpacity>
</Animated.View>
);
};
if (loading && sessions.length === 0) {
return (
<SafeAreaView style={styles.safeArea}>
<HeaderBar title="今日训练" onBack={() => router.back()} />
<HeaderBar title="训练记录" onBack={() => router.back()} />
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text>
</View>
@@ -401,19 +547,19 @@ export default function TodayWorkoutScreen() {
);
}
if (!currentSession) {
if (sessions.length === 0 && !loading) {
return (
<View style={styles.safeArea}>
<HeaderBar title="今日训练" onBack={() => router.back()} />
<HeaderBar title="训练记录" onBack={() => router.back()} />
<View style={styles.emptyContainer}>
<Ionicons name="calendar-outline" size={64} color="#9CA3AF" />
<Text style={styles.emptyTitle}></Text>
<Text style={styles.emptyText}></Text>
<Ionicons name="fitness-outline" size={64} color="#9CA3AF" />
<Text style={styles.emptyTitle}></Text>
<Text style={styles.emptyText}></Text>
<TouchableOpacity
style={styles.createPlanBtn}
onPress={() => router.push('/training-plan' as any)}
style={styles.createSessionBtn}
onPress={handleCreateSession}
>
<Text style={styles.createPlanBtnText}></Text>
<Text style={styles.createSessionBtnText}></Text>
</TouchableOpacity>
</View>
</View>
@@ -423,101 +569,49 @@ export default function TodayWorkoutScreen() {
return (
<View style={styles.safeArea}>
{/* 动态背景 */}
<DynamicBackground color={goalConfig.color} />
<DynamicBackground color={palette.primary} />
<SafeAreaView style={styles.contentWrapper}>
<HeaderBar
title="开始训练"
title="训练记录"
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
tone="light"
right={
currentSession?.status === 'in_progress' ? (
<TouchableOpacity
style={[styles.addExerciseBtn, { backgroundColor: palette.primary }]}
onPress={() => router.push(`/training-plan/schedule/select?sessionId=${currentSession.id}` as any)}
disabled={loading}
>
<Ionicons name="add" size={16} color={palette.ink} />
</TouchableOpacity>
) : null
<TouchableOpacity
style={[styles.addSessionBtn, { backgroundColor: palette.primary }]}
onPress={handleCreateSession}
disabled={loading}
>
<Ionicons name="add" size={20} color={palette.ink} />
</TouchableOpacity>
}
/>
<View style={styles.content}>
{/* 训练计划信息头部 */}
<TouchableOpacity onPress={() => router.push(`/training-plan`)}>
<View style={[styles.planHeader, { backgroundColor: `${goalConfig.color}20` }]}>
{/* 删除按钮 - 右上角 */}
<TouchableOpacity
style={styles.deleteBtn}
onPress={handleDeleteSession}
disabled={loading}
>
<Ionicons name="trash-outline" size={18} color="#EF4444" />
</TouchableOpacity>
<View style={[styles.planColorIndicator, { backgroundColor: goalConfig.color }]} />
<View style={styles.planInfo}>
<ThemedText style={styles.planTitle}>{goalConfig.title}</ThemedText>
<ThemedText style={styles.planDescription}>
{currentSession.trainingPlan?.name || '今日训练'}
</ThemedText>
{/* 进度统计文字 */}
{currentSession.status !== 'planned' && (
<Text style={styles.planProgressStats}>
{workoutStats.completed}/{workoutStats.total}
</Text>
)}
</View>
{/* 右侧区域:圆环进度或开始按钮 */}
{currentSession.status === 'planned' ? (
<TouchableOpacity
style={[styles.planStartBtn, { backgroundColor: goalConfig.color }]}
onPress={handleStartWorkout}
disabled={loading}
>
<Ionicons name="play" size={20} color="#FFFFFF" />
</TouchableOpacity>
) : (
<View style={styles.circularProgressContainer}>
<CircularRing
size={60}
strokeWidth={6}
trackColor={`${goalConfig.color}20`}
progressColor={goalConfig.color}
progress={completionPercentage / 100}
showCenterText={false}
durationMs={800}
/>
<View style={styles.circularProgressText}>
<Text style={[styles.circularProgressPercentage, { color: goalConfig.color }]}>
{completionPercentage}%
</Text>
</View>
</View>
)}
</View>
</TouchableOpacity>
{/* 训练完成提示 */}
{currentSession.status === 'completed' && (
<View style={styles.completedBanner}>
<Ionicons name="checkmark-circle" size={24} color="#22C55E" />
<Text style={styles.completedBannerText}></Text>
</View>
)}
{/* 动作列表 */}
{/* 会话列表 */}
<FlatList
data={exercises}
data={sessions}
keyExtractor={(item) => item.id}
renderItem={renderExerciseItem}
renderItem={renderSessionItem}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshing={refreshing}
onRefresh={handleRefresh}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.1}
ListFooterComponent={
loadingMore ? (
<View style={styles.loadMoreContainer}>
<Text style={styles.loadMoreText}>...</Text>
</View>
) : sessionsPagination && sessionsPagination.page >= sessionsPagination.totalPages ? (
<View style={styles.loadMoreContainer}>
<Text style={styles.loadMoreText}></Text>
</View>
) : null
}
/>
</View>
</SafeAreaView>
@@ -603,6 +697,7 @@ export default function TodayWorkoutScreen() {
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</View>
);
}
@@ -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',
},
});

View File

@@ -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 (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
<View style={styles.container}>
{/* 背景遮罩 */}
<Animated.View
style={[
styles.backdrop,
{
opacity: opacityAnim,
},
]}
>
<TouchableOpacity
style={StyleSheet.absoluteFillObject}
activeOpacity={1}
onPress={handleCancel}
/>
</Animated.View>
{/* 弹窗内容 */}
<Animated.View
style={[
styles.sheet,
{
transform: [{ translateY: slideAnim }],
},
]}
>
{/* 拖拽指示器 */}
<View style={styles.dragIndicator} />
{/* 标题区域 */}
{(title || subtitle) && (
<View style={styles.header}>
{title && <Text style={styles.title}>{title}</Text>}
{subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
</View>
)}
{/* 选项列表 */}
<View style={styles.optionsContainer}>
{options.map((option, index) => (
<TouchableOpacity
key={option.id}
style={[
styles.option,
index === 0 && styles.firstOption,
index === options.length - 1 && styles.lastOption,
]}
onPress={() => handleOptionPress(option)}
activeOpacity={0.7}
>
<View style={styles.optionContent}>
{option.icon && (
<View style={styles.iconContainer}>
<Ionicons
name={option.icon}
size={20}
color={option.iconColor || (option.destructive ? '#EF4444' : '#374151')}
/>
</View>
)}
<View style={styles.textContainer}>
<Text
style={[
styles.optionTitle,
option.destructive && styles.destructiveText,
]}
>
{option.title}
</Text>
{option.subtitle && (
<Text style={styles.optionSubtitle}>{option.subtitle}</Text>
)}
</View>
</View>
<Ionicons name="chevron-forward" size={16} color="#9CA3AF" />
</TouchableOpacity>
))}
</View>
{/* 取消按钮 */}
<TouchableOpacity
style={styles.cancelButton}
onPress={handleCancel}
activeOpacity={0.7}
>
<Text style={styles.cancelText}>{cancelText}</Text>
</TouchableOpacity>
{/* 底部安全区域 */}
<View style={styles.safeBottom} />
</Animated.View>
</View>
</Modal>
);
}
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底部安全区域高度
},
});

View File

@@ -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 (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
<View style={styles.container}>
{/* 背景遮罩 */}
<Animated.View
style={[
styles.backdrop,
{
opacity: opacityAnim,
},
]}
>
<TouchableOpacity
style={StyleSheet.absoluteFillObject}
activeOpacity={1}
onPress={handleCancel}
/>
</Animated.View>
{/* 弹窗内容 */}
<Animated.View
style={[
styles.dialog,
{
transform: [{ scale: scaleAnim }],
opacity: opacityAnim,
},
]}
>
{/* 图标 */}
{icon && (
<View style={[styles.iconContainer, { backgroundColor: `${iconColor || defaultIconColor}15` }]}>
<Ionicons
name={icon}
size={32}
color={iconColor || defaultIconColor}
/>
</View>
)}
{/* 标题 */}
<Text style={styles.title}>{title}</Text>
{/* 消息 */}
{message && <Text style={styles.message}>{message}</Text>}
{/* 按钮组 */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={handleCancel}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>{cancelText}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.button,
styles.confirmButton,
{ backgroundColor: confirmButtonColor },
]}
onPress={handleConfirm}
activeOpacity={0.7}
>
<Text style={styles.confirmButtonText}>{confirmText}</Text>
</TouchableOpacity>
</View>
</Animated.View>
</View>
</Modal>
);
}
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',
},
});

View File

@@ -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<DialogContextType | null>(null);
export function DialogProvider({ children }: { children: React.ReactNode }) {
const {
confirmDialog,
showConfirm,
hideConfirm,
actionSheet,
showActionSheet,
hideActionSheet,
} = useDialog();
const contextValue: DialogContextType = {
showConfirm,
showActionSheet,
};
return (
<DialogContext.Provider value={contextValue}>
{children}
{/* 确认弹窗 */}
<ConfirmDialog
visible={confirmDialog.visible}
onClose={hideConfirm}
title={confirmDialog.config.title}
message={confirmDialog.config.message}
confirmText={confirmDialog.config.confirmText}
cancelText={confirmDialog.config.cancelText}
onConfirm={confirmDialog.onConfirm}
destructive={confirmDialog.config.destructive}
icon={confirmDialog.config.icon}
iconColor={confirmDialog.config.iconColor}
/>
{/* ActionSheet */}
<ActionSheet
visible={actionSheet.visible}
onClose={hideActionSheet}
title={actionSheet.config.title}
subtitle={actionSheet.config.subtitle}
cancelText={actionSheet.config.cancelText}
options={actionSheet.options}
/>
</DialogContext.Provider>
);
}
export function useGlobalDialog() {
const context = useContext(DialogContext);
if (!context) {
throw new Error('useGlobalDialog must be used within a DialogProvider');
}
return context;
}

227
docs/dialog-components.md Normal file
View File

@@ -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 (
<View>
<TouchableOpacity onPress={handleDelete}>
<Text>删除</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleMoreActions}>
<Text>更多操作</Text>
</TouchableOpacity>
</View>
);
}
```
## 独立使用方式
### 1. ConfirmDialog 确认弹窗
```tsx
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
function MyComponent() {
const [showDialog, setShowDialog] = useState(false);
return (
<>
<TouchableOpacity onPress={() => setShowDialog(true)}>
<Text>显示确认弹窗</Text>
</TouchableOpacity>
<ConfirmDialog
visible={showDialog}
onClose={() => 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 (
<>
<TouchableOpacity onPress={() => setShowSheet(true)}>
<Text>选择照片</Text>
</TouchableOpacity>
<ActionSheet
visible={showSheet}
onClose={() => 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. **触觉反馈**:重要操作会自动提供触觉反馈

92
hooks/useDialog.ts Normal file
View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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<WorkoutSessionListResponse>(`/workouts/sessions?page=${page}&limit=${limit}`);
}
async createSession(dto: CreateWorkoutSessionDto): Promise<WorkoutSession> {
return api.post<WorkoutSession>('/workouts/sessions', dto);
}
async getSessionDetail(sessionId: string): Promise<WorkoutSession> {
return api.get<WorkoutSession>(`/workouts/sessions/${sessionId}`);
}

View File

@@ -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;