Compare commits
2 Commits
3312250f2d
...
4c6a0e0399
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c6a0e0399 | |||
| 5a4d86ff7d |
15
.kiro/steering/chinese-language.md
Normal file
15
.kiro/steering/chinese-language.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
inclusion: always
|
||||||
|
---
|
||||||
|
|
||||||
|
# 中文回复规则
|
||||||
|
|
||||||
|
请始终使用中文进行回复和编写文档。包括:
|
||||||
|
|
||||||
|
- 所有对话回复都使用中文
|
||||||
|
- 代码注释使用中文
|
||||||
|
- 文档和说明使用中文
|
||||||
|
- 错误信息和提示使用中文
|
||||||
|
- 变量名和函数名可以使用英文,但注释和文档说明必须是中文
|
||||||
|
|
||||||
|
这个规则适用于所有交互,除非用户明确要求使用其他语言。
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -3,5 +3,6 @@
|
|||||||
"source.fixAll": "explicit",
|
"source.fixAll": "explicit",
|
||||||
"source.organizeImports": "explicit",
|
"source.organizeImports": "explicit",
|
||||||
"source.sortMembers": "explicit"
|
"source.sortMembers": "explicit"
|
||||||
}
|
},
|
||||||
|
"kiroAgent.configureMCP": "Enabled"
|
||||||
}
|
}
|
||||||
|
|||||||
26
CLAUDE.md
26
CLAUDE.md
@@ -13,7 +13,29 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Architecture
|
## Architecture
|
||||||
- **Framework**: React Native (Expo) with TypeScript.
|
- **Framework**: React Native (Expo) with TypeScript.
|
||||||
- **Navigation**: Expo Router for file-based routing (`app/` directory).
|
- **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`).
|
- **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.
|
- **Platform-Specific**: Android (`android/`) and iOS (`ios/`) configurations with native modules.
|
||||||
- **Hooks**: Custom hooks for color scheme (`useColorScheme`) and theme management (`useThemeColor`).
|
- **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, etc.).
|
- **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
|
||||||
2
app.json
2
app.json
@@ -10,7 +10,7 @@
|
|||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"jsEngine": "jsc",
|
"jsEngine": "jsc",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.anonymous.digitalpilates",
|
"bundleIdentifier": "com.anonymous.digitalpilates",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
|
|||||||
@@ -160,11 +160,6 @@ export default function HomeScreen() {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!isLoggedIn) {
|
|
||||||
console.log('fetchRecommendations not logged in');
|
|
||||||
setItems(getFallbackItems());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const cards = await fetchRecommendations();
|
const cards = await fetchRecommendations();
|
||||||
|
|
||||||
@@ -295,7 +290,7 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.featureCard, styles.featureCardPrimary]}
|
style={[styles.featureCard, styles.featureCardPrimary]}
|
||||||
onPress={() => router.push('/ai-posture-assessment')}
|
onPress={() => pushIfAuthedElseLogin('/ai-posture-assessment')}
|
||||||
>
|
>
|
||||||
<View style={styles.featureIconWrapper}>
|
<View style={styles.featureIconWrapper}>
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import { store } from '@/store';
|
|||||||
import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice';
|
import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import RNExitApp from 'react-native-exit-app';
|
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';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||||
@@ -49,14 +51,15 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DialogProvider>
|
||||||
{children}
|
{children}
|
||||||
<PrivacyConsentModal
|
<PrivacyConsentModal
|
||||||
visible={showPrivacyModal}
|
visible={showPrivacyModal}
|
||||||
onAgree={handlePrivacyAgree}
|
onAgree={handlePrivacyAgree}
|
||||||
onDisagree={handlePrivacyDisagree}
|
onDisagree={handlePrivacyDisagree}
|
||||||
/>
|
/>
|
||||||
</>
|
<Toast />
|
||||||
|
</DialogProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +83,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="training-plan" 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/edit" />
|
||||||
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="ai-coach-chat" options={{ headerShown: false }} />
|
<Stack.Screen name="ai-coach-chat" options={{ headerShown: false }} />
|
||||||
@@ -91,6 +95,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
|
<Toast />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</Bootstrapper>
|
</Bootstrapper>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Colors } from '@/constants/Colors';
|
|||||||
import { useAppDispatch } from '@/hooks/redux';
|
import { useAppDispatch } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { login } from '@/store/userSlice';
|
import { login } from '@/store/userSlice';
|
||||||
|
import Toast from 'react-native-toast-message';
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -111,6 +112,11 @@ export default function LoginScreen() {
|
|||||||
throw new Error('未获取到 Apple 身份令牌');
|
throw new Error('未获取到 Apple 身份令牌');
|
||||||
}
|
}
|
||||||
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
||||||
|
|
||||||
|
Toast.show({
|
||||||
|
text1: '登录成功',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
// 登录成功后处理重定向
|
// 登录成功后处理重定向
|
||||||
const to = searchParams?.redirectTo as string | undefined;
|
const to = searchParams?.redirectTo as string | undefined;
|
||||||
const paramsJson = searchParams?.redirectParams as string | undefined;
|
const paramsJson = searchParams?.redirectParams as string | undefined;
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export default function SplashScreen() {
|
|||||||
// 如果出现错误,默认显示引导页面
|
// 如果出现错误,默认显示引导页面
|
||||||
// setTimeout(() => {
|
// setTimeout(() => {
|
||||||
// router.replace('/onboarding');
|
// router.replace('/onboarding');
|
||||||
// setIsLoading(false);
|
|
||||||
// }, 1000);
|
// }, 1000);
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
type WeightUnit = 'kg' | 'lb';
|
type WeightUnit = 'kg' | 'lb';
|
||||||
type HeightUnit = 'cm' | 'ft';
|
type HeightUnit = 'cm' | 'ft';
|
||||||
@@ -50,7 +49,6 @@ const STORAGE_KEY = '@user_profile';
|
|||||||
export default function EditProfileScreen() {
|
export default function EditProfileScreen() {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const colors = Colors[colorScheme ?? 'light'];
|
const colors = Colors[colorScheme ?? 'light'];
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any);
|
const accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any);
|
||||||
const userId: string | undefined = useMemo(() => {
|
const userId: string | undefined = useMemo(() => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import MaskedView from '@react-native-masked-view/masked-view';
|
import MaskedView from '@react-native-masked-view/masked-view';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Alert, FlatList, Modal, Pressable, SafeAreaView, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
import { Alert, FlatList, Modal, Pressable, SafeAreaView, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeInUp,
|
FadeInUp,
|
||||||
@@ -21,6 +21,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
|||||||
import { Colors, palette } from '@/constants/Colors';
|
import { Colors, palette } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { TrainingPlan } from '@/services/trainingPlanApi';
|
||||||
import {
|
import {
|
||||||
addExercise,
|
addExercise,
|
||||||
clearExercises,
|
clearExercises,
|
||||||
@@ -29,7 +30,7 @@ import {
|
|||||||
loadExercises,
|
loadExercises,
|
||||||
toggleCompletion
|
toggleCompletion
|
||||||
} from '@/store/scheduleExerciseSlice';
|
} from '@/store/scheduleExerciseSlice';
|
||||||
import { activatePlan, clearError, deletePlan, loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice';
|
import { activatePlan, clearError, deletePlan, loadPlans } from '@/store/trainingPlanSlice';
|
||||||
import { buildClassicalSession } from '@/utils/classicalSession';
|
import { buildClassicalSession } from '@/utils/classicalSession';
|
||||||
|
|
||||||
// Tab 类型定义
|
// Tab 类型定义
|
||||||
@@ -248,13 +249,14 @@ export default function TrainingPlanScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const params = useLocalSearchParams<{ planId?: string; tab?: string }>();
|
const params = useLocalSearchParams<{ planId?: string; tab?: string }>();
|
||||||
const { plans, currentId, loading, error } = useAppSelector((s) => s.trainingPlan);
|
const { plans, loading, error } = useAppSelector((s) => s.trainingPlan);
|
||||||
const { exercises, error: scheduleError } = useAppSelector((s) => s.scheduleExercise);
|
const { exercises, error: scheduleError } = useAppSelector((s) => s.scheduleExercise);
|
||||||
|
|
||||||
|
console.log('plans', plans);
|
||||||
// Tab 状态管理 - 支持从URL参数设置初始tab
|
// Tab 状态管理 - 支持从URL参数设置初始tab
|
||||||
const initialTab: TabType = params.tab === 'schedule' ? 'schedule' : 'list';
|
const initialTab: TabType = params.tab === 'schedule' ? 'schedule' : 'list';
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(initialTab);
|
const [activeTab, setActiveTab] = useState<TabType>(initialTab);
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(params.planId || currentId || null);
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(params.planId || null);
|
||||||
|
|
||||||
// 一键排课配置
|
// 一键排课配置
|
||||||
const [genVisible, setGenVisible] = useState(false);
|
const [genVisible, setGenVisible] = useState(false);
|
||||||
@@ -274,9 +276,21 @@ export default function TrainingPlanScreen() {
|
|||||||
}
|
}
|
||||||
}, [selectedPlanId, dispatch]);
|
}, [selectedPlanId, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
// 每次页面获得焦点时,如果当前有选中的计划,重新加载其排课数据
|
||||||
dispatch(loadPlans());
|
useFocusEffect(
|
||||||
}, [dispatch]);
|
useCallback(() => {
|
||||||
|
if (selectedPlanId) {
|
||||||
|
dispatch(loadExercises(selectedPlanId));
|
||||||
|
}
|
||||||
|
}, [selectedPlanId, dispatch])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 每次页面获得焦点时重新加载训练计划数据
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
dispatch(loadPlans());
|
||||||
|
}, [dispatch])
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -314,7 +328,7 @@ export default function TrainingPlanScreen() {
|
|||||||
const handleTabChange = (tab: TabType) => {
|
const handleTabChange = (tab: TabType) => {
|
||||||
if (tab === 'schedule' && !selectedPlanId && plans.length > 0) {
|
if (tab === 'schedule' && !selectedPlanId && plans.length > 0) {
|
||||||
// 如果没有选中计划但要切换到排课页面,自动选择当前激活的计划或第一个计划
|
// 如果没有选中计划但要切换到排课页面,自动选择当前激活的计划或第一个计划
|
||||||
const targetPlan = plans.find(p => p.id === currentId) || plans[0];
|
const targetPlan = plans.find(p => p.isActive) || plans[0];
|
||||||
setSelectedPlanId(targetPlan.id);
|
setSelectedPlanId(targetPlan.id);
|
||||||
}
|
}
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
@@ -426,7 +440,7 @@ export default function TrainingPlanScreen() {
|
|||||||
key={p.id}
|
key={p.id}
|
||||||
plan={p}
|
plan={p}
|
||||||
index={index}
|
index={index}
|
||||||
isActive={p.id === currentId}
|
isActive={p.isActive}
|
||||||
onPress={() => handlePlanSelect(p)}
|
onPress={() => handlePlanSelect(p)}
|
||||||
onDelete={() => dispatch(deletePlan(p.id))}
|
onDelete={() => dispatch(deletePlan(p.id))}
|
||||||
onActivate={() => handleActivate(p.id)}
|
onActivate={() => handleActivate(p.id)}
|
||||||
@@ -1098,7 +1112,7 @@ const styles = StyleSheet.create({
|
|||||||
// 主内容区域
|
// 主内容区域
|
||||||
mainContent: {
|
mainContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingBottom: 100, // 为底部 tab 留出空间
|
paddingBottom: 60, // 为底部 tab 留出空间
|
||||||
},
|
},
|
||||||
|
|
||||||
// 排课页面样式
|
// 排课页面样式
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ThemedView } from '@/components/ThemedView';
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { palette } from '@/constants/Colors';
|
import { palette } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { PlanGoal } from '@/services/trainingPlanApi';
|
||||||
import {
|
import {
|
||||||
clearError,
|
clearError,
|
||||||
loadPlans,
|
loadPlans,
|
||||||
@@ -21,7 +22,6 @@ import {
|
|||||||
setStartDateNextMonday,
|
setStartDateNextMonday,
|
||||||
setStartWeight,
|
setStartWeight,
|
||||||
toggleDayOfWeek,
|
toggleDayOfWeek,
|
||||||
type PlanGoal
|
|
||||||
} from '@/store/trainingPlanSlice';
|
} from '@/store/trainingPlanSlice';
|
||||||
|
|
||||||
const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||||
|
|||||||
@@ -48,12 +48,43 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
const planId = params.planId;
|
const planId = params.planId;
|
||||||
const sessionId = params.sessionId;
|
const sessionId = params.sessionId;
|
||||||
const plan = useMemo(() => plans.find(p => p.id === planId), [plans, planId]);
|
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来确定是训练计划模式还是训练会话模式
|
// 根据是否有sessionId来确定是训练计划模式还是训练会话模式
|
||||||
const isSessionMode = !!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 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 [keyword, setKeyword] = useState('');
|
||||||
const [category, setCategory] = useState<string>('全部');
|
const [category, setCategory] = useState<string>('全部');
|
||||||
@@ -139,7 +170,7 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
if (isSessionMode && sessionId) {
|
if (isSessionMode && sessionId) {
|
||||||
// 训练会话模式:添加到训练会话
|
// 训练会话模式:添加到训练会话
|
||||||
await dispatch(addWorkoutExercise({ sessionId, dto: newExerciseDto })).unwrap();
|
await dispatch(addWorkoutExercise({ sessionId, dto: newExerciseDto })).unwrap();
|
||||||
} else if (plan) {
|
} else if (plan && planId) {
|
||||||
// 训练计划模式:添加到训练计划
|
// 训练计划模式:添加到训练计划
|
||||||
const planExerciseDto = {
|
const planExerciseDto = {
|
||||||
exerciseKey: selected.key,
|
exerciseKey: selected.key,
|
||||||
@@ -149,7 +180,7 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
itemType: 'exercise' as const,
|
itemType: 'exercise' as const,
|
||||||
note: `${selected.category}训练`,
|
note: `${selected.category}训练`,
|
||||||
};
|
};
|
||||||
await dispatch(addExercise({ planId: plan.id, dto: planExerciseDto })).unwrap();
|
await dispatch(addExercise({ planId: planId, dto: planExerciseDto })).unwrap();
|
||||||
} else {
|
} else {
|
||||||
throw new Error('缺少必要的参数');
|
throw new Error('缺少必要的参数');
|
||||||
}
|
}
|
||||||
@@ -191,9 +222,9 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
if (isSessionMode && sessionId) {
|
if (isSessionMode && sessionId) {
|
||||||
// 训练会话模式
|
// 训练会话模式
|
||||||
await dispatch(addWorkoutExercise({ sessionId, dto: restDto })).unwrap();
|
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 {
|
} else {
|
||||||
throw new Error('缺少必要的参数');
|
throw new Error('缺少必要的参数');
|
||||||
}
|
}
|
||||||
@@ -224,9 +255,9 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
if (isSessionMode && sessionId) {
|
if (isSessionMode && sessionId) {
|
||||||
// 训练会话模式
|
// 训练会话模式
|
||||||
await dispatch(addWorkoutExercise({ sessionId, dto: noteDto })).unwrap();
|
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 {
|
} else {
|
||||||
throw new Error('缺少必要的参数');
|
throw new Error('缺少必要的参数');
|
||||||
}
|
}
|
||||||
@@ -256,14 +287,36 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
setSelectedKey(key);
|
setSelectedKey(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!goalConfig || (!plan && !isSessionMode)) {
|
// 加载状态
|
||||||
|
if (sessionLoading) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<HeaderBar title="选择动作" onBack={() => router.back()} />
|
<HeaderBar title="选择动作" onBack={() => router.back()} />
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<ThemedText style={styles.errorText}>
|
<ThemedText style={styles.errorText}>加载中...</ThemedText>
|
||||||
{isSessionMode ? '找不到指定的训练会话' : '找不到指定的训练计划'}
|
</View>
|
||||||
</ThemedText>
|
</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>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,20 @@ export default function WorkoutLayout() {
|
|||||||
presentation: 'card',
|
presentation: 'card',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="create-session"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'modal',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="session/[id]"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'card',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
516
app/workout/create-session.tsx
Normal file
516
app/workout/create-session.tsx
Normal 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
1115
app/workout/session/[id].tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||||
|
|
||||||
import { CircularRing } from '@/components/CircularRing';
|
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { palette } from '@/constants/Colors';
|
import { palette } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
@@ -15,20 +14,22 @@ import type { WorkoutExercise } from '@/services/workoutsApi';
|
|||||||
import {
|
import {
|
||||||
clearWorkoutError,
|
clearWorkoutError,
|
||||||
completeWorkoutExercise,
|
completeWorkoutExercise,
|
||||||
|
createWorkoutSession,
|
||||||
deleteWorkoutSession,
|
deleteWorkoutSession,
|
||||||
loadTodayWorkout,
|
loadWorkoutSessions,
|
||||||
skipWorkoutExercise,
|
skipWorkoutExercise,
|
||||||
startWorkoutExercise,
|
startWorkoutExercise,
|
||||||
startWorkoutSession
|
startWorkoutSession
|
||||||
} from '@/store/workoutSlice';
|
} from '@/store/workoutSlice';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
// ==================== 工具函数 ====================
|
// ==================== 工具函数 ====================
|
||||||
|
|
||||||
// 计算两个时间之间的耗时(秒)
|
// 计算两个时间之间的耗时(秒)
|
||||||
const calculateDuration = (startTime: string, endTime: string): number => {
|
const calculateDuration = (startTime: string, endTime: string): number => {
|
||||||
const start = new Date(startTime);
|
const start = dayjs(startTime);
|
||||||
const end = new Date(endTime);
|
const end = dayjs(endTime);
|
||||||
return Math.floor((end.getTime() - start.getTime()) / 1000);
|
return Math.floor((end.valueOf() - start.valueOf()) / 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 格式化耗时显示(分钟:秒)
|
// 格式化耗时显示(分钟:秒)
|
||||||
@@ -77,7 +78,11 @@ function DynamicBackground({ color }: { color: string }) {
|
|||||||
export default function TodayWorkoutScreen() {
|
export default function TodayWorkoutScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
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<{
|
const [completionModal, setCompletionModal] = useState<{
|
||||||
@@ -92,15 +97,52 @@ export default function TodayWorkoutScreen() {
|
|||||||
reps: 0,
|
reps: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const goalConfig = currentSession?.trainingPlan
|
const goalConfig = currentSession?.trainingPlan
|
||||||
? (GOAL_TEXT[currentSession.trainingPlan.goal] || { title: '今日训练', color: palette.primary, description: '开始你的训练之旅' })
|
? (GOAL_TEXT[currentSession.trainingPlan.goal] || { title: '今日训练', color: palette.primary, description: '开始你的训练之旅' })
|
||||||
: { title: '今日训练', color: palette.primary, description: '开始你的训练之旅' };
|
: { title: '今日训练', color: palette.primary, description: '开始你的训练之旅' };
|
||||||
|
|
||||||
// 加载今日训练数据
|
// 加载训练会话列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(loadTodayWorkout());
|
dispatch(loadWorkoutSessions({ page: 1, limit: 10 }));
|
||||||
}, [dispatch]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -130,19 +172,16 @@ export default function TodayWorkoutScreen() {
|
|||||||
const handleStartWorkout = () => {
|
const handleStartWorkout = () => {
|
||||||
if (!currentSession) return;
|
if (!currentSession) return;
|
||||||
|
|
||||||
Alert.alert(
|
showConfirm(
|
||||||
'开始训练',
|
{
|
||||||
'准备好开始今日的训练了吗?',
|
title: '开始训练',
|
||||||
[
|
message: '准备好开始今日的训练了吗?',
|
||||||
{ text: '取消', style: 'cancel' },
|
icon: 'fitness-outline',
|
||||||
{
|
},
|
||||||
text: '开始',
|
() => {
|
||||||
onPress: () => {
|
dispatch(startWorkoutSession({ sessionId: currentSession.id }));
|
||||||
dispatch(startWorkoutSession({ sessionId: currentSession.id }));
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,20 +228,55 @@ export default function TodayWorkoutScreen() {
|
|||||||
const handleSkipExercise = (exercise: WorkoutExercise) => {
|
const handleSkipExercise = (exercise: WorkoutExercise) => {
|
||||||
if (!currentSession) return;
|
if (!currentSession) return;
|
||||||
|
|
||||||
Alert.alert(
|
showConfirm(
|
||||||
'跳过动作',
|
{
|
||||||
`确定要跳过"${exercise.name}"吗?`,
|
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: '跳过',
|
id: 'custom',
|
||||||
style: 'destructive',
|
title: '自定义训练',
|
||||||
|
subtitle: '创建一个空的训练,可以自由添加动作',
|
||||||
|
icon: 'create-outline',
|
||||||
|
iconColor: '#3B82F6',
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
dispatch(skipWorkoutExercise({
|
// 创建空的自定义会话
|
||||||
sessionId: currentSession.id,
|
const sessionName = `训练 ${dayjs().format('YYYY年MM月DD日')}`;
|
||||||
exerciseId: exercise.id
|
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 = () => {
|
const handleDeleteSession = () => {
|
||||||
if (!currentSession) return;
|
if (!currentSession) return;
|
||||||
|
|
||||||
Alert.alert(
|
showConfirm(
|
||||||
'删除训练会话',
|
{
|
||||||
'确定要删除这个训练会话吗?删除后无法恢复。',
|
title: '删除训练会话',
|
||||||
[
|
message: '确定要删除这个训练会话吗?删除后无法恢复。',
|
||||||
{ text: '取消', style: 'cancel' },
|
icon: 'trash-outline',
|
||||||
{
|
destructive: true,
|
||||||
text: '删除',
|
},
|
||||||
style: 'destructive',
|
() => {
|
||||||
onPress: () => {
|
dispatch(deleteWorkoutSession(currentSession.id));
|
||||||
dispatch(deleteWorkoutSession(currentSession.id));
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
// 删除成功后清空当前会话
|
||||||
// 删除成功后返回上一页
|
// 不需要返回上一页,因为现在显示的是会话列表
|
||||||
router.back();
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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 (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<HeaderBar title="今日训练" onBack={() => router.back()} />
|
<HeaderBar title="训练记录" onBack={() => router.back()} />
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<Text style={styles.loadingText}>加载中...</Text>
|
<Text style={styles.loadingText}>加载中...</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -401,19 +547,19 @@ export default function TodayWorkoutScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentSession) {
|
if (sessions.length === 0 && !loading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.safeArea}>
|
<View style={styles.safeArea}>
|
||||||
<HeaderBar title="今日训练" onBack={() => router.back()} />
|
<HeaderBar title="训练记录" onBack={() => router.back()} />
|
||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<Ionicons name="calendar-outline" size={64} color="#9CA3AF" />
|
<Ionicons name="fitness-outline" size={64} color="#9CA3AF" />
|
||||||
<Text style={styles.emptyTitle}>暂无今日训练</Text>
|
<Text style={styles.emptyTitle}>暂无训练记录</Text>
|
||||||
<Text style={styles.emptyText}>请先激活一个训练计划</Text>
|
<Text style={styles.emptyText}>开始你的第一次训练吧</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.createPlanBtn}
|
style={styles.createSessionBtn}
|
||||||
onPress={() => router.push('/training-plan' as any)}
|
onPress={handleCreateSession}
|
||||||
>
|
>
|
||||||
<Text style={styles.createPlanBtnText}>去创建训练计划</Text>
|
<Text style={styles.createSessionBtnText}>新建训练会话</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -423,101 +569,49 @@ export default function TodayWorkoutScreen() {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.safeArea}>
|
<View style={styles.safeArea}>
|
||||||
{/* 动态背景 */}
|
{/* 动态背景 */}
|
||||||
<DynamicBackground color={goalConfig.color} />
|
<DynamicBackground color={palette.primary} />
|
||||||
|
|
||||||
<SafeAreaView style={styles.contentWrapper}>
|
<SafeAreaView style={styles.contentWrapper}>
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title="开始训练"
|
title="训练记录"
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
withSafeTop={false}
|
withSafeTop={false}
|
||||||
transparent={true}
|
transparent={true}
|
||||||
tone="light"
|
tone="light"
|
||||||
right={
|
right={
|
||||||
currentSession?.status === 'in_progress' ? (
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
style={[styles.addSessionBtn, { backgroundColor: palette.primary }]}
|
||||||
style={[styles.addExerciseBtn, { backgroundColor: palette.primary }]}
|
onPress={handleCreateSession}
|
||||||
onPress={() => router.push(`/training-plan/schedule/select?sessionId=${currentSession.id}` as any)}
|
disabled={loading}
|
||||||
disabled={loading}
|
>
|
||||||
>
|
<Ionicons name="add" size={20} color={palette.ink} />
|
||||||
<Ionicons name="add" size={16} color={palette.ink} />
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
) : null
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.content}>
|
<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
|
<FlatList
|
||||||
data={exercises}
|
data={sessions}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
renderItem={renderExerciseItem}
|
renderItem={renderSessionItem}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
showsVerticalScrollIndicator={false}
|
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>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
@@ -603,6 +697,7 @@ export default function TodayWorkoutScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -944,13 +1039,13 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
},
|
},
|
||||||
createPlanBtn: {
|
createSessionBtn: {
|
||||||
backgroundColor: '#22C55E',
|
backgroundColor: '#22C55E',
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
createPlanBtnText: {
|
createSessionBtnText: {
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
@@ -1054,11 +1149,11 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 添加动作按钮
|
// 添加会话按钮
|
||||||
addExerciseBtn: {
|
addSessionBtn: {
|
||||||
width: 28,
|
width: 32,
|
||||||
height: 28,
|
height: 32,
|
||||||
borderRadius: 14,
|
borderRadius: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@@ -1067,4 +1162,75 @@ const styles = StyleSheet.create({
|
|||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
elevation: 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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import {
|
import {
|
||||||
BMI_CATEGORIES,
|
BMI_CATEGORIES,
|
||||||
canCalculateBMI,
|
canCalculateBMI,
|
||||||
@@ -5,7 +6,6 @@ import {
|
|||||||
type BMIResult
|
type BMIResult
|
||||||
} from '@/utils/bmi';
|
} from '@/utils/bmi';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import Toast from 'react-native-toast-message';
|
||||||
|
|
||||||
interface BMICardProps {
|
interface BMICardProps {
|
||||||
weight?: number;
|
weight?: number;
|
||||||
@@ -24,7 +25,7 @@ interface BMICardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BMICard({ weight, height, style }: BMICardProps) {
|
export function BMICard({ weight, height, style }: BMICardProps) {
|
||||||
const router = useRouter();
|
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||||
const [showInfoModal, setShowInfoModal] = useState(false);
|
const [showInfoModal, setShowInfoModal] = useState(false);
|
||||||
|
|
||||||
const canCalculate = canCalculateBMI(weight, height);
|
const canCalculate = canCalculateBMI(weight, height);
|
||||||
@@ -41,7 +42,11 @@ export function BMICard({ weight, height, style }: BMICardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGoToProfile = () => {
|
const handleGoToProfile = () => {
|
||||||
router.push('/profile/edit');
|
Toast.show({
|
||||||
|
text1: '请先登录',
|
||||||
|
type: 'info',
|
||||||
|
});
|
||||||
|
pushIfAuthedElseLogin('/profile/edit');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShowInfoModal = () => {
|
const handleShowInfoModal = () => {
|
||||||
@@ -436,4 +441,4 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
317
components/ui/ActionSheet.tsx
Normal file
317
components/ui/ActionSheet.tsx
Normal 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底部安全区域高度
|
||||||
|
},
|
||||||
|
});
|
||||||
251
components/ui/ConfirmDialog.tsx
Normal file
251
components/ui/ConfirmDialog.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
67
components/ui/DialogProvider.tsx
Normal file
67
components/ui/DialogProvider.tsx
Normal 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
227
docs/dialog-components.md
Normal 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. **触觉反馈**:重要操作会自动提供触觉反馈
|
||||||
@@ -64,7 +64,7 @@ export function useAuthGuard() {
|
|||||||
await dispatch(logoutAction()).unwrap();
|
await dispatch(logoutAction()).unwrap();
|
||||||
|
|
||||||
// 跳转到登录页面
|
// 跳转到登录页面
|
||||||
router.replace('/auth/login');
|
router.push('/auth/login');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('退出登录失败:', error);
|
console.error('退出登录失败:', error);
|
||||||
Alert.alert('错误', '退出登录失败,请稍后重试');
|
Alert.alert('错误', '退出登录失败,请稍后重试');
|
||||||
@@ -105,7 +105,7 @@ export function useAuthGuard() {
|
|||||||
Alert.alert('账号已注销', '您的账号已成功注销', [
|
Alert.alert('账号已注销', '您的账号已成功注销', [
|
||||||
{
|
{
|
||||||
text: '确定',
|
text: '确定',
|
||||||
onPress: () => router.replace('/auth/login'),
|
onPress: () => router.push('/auth/login'),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
92
hooks/useDialog.ts
Normal file
92
hooks/useDialog.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -344,10 +344,14 @@
|
|||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
|
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
|
||||||
PRODUCT_NAME = digitalpilates;
|
PRODUCT_NAME = digitalpilates;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "digitalpilates/digitalpilates-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "digitalpilates/digitalpilates-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -377,9 +381,13 @@
|
|||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
|
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
|
||||||
PRODUCT_NAME = digitalpilates;
|
PRODUCT_NAME = digitalpilates;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "digitalpilates/digitalpilates-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "digitalpilates/digitalpilates-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
|
|||||||
@@ -1,91 +1,84 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>普拉提助手</string>
|
<string>普拉提助手</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.2</string>
|
<string>1.0.2</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>digitalpilates</string>
|
<string>digitalpilates</string>
|
||||||
<string>com.anonymous.digitalpilates</string>
|
<string>com.anonymous.digitalpilates</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>2</string>
|
<string>2</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>应用需要使用相机以拍摄您的体态照片用于AI测评。</string>
|
<string>应用需要使用相机以拍摄您的体态照片用于AI测评。</string>
|
||||||
<key>NSHealthShareUsageDescription</key>
|
<key>NSHealthShareUsageDescription</key>
|
||||||
<string>应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。</string>
|
<string>应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。</string>
|
||||||
<key>NSHealthUpdateUsageDescription</key>
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
<string>Allow $(PRODUCT_NAME) to update health info</string>
|
<string>Allow $(PRODUCT_NAME) to update health info</string>
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<string>应用需要写入相册以保存拍摄的体态照片(可选)。</string>
|
<string>应用需要写入相册以保存拍摄的体态照片(可选)。</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>应用需要访问相册以选择您的体态照片用于AI测评。</string>
|
<string>应用需要访问相册以选择您的体态照片用于AI测评。</string>
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSUserActivityTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>SplashScreen</string>
|
<string>SplashScreen</string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIRequiresFullScreen</key>
|
<key>UIRequiresFullScreen</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>UIStatusBarStyle</key>
|
<key>UIStatusBarStyle</key>
|
||||||
<string>UIStatusBarStyleDefault</string>
|
<string>UIStatusBarStyleDefault</string>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UIUserInterfaceStyle</key>
|
||||||
<array>
|
<string>Light</string>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<false/>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
</dict>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
</plist>
|
||||||
</array>
|
|
||||||
<key>UIUserInterfaceStyle</key>
|
|
||||||
<string>Light</string>
|
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -49,6 +49,7 @@
|
|||||||
"react-native-safe-area-context": "5.4.0",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
"react-native-screens": "~4.11.1",
|
"react-native-screens": "~4.11.1",
|
||||||
"react-native-svg": "^15.12.1",
|
"react-native-svg": "^15.12.1",
|
||||||
|
"react-native-toast-message": "^2.3.3",
|
||||||
"react-native-web": "~0.20.0",
|
"react-native-web": "~0.20.0",
|
||||||
"react-native-webview": "13.13.5",
|
"react-native-webview": "13.13.5",
|
||||||
"react-redux": "^9.2.0"
|
"react-redux": "^9.2.0"
|
||||||
@@ -11227,6 +11228,16 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-toast-message": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-toast-message/-/react-native-toast-message-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-4IIUHwUPvKHu4gjD0Vj2aGQzqPATiblL1ey8tOqsxOWRPGGu52iIbL8M/mCz4uyqecvPdIcMY38AfwRuUADfQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-uuid": {
|
"node_modules/react-native-uuid": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/react-native-uuid/-/react-native-uuid-2.0.3.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/react-native-uuid/-/react-native-uuid-2.0.3.tgz",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"react-native-safe-area-context": "5.4.0",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
"react-native-screens": "~4.11.1",
|
"react-native-screens": "~4.11.1",
|
||||||
"react-native-svg": "^15.12.1",
|
"react-native-svg": "^15.12.1",
|
||||||
|
"react-native-toast-message": "^2.3.3",
|
||||||
"react-native-web": "~0.20.0",
|
"react-native-web": "~0.20.0",
|
||||||
"react-native-webview": "13.13.5",
|
"react-native-webview": "13.13.5",
|
||||||
"react-redux": "^9.2.0"
|
"react-redux": "^9.2.0"
|
||||||
|
|||||||
@@ -11,56 +11,52 @@ export interface CreateTrainingPlanDto {
|
|||||||
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
|
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrainingPlanResponse {
|
export type PlanMode = 'daysOfWeek' | 'sessionsPerWeek';
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
name: string;
|
|
||||||
createdAt: string;
|
|
||||||
startDate: string;
|
|
||||||
mode: 'daysOfWeek' | 'sessionsPerWeek';
|
|
||||||
daysOfWeek: number[];
|
|
||||||
sessionsPerWeek: number;
|
|
||||||
goal: string;
|
|
||||||
startWeightKg: number | null;
|
|
||||||
preferredTimeOfDay: 'morning' | 'noon' | 'evening' | '';
|
|
||||||
updatedAt: string;
|
|
||||||
deleted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrainingPlanSummary {
|
export type PlanGoal =
|
||||||
id: string;
|
| 'postpartum_recovery' // 产后恢复
|
||||||
createdAt: string;
|
| 'fat_loss' // 减脂塑形
|
||||||
startDate: string;
|
| 'posture_correction' // 体态矫正
|
||||||
goal: string;
|
| 'core_strength' // 核心力量
|
||||||
mode: 'daysOfWeek' | 'sessionsPerWeek';
|
| 'flexibility' // 柔韧灵活
|
||||||
daysOfWeek: number[];
|
| 'rehab' // 康复保健
|
||||||
sessionsPerWeek: number;
|
| 'stress_relief'; // 释压放松
|
||||||
preferredTimeOfDay: 'morning' | 'noon' | 'evening' | '';
|
|
||||||
startWeightKg: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export type TrainingPlan = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string; // ISO
|
||||||
|
startDate: string; // ISO (当天或下周一)
|
||||||
|
mode: PlanMode;
|
||||||
|
daysOfWeek: number[]; // 0(日) - 6(六)
|
||||||
|
sessionsPerWeek: number; // 1..7
|
||||||
|
goal: PlanGoal | '';
|
||||||
|
startWeightKg?: number;
|
||||||
|
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
|
||||||
|
name?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
export interface TrainingPlanListResponse {
|
export interface TrainingPlanListResponse {
|
||||||
list: TrainingPlanSummary[];
|
list: TrainingPlan[];
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TrainingPlanApi {
|
class TrainingPlanApi {
|
||||||
async create(dto: CreateTrainingPlanDto): Promise<TrainingPlanResponse> {
|
async create(dto: CreateTrainingPlanDto): Promise<TrainingPlan> {
|
||||||
return api.post<TrainingPlanResponse>('/training-plans', dto);
|
return api.post<TrainingPlan>('/training-plans', dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(page: number = 1, limit: number = 10): Promise<TrainingPlanListResponse> {
|
async list(page: number = 1, limit: number = 10): Promise<TrainingPlanListResponse> {
|
||||||
return api.get<TrainingPlanListResponse>(`/training-plans?page=${page}&limit=${limit}`);
|
return api.get<TrainingPlanListResponse>(`/training-plans?page=${page}&limit=${limit}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async detail(id: string): Promise<TrainingPlanResponse> {
|
async detail(id: string): Promise<TrainingPlan> {
|
||||||
return api.get<TrainingPlanResponse>(`/training-plans/${id}`);
|
return api.get<TrainingPlan>(`/training-plans/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: CreateTrainingPlanDto): Promise<TrainingPlanResponse> {
|
async update(id: string, dto: CreateTrainingPlanDto): Promise<TrainingPlan> {
|
||||||
return api.post<TrainingPlanResponse>(`/training-plans/${id}/update`, dto);
|
return api.post<TrainingPlan>(`/training-plans/${id}/update`, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<{ success: boolean }> {
|
async delete(id: string): Promise<{ success: boolean }> {
|
||||||
@@ -71,9 +67,9 @@ class TrainingPlanApi {
|
|||||||
return api.post<{ success: boolean }>(`/training-plans/${id}/activate`);
|
return api.post<{ success: boolean }>(`/training-plans/${id}/activate`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActivePlan(): Promise<TrainingPlanResponse | null> {
|
async getActivePlan(): Promise<TrainingPlan | null> {
|
||||||
try {
|
try {
|
||||||
return api.get<TrainingPlanResponse>('/training-plans/active');
|
return api.get<TrainingPlan>('/training-plans/active');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 如果没有激活的计划,返回null
|
// 如果没有激活的计划,返回null
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -107,6 +107,12 @@ export interface UpdateWorkoutExerciseDto {
|
|||||||
itemType?: 'exercise' | 'rest' | 'note';
|
itemType?: 'exercise' | 'rest' | 'note';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateWorkoutSessionDto {
|
||||||
|
name: string;
|
||||||
|
trainingPlanId?: string;
|
||||||
|
scheduledDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkoutSessionListResponse {
|
export interface WorkoutSessionListResponse {
|
||||||
sessions: WorkoutSession[];
|
sessions: WorkoutSession[];
|
||||||
pagination: {
|
pagination: {
|
||||||
@@ -142,6 +148,10 @@ class WorkoutsApi {
|
|||||||
return api.get<WorkoutSessionListResponse>(`/workouts/sessions?page=${page}&limit=${limit}`);
|
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> {
|
async getSessionDetail(sessionId: string): Promise<WorkoutSession> {
|
||||||
return api.get<WorkoutSession>(`/workouts/sessions/${sessionId}`);
|
return api.get<WorkoutSession>(`/workouts/sessions/${sessionId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,17 @@
|
|||||||
import { CreateTrainingPlanDto, trainingPlanApi, TrainingPlanSummary } from '@/services/trainingPlanApi';
|
import { CreateTrainingPlanDto, PlanGoal, PlanMode, TrainingPlan, trainingPlanApi } from '@/services/trainingPlanApi';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
export type PlanMode = 'daysOfWeek' | 'sessionsPerWeek';
|
|
||||||
|
|
||||||
export type PlanGoal =
|
|
||||||
| 'postpartum_recovery' // 产后恢复
|
|
||||||
| 'fat_loss' // 减脂塑形
|
|
||||||
| 'posture_correction' // 体态矫正
|
|
||||||
| 'core_strength' // 核心力量
|
|
||||||
| 'flexibility' // 柔韧灵活
|
|
||||||
| 'rehab' // 康复保健
|
|
||||||
| 'stress_relief'; // 释压放松
|
|
||||||
|
|
||||||
export type TrainingPlan = {
|
|
||||||
id: string;
|
|
||||||
createdAt: string; // ISO
|
|
||||||
startDate: string; // ISO (当天或下周一)
|
|
||||||
mode: PlanMode;
|
|
||||||
daysOfWeek: number[]; // 0(日) - 6(六)
|
|
||||||
sessionsPerWeek: number; // 1..7
|
|
||||||
goal: PlanGoal | '';
|
|
||||||
startWeightKg?: number;
|
|
||||||
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TrainingPlanState = {
|
export type TrainingPlanState = {
|
||||||
plans: TrainingPlan[];
|
plans: TrainingPlan[];
|
||||||
currentId?: string | null;
|
|
||||||
editingId?: string | null;
|
editingId?: string | null;
|
||||||
draft: Omit<TrainingPlan, 'id' | 'createdAt'>;
|
draft: Omit<TrainingPlan, 'id' | 'createdAt'>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY_LEGACY_SINGLE = '@training_plan';
|
|
||||||
const STORAGE_KEY_LIST = '@training_plans';
|
const STORAGE_KEY_LIST = '@training_plans';
|
||||||
|
|
||||||
function nextMondayISO(): string {
|
function nextMondayISO(): string {
|
||||||
@@ -50,7 +26,6 @@ function nextMondayISO(): string {
|
|||||||
|
|
||||||
const initialState: TrainingPlanState = {
|
const initialState: TrainingPlanState = {
|
||||||
plans: [],
|
plans: [],
|
||||||
currentId: null,
|
|
||||||
editingId: null,
|
editingId: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -73,13 +48,9 @@ export const loadPlans = createAsyncThunk('trainingPlan/loadPlans', async (_, {
|
|||||||
try {
|
try {
|
||||||
// 尝试从服务器获取数据
|
// 尝试从服务器获取数据
|
||||||
const response = await trainingPlanApi.list(1, 100); // 获取所有计划
|
const response = await trainingPlanApi.list(1, 100); // 获取所有计划
|
||||||
console.log('response', response);
|
const plans = response.list;
|
||||||
const plans: TrainingPlanSummary[] = response.list;
|
|
||||||
|
|
||||||
// 读取最后一次使用的 currentId(从本地存储)
|
return { plans };
|
||||||
const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null;
|
|
||||||
|
|
||||||
return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null };
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// 如果API调用失败,回退到本地存储
|
// 如果API调用失败,回退到本地存储
|
||||||
console.warn('API调用失败,使用本地存储:', error.message);
|
console.warn('API调用失败,使用本地存储:', error.message);
|
||||||
@@ -89,27 +60,13 @@ export const loadPlans = createAsyncThunk('trainingPlan/loadPlans', async (_, {
|
|||||||
if (listStr) {
|
if (listStr) {
|
||||||
try {
|
try {
|
||||||
const plans = JSON.parse(listStr) as TrainingPlan[];
|
const plans = JSON.parse(listStr) as TrainingPlan[];
|
||||||
const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null;
|
return { plans } as { plans: TrainingPlan[] };
|
||||||
return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null };
|
|
||||||
} catch {
|
} catch {
|
||||||
// 解析失败则视为无数据
|
// 解析失败则视为无数据
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 旧版:单计划
|
return { plans: [] } as { plans: TrainingPlan[] };
|
||||||
const legacyStr = await AsyncStorage.getItem(STORAGE_KEY_LEGACY_SINGLE);
|
|
||||||
if (legacyStr) {
|
|
||||||
try {
|
|
||||||
const legacy = JSON.parse(legacyStr) as TrainingPlan;
|
|
||||||
const plans = [legacy];
|
|
||||||
const currentId = legacy.id;
|
|
||||||
return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null };
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { plans: [], currentId: null } as { plans: TrainingPlan[]; currentId: string | null };
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,28 +91,9 @@ export const saveDraftAsPlan = createAsyncThunk(
|
|||||||
preferredTimeOfDay: draft.preferredTimeOfDay,
|
preferredTimeOfDay: draft.preferredTimeOfDay,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await trainingPlanApi.create(createDto);
|
const newPlan = await trainingPlanApi.create(createDto);
|
||||||
|
|
||||||
const plan: TrainingPlan = {
|
return newPlan;
|
||||||
id: response.id,
|
|
||||||
createdAt: response.createdAt,
|
|
||||||
startDate: response.startDate,
|
|
||||||
mode: response.mode,
|
|
||||||
daysOfWeek: response.daysOfWeek,
|
|
||||||
sessionsPerWeek: response.sessionsPerWeek,
|
|
||||||
goal: response.goal as PlanGoal,
|
|
||||||
startWeightKg: response.startWeightKg || undefined,
|
|
||||||
preferredTimeOfDay: response.preferredTimeOfDay,
|
|
||||||
name: response.name,
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextPlans = [...(s.plans || []), plan];
|
|
||||||
|
|
||||||
// 同时保存到本地存储作为缓存
|
|
||||||
await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans));
|
|
||||||
await AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, plan.id);
|
|
||||||
|
|
||||||
return { plans: nextPlans, currentId: plan.id } as { plans: TrainingPlan[]; currentId: string };
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return rejectWithValue(error.message || '创建训练计划失败');
|
return rejectWithValue(error.message || '创建训练计划失败');
|
||||||
}
|
}
|
||||||
@@ -257,16 +195,11 @@ export const deletePlan = createAsyncThunk(
|
|||||||
|
|
||||||
// 更新本地状态
|
// 更新本地状态
|
||||||
const nextPlans = (s.plans || []).filter((p) => p.id !== planId);
|
const nextPlans = (s.plans || []).filter((p) => p.id !== planId);
|
||||||
let nextCurrentId = s.currentId || null;
|
|
||||||
if (nextCurrentId === planId) {
|
|
||||||
nextCurrentId = nextPlans.length > 0 ? nextPlans[nextPlans.length - 1].id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同时更新本地存储
|
// 同时更新本地存储
|
||||||
await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans));
|
await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans));
|
||||||
await AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, nextCurrentId ?? '');
|
|
||||||
|
|
||||||
return { plans: nextPlans, currentId: nextCurrentId } as { plans: TrainingPlan[]; currentId: string | null };
|
return { plans: nextPlans } as { plans: TrainingPlan[] };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return rejectWithValue(error.message || '删除训练计划失败');
|
return rejectWithValue(error.message || '删除训练计划失败');
|
||||||
}
|
}
|
||||||
@@ -277,11 +210,6 @@ const trainingPlanSlice = createSlice({
|
|||||||
name: 'trainingPlan',
|
name: 'trainingPlan',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setCurrentPlan(state, action: PayloadAction<string | null | undefined>) {
|
|
||||||
state.currentId = action.payload ?? null;
|
|
||||||
// 保存到本地存储
|
|
||||||
AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, action.payload ?? '');
|
|
||||||
},
|
|
||||||
setMode(state, action: PayloadAction<PlanMode>) {
|
setMode(state, action: PayloadAction<PlanMode>) {
|
||||||
state.draft.mode = action.payload;
|
state.draft.mode = action.payload;
|
||||||
},
|
},
|
||||||
@@ -333,13 +261,6 @@ const trainingPlanSlice = createSlice({
|
|||||||
.addCase(loadPlans.fulfilled, (state, action) => {
|
.addCase(loadPlans.fulfilled, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.plans = action.payload.plans;
|
state.plans = action.payload.plans;
|
||||||
state.currentId = action.payload.currentId;
|
|
||||||
// 若存在历史计划,初始化 draft 基于该计划(便于编辑)
|
|
||||||
const current = state.plans.find((p) => p.id === state.currentId) || state.plans[state.plans.length - 1];
|
|
||||||
if (current) {
|
|
||||||
const { id, createdAt, ...rest } = current;
|
|
||||||
state.draft = { ...rest };
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.addCase(loadPlans.rejected, (state, action) => {
|
.addCase(loadPlans.rejected, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
@@ -352,8 +273,6 @@ const trainingPlanSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(saveDraftAsPlan.fulfilled, (state, action) => {
|
.addCase(saveDraftAsPlan.fulfilled, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.plans = action.payload.plans;
|
|
||||||
state.currentId = action.payload.currentId;
|
|
||||||
})
|
})
|
||||||
.addCase(saveDraftAsPlan.rejected, (state, action) => {
|
.addCase(saveDraftAsPlan.rejected, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
@@ -393,9 +312,6 @@ const trainingPlanSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(activatePlan.fulfilled, (state, action) => {
|
.addCase(activatePlan.fulfilled, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.currentId = action.payload.id;
|
|
||||||
// 保存到本地存储
|
|
||||||
AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, action.payload.id);
|
|
||||||
})
|
})
|
||||||
.addCase(activatePlan.rejected, (state, action) => {
|
.addCase(activatePlan.rejected, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
@@ -409,7 +325,6 @@ const trainingPlanSlice = createSlice({
|
|||||||
.addCase(deletePlan.fulfilled, (state, action) => {
|
.addCase(deletePlan.fulfilled, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.plans = action.payload.plans;
|
state.plans = action.payload.plans;
|
||||||
state.currentId = action.payload.currentId;
|
|
||||||
})
|
})
|
||||||
.addCase(deletePlan.rejected, (state, action) => {
|
.addCase(deletePlan.rejected, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
@@ -419,7 +334,6 @@ const trainingPlanSlice = createSlice({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
setCurrentPlan,
|
|
||||||
setMode,
|
setMode,
|
||||||
toggleDayOfWeek,
|
toggleDayOfWeek,
|
||||||
setSessionsPerWeek,
|
setSessionsPerWeek,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
workoutsApi,
|
workoutsApi,
|
||||||
type AddWorkoutExerciseDto,
|
type AddWorkoutExerciseDto,
|
||||||
type CompleteWorkoutExerciseDto,
|
type CompleteWorkoutExerciseDto,
|
||||||
|
type CreateWorkoutSessionDto,
|
||||||
type StartWorkoutDto,
|
type StartWorkoutDto,
|
||||||
type StartWorkoutExerciseDto,
|
type StartWorkoutExerciseDto,
|
||||||
type UpdateWorkoutExerciseDto,
|
type UpdateWorkoutExerciseDto,
|
||||||
@@ -159,10 +160,10 @@ export const loadWorkoutStats = createAsyncThunk(
|
|||||||
// 获取训练会话列表
|
// 获取训练会话列表
|
||||||
export const loadWorkoutSessions = createAsyncThunk(
|
export const loadWorkoutSessions = createAsyncThunk(
|
||||||
'workout/loadSessions',
|
'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 {
|
try {
|
||||||
const result = await workoutsApi.getSessions(page, limit);
|
const result = await workoutsApi.getSessions(page, limit);
|
||||||
return result;
|
return { ...result, append };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return rejectWithValue(error.message || '获取训练列表失败');
|
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(
|
export const deleteWorkoutSession = createAsyncThunk(
|
||||||
'workout/deleteSession',
|
'workout/deleteSession',
|
||||||
@@ -343,7 +358,13 @@ const workoutSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(loadWorkoutSessions.fulfilled, (state, action) => {
|
.addCase(loadWorkoutSessions.fulfilled, (state, action) => {
|
||||||
state.loading = false;
|
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;
|
state.sessionsPagination = action.payload.pagination;
|
||||||
})
|
})
|
||||||
.addCase(loadWorkoutSessions.rejected, (state, action) => {
|
.addCase(loadWorkoutSessions.rejected, (state, action) => {
|
||||||
@@ -366,6 +387,24 @@ const workoutSlice = createSlice({
|
|||||||
state.error = action.payload as string;
|
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
|
// deleteWorkoutSession
|
||||||
.addCase(deleteWorkoutSession.pending, (state) => {
|
.addCase(deleteWorkoutSession.pending, (state) => {
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user