feat: 更新训练计划和打卡功能
- 在训练计划中新增训练项目的添加、更新和删除功能,支持用户灵活管理训练内容 - 优化训练计划排课界面,提升用户体验 - 更新打卡功能,支持按日期加载和展示打卡记录 - 删除不再使用的打卡相关页面,简化代码结构 - 新增今日训练页面,集成今日训练计划和动作展示 - 更新样式以适应新功能的展示和交互
This commit is contained in:
@@ -4,13 +4,14 @@ import { SearchBox } from '@/components/SearchBox';
|
|||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { listRecommendedArticles } from '@/services/articles';
|
import { listRecommendedArticles } from '@/services/articles';
|
||||||
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
|
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
|
||||||
|
import { loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice';
|
||||||
// Removed WorkoutCard import since we no longer use the horizontal carousel
|
// Removed WorkoutCard import since we no longer use the horizontal carousel
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { getChineseGreeting } from '@/utils/date';
|
import { getChineseGreeting } from '@/utils/date';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
|
import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
|
||||||
@@ -20,12 +21,17 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||||
|
|
||||||
|
// 训练计划状态
|
||||||
|
const { plans, currentId } = useAppSelector((s) => s.trainingPlan);
|
||||||
|
const [activePlan, setActivePlan] = React.useState<TrainingPlan | null>(null);
|
||||||
|
|
||||||
// Draggable coach badge state
|
// Draggable coach badge state
|
||||||
const pan = React.useRef(new Animated.ValueXY()).current;
|
const pan = React.useRef(new Animated.ValueXY()).current;
|
||||||
const [coachSize, setCoachSize] = React.useState({ width: 0, height: 0 });
|
const [coachSize, setCoachSize] = React.useState({ width: 0, height: 0 });
|
||||||
@@ -101,6 +107,17 @@ export default function HomeScreen() {
|
|||||||
// 打底数据(接口不可用时)
|
// 打底数据(接口不可用时)
|
||||||
const getFallbackItems = React.useCallback((): RecommendItem[] => {
|
const getFallbackItems = React.useCallback((): RecommendItem[] => {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
type: 'plan',
|
||||||
|
key: 'today-workout',
|
||||||
|
image:
|
||||||
|
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
||||||
|
title: '今日训练',
|
||||||
|
subtitle: '完成一次普拉提训练,记录你的坚持',
|
||||||
|
level: '初学者',
|
||||||
|
progress: 0,
|
||||||
|
onPress: () => pushIfAuthedElseLogin('/workout/today'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'plan',
|
type: 'plan',
|
||||||
key: 'assess',
|
key: 'assess',
|
||||||
@@ -122,10 +139,27 @@ export default function HomeScreen() {
|
|||||||
readCount: a.readCount,
|
readCount: a.readCount,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
}, [router]);
|
}, [router, pushIfAuthedElseLogin]);
|
||||||
|
|
||||||
const [items, setItems] = React.useState<RecommendItem[]>(() => getFallbackItems());
|
const [items, setItems] = React.useState<RecommendItem[]>(() => getFallbackItems());
|
||||||
|
|
||||||
|
// 加载训练计划数据
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
dispatch(loadPlans());
|
||||||
|
}
|
||||||
|
}, [isLoggedIn, dispatch]);
|
||||||
|
|
||||||
|
// 获取激活的训练计划
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isLoggedIn && currentId && plans.length > 0) {
|
||||||
|
const currentPlan = plans.find(p => p.id === currentId);
|
||||||
|
setActivePlan(currentPlan || null);
|
||||||
|
} else {
|
||||||
|
setActivePlan(null);
|
||||||
|
}
|
||||||
|
}, [isLoggedIn, currentId, plans]);
|
||||||
|
|
||||||
// 拉取推荐接口(已登录时)
|
// 拉取推荐接口(已登录时)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
@@ -158,10 +192,10 @@ export default function HomeScreen() {
|
|||||||
type: 'plan',
|
type: 'plan',
|
||||||
key: c.id || 'checkin',
|
key: c.id || 'checkin',
|
||||||
image: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
image: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
||||||
title: c.title || '今日打卡',
|
title: c.title || '今日训练',
|
||||||
subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持',
|
subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
onPress: () => pushIfAuthedElseLogin('/checkin?date=' + dayjs().format('YYYY-MM-DD')),
|
onPress: () => pushIfAuthedElseLogin('/workout/today'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,6 +210,14 @@ export default function HomeScreen() {
|
|||||||
return () => { canceled = true; };
|
return () => { canceled = true; };
|
||||||
}, [isLoggedIn, pushIfAuthedElseLogin, getFallbackItems]);
|
}, [isLoggedIn, pushIfAuthedElseLogin, getFallbackItems]);
|
||||||
|
|
||||||
|
// 处理点击训练计划卡片,跳转到锻炼tab
|
||||||
|
const handlePlanCardPress = () => {
|
||||||
|
if (activePlan) {
|
||||||
|
// 跳转到训练计划页面的锻炼tab,并传递planId参数
|
||||||
|
router.push(`/training-plan?planId=${activePlan.id}&tab=schedule` as any);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||||
<ThemedView style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
<ThemedView style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||||
@@ -244,36 +286,37 @@ export default function HomeScreen() {
|
|||||||
style={[styles.featureCard, styles.featureCardPrimary]}
|
style={[styles.featureCard, styles.featureCardPrimary]}
|
||||||
onPress={() => router.push('/ai-posture-assessment')}
|
onPress={() => router.push('/ai-posture-assessment')}
|
||||||
>
|
>
|
||||||
|
<View style={styles.featureIconWrapper}>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/images/demo/imageBody.jpeg')}
|
||||||
|
style={styles.featureIconImage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
<ThemedText style={styles.featureTitle}>AI体态评估</ThemedText>
|
<ThemedText style={styles.featureTitle}>AI体态评估</ThemedText>
|
||||||
<ThemedText style={styles.featureSubtitle}>3分钟获取体态报告</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
style={[styles.featureCard, styles.featureCardSecondary]}
|
|
||||||
onPress={() => router.push('/ai-coach-chat?name=Sarah' as any)}
|
|
||||||
>
|
|
||||||
<ThemedText style={styles.featureTitle}>在线教练</ThemedText>
|
|
||||||
<ThemedText style={styles.featureSubtitle}>认证教练 · 1对1即时解答</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
style={[styles.featureCard, styles.featureCardTertiary]}
|
|
||||||
onPress={() => pushIfAuthedElseLogin('/checkin?date=' + dayjs().format('YYYY-MM-DD'))}
|
|
||||||
>
|
|
||||||
<ThemedText style={styles.featureTitle}>每日打卡</ThemedText>
|
|
||||||
<ThemedText style={styles.featureSubtitle}>自选动作 · 记录完成</ThemedText>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.featureCard, styles.featureCardQuaternary]}
|
style={[styles.featureCard, styles.featureCardQuaternary]}
|
||||||
onPress={() => pushIfAuthedElseLogin('/training-plan')}
|
onPress={() => pushIfAuthedElseLogin('/training-plan')}
|
||||||
>
|
>
|
||||||
<ThemedText style={styles.featureTitle}>训练计划制定</ThemedText>
|
<View style={styles.featureIconWrapper}>
|
||||||
<ThemedText style={styles.featureSubtitle}>按周安排 · 个性化目标</ThemedText>
|
<View style={styles.featureIconPlaceholder}>
|
||||||
|
<ThemedText style={styles.featureIconText}>💪</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ThemedText style={styles.featureTitle}>计划管理</ThemedText>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* My Plan Section - 显示激活的训练计划 */}
|
||||||
|
{/* {activePlan && (
|
||||||
|
<MyPlanCard
|
||||||
|
plan={activePlan}
|
||||||
|
onPress={handlePlanCardPress}
|
||||||
|
/>
|
||||||
|
)} */}
|
||||||
|
|
||||||
{/* Today Plan Section */}
|
{/* Today Plan Section */}
|
||||||
<View style={styles.sectionContainer}>
|
<View style={styles.sectionContainer}>
|
||||||
<ThemedText style={styles.sectionTitle}>为你推荐</ThemedText>
|
<ThemedText style={styles.sectionTitle}>为你推荐</ThemedText>
|
||||||
@@ -408,48 +451,89 @@ const styles = StyleSheet.create({
|
|||||||
featureGrid: {
|
featureGrid: {
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
gap: 12,
|
||||||
flexWrap: 'wrap',
|
|
||||||
},
|
},
|
||||||
featureCard: {
|
featureCard: {
|
||||||
width: '48%',
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: 12,
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
marginBottom: 12,
|
// 精致的阴影效果
|
||||||
// 轻量阴影,减少臃肿感
|
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOpacity: 0.04,
|
shadowOpacity: 0.06,
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
elevation: 2,
|
elevation: 3,
|
||||||
|
// 渐变边框效果
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
// 添加微妙的内阴影效果
|
||||||
|
position: 'relative',
|
||||||
|
minHeight: 48,
|
||||||
},
|
},
|
||||||
featureCardPrimary: {
|
featureCardPrimary: {
|
||||||
backgroundColor: '#EEF2FF', // 柔和的靛蓝背景
|
// 由于RN不支持CSS渐变,使用渐变色背景
|
||||||
|
backgroundColor: '#667eea',
|
||||||
},
|
},
|
||||||
featureCardSecondary: {
|
featureCardSecondary: {
|
||||||
backgroundColor: '#F0FDFA', // 柔和的青绿背景
|
backgroundColor: '#4facfe',
|
||||||
},
|
},
|
||||||
featureCardTertiary: {
|
featureCardTertiary: {
|
||||||
backgroundColor: '#FFF7ED', // 柔和的橙色背景
|
backgroundColor: '#43e97b',
|
||||||
},
|
},
|
||||||
featureCardQuaternary: {
|
featureCardQuaternary: {
|
||||||
backgroundColor: '#F5F3FF', // 柔和的紫色背景
|
backgroundColor: '#fa709a',
|
||||||
},
|
},
|
||||||
featureIcon: {
|
featureIconWrapper: {
|
||||||
fontSize: 28,
|
width: 32,
|
||||||
marginBottom: 8,
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 10,
|
||||||
|
// 图标容器的阴影
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
elevation: 2,
|
||||||
},
|
},
|
||||||
|
featureIconImage: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
resizeMode: 'cover',
|
||||||
|
},
|
||||||
|
featureIconPlaceholder: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
featureIconText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
|
||||||
featureTitle: {
|
featureTitle: {
|
||||||
fontSize: 16,
|
fontSize: 14,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#0F172A',
|
color: '#FFFFFF',
|
||||||
marginBottom: 4,
|
textAlign: 'left',
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
featureSubtitle: {
|
featureSubtitle: {
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
color: '#6B7280',
|
color: 'rgba(255, 255, 255, 0.85)',
|
||||||
lineHeight: 15,
|
lineHeight: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
planList: {
|
planList: {
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
|
|||||||
@@ -557,7 +557,7 @@ export default function AICoachChatScreen() {
|
|||||||
style={styles.weightInput}
|
style={styles.weightInput}
|
||||||
onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text)}
|
onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text)}
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
blurOnSubmit
|
submitBehavior="blurAndSubmit"
|
||||||
/>
|
/>
|
||||||
<Text style={styles.weightUnit}>kg</Text>
|
<Text style={styles.weightUnit}>kg</Text>
|
||||||
<TouchableOpacity accessibilityRole="button" style={styles.weightSaveBtn} onPress={() => handleSubmitWeight((preset || '').toString())}>
|
<TouchableOpacity accessibilityRole="button" style={styles.weightSaveBtn} onPress={() => handleSubmitWeight((preset || '').toString())}>
|
||||||
@@ -603,7 +603,7 @@ export default function AICoachChatScreen() {
|
|||||||
// 不阻断对话体验
|
// 不阻断对话体验
|
||||||
}
|
}
|
||||||
// 在对话中插入“确认消息”并发送给教练
|
// 在对话中插入“确认消息”并发送给教练
|
||||||
const textMsg = `我记录了今日体重:${val} kg。请基于这一变化给出训练/营养建议。`;
|
const textMsg = `记录了今日体重:${val} kg。`;
|
||||||
await send(textMsg);
|
await send(textMsg);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('保存失败', e?.message || '请稍后重试');
|
Alert.alert('保存失败', e?.message || '请稍后重试');
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
|
||||||
import { Colors } from '@/constants/Colors';
|
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
|
||||||
import { DailyStatusItem, fetchDailyStatusRange } from '@/services/checkins';
|
|
||||||
import { loadMonthCheckins } from '@/store/checkinSlice';
|
|
||||||
import { getMonthDaysZh } from '@/utils/date';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { Dimensions, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
function formatDate(d: Date) {
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
|
||||||
const day = `${d.getDate()}`.padStart(2, '0');
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CheckinCalendarScreen() {
|
|
||||||
const router = useRouter();
|
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
|
||||||
const colorTokens = Colors[theme];
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const checkin = useAppSelector((s) => (s as any).checkin);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const [cursor, setCursor] = useState(dayjs());
|
|
||||||
const days = useMemo(() => getMonthDaysZh(cursor), [cursor]);
|
|
||||||
const monthTitle = useMemo(() => `${cursor.format('YYYY年M月')} 打卡`, [cursor]);
|
|
||||||
const [statusMap, setStatusMap] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(loadMonthCheckins({ year: cursor.year(), month1Based: cursor.month() + 1 }));
|
|
||||||
const y = cursor.year();
|
|
||||||
const m = cursor.month() + 1;
|
|
||||||
const pad = (n: number) => `${n}`.padStart(2, '0');
|
|
||||||
const startDate = `${y}-${pad(m)}-01`;
|
|
||||||
const endDate = `${y}-${pad(m)}-${pad(new Date(y, m, 0).getDate())}`;
|
|
||||||
fetchDailyStatusRange(startDate, endDate)
|
|
||||||
.then((list: DailyStatusItem[]) => {
|
|
||||||
const next: Record<string, boolean> = {};
|
|
||||||
for (const it of list) {
|
|
||||||
if (typeof it?.date === 'string') next[it.date] = !!it?.checkedIn;
|
|
||||||
}
|
|
||||||
setStatusMap(next);
|
|
||||||
})
|
|
||||||
.catch(() => setStatusMap({}));
|
|
||||||
}, [cursor, dispatch]);
|
|
||||||
|
|
||||||
const goPrevMonth = () => setCursor((c) => c.subtract(1, 'month'));
|
|
||||||
const goNextMonth = () => setCursor((c) => c.add(1, 'month'));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
|
||||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
|
||||||
<HeaderBar title="打卡日历" onBack={() => router.back()} withSafeTop={false} transparent />
|
|
||||||
<View style={styles.headerRow}>
|
|
||||||
<TouchableOpacity style={[styles.monthBtn, { backgroundColor: colorTokens.card }]} onPress={goPrevMonth}><Text style={[styles.monthBtnText, { color: colorTokens.text }]}>上一月</Text></TouchableOpacity>
|
|
||||||
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>{monthTitle}</Text>
|
|
||||||
<TouchableOpacity style={[styles.monthBtn, { backgroundColor: colorTokens.card }]} onPress={goNextMonth}><Text style={[styles.monthBtnText, { color: colorTokens.text }]}>下一月</Text></TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FlatList
|
|
||||||
data={days}
|
|
||||||
keyExtractor={(item) => item.date.format('YYYY-MM-DD')}
|
|
||||||
numColumns={5}
|
|
||||||
columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 12 }}
|
|
||||||
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 10, paddingBottom: insets.bottom + 20 }}
|
|
||||||
renderItem={({ item }) => {
|
|
||||||
const d = item.date.toDate();
|
|
||||||
const dateStr = formatDate(d);
|
|
||||||
const hasAny = statusMap[dateStr] ?? !!(checkin?.byDate?.[dateStr]?.items?.length);
|
|
||||||
const isToday = formatDate(new Date()) === dateStr;
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={async () => {
|
|
||||||
// 通过路由参数传入日期,便于目标页初始化
|
|
||||||
router.push({ pathname: '/checkin', params: { date: dateStr } });
|
|
||||||
}}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
style={[styles.dayCell, { backgroundColor: colorTokens.card }, hasAny && styles.dayCellCompleted, isToday && styles.dayCellToday]}
|
|
||||||
>
|
|
||||||
<Text style={[styles.dayNumber, { color: colorTokens.text }]}>{item.dayOfMonth}</Text>
|
|
||||||
{hasAny && <View style={styles.dot} />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
|
||||||
const cellSize = (width - 40 - 4 * 12) / 5; // 20 padding *2, 12 spacing *4
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
|
||||||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
|
||||||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 20, paddingTop: 6 },
|
|
||||||
monthTitle: { fontSize: 18, fontWeight: '800' },
|
|
||||||
monthBtn: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999 },
|
|
||||||
monthBtnText: { fontWeight: '700' },
|
|
||||||
dayCell: {
|
|
||||||
width: cellSize,
|
|
||||||
height: cellSize,
|
|
||||||
borderRadius: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
dayCellCompleted: { backgroundColor: '#ECFDF5', borderWidth: 1, borderColor: '#A7F3D0' },
|
|
||||||
dayCellToday: { borderWidth: 1, borderColor: '#BBF246' },
|
|
||||||
dayNumber: { fontWeight: '800', fontSize: 16 },
|
|
||||||
dot: { position: 'absolute', top: 6, right: 6, width: 8, height: 8, borderRadius: 4, backgroundColor: '#10B981' },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
|
||||||
import { Colors } from '@/constants/Colors';
|
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
|
||||||
import type { CheckinExercise } from '@/store/checkinSlice';
|
|
||||||
import { getDailyCheckins, removeExercise, replaceExercises, setCurrentDate, toggleExerciseCompleted } from '@/store/checkinSlice';
|
|
||||||
import { loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice';
|
|
||||||
import { buildClassicalSession } from '@/utils/classicalSession';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
|
||||||
|
|
||||||
function formatDate(d: Date) {
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
|
||||||
const day = `${d.getDate()}`.padStart(2, '0');
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CheckinHome() {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useLocalSearchParams<{ date?: string }>();
|
|
||||||
const today = useMemo(() => formatDate(new Date()), []);
|
|
||||||
const checkin = useAppSelector((s) => (s as any).checkin);
|
|
||||||
const training = useAppSelector((s) => (s as any).trainingPlan);
|
|
||||||
const routeDateParam = typeof params?.date === 'string' && params.date ? params.date : undefined;
|
|
||||||
const currentDate: string = routeDateParam || (checkin?.currentDate as string) || today;
|
|
||||||
const record = checkin?.byDate?.[currentDate] as (undefined | { items?: CheckinExercise[]; note?: string; raw?: any[] });
|
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
|
||||||
const colorTokens = Colors[theme];
|
|
||||||
|
|
||||||
console.log('CheckinHome render', {
|
|
||||||
currentDate,
|
|
||||||
routeDateParam,
|
|
||||||
itemsCount: record?.items?.length || 0,
|
|
||||||
rawCount: (record as any)?.raw?.length || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const lastFetchedRef = useRef<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
// 初始化当前日期:路由参数优先,其次 store,最后今天
|
|
||||||
if (currentDate && checkin?.currentDate !== currentDate) {
|
|
||||||
dispatch(setCurrentDate(currentDate));
|
|
||||||
}
|
|
||||||
// 仅当切换日期时获取一次,避免重复请求
|
|
||||||
if (currentDate && lastFetchedRef.current !== currentDate) {
|
|
||||||
lastFetchedRef.current = currentDate;
|
|
||||||
dispatch(getDailyCheckins(currentDate)).unwrap().catch((err: any) => {
|
|
||||||
Alert.alert('获取打卡失败', err?.message || '请稍后重试');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [dispatch, currentDate]);
|
|
||||||
|
|
||||||
// 加载训练计划列表:仅在页面挂载时尝试一次,避免因失败导致的重复请求
|
|
||||||
const hasLoadedPlansRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasLoadedPlansRef.current) return;
|
|
||||||
hasLoadedPlansRef.current = true;
|
|
||||||
dispatch(loadPlans());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
// 同步触发逻辑改为显式操作处调用,避免页面渲染期间的副作用
|
|
||||||
|
|
||||||
const [genVisible, setGenVisible] = useState(false);
|
|
||||||
const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner');
|
|
||||||
const [genWithRests, setGenWithRests] = useState(true);
|
|
||||||
const [genWithNotes, setGenWithNotes] = useState(true);
|
|
||||||
const [genRest, setGenRest] = useState('30');
|
|
||||||
|
|
||||||
// 计算“进行中的训练计划”(startDate <= 当前日期)。若 currentId 存在,优先该计划。
|
|
||||||
const activePlan: TrainingPlan | null = useMemo(() => {
|
|
||||||
const plans: TrainingPlan[] = training?.plans || [];
|
|
||||||
if (!plans.length) return null;
|
|
||||||
const current = training?.currentId ? plans.find((p) => p.id === training.currentId) : null;
|
|
||||||
const dateObj = new Date(`${currentDate}T00:00:00`);
|
|
||||||
if (current && new Date(current.startDate) <= dateObj) return current;
|
|
||||||
const ongoing = plans
|
|
||||||
.filter((p) => new Date(p.startDate) <= dateObj)
|
|
||||||
.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
|
||||||
return ongoing[0] ?? null;
|
|
||||||
}, [training?.plans, training?.currentId, currentDate]);
|
|
||||||
|
|
||||||
const planStartText = useMemo(() => {
|
|
||||||
if (!activePlan?.startDate) return '';
|
|
||||||
const d = new Date(activePlan.startDate);
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
|
||||||
const day = `${d.getDate()}`.padStart(2, '0');
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}, [activePlan?.startDate]);
|
|
||||||
|
|
||||||
const onGenerate = () => {
|
|
||||||
const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10)));
|
|
||||||
const { items, note } = buildClassicalSession({ withSectionRests: genWithRests, restSeconds: restSec, withNotes: genWithNotes, level: genLevel });
|
|
||||||
dispatch(replaceExercises({ date: currentDate, items, note }));
|
|
||||||
// 自动同步将由中间件处理
|
|
||||||
setGenVisible(false);
|
|
||||||
Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
|
||||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
|
||||||
<View pointerEvents="none" style={styles.bgOrnaments}>
|
|
||||||
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentPrimary, top: -60, right: -60 }]} />
|
|
||||||
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentAccent, bottom: -70, left: -70 }]} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<HeaderBar title="每日打卡" onBack={() => router.back()} withSafeTop={false} transparent />
|
|
||||||
<View style={[styles.hero, { backgroundColor: colorTokens.heroSurfaceTint }]}>
|
|
||||||
<Text style={[styles.title, { color: colorTokens.text }]}>{currentDate}</Text>
|
|
||||||
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}>请选择动作并记录完成情况</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 训练计划提示(非强制) */}
|
|
||||||
<View style={{ paddingHorizontal: 20, marginTop: 8 }}>
|
|
||||||
{activePlan ? (
|
|
||||||
<View style={[styles.planHintCard, { backgroundColor: colorTokens.card, borderColor: colorTokens.border }]}>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={[styles.planHintTitle, { color: colorTokens.text }]}>已有训练计划进行中</Text>
|
|
||||||
{!!planStartText && (
|
|
||||||
<Text style={[styles.planHintSub, { color: colorTokens.textMuted }]}>开始于 {planStartText}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.hintBtn, { borderColor: colorTokens.primary }]}
|
|
||||||
onPress={() => router.push('/training-plan' as any)}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="查看训练计划"
|
|
||||||
>
|
|
||||||
<Text style={[styles.hintBtnText, { color: colorTokens.primary }]}>查看训练计划</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={[styles.planHintCard, { backgroundColor: colorTokens.card, borderColor: colorTokens.border }]}>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={[styles.planHintTitle, { color: colorTokens.text }]}>你还没有训练计划</Text>
|
|
||||||
<Text style={[styles.planHintSub, { color: colorTokens.textMuted }]}>创建计划可明确每周节奏与目标(可跳过)</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.hintPrimaryBtn, { backgroundColor: colorTokens.primary }]}
|
|
||||||
onPress={() => router.push('/training-plan/create' as any)}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="创建训练计划"
|
|
||||||
>
|
|
||||||
<Text style={[styles.hintPrimaryBtnText, { color: colorTokens.onPrimary }]}>创建训练计划</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.actionRow}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]}
|
|
||||||
onPress={() => router.push({ pathname: '/checkin/select', params: { date: currentDate } })}
|
|
||||||
>
|
|
||||||
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}>新增动作</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={{ height: 10 }} />
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.secondaryBtn, { borderColor: colorTokens.primary }]}
|
|
||||||
onPress={() => setGenVisible(true)}
|
|
||||||
>
|
|
||||||
<Text style={[styles.secondaryBtnText, { color: colorTokens.primary }]}>一键排课(经典序列)</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FlatList
|
|
||||||
data={(record?.items && record.items.length > 0)
|
|
||||||
? record.items
|
|
||||||
: ((record as any)?.raw || [])}
|
|
||||||
keyExtractor={(item, index) => (item?.key || item?.id || `${currentDate}_${index}`)}
|
|
||||||
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 20 }}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View style={[styles.emptyBox, { backgroundColor: colorTokens.card }]}>
|
|
||||||
<Text style={[styles.emptyText, { color: colorTokens.textMuted }]}>还没有选择任何动作,点击“新增动作”开始吧。</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
renderItem={({ item }) => {
|
|
||||||
// 若为后端原始项(无 key),以标题/时间为卡片,禁用交互
|
|
||||||
const isRaw = !item?.key;
|
|
||||||
if (isRaw) {
|
|
||||||
const title = item?.title || '每日训练打卡';
|
|
||||||
const status = item?.status || '';
|
|
||||||
const startedAt = item?.startedAt ? new Date(item.startedAt).toLocaleString() : '';
|
|
||||||
return (
|
|
||||||
<View style={[styles.card, { backgroundColor: colorTokens.card }]}>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={[styles.cardTitle, { color: colorTokens.text }]}>{title}</Text>
|
|
||||||
{!!status && <Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{status}</Text>}
|
|
||||||
{!!startedAt && <Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{startedAt}</Text>}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const exercise = item as CheckinExercise;
|
|
||||||
const type = exercise.itemType ?? 'exercise';
|
|
||||||
const isRest = type === 'rest';
|
|
||||||
const isNote = type === 'note';
|
|
||||||
const cardStyle = [styles.card, { backgroundColor: colorTokens.card }];
|
|
||||||
if (isRest || isNote) {
|
|
||||||
return (
|
|
||||||
<View style={styles.inlineRow}>
|
|
||||||
<Ionicons name={isRest ? 'time-outline' : 'information-circle-outline'} size={14} color={colorTokens.textMuted} />
|
|
||||||
<View style={[styles.inlineBadge, isRest ? styles.inlineBadgeRest : styles.inlineBadgeNote, { borderColor: colorTokens.border }]}>
|
|
||||||
<Text style={[isNote ? styles.inlineTextItalic : styles.inlineText, { color: colorTokens.textMuted }]}>
|
|
||||||
{isRest ? `间隔休息 ${exercise.restSec ?? 30}s` : (exercise.note || '提示')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.inlineRemoveBtn}
|
|
||||||
onPress={() =>
|
|
||||||
Alert.alert('确认移除', '确定要移除该条目吗?', [
|
|
||||||
{ text: '取消', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: '移除',
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: () => {
|
|
||||||
dispatch(removeExercise({ date: currentDate, key: exercise.key }));
|
|
||||||
// 自动同步将由中间件处理
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
|
||||||
>
|
|
||||||
<Ionicons name="close-outline" size={16} color={colorTokens.textMuted} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View style={cardStyle as any}>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={[styles.cardTitle, { color: colorTokens.text }]}>{exercise.name}</Text>
|
|
||||||
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{exercise.category}</Text>
|
|
||||||
{isNote && (
|
|
||||||
<Text style={[styles.cardMetaItalic, { color: colorTokens.textMuted }]}>{exercise.note || '提示'}</Text>
|
|
||||||
)}
|
|
||||||
{!isNote && (
|
|
||||||
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>
|
|
||||||
{isRest
|
|
||||||
? `建议休息 ${exercise.restSec ?? 30}s`
|
|
||||||
: `组数 ${exercise.sets}${exercise.reps ? ` · 每组 ${exercise.reps} 次` : ''}${exercise.durationSec ? ` · 每组 ${exercise.durationSec}s` : ''}`}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{type === 'exercise' && (
|
|
||||||
<TouchableOpacity
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={exercise.completed ? '已完成,点击取消完成' : '未完成,点击标记完成'}
|
|
||||||
style={styles.doneIconBtn}
|
|
||||||
onPress={() => {
|
|
||||||
dispatch(toggleExerciseCompleted({ date: currentDate, key: exercise.key }));
|
|
||||||
// 自动同步将由中间件处理
|
|
||||||
}}
|
|
||||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={exercise.completed ? 'checkmark-circle' : 'checkmark-circle-outline'}
|
|
||||||
size={24}
|
|
||||||
color={exercise.completed ? colorTokens.primary : colorTokens.textMuted}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.removeBtn, { backgroundColor: colorTokens.border }]}
|
|
||||||
onPress={() =>
|
|
||||||
Alert.alert('确认移除', '确定要移除该动作吗?', [
|
|
||||||
{ text: '取消', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: '移除',
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: () => {
|
|
||||||
dispatch(removeExercise({ date: currentDate, key: exercise.key }));
|
|
||||||
// 自动同步将由中间件处理
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text style={[styles.removeBtnText, { color: colorTokens.text }]}>移除</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 生成配置弹窗 */}
|
|
||||||
<Modal visible={genVisible} transparent animationType="fade" onRequestClose={() => setGenVisible(false)}>
|
|
||||||
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setGenVisible(false)}>
|
|
||||||
<TouchableOpacity activeOpacity={1} style={[styles.modalSheet, { backgroundColor: colorTokens.card }]} onPress={(e) => e.stopPropagation() as any}>
|
|
||||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>经典排课配置</Text>
|
|
||||||
<Text style={[styles.modalLabel, { color: colorTokens.textMuted }]}>强度水平</Text>
|
|
||||||
<View style={styles.segmentedRow}>
|
|
||||||
{(['beginner', 'intermediate', 'advanced'] as const).map((lv) => (
|
|
||||||
<TouchableOpacity key={lv} style={[styles.segment, genLevel === lv && { backgroundColor: colorTokens.primary }]} onPress={() => setGenLevel(lv)}>
|
|
||||||
<Text style={[styles.segmentText, genLevel === lv && { color: colorTokens.onPrimary }]}>
|
|
||||||
{lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<View style={styles.switchRow}>
|
|
||||||
<Text style={[styles.switchLabel, { color: colorTokens.text }]}>段间休息</Text>
|
|
||||||
<Switch value={genWithRests} onValueChange={setGenWithRests} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.switchRow}>
|
|
||||||
<Text style={[styles.switchLabel, { color: colorTokens.text }]}>插入操作提示</Text>
|
|
||||||
<Switch value={genWithNotes} onValueChange={setGenWithNotes} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.inputRow}>
|
|
||||||
<Text style={[styles.switchLabel, { color: colorTokens.textMuted }]}>休息秒数</Text>
|
|
||||||
<TextInput value={genRest} onChangeText={setGenRest} keyboardType="number-pad" style={[styles.input, { borderColor: colorTokens.border, color: colorTokens.text }]} />
|
|
||||||
</View>
|
|
||||||
<View style={{ height: 8 }} />
|
|
||||||
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]} onPress={onGenerate}>
|
|
||||||
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}>生成今日计划</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Modal>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
|
||||||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
|
||||||
header: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8 },
|
|
||||||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 },
|
|
||||||
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
|
||||||
hero: { backgroundColor: 'rgba(187,242,70,0.18)', borderRadius: 16, padding: 14 },
|
|
||||||
title: { fontSize: 24, fontWeight: '800', color: '#111827' },
|
|
||||||
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
|
||||||
bgOrnaments: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
|
||||||
blob: { position: 'absolute', width: 260, height: 260, borderRadius: 999 },
|
|
||||||
blobPrimary: { backgroundColor: '#00000000' },
|
|
||||||
blobPurple: { backgroundColor: '#00000000' },
|
|
||||||
actionRow: { paddingHorizontal: 20, marginTop: 8 },
|
|
||||||
primaryBtn: { backgroundColor: '#111827', paddingVertical: 10, borderRadius: 10, alignItems: 'center' },
|
|
||||||
primaryBtnText: { color: '#FFFFFF', fontWeight: '800' },
|
|
||||||
secondaryBtn: { borderWidth: 2, paddingVertical: 10, borderRadius: 10, alignItems: 'center' },
|
|
||||||
secondaryBtnText: { fontWeight: '800' },
|
|
||||||
// 训练计划提示卡片
|
|
||||||
planHintCard: { flexDirection: 'row', alignItems: 'center', gap: 10, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 12, borderWidth: 1 },
|
|
||||||
planHintTitle: { fontSize: 14, fontWeight: '800' },
|
|
||||||
planHintSub: { marginTop: 4, fontSize: 12 },
|
|
||||||
hintBtn: { paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, borderWidth: 1 },
|
|
||||||
hintBtnText: { fontWeight: '800' },
|
|
||||||
hintPrimaryBtn: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10 },
|
|
||||||
hintPrimaryBtnText: { fontWeight: '800' },
|
|
||||||
emptyBox: { marginTop: 16, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginHorizontal: 0 },
|
|
||||||
emptyText: { color: '#6B7280' },
|
|
||||||
card: { marginTop: 12, marginHorizontal: 0, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 },
|
|
||||||
cardTitle: { fontSize: 16, fontWeight: '800', color: '#111827' },
|
|
||||||
cardMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
|
||||||
cardMetaItalic: { marginTop: 4, fontSize: 12, color: '#6B7280', fontStyle: 'italic' },
|
|
||||||
removeBtn: { backgroundColor: '#F3F4F6', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8 },
|
|
||||||
removeBtnText: { color: '#111827', fontWeight: '700' },
|
|
||||||
doneIconBtn: { paddingHorizontal: 4, paddingVertical: 4, borderRadius: 16, marginRight: 8 },
|
|
||||||
inlineRow: { marginTop: 10, marginHorizontal: 20, flexDirection: 'row', alignItems: 'center' },
|
|
||||||
inlineBadge: { marginLeft: 6, borderWidth: 1, borderRadius: 999, paddingVertical: 6, paddingHorizontal: 10 },
|
|
||||||
inlineBadgeRest: { backgroundColor: '#F8FAFC' },
|
|
||||||
inlineBadgeNote: { backgroundColor: '#F9FAFB' },
|
|
||||||
inlineText: { fontSize: 12, fontWeight: '700' },
|
|
||||||
inlineTextItalic: { fontSize: 12, fontStyle: 'italic' },
|
|
||||||
inlineRemoveBtn: { marginLeft: 6, padding: 4, borderRadius: 999 },
|
|
||||||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'flex-end' },
|
|
||||||
modalSheet: { width: '100%', borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 24 },
|
|
||||||
modalTitle: { fontSize: 16, fontWeight: '800', marginBottom: 8 },
|
|
||||||
modalLabel: { fontSize: 12, marginBottom: 6 },
|
|
||||||
segmentedRow: { flexDirection: 'row', gap: 8, marginBottom: 8 },
|
|
||||||
segment: { flex: 1, borderRadius: 999, borderWidth: 1, borderColor: '#E5E7EB', paddingVertical: 8, alignItems: 'center' },
|
|
||||||
segmentText: { fontWeight: '700' },
|
|
||||||
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 8 },
|
|
||||||
switchLabel: { fontWeight: '700' },
|
|
||||||
inputRow: { marginTop: 8 },
|
|
||||||
input: { height: 40, borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 },
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
|
||||||
import { Colors } from '@/constants/Colors';
|
|
||||||
import { useAppDispatch } from '@/hooks/redux';
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
|
||||||
import { fetchExerciseConfig, normalizeToLibraryItems } from '@/services/exercises';
|
|
||||||
import { addExercise } from '@/store/checkinSlice';
|
|
||||||
import { EXERCISE_LIBRARY, getCategories } from '@/utils/exerciseLibrary';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import * as Haptics from 'expo-haptics';
|
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native';
|
|
||||||
|
|
||||||
function formatDate(d: Date) {
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
|
||||||
const day = `${d.getDate()}`.padStart(2, '0');
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SelectExerciseScreen() {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useLocalSearchParams<{ date?: string }>();
|
|
||||||
const today = useMemo(() => formatDate(new Date()), []);
|
|
||||||
const currentDate = (typeof params?.date === 'string' && params.date) ? params.date : today;
|
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
|
||||||
const colorTokens = Colors[theme];
|
|
||||||
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [category, setCategory] = useState<string>('全部');
|
|
||||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
|
||||||
const [sets, setSets] = useState(3);
|
|
||||||
const [reps, setReps] = useState<number | undefined>(undefined);
|
|
||||||
const [showCustomReps, setShowCustomReps] = useState(false);
|
|
||||||
const [customRepsInput, setCustomRepsInput] = useState('');
|
|
||||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
|
||||||
const [serverLibrary, setServerLibrary] = useState<{ key: string; name: string; description: string; category: string }[] | null>(null);
|
|
||||||
const [serverCategories, setServerCategories] = useState<string[] | null>(null);
|
|
||||||
|
|
||||||
const controlsOpacity = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
|
||||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
|
||||||
let aborted = false;
|
|
||||||
const CACHE_KEY = '@exercise_config_v1';
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const cached = await AsyncStorage.getItem(CACHE_KEY);
|
|
||||||
if (cached && !aborted) {
|
|
||||||
const parsed = JSON.parse(cached);
|
|
||||||
const items = normalizeToLibraryItems(parsed);
|
|
||||||
if (items.length) {
|
|
||||||
setServerLibrary(items);
|
|
||||||
const cats = Array.from(new Set(items.map((i) => i.category)));
|
|
||||||
setServerCategories(cats);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
const resp = await fetchExerciseConfig();
|
|
||||||
console.log('fetchExerciseConfig', resp);
|
|
||||||
if (aborted) return;
|
|
||||||
const items = normalizeToLibraryItems(resp);
|
|
||||||
setServerLibrary(items);
|
|
||||||
const cats = Array.from(new Set(items.map((i) => i.category)));
|
|
||||||
setServerCategories(cats);
|
|
||||||
try { await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(resp)); } catch {}
|
|
||||||
} catch (err) {}
|
|
||||||
})();
|
|
||||||
return () => { aborted = true; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
|
||||||
const base = serverCategories ?? getCategories();
|
|
||||||
return ['全部', ...base];
|
|
||||||
}, [serverCategories]);
|
|
||||||
const mainCategories = useMemo(() => {
|
|
||||||
const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑'];
|
|
||||||
const exists = (name: string) => categories.includes(name);
|
|
||||||
const picked = preferred.filter(exists);
|
|
||||||
// 兜底:若某些偏好分类不存在,补足其他分类
|
|
||||||
const rest = categories.filter((c) => !picked.includes(c));
|
|
||||||
while (picked.length < 5 && rest.length) picked.push(rest.shift() as string);
|
|
||||||
return picked;
|
|
||||||
}, [categories]);
|
|
||||||
const library = useMemo(() => serverLibrary ?? EXERCISE_LIBRARY, [serverLibrary]);
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const kw = keyword.trim().toLowerCase();
|
|
||||||
const base = kw
|
|
||||||
? library.filter((e) => e.name.toLowerCase().includes(kw) || (e.description || '').toLowerCase().includes(kw))
|
|
||||||
: library;
|
|
||||||
if (category === '全部') return base;
|
|
||||||
return base.filter((e) => e.category === category);
|
|
||||||
}, [keyword, category, library]);
|
|
||||||
|
|
||||||
const selected = useMemo(() => library.find((e) => e.key === selectedKey) || null, [selectedKey, library]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Animated.timing(controlsOpacity, {
|
|
||||||
toValue: selected ? 1 : 0,
|
|
||||||
duration: selected ? 220 : 160,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start();
|
|
||||||
}, [selected, controlsOpacity]);
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
if (!selected) return;
|
|
||||||
dispatch(addExercise({
|
|
||||||
date: currentDate,
|
|
||||||
item: {
|
|
||||||
key: selected.key,
|
|
||||||
name: selected.name,
|
|
||||||
category: selected.category,
|
|
||||||
sets: Math.max(1, sets),
|
|
||||||
reps: reps && reps > 0 ? reps : undefined,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
console.log('addExercise', currentDate, selected.key, sets, reps);
|
|
||||||
// 自动同步将由中间件处理,无需手动调用 syncCheckin
|
|
||||||
router.back();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSelectItem = (key: string) => {
|
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
|
||||||
if (selectedKey === key) {
|
|
||||||
setSelectedKey(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSets(3);
|
|
||||||
setReps(undefined);
|
|
||||||
setShowCustomReps(false);
|
|
||||||
setCustomRepsInput('');
|
|
||||||
setSelectedKey(key);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
|
||||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
|
||||||
<View pointerEvents="none" style={styles.bgOrnaments}>
|
|
||||||
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentPrimary, top: -60, right: -60 }]} />
|
|
||||||
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentAccent, bottom: -70, left: -70 }]} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<HeaderBar title="选择动作" onBack={() => router.back()} withSafeTop={false} transparent />
|
|
||||||
<View style={[styles.hero, { backgroundColor: colorTokens.heroSurfaceTint }]}>
|
|
||||||
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}>从动作库里选择一个动作,设置组数与每组次数</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 大分类宫格(无横向滚动) */}
|
|
||||||
<View style={styles.catGrid}>
|
|
||||||
{[...mainCategories, '更多'].map((item) => {
|
|
||||||
const active = category === item;
|
|
||||||
const meta: Record<string, { bg: string }> = {
|
|
||||||
全部: { bg: 'rgba(187,242,70,0.22)' },
|
|
||||||
核心与腹部: { bg: 'rgba(187,242,70,0.18)' },
|
|
||||||
脊柱与后链: { bg: 'rgba(149,204,227,0.20)' },
|
|
||||||
侧链与髋: { bg: 'rgba(164,138,237,0.20)' },
|
|
||||||
平衡与支撑: { bg: 'rgba(252,196,111,0.22)' },
|
|
||||||
进阶控制: { bg: 'rgba(237,71,71,0.18)' },
|
|
||||||
柔韧与拉伸: { bg: 'rgba(149,204,227,0.18)' },
|
|
||||||
更多: { bg: 'rgba(24,24,27,0.06)' },
|
|
||||||
};
|
|
||||||
const scale = new Animated.Value(1);
|
|
||||||
const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
|
||||||
const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
|
||||||
const handlePress = () => {
|
|
||||||
onPressOut();
|
|
||||||
if (item === '更多') {
|
|
||||||
setShowCategoryPicker(true);
|
|
||||||
Haptics.selectionAsync();
|
|
||||||
} else {
|
|
||||||
setCategory(item);
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Animated.View key={item} style={[styles.catTileWrapper, { transform: [{ scale }] }]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
activeOpacity={0.9}
|
|
||||||
onPressIn={onPressIn}
|
|
||||||
onPressOut={handlePress}
|
|
||||||
style={[styles.catTile, { backgroundColor: meta[item]?.bg ?? colorTokens.surface }, active && styles.catTileActive]}
|
|
||||||
>
|
|
||||||
<Text style={[styles.catText, { color: active ? colorTokens.onPrimary : colorTokens.text }]}>{item}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 分类选择弹层(更多) */}
|
|
||||||
<Modal
|
|
||||||
visible={showCategoryPicker}
|
|
||||||
animationType="fade"
|
|
||||||
transparent
|
|
||||||
onRequestClose={() => setShowCategoryPicker(false)}
|
|
||||||
>
|
|
||||||
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setShowCategoryPicker(false)}>
|
|
||||||
<TouchableOpacity activeOpacity={1} style={[styles.modalSheet, { backgroundColor: colorTokens.card }]}
|
|
||||||
onPress={(e) => e.stopPropagation() as any}
|
|
||||||
>
|
|
||||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>选择分类</Text>
|
|
||||||
<View style={styles.catGridModal}>
|
|
||||||
{categories.filter((c) => c !== '全部').map((c) => {
|
|
||||||
const scale = new Animated.Value(1);
|
|
||||||
const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
|
||||||
const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
|
||||||
return (
|
|
||||||
<Animated.View key={c} style={[styles.catTileWrapper, { transform: [{ scale }] }]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPressIn={onPressIn}
|
|
||||||
onPressOut={() => {
|
|
||||||
onPressOut();
|
|
||||||
setCategory(c);
|
|
||||||
setShowCategoryPicker(false);
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
style={[styles.catTile, { backgroundColor: 'rgba(24,24,27,0.06)' }]}
|
|
||||||
>
|
|
||||||
<Text style={styles.catText}>{c}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<View style={styles.searchRow}>
|
|
||||||
<TextInput
|
|
||||||
value={keyword}
|
|
||||||
onChangeText={setKeyword}
|
|
||||||
placeholder="搜索动作名称/要点"
|
|
||||||
placeholderTextColor={colorTokens.textMuted}
|
|
||||||
style={[styles.searchInput, { backgroundColor: colorTokens.card, color: colorTokens.text, borderColor: colorTokens.border }]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FlatList
|
|
||||||
data={filtered}
|
|
||||||
keyExtractor={(item) => item.key}
|
|
||||||
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 40 }}
|
|
||||||
renderItem={({ item }) => {
|
|
||||||
const isSelected = item.key === selectedKey;
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.itemCard,
|
|
||||||
{ backgroundColor: colorTokens.card },
|
|
||||||
isSelected && { borderWidth: 2, borderColor: colorTokens.primary },
|
|
||||||
]}
|
|
||||||
onPress={() => onSelectItem(item.key)}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={[styles.itemTitle, { color: colorTokens.text }]}>{item.name}</Text>
|
|
||||||
<Text style={[styles.itemMeta, { color: colorTokens.textMuted }]}>{item.category}</Text>
|
|
||||||
<Text style={[styles.itemDesc, { color: colorTokens.textMuted }]}>{item.description}</Text>
|
|
||||||
</View>
|
|
||||||
{isSelected && <Ionicons name="chevron-down" size={20} color={colorTokens.text} />}
|
|
||||||
{isSelected && (
|
|
||||||
<Animated.View style={[styles.expandedBox, { opacity: controlsOpacity }]}>
|
|
||||||
<View style={styles.controlsRow}>
|
|
||||||
<View style={[styles.counterBox, { backgroundColor: colorTokens.surface }]}>
|
|
||||||
<Text style={[styles.counterLabel, { color: colorTokens.textMuted }]}>组数</Text>
|
|
||||||
<View style={styles.counterRow}>
|
|
||||||
<TouchableOpacity style={[styles.counterBtn, { backgroundColor: colorTokens.border }]} onPress={() => setSets(Math.max(1, sets - 1))}><Text style={[styles.counterBtnText, { color: colorTokens.text }]}>-</Text></TouchableOpacity>
|
|
||||||
<Text style={[styles.counterValue, { color: colorTokens.text }]}>{sets}</Text>
|
|
||||||
<TouchableOpacity style={[styles.counterBtn, { backgroundColor: colorTokens.border }]} onPress={() => setSets(Math.min(20, sets + 1))}><Text style={[styles.counterBtnText, { color: colorTokens.text }]}>+</Text></TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[styles.counterBox, { backgroundColor: colorTokens.surface }]}>
|
|
||||||
<Text style={[styles.counterLabel, { color: colorTokens.textMuted }]}>每组次数</Text>
|
|
||||||
<View style={styles.repsChipsRow}>
|
|
||||||
{[6, 8, 10, 12, 15, 20, 25, 30].map((v) => {
|
|
||||||
const active = reps === v;
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={v}
|
|
||||||
style={[styles.repChip, active && { backgroundColor: colorTokens.primary, borderColor: colorTokens.primary }]}
|
|
||||||
onPress={() => {
|
|
||||||
setReps(v);
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={[styles.repChipText, active && { color: colorTokens.onPrimary }]}>{v}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.repChipGhost, { borderColor: colorTokens.border }]}
|
|
||||||
onPress={() => {
|
|
||||||
setShowCustomReps((s) => !s);
|
|
||||||
Haptics.selectionAsync();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={[styles.repChipGhostText, { color: colorTokens.text }]}>自定义</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
{showCustomReps && (
|
|
||||||
<View style={styles.customRepsRow}>
|
|
||||||
<TextInput
|
|
||||||
keyboardType="number-pad"
|
|
||||||
value={customRepsInput}
|
|
||||||
onChangeText={setCustomRepsInput}
|
|
||||||
placeholder="输入次数 (1-100)"
|
|
||||||
placeholderTextColor={colorTokens.textMuted}
|
|
||||||
style={[styles.customRepsInput, { borderColor: colorTokens.border, color: colorTokens.text }]}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.customRepsBtn, { backgroundColor: colorTokens.primary }]}
|
|
||||||
onPress={() => {
|
|
||||||
const n = Math.max(1, Math.min(100, parseInt(customRepsInput || '0', 10)));
|
|
||||||
if (!Number.isNaN(n)) {
|
|
||||||
setReps(n);
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={[styles.customRepsBtnText, { color: colorTokens.onPrimary }]}>确定</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }, (!reps || reps <= 0) && { opacity: 0.5 }]}
|
|
||||||
disabled={!reps || reps <= 0}
|
|
||||||
onPress={handleAdd}
|
|
||||||
>
|
|
||||||
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}>添加到今日打卡</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
|
||||||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
|
||||||
header: { paddingHorizontal: 20, paddingTop: 10, paddingBottom: 10 },
|
|
||||||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 },
|
|
||||||
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
|
||||||
headerTitle: { fontSize: 18, fontWeight: '800', color: '#1A1A1A' },
|
|
||||||
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
|
||||||
catCard: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 14, flexDirection: 'row', alignItems: 'center' },
|
|
||||||
catCardActive: { borderWidth: 2, borderColor: '#BBF246' },
|
|
||||||
catEmoji: { fontSize: 16, marginRight: 6 },
|
|
||||||
catText: { fontSize: 13, fontWeight: '800' },
|
|
||||||
hero: { backgroundColor: 'rgba(187,242,70,0.18)', borderRadius: 16, padding: 14, marginTop: 8 },
|
|
||||||
bgOrnaments: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
|
||||||
blob: { position: 'absolute', width: 260, height: 260, borderRadius: 999 },
|
|
||||||
catGrid: { paddingHorizontal: 16, paddingTop: 10, flexDirection: 'row', flexWrap: 'wrap' },
|
|
||||||
catTileWrapper: { width: '33.33%', padding: 6 },
|
|
||||||
catTile: { borderRadius: 14, paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
catTileActive: { borderWidth: 2, borderColor: '#BBF246' },
|
|
||||||
searchRow: { paddingHorizontal: 20, marginTop: 8 },
|
|
||||||
searchInput: { backgroundColor: '#FFFFFF', borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10, color: '#111827' },
|
|
||||||
itemCard: { backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginTop: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 },
|
|
||||||
itemCardSelected: { borderWidth: 2, borderColor: '#10B981' },
|
|
||||||
itemTitle: { fontSize: 16, fontWeight: '800', color: '#111827' },
|
|
||||||
itemMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
|
||||||
itemDesc: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
|
||||||
expandedBox: { marginTop: 12 },
|
|
||||||
controlsRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 10 },
|
|
||||||
counterBox: { backgroundColor: '#F3F4F6', borderRadius: 8, padding: 8 },
|
|
||||||
counterLabel: { fontSize: 10, color: '#6B7280' },
|
|
||||||
counterRow: { flexDirection: 'row', alignItems: 'center' },
|
|
||||||
counterBtn: { backgroundColor: '#E5E7EB', width: 28, height: 28, borderRadius: 6, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
counterBtnText: { fontWeight: '800', color: '#111827' },
|
|
||||||
counterValue: { minWidth: 40, textAlign: 'center', fontWeight: '700', color: '#111827' },
|
|
||||||
repsChipsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 6 },
|
|
||||||
repChip: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, backgroundColor: '#F3F4F6', borderWidth: 1, borderColor: '#E5E7EB' },
|
|
||||||
repChipText: { color: '#111827', fontWeight: '700' },
|
|
||||||
repChipGhost: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, borderWidth: 1, backgroundColor: 'transparent' },
|
|
||||||
repChipGhostText: { fontWeight: '700' },
|
|
||||||
customRepsRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 8 },
|
|
||||||
customRepsInput: { flex: 1, height: 40, borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 },
|
|
||||||
customRepsBtn: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10 },
|
|
||||||
customRepsBtnText: { fontWeight: '800' },
|
|
||||||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'flex-end' },
|
|
||||||
modalSheet: { width: '100%', borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 24 },
|
|
||||||
modalTitle: { fontSize: 16, fontWeight: '800', marginBottom: 8 },
|
|
||||||
catGridModal: { flexDirection: 'row', flexWrap: 'wrap' },
|
|
||||||
primaryBtn: { backgroundColor: '#111827', paddingVertical: 12, borderRadius: 12, alignItems: 'center' },
|
|
||||||
primaryBtnText: { color: '#FFFFFF', fontWeight: '800' },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -21,31 +21,17 @@ 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 {
|
||||||
|
addExercise,
|
||||||
|
clearExercises,
|
||||||
|
clearError as clearScheduleError,
|
||||||
|
deleteExercise,
|
||||||
|
loadExercises,
|
||||||
|
toggleCompletion
|
||||||
|
} from '@/store/scheduleExerciseSlice';
|
||||||
import { activatePlan, clearError, deletePlan, loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice';
|
import { activatePlan, clearError, deletePlan, loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice';
|
||||||
import { buildClassicalSession } from '@/utils/classicalSession';
|
import { buildClassicalSession } from '@/utils/classicalSession';
|
||||||
|
|
||||||
// 训练计划排课项目类型
|
|
||||||
export interface ScheduleExercise {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
category: string;
|
|
||||||
sets: number;
|
|
||||||
reps?: number;
|
|
||||||
durationSec?: number;
|
|
||||||
restSec?: number;
|
|
||||||
note?: string;
|
|
||||||
itemType?: 'exercise' | 'rest' | 'note';
|
|
||||||
completed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 训练计划排课数据
|
|
||||||
export interface PlanSchedule {
|
|
||||||
planId: string;
|
|
||||||
exercises: ScheduleExercise[];
|
|
||||||
note?: string;
|
|
||||||
lastModified: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab 类型定义
|
// Tab 类型定义
|
||||||
type TabType = 'list' | 'schedule';
|
type TabType = 'list' | 'schedule';
|
||||||
|
|
||||||
@@ -258,18 +244,15 @@ function BottomTabs({ activeTab, onTabChange, selectedPlan }: {
|
|||||||
export default function TrainingPlanScreen() {
|
export default function TrainingPlanScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const params = useLocalSearchParams<{ planId?: string; newExercise?: string }>();
|
const params = useLocalSearchParams<{ planId?: string; tab?: string }>();
|
||||||
const { plans, currentId, loading, error } = useAppSelector((s) => s.trainingPlan);
|
const { plans, currentId, loading, error } = useAppSelector((s) => s.trainingPlan);
|
||||||
|
const { exercises, error: scheduleError } = useAppSelector((s) => s.scheduleExercise);
|
||||||
|
|
||||||
// Tab 状态管理
|
// Tab 状态管理 - 支持从URL参数设置初始tab
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('list');
|
const initialTab: TabType = params.tab === 'schedule' ? 'schedule' : 'list';
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>(initialTab);
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(params.planId || currentId || null);
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(params.planId || currentId || null);
|
||||||
|
|
||||||
// 排课相关状态
|
|
||||||
const [exercises, setExercises] = useState<ScheduleExercise[]>([]);
|
|
||||||
const [scheduleNote, setScheduleNote] = useState('');
|
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
||||||
|
|
||||||
// 一键排课配置
|
// 一键排课配置
|
||||||
const [genVisible, setGenVisible] = useState(false);
|
const [genVisible, setGenVisible] = useState(false);
|
||||||
const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner');
|
const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner');
|
||||||
@@ -279,54 +262,14 @@ export default function TrainingPlanScreen() {
|
|||||||
|
|
||||||
const selectedPlan = useMemo(() => plans.find(p => p.id === selectedPlanId), [plans, selectedPlanId]);
|
const selectedPlan = useMemo(() => plans.find(p => p.id === selectedPlanId), [plans, selectedPlanId]);
|
||||||
|
|
||||||
// 模拟加载排课数据的函数
|
// 监听选中计划变化,加载对应的排课数据
|
||||||
const loadScheduleData = async (planId: string): Promise<PlanSchedule | null> => {
|
|
||||||
// 模拟 API 调用延迟
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
|
||||||
|
|
||||||
// 模拟数据 - 在实际应用中,这里应该从后端或本地存储获取数据
|
|
||||||
const mockData: Record<string, PlanSchedule> = {
|
|
||||||
// 示例数据结构,实际应用中应从服务器或本地存储获取
|
|
||||||
// 'plan1': {
|
|
||||||
// planId: 'plan1',
|
|
||||||
// exercises: [...],
|
|
||||||
// note: '示例备注',
|
|
||||||
// lastModified: new Date().toISOString()
|
|
||||||
// }
|
|
||||||
};
|
|
||||||
|
|
||||||
return mockData[planId] || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 监听 selectedPlan 变化,加载对应的排课数据
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSchedule = async () => {
|
if (selectedPlanId) {
|
||||||
if (selectedPlan) {
|
dispatch(loadExercises(selectedPlanId));
|
||||||
try {
|
} else {
|
||||||
const scheduleData = await loadScheduleData(selectedPlan.id);
|
dispatch(clearExercises());
|
||||||
if (scheduleData) {
|
}
|
||||||
setExercises(scheduleData.exercises);
|
}, [selectedPlanId, dispatch]);
|
||||||
setScheduleNote(scheduleData.note || '');
|
|
||||||
} else {
|
|
||||||
// 如果没有保存的排课数据,重置为默认空状态
|
|
||||||
setExercises([]);
|
|
||||||
setScheduleNote('');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载排课数据失败:', error);
|
|
||||||
// 出错时重置为默认空状态
|
|
||||||
setExercises([]);
|
|
||||||
setScheduleNote('');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 没有选中计划时,重置为默认空状态
|
|
||||||
setExercises([]);
|
|
||||||
setScheduleNote('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSchedule();
|
|
||||||
}, [selectedPlan]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(loadPlans());
|
dispatch(loadPlans());
|
||||||
@@ -342,19 +285,15 @@ export default function TrainingPlanScreen() {
|
|||||||
}
|
}
|
||||||
}, [error, dispatch]);
|
}, [error, dispatch]);
|
||||||
|
|
||||||
// 处理从选择页面传回的新动作
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (params.newExercise) {
|
if (scheduleError) {
|
||||||
try {
|
console.error('排课错误:', scheduleError);
|
||||||
const newExercise: ScheduleExercise = JSON.parse(params.newExercise);
|
const timer = setTimeout(() => {
|
||||||
setExercises(prev => [...prev, newExercise]);
|
dispatch(clearScheduleError());
|
||||||
setHasUnsavedChanges(true);
|
}, 3000);
|
||||||
router.setParams({ newExercise: undefined } as any);
|
return () => clearTimeout(timer);
|
||||||
} catch (error) {
|
|
||||||
console.error('解析新动作数据失败:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [params.newExercise]);
|
}, [scheduleError, dispatch]);
|
||||||
|
|
||||||
const handleActivate = async (planId: string) => {
|
const handleActivate = async (planId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -367,7 +306,6 @@ export default function TrainingPlanScreen() {
|
|||||||
const handlePlanSelect = (plan: TrainingPlan) => {
|
const handlePlanSelect = (plan: TrainingPlan) => {
|
||||||
setSelectedPlanId(plan.id);
|
setSelectedPlanId(plan.id);
|
||||||
setActiveTab('schedule');
|
setActiveTab('schedule');
|
||||||
// TODO: 加载该计划的排课数据
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTabChange = (tab: TabType) => {
|
const handleTabChange = (tab: TabType) => {
|
||||||
@@ -380,78 +318,70 @@ export default function TrainingPlanScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 排课相关方法
|
// 排课相关方法
|
||||||
const handleSave = async () => {
|
|
||||||
if (!selectedPlan) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const scheduleData: PlanSchedule = {
|
|
||||||
planId: selectedPlan.id,
|
|
||||||
exercises,
|
|
||||||
note: scheduleNote,
|
|
||||||
lastModified: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('保存排课数据:', scheduleData);
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
Alert.alert('保存成功', '训练计划排课已保存');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存排课失败:', error);
|
|
||||||
Alert.alert('保存失败', '请稍后重试');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddExercise = () => {
|
const handleAddExercise = () => {
|
||||||
router.push(`/training-plan/schedule/select?planId=${selectedPlanId}` as any);
|
router.push(`/training-plan/schedule/select?planId=${selectedPlanId}` as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveExercise = (key: string) => {
|
const handleRemoveExercise = (exerciseId: string) => {
|
||||||
|
if (!selectedPlanId) return;
|
||||||
|
|
||||||
Alert.alert('确认移除', '确定要移除该动作吗?', [
|
Alert.alert('确认移除', '确定要移除该动作吗?', [
|
||||||
{ text: '取消', style: 'cancel' },
|
{ text: '取消', style: 'cancel' },
|
||||||
{
|
{
|
||||||
text: '移除',
|
text: '移除',
|
||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
setExercises(prev => prev.filter(ex => ex.key !== key));
|
dispatch(deleteExercise({ planId: selectedPlanId, exerciseId }));
|
||||||
setHasUnsavedChanges(true);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleCompleted = (key: string) => {
|
const handleToggleCompleted = (exerciseId: string, currentCompleted: boolean) => {
|
||||||
setExercises(prev => prev.map(ex =>
|
if (!selectedPlanId) return;
|
||||||
ex.key === key ? { ...ex, completed: !ex.completed } : ex
|
|
||||||
));
|
dispatch(toggleCompletion({
|
||||||
setHasUnsavedChanges(true);
|
planId: selectedPlanId,
|
||||||
|
exerciseId,
|
||||||
|
completed: !currentCompleted
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onGenerate = () => {
|
const onGenerate = async () => {
|
||||||
|
if (!selectedPlanId) return;
|
||||||
|
|
||||||
const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10)));
|
const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10)));
|
||||||
const { items, note } = buildClassicalSession({
|
const { items } = buildClassicalSession({
|
||||||
withSectionRests: genWithRests,
|
withSectionRests: genWithRests,
|
||||||
restSeconds: restSec,
|
restSeconds: restSec,
|
||||||
withNotes: genWithNotes,
|
withNotes: genWithNotes,
|
||||||
level: genLevel
|
level: genLevel
|
||||||
});
|
});
|
||||||
|
|
||||||
const scheduleItems: ScheduleExercise[] = items.map((item, index) => ({
|
|
||||||
key: `generated_${Date.now()}_${index}`,
|
|
||||||
name: item.name,
|
|
||||||
category: item.category,
|
|
||||||
sets: item.sets,
|
|
||||||
reps: item.reps,
|
|
||||||
durationSec: item.durationSec,
|
|
||||||
restSec: item.restSec,
|
|
||||||
note: item.note,
|
|
||||||
itemType: item.itemType,
|
|
||||||
completed: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setExercises(scheduleItems);
|
|
||||||
setScheduleNote(note || '');
|
|
||||||
setHasUnsavedChanges(true);
|
|
||||||
setGenVisible(false);
|
setGenVisible(false);
|
||||||
Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。');
|
|
||||||
|
try {
|
||||||
|
// 按顺序添加每个生成的训练项目
|
||||||
|
for (const item of items) {
|
||||||
|
const dto = {
|
||||||
|
exerciseKey: item.key, // 使用key作为exerciseKey
|
||||||
|
name: item.name,
|
||||||
|
sets: item.sets,
|
||||||
|
reps: item.reps,
|
||||||
|
durationSec: item.durationSec,
|
||||||
|
restSec: item.restSec,
|
||||||
|
note: item.note,
|
||||||
|
itemType: item.itemType || 'exercise' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
await dispatch(addExercise({ planId: selectedPlanId, dto })).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('生成排课失败:', error);
|
||||||
|
Alert.alert('生成失败', '请稍后重试');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染训练计划列表
|
// 渲染训练计划列表
|
||||||
@@ -462,9 +392,9 @@ export default function TrainingPlanScreen() {
|
|||||||
<ThemedText style={styles.subtitle}>点击计划卡片进入排课模式,或使用底部切换</ThemedText>
|
<ThemedText style={styles.subtitle}>点击计划卡片进入排课模式,或使用底部切换</ThemedText>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{error && (
|
{(error || scheduleError) && (
|
||||||
<Animated.View entering={FadeInUp.duration(400)} style={styles.errorContainer}>
|
<Animated.View entering={FadeInUp.duration(400)} style={styles.errorContainer}>
|
||||||
<ThemedText style={styles.errorText}>⚠️ {error}</ThemedText>
|
<ThemedText style={styles.errorText}>⚠️ {error || scheduleError}</ThemedText>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -567,7 +497,7 @@ export default function TrainingPlanScreen() {
|
|||||||
{/* 动作列表 */}
|
{/* 动作列表 */}
|
||||||
<FlatList
|
<FlatList
|
||||||
data={exercises}
|
data={exercises}
|
||||||
keyExtractor={(item) => item.key}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={styles.scheduleListContent}
|
contentContainerStyle={styles.scheduleListContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
@@ -607,7 +537,7 @@ export default function TrainingPlanScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.inlineRemoveBtn}
|
style={styles.inlineRemoveBtn}
|
||||||
onPress={() => handleRemoveExercise(item.key)}
|
onPress={() => handleRemoveExercise(item.id)}
|
||||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||||||
>
|
>
|
||||||
<Ionicons name="close-outline" size={16} color="#888F92" />
|
<Ionicons name="close-outline" size={16} color="#888F92" />
|
||||||
@@ -624,9 +554,9 @@ export default function TrainingPlanScreen() {
|
|||||||
<View style={styles.exerciseContent}>
|
<View style={styles.exerciseContent}>
|
||||||
<View style={styles.exerciseInfo}>
|
<View style={styles.exerciseInfo}>
|
||||||
<ThemedText style={styles.exerciseName}>{item.name}</ThemedText>
|
<ThemedText style={styles.exerciseName}>{item.name}</ThemedText>
|
||||||
<ThemedText style={styles.exerciseCategory}>{item.category}</ThemedText>
|
<ThemedText style={styles.exerciseCategory}>{item.exercise?.categoryName || '运动'}</ThemedText>
|
||||||
<ThemedText style={styles.exerciseMeta}>
|
<ThemedText style={styles.exerciseMeta}>
|
||||||
组数 {item.sets}
|
组数 {item.sets || 1}
|
||||||
{item.reps ? ` · 每组 ${item.reps} 次` : ''}
|
{item.reps ? ` · 每组 ${item.reps} 次` : ''}
|
||||||
{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''}
|
{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''}
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
@@ -635,7 +565,7 @@ export default function TrainingPlanScreen() {
|
|||||||
<View style={styles.exerciseActions}>
|
<View style={styles.exerciseActions}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.completeBtn}
|
style={styles.completeBtn}
|
||||||
onPress={() => handleToggleCompleted(item.key)}
|
onPress={() => handleToggleCompleted(item.id, item.completed)}
|
||||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@@ -647,7 +577,7 @@ export default function TrainingPlanScreen() {
|
|||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.removeBtn}
|
style={styles.removeBtn}
|
||||||
onPress={() => handleRemoveExercise(item.key)}
|
onPress={() => handleRemoveExercise(item.id)}
|
||||||
>
|
>
|
||||||
<Text style={styles.removeBtnText}>移除</Text>
|
<Text style={styles.removeBtnText}>移除</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -678,10 +608,6 @@ export default function TrainingPlanScreen() {
|
|||||||
<TouchableOpacity onPress={() => router.push('/training-plan/create' as any)} style={styles.headerRightBtn}>
|
<TouchableOpacity onPress={() => router.push('/training-plan/create' as any)} style={styles.headerRightBtn}>
|
||||||
<ThemedText style={styles.headerRightBtnText}>+ 新建</ThemedText>
|
<ThemedText style={styles.headerRightBtnText}>+ 新建</ThemedText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : hasUnsavedChanges ? (
|
|
||||||
<TouchableOpacity onPress={handleSave} style={styles.headerRightBtn}>
|
|
||||||
<ThemedText style={styles.headerRightBtnText}>保存</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1462,4 +1388,17 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 统计显示
|
||||||
|
statsContainer: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
backgroundColor: 'rgba(187,242,70,0.2)',
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
statsText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: palette.ink,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { palette } from '@/constants/Colors';
|
import { palette } from '@/constants/Colors';
|
||||||
import { useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { fetchExerciseConfig, normalizeToLibraryItems } from '@/services/exercises';
|
import { loadExerciseLibrary } from '@/store/exerciseLibrarySlice';
|
||||||
import { EXERCISE_LIBRARY, getCategories } from '@/utils/exerciseLibrary';
|
import { EXERCISE_LIBRARY, getCategories } from '@/utils/exerciseLibrary';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native';
|
import { Alert, Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native';
|
||||||
|
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
import type { ScheduleExercise } from './index';
|
import { addExercise } from '@/store/scheduleExerciseSlice';
|
||||||
|
import { addWorkoutExercise } from '@/store/workoutSlice';
|
||||||
|
|
||||||
const GOAL_TEXT: Record<string, { title: string; color: string; description: string }> = {
|
const GOAL_TEXT: Record<string, { title: string; color: string; description: string }> = {
|
||||||
postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' },
|
postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' },
|
||||||
@@ -40,12 +40,20 @@ function DynamicBackground({ color }: { color: string }) {
|
|||||||
|
|
||||||
export default function SelectExerciseForScheduleScreen() {
|
export default function SelectExerciseForScheduleScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams<{ planId?: string }>();
|
const dispatch = useAppDispatch();
|
||||||
|
const params = useLocalSearchParams<{ planId?: string; sessionId?: string }>();
|
||||||
const { plans } = useAppSelector((s) => s.trainingPlan);
|
const { plans } = useAppSelector((s) => s.trainingPlan);
|
||||||
|
const { currentSession } = useAppSelector((s) => s.workout);
|
||||||
|
|
||||||
const planId = params.planId;
|
const planId = params.planId;
|
||||||
|
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 goalConfig = plan ? (GOAL_TEXT[plan.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' }) : null;
|
const session = useMemo(() => sessionId ? currentSession : null, [sessionId, currentSession]);
|
||||||
|
|
||||||
|
// 根据是否有sessionId来确定是训练计划模式还是训练会话模式
|
||||||
|
const isSessionMode = !!sessionId;
|
||||||
|
const targetGoal = plan?.goal || session?.trainingPlan?.goal;
|
||||||
|
const goalConfig = targetGoal ? (GOAL_TEXT[targetGoal] || { title: isSessionMode ? '添加动作' : '训练计划', color: palette.primary, description: isSessionMode ? '选择要添加的动作' : '开始你的训练之旅' }) : null;
|
||||||
|
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [category, setCategory] = useState<string>('全部');
|
const [category, setCategory] = useState<string>('全部');
|
||||||
@@ -55,8 +63,12 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
const [showCustomReps, setShowCustomReps] = useState(false);
|
const [showCustomReps, setShowCustomReps] = useState(false);
|
||||||
const [customRepsInput, setCustomRepsInput] = useState('');
|
const [customRepsInput, setCustomRepsInput] = useState('');
|
||||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||||
const [serverLibrary, setServerLibrary] = useState<{ key: string; name: string; description: string; category: string }[] | null>(null);
|
const [showRestModal, setShowRestModal] = useState(false);
|
||||||
const [serverCategories, setServerCategories] = useState<string[] | null>(null);
|
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||||
|
const [restDuration, setRestDuration] = useState(30);
|
||||||
|
const [noteContent, setNoteContent] = useState('');
|
||||||
|
const { categories: serverCategoryDtos, exercises: serverExercises } = useAppSelector((s) => s.exerciseLibrary);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
const controlsOpacity = useRef(new Animated.Value(0)).current;
|
const controlsOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
@@ -67,39 +79,16 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let aborted = false;
|
dispatch(loadExerciseLibrary());
|
||||||
const CACHE_KEY = '@exercise_config_v1';
|
}, [dispatch]);
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const cached = await AsyncStorage.getItem(CACHE_KEY);
|
|
||||||
if (cached && !aborted) {
|
|
||||||
const parsed = JSON.parse(cached);
|
|
||||||
const items = normalizeToLibraryItems(parsed);
|
|
||||||
if (items.length) {
|
|
||||||
setServerLibrary(items);
|
|
||||||
const cats = Array.from(new Set(items.map((i) => i.category)));
|
|
||||||
setServerCategories(cats);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
try {
|
|
||||||
const resp = await fetchExerciseConfig();
|
|
||||||
console.log('fetchExerciseConfig', resp);
|
|
||||||
if (aborted) return;
|
|
||||||
const items = normalizeToLibraryItems(resp);
|
|
||||||
setServerLibrary(items);
|
|
||||||
const cats = Array.from(new Set(items.map((i) => i.category)));
|
|
||||||
setServerCategories(cats);
|
|
||||||
try { await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(resp)); } catch { }
|
|
||||||
} catch (err) { }
|
|
||||||
})();
|
|
||||||
return () => { aborted = true; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const base = serverCategories ?? getCategories();
|
const base = serverCategoryDtos && serverCategoryDtos.length
|
||||||
return ['全部', ...base];
|
? serverCategoryDtos.map((c) => c.name)
|
||||||
}, [serverCategories]);
|
: getCategories();
|
||||||
|
const unique = Array.from(new Set(base));
|
||||||
|
return ['全部', ...unique];
|
||||||
|
}, [serverCategoryDtos]);
|
||||||
|
|
||||||
const mainCategories = useMemo(() => {
|
const mainCategories = useMemo(() => {
|
||||||
const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑'];
|
const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑'];
|
||||||
@@ -110,7 +99,7 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
return picked;
|
return picked;
|
||||||
}, [categories]);
|
}, [categories]);
|
||||||
|
|
||||||
const library = useMemo(() => serverLibrary ?? EXERCISE_LIBRARY, [serverLibrary]);
|
const library = useMemo(() => (serverExercises && serverExercises.length ? serverExercises : EXERCISE_LIBRARY), [serverExercises]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const kw = keyword.trim().toLowerCase();
|
const kw = keyword.trim().toLowerCase();
|
||||||
@@ -131,29 +120,126 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
}).start();
|
}).start();
|
||||||
}, [selected, controlsOpacity]);
|
}, [selected, controlsOpacity]);
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = async () => {
|
||||||
if (!selected || !plan) return;
|
if (!selected || adding) return;
|
||||||
|
|
||||||
const exerciseData: ScheduleExercise = {
|
console.log('选择动作:', selected);
|
||||||
key: `${selected.key}_${Date.now()}`,
|
|
||||||
|
const newExerciseDto = {
|
||||||
|
exerciseKey: selected.key,
|
||||||
name: selected.name,
|
name: selected.name,
|
||||||
category: selected.category,
|
plannedSets: sets,
|
||||||
sets: Math.max(1, sets),
|
plannedReps: reps,
|
||||||
reps: reps && reps > 0 ? reps : undefined,
|
itemType: 'exercise' as const,
|
||||||
itemType: 'exercise',
|
note: `${selected.category}训练`,
|
||||||
completed: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('添加动作到排课:', exerciseData);
|
setAdding(true);
|
||||||
|
try {
|
||||||
// 通过路由参数传递数据回到排课页面
|
if (isSessionMode && sessionId) {
|
||||||
router.push({
|
// 训练会话模式:添加到训练会话
|
||||||
pathname: '/training-plan/schedule',
|
await dispatch(addWorkoutExercise({ sessionId, dto: newExerciseDto })).unwrap();
|
||||||
params: {
|
} else if (plan) {
|
||||||
planId: planId,
|
// 训练计划模式:添加到训练计划
|
||||||
newExercise: JSON.stringify(exerciseData)
|
const planExerciseDto = {
|
||||||
|
exerciseKey: selected.key,
|
||||||
|
name: selected.name,
|
||||||
|
sets: sets,
|
||||||
|
reps: reps,
|
||||||
|
itemType: 'exercise' as const,
|
||||||
|
note: `${selected.category}训练`,
|
||||||
|
};
|
||||||
|
await dispatch(addExercise({ planId: plan.id, dto: planExerciseDto })).unwrap();
|
||||||
|
} else {
|
||||||
|
throw new Error('缺少必要的参数');
|
||||||
}
|
}
|
||||||
} as any);
|
|
||||||
|
// 返回到上一页
|
||||||
|
router.back();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加动作失败:', error);
|
||||||
|
Alert.alert('添加失败', '添加动作时出现错误,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加休息项目
|
||||||
|
const handleAddRest = () => {
|
||||||
|
setShowRestModal(true);
|
||||||
|
Haptics.selectionAsync();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加备注项目
|
||||||
|
const handleAddNote = () => {
|
||||||
|
setShowNoteModal(true);
|
||||||
|
Haptics.selectionAsync();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认添加休息
|
||||||
|
const confirmAddRest = async () => {
|
||||||
|
if (adding) return;
|
||||||
|
|
||||||
|
const restDto = {
|
||||||
|
name: `间隔休息 ${restDuration}s`,
|
||||||
|
restSec: restDuration,
|
||||||
|
itemType: 'rest' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
if (isSessionMode && sessionId) {
|
||||||
|
// 训练会话模式
|
||||||
|
await dispatch(addWorkoutExercise({ sessionId, dto: restDto })).unwrap();
|
||||||
|
} else if (plan) {
|
||||||
|
// 训练计划模式
|
||||||
|
await dispatch(addExercise({ planId: plan.id, dto: restDto })).unwrap();
|
||||||
|
} else {
|
||||||
|
throw new Error('缺少必要的参数');
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowRestModal(false);
|
||||||
|
setRestDuration(30);
|
||||||
|
router.back();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加休息失败:', error);
|
||||||
|
Alert.alert('添加失败', '添加休息时出现错误,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认添加备注
|
||||||
|
const confirmAddNote = async () => {
|
||||||
|
if (adding || !noteContent.trim()) return;
|
||||||
|
|
||||||
|
const noteDto = {
|
||||||
|
name: '训练提示',
|
||||||
|
note: noteContent.trim(),
|
||||||
|
itemType: 'note' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
if (isSessionMode && sessionId) {
|
||||||
|
// 训练会话模式
|
||||||
|
await dispatch(addWorkoutExercise({ sessionId, dto: noteDto })).unwrap();
|
||||||
|
} else if (plan) {
|
||||||
|
// 训练计划模式
|
||||||
|
await dispatch(addExercise({ planId: plan.id, dto: noteDto })).unwrap();
|
||||||
|
} else {
|
||||||
|
throw new Error('缺少必要的参数');
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowNoteModal(false);
|
||||||
|
setNoteContent('');
|
||||||
|
router.back();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加备注失败:', error);
|
||||||
|
Alert.alert('添加失败', '添加备注时出现错误,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelectItem = (key: string) => {
|
const onSelectItem = (key: string) => {
|
||||||
@@ -162,19 +248,22 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
setSelectedKey(null);
|
setSelectedKey(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSets(3);
|
const sel = library.find((e) => e.key === key) as any;
|
||||||
setReps(undefined);
|
setSets(sel?.beginnerSets ?? 3);
|
||||||
|
setReps(sel?.beginnerReps);
|
||||||
setShowCustomReps(false);
|
setShowCustomReps(false);
|
||||||
setCustomRepsInput('');
|
setCustomRepsInput('');
|
||||||
setSelectedKey(key);
|
setSelectedKey(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!plan || !goalConfig) {
|
if (!goalConfig || (!plan && !isSessionMode)) {
|
||||||
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>
|
<ThemedText style={styles.errorText}>
|
||||||
|
{isSessionMode ? '找不到指定的训练会话' : '找不到指定的训练计划'}
|
||||||
|
</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@@ -187,7 +276,7 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
|
|
||||||
<SafeAreaView style={styles.contentWrapper}>
|
<SafeAreaView style={styles.contentWrapper}>
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title="选择动作"
|
title={isSessionMode ? "添加动作" : "选择动作"}
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
withSafeTop={false}
|
withSafeTop={false}
|
||||||
transparent={true}
|
transparent={true}
|
||||||
@@ -200,10 +289,31 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
<View style={[styles.planColorIndicator, { backgroundColor: goalConfig.color }]} />
|
<View style={[styles.planColorIndicator, { backgroundColor: goalConfig.color }]} />
|
||||||
<View style={styles.planInfo}>
|
<View style={styles.planInfo}>
|
||||||
<ThemedText style={styles.planTitle}>{goalConfig.title}</ThemedText>
|
<ThemedText style={styles.planTitle}>{goalConfig.title}</ThemedText>
|
||||||
<ThemedText style={styles.planDescription}>从动作库里选择一个动作,设置组数与每组次数</ThemedText>
|
<ThemedText style={styles.planDescription}>
|
||||||
|
{isSessionMode ? '为当前训练会话添加动作' : '选择动作或添加休息、备注项目'}
|
||||||
|
</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 快捷添加区域 */}
|
||||||
|
<View style={styles.quickAddSection}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.quickAddBtn, { backgroundColor: `${goalConfig.color}15`, borderColor: goalConfig.color }]}
|
||||||
|
onPress={handleAddRest}
|
||||||
|
>
|
||||||
|
<Ionicons name="time-outline" size={20} color={goalConfig.color} />
|
||||||
|
<Text style={[styles.quickAddText, { color: goalConfig.color }]}>添加休息</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.quickAddBtn, { backgroundColor: `${goalConfig.color}15`, borderColor: goalConfig.color }]}
|
||||||
|
onPress={handleAddNote}
|
||||||
|
>
|
||||||
|
<Ionicons name="document-text-outline" size={20} color={goalConfig.color} />
|
||||||
|
<Text style={[styles.quickAddText, { color: goalConfig.color }]}>添加备注</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* 大分类宫格 */}
|
{/* 大分类宫格 */}
|
||||||
<View style={styles.catGrid}>
|
<View style={styles.catGrid}>
|
||||||
{[...mainCategories, '更多'].map((item) => {
|
{[...mainCategories, '更多'].map((item) => {
|
||||||
@@ -327,6 +437,16 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.itemTitle}>{item.name}</Text>
|
<Text style={styles.itemTitle}>{item.name}</Text>
|
||||||
<Text style={styles.itemMeta}>{item.category}</Text>
|
<Text style={styles.itemMeta}>{item.category}</Text>
|
||||||
|
{((item as any).targetMuscleGroups || (item as any).equipmentName) && (
|
||||||
|
<Text style={styles.itemMeta}>
|
||||||
|
{[(item as any).targetMuscleGroups, (item as any).equipmentName].filter(Boolean).join(' · ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{(((item as any).beginnerSets || (item as any).beginnerReps)) && (
|
||||||
|
<Text style={styles.itemMeta}>
|
||||||
|
建议 {(item as any).beginnerSets ?? '-'} 组 × {(item as any).beginnerReps ?? '-'} 次
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Text style={styles.itemDesc}>{item.description}</Text>
|
<Text style={styles.itemDesc}>{item.description}</Text>
|
||||||
</View>
|
</View>
|
||||||
{isSelected && <Ionicons name="chevron-down" size={20} color={goalConfig.color} />}
|
{isSelected && <Ionicons name="chevron-down" size={20} color={goalConfig.color} />}
|
||||||
@@ -414,12 +534,14 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
style={[
|
style={[
|
||||||
styles.addBtn,
|
styles.addBtn,
|
||||||
{ backgroundColor: goalConfig.color },
|
{ backgroundColor: goalConfig.color },
|
||||||
(!reps || reps <= 0) && { opacity: 0.5 }
|
((!reps || reps <= 0) || adding) && { opacity: 0.5 }
|
||||||
]}
|
]}
|
||||||
disabled={!reps || reps <= 0}
|
disabled={!reps || reps <= 0 || adding}
|
||||||
onPress={handleAdd}
|
onPress={handleAdd}
|
||||||
>
|
>
|
||||||
<Text style={styles.addBtnText}>添加到训练计划</Text>
|
<Text style={styles.addBtnText}>
|
||||||
|
{adding ? '添加中...' : (isSessionMode ? '添加到训练会话' : '添加到训练计划')}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
@@ -429,6 +551,104 @@ export default function SelectExerciseForScheduleScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|
||||||
|
{/* 休息时间配置模态框 */}
|
||||||
|
<Modal visible={showRestModal} transparent animationType="fade" onRequestClose={() => setShowRestModal(false)}>
|
||||||
|
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setShowRestModal(false)}>
|
||||||
|
<TouchableOpacity activeOpacity={1} style={styles.modalSheet} onPress={(e) => e.stopPropagation() as any}>
|
||||||
|
<Text style={styles.modalTitle}>设置休息时间</Text>
|
||||||
|
|
||||||
|
<View style={styles.restTimeRow}>
|
||||||
|
{[15, 30, 45, 60, 90, 120].map((v) => {
|
||||||
|
const active = restDuration === v;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={v}
|
||||||
|
style={[
|
||||||
|
styles.restChip,
|
||||||
|
active && { backgroundColor: goalConfig.color, borderColor: goalConfig.color }
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
setRestDuration(v);
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={[styles.restChipText, active && { color: '#FFFFFF' }]}>{v}s</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.customRestSection}>
|
||||||
|
<Text style={styles.sectionLabel}>自定义时间</Text>
|
||||||
|
<View style={styles.customRestRow}>
|
||||||
|
<TextInput
|
||||||
|
value={restDuration.toString()}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
const num = parseInt(text) || 30;
|
||||||
|
setRestDuration(Math.max(10, Math.min(300, num)));
|
||||||
|
}}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
style={styles.customRestInput}
|
||||||
|
/>
|
||||||
|
<Text style={styles.customRestUnit}>秒</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.confirmBtn, { backgroundColor: goalConfig.color }]}
|
||||||
|
onPress={confirmAddRest}
|
||||||
|
disabled={adding}
|
||||||
|
>
|
||||||
|
<Text style={styles.confirmBtnText}>{adding ? '添加中...' : '确认添加'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 备注配置模态框 */}
|
||||||
|
<Modal visible={showNoteModal} transparent animationType="fade" onRequestClose={() => setShowNoteModal(false)}>
|
||||||
|
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setShowNoteModal(false)}>
|
||||||
|
<TouchableOpacity activeOpacity={1} style={styles.modalSheet} onPress={(e) => e.stopPropagation() as any}>
|
||||||
|
<Text style={styles.modalTitle}>添加训练提示</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={noteContent}
|
||||||
|
onChangeText={setNoteContent}
|
||||||
|
placeholder="输入训练提醒或注意事项..."
|
||||||
|
placeholderTextColor="#888F92"
|
||||||
|
style={styles.noteModalInput}
|
||||||
|
multiline
|
||||||
|
maxLength={100}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.noteModalInfo}>
|
||||||
|
<Text style={styles.noteCounter}>{noteContent.length}/100</Text>
|
||||||
|
{noteContent.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setNoteContent('')}
|
||||||
|
style={styles.noteClearBtn}
|
||||||
|
>
|
||||||
|
<Ionicons name="close-circle" size={20} color="#888F92" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.confirmBtn,
|
||||||
|
{ backgroundColor: goalConfig.color },
|
||||||
|
(!noteContent.trim() || adding) && { opacity: 0.5 }
|
||||||
|
]}
|
||||||
|
onPress={confirmAddNote}
|
||||||
|
disabled={!noteContent.trim() || adding}
|
||||||
|
>
|
||||||
|
<Text style={styles.confirmBtnText}>{adding ? '添加中...' : '确认添加'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -492,6 +712,28 @@ const styles = StyleSheet.create({
|
|||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 快捷添加区域
|
||||||
|
quickAddSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
quickAddBtn: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
quickAddText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
|
||||||
// 分类网格
|
// 分类网格
|
||||||
catGrid: {
|
catGrid: {
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
@@ -671,7 +913,104 @@ const styles = StyleSheet.create({
|
|||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 休息时间配置
|
||||||
|
restTimeRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
restChip: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
restChipText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#384046',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 模态框自定义休息时间
|
||||||
|
customRestSection: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
sectionLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#384046',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
customRestRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
customRestInput: {
|
||||||
|
flex: 1,
|
||||||
|
height: 40,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
color: '#384046',
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
customRestUnit: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#384046',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 备注模态框
|
||||||
|
noteModalInput: {
|
||||||
|
minHeight: 100,
|
||||||
|
maxHeight: 150,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
color: '#384046',
|
||||||
|
fontSize: 14,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
noteModalInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
noteCounter: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#888F92',
|
||||||
|
},
|
||||||
|
noteClearBtn: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 确认按钮
|
||||||
|
confirmBtn: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
confirmBtnText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '800',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
|
||||||
addBtn: {
|
addBtn: {
|
||||||
|
marginTop: 20,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
15
app/workout/_layout.tsx
Normal file
15
app/workout/_layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function WorkoutLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="today"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'card',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
1066
app/workout/today.tsx
Normal file
1066
app/workout/today.tsx
Normal file
File diff suppressed because it is too large
Load Diff
BIN
assets/images/demo/imageBody.jpeg
Normal file
BIN
assets/images/demo/imageBody.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 439 KiB |
223
components/MyPlanCard.tsx
Normal file
223
components/MyPlanCard.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { type TrainingPlan } from '@/store/trainingPlanSlice';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
interface MyPlanCardProps {
|
||||||
|
plan: TrainingPlan;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '舒缓身心,改善睡眠' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MyPlanCard({ plan, onPress }: MyPlanCardProps) {
|
||||||
|
const goalConfig = GOAL_TEXT[plan.goal] || { title: '训练计划', color: '#FF6B47', description: '开始你的训练之旅' };
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const months = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
|
return `${months[date.getMonth()]}, ${date.getFullYear()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取训练类型显示文本
|
||||||
|
const getWorkoutTypeText = () => {
|
||||||
|
if (plan.goal === 'core_strength') return 'Body Weight';
|
||||||
|
if (plan.goal === 'flexibility') return 'Flexibility';
|
||||||
|
if (plan.goal === 'posture_correction') return 'Posture';
|
||||||
|
return 'Body Weight';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取周数和训练次数
|
||||||
|
const getWeekInfo = () => {
|
||||||
|
const startDate = new Date(plan.startDate);
|
||||||
|
const now = new Date();
|
||||||
|
const diffTime = Math.abs(now.getTime() - startDate.getTime());
|
||||||
|
const diffWeeks = Math.ceil(diffTime / (1000 * 60 * 60 * 24 * 7));
|
||||||
|
const currentWeek = Math.min(diffWeeks, 12); // 假设最多12周
|
||||||
|
|
||||||
|
const totalSessions = plan.mode === 'daysOfWeek'
|
||||||
|
? plan.daysOfWeek.length * currentWeek
|
||||||
|
: plan.sessionsPerWeek * currentWeek;
|
||||||
|
|
||||||
|
return {
|
||||||
|
week: currentWeek,
|
||||||
|
session: Math.min(totalSessions, 60), // 假设最多60次训练
|
||||||
|
totalSessions: 60
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const weekInfo = getWeekInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>我的计划</Text>
|
||||||
|
<Pressable style={styles.menuButton}>
|
||||||
|
<View style={styles.menuDot} />
|
||||||
|
<View style={styles.menuDot} />
|
||||||
|
<View style={styles.menuDot} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<Text style={styles.date}>{formatDate(plan.startDate)}</Text>
|
||||||
|
|
||||||
|
{/* Main Card */}
|
||||||
|
<Pressable style={styles.card} onPress={onPress}>
|
||||||
|
{/* Icon */}
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<View style={[styles.iconCircle, { backgroundColor: goalConfig.color }]}>
|
||||||
|
<Ionicons name="flash" size={24} color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Week indicator */}
|
||||||
|
<Text style={styles.weekText}>周 {weekInfo.week}</Text>
|
||||||
|
|
||||||
|
{/* Workout type */}
|
||||||
|
<Text style={styles.workoutType}>{getWorkoutTypeText()}</Text>
|
||||||
|
|
||||||
|
{/* Session info */}
|
||||||
|
<Text style={styles.sessionInfo}>训练 {weekInfo.session} 次</Text>
|
||||||
|
|
||||||
|
{/* Next exercise section */}
|
||||||
|
<View style={styles.nextExerciseContainer}>
|
||||||
|
<View style={styles.nextExerciseIcon}>
|
||||||
|
<Ionicons name="play" size={16} color="#666666" />
|
||||||
|
<Ionicons name="play" size={16} color="#666666" style={{ marginLeft: -4 }} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.nextExerciseText}>
|
||||||
|
<Text style={styles.nextExerciseLabel}>下一个动作</Text>
|
||||||
|
<Text style={styles.nextExerciseName}>下蹲</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginHorizontal: 24,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
marginBottom: 18,
|
||||||
|
},
|
||||||
|
menuButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
menuDot: {
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: '#999999',
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#999999',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#F5E6E0',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 24,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
marginRight: 20,
|
||||||
|
},
|
||||||
|
iconCircle: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
weekText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#999999',
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
workoutType: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
sessionInfo: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666666',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
nextExerciseContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
nextExerciseIcon: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
nextExerciseText: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
nextExerciseLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#999999',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
nextExerciseName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,31 +1,48 @@
|
|||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
|
|
||||||
export type ExerciseCategoryDto = {
|
// 与后端保持一致的数据结构定义
|
||||||
|
export interface ExerciseLibraryItem {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
sortOrder: number;
|
description?: string;
|
||||||
};
|
category: string; // 中文分类名(显示用)
|
||||||
|
targetMuscleGroups: string;
|
||||||
|
equipmentName?: string;
|
||||||
|
beginnerReps?: number;
|
||||||
|
beginnerSets?: number;
|
||||||
|
breathingCycles?: number;
|
||||||
|
holdDuration?: number;
|
||||||
|
specialInstructions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ExerciseDto = {
|
export interface ExerciseCategoryDto {
|
||||||
|
key: string; // 英文 key
|
||||||
|
name: string; // 中文名
|
||||||
|
type: 'mat_pilates' | 'equipment_pilates';
|
||||||
|
equipmentName?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExerciseDto {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description?: string;
|
||||||
categoryKey: string;
|
categoryKey: string;
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
sortOrder: number;
|
targetMuscleGroups: string;
|
||||||
};
|
equipmentName?: string;
|
||||||
|
beginnerReps?: number;
|
||||||
|
beginnerSets?: number;
|
||||||
|
breathingCycles?: number;
|
||||||
|
holdDuration?: number;
|
||||||
|
specialInstructions?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type ExerciseConfigResponse = {
|
export interface ExerciseConfigResponse {
|
||||||
categories: ExerciseCategoryDto[];
|
categories: ExerciseCategoryDto[];
|
||||||
exercises: ExerciseDto[];
|
exercises: ExerciseDto[];
|
||||||
};
|
}
|
||||||
|
|
||||||
export type ExerciseLibraryItem = {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
category: string; // display name
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchExerciseConfig(): Promise<ExerciseConfigResponse> {
|
export async function fetchExerciseConfig(): Promise<ExerciseConfigResponse> {
|
||||||
return await api.get<ExerciseConfigResponse>('/exercises/config');
|
return await api.get<ExerciseConfigResponse>('/exercises/config');
|
||||||
@@ -38,7 +55,13 @@ export function normalizeToLibraryItems(resp: ExerciseConfigResponse | null | un
|
|||||||
name: e.name,
|
name: e.name,
|
||||||
description: e.description,
|
description: e.description,
|
||||||
category: e.categoryName || e.categoryKey,
|
category: e.categoryName || e.categoryKey,
|
||||||
|
targetMuscleGroups: e.targetMuscleGroups,
|
||||||
|
equipmentName: e.equipmentName,
|
||||||
|
beginnerReps: e.beginnerReps,
|
||||||
|
beginnerSets: e.beginnerSets,
|
||||||
|
breathingCycles: e.breathingCycles,
|
||||||
|
holdDuration: e.holdDuration,
|
||||||
|
specialInstructions: e.specialInstructions,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
106
services/scheduleExerciseApi.ts
Normal file
106
services/scheduleExerciseApi.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
// 训练项目数据结构
|
||||||
|
export interface ScheduleExercise {
|
||||||
|
id: string;
|
||||||
|
trainingPlanId: string;
|
||||||
|
userId: string;
|
||||||
|
exerciseKey?: string;
|
||||||
|
name: string;
|
||||||
|
sets?: number;
|
||||||
|
reps?: number;
|
||||||
|
durationSec?: number;
|
||||||
|
restSec?: number;
|
||||||
|
note?: string;
|
||||||
|
itemType: 'exercise' | 'rest' | 'note';
|
||||||
|
completed: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deleted: boolean;
|
||||||
|
|
||||||
|
// 关联的动作信息(仅exercise类型时存在)
|
||||||
|
exercise?: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
categoryKey: string;
|
||||||
|
categoryName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建训练项目的请求体
|
||||||
|
export interface CreateScheduleExerciseDto {
|
||||||
|
exerciseKey?: string;
|
||||||
|
name: string;
|
||||||
|
sets?: number;
|
||||||
|
reps?: number;
|
||||||
|
durationSec?: number;
|
||||||
|
restSec?: number;
|
||||||
|
note?: string;
|
||||||
|
itemType: 'exercise' | 'rest' | 'note';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新训练项目的请求体
|
||||||
|
export interface UpdateScheduleExerciseDto {
|
||||||
|
exerciseKey?: string;
|
||||||
|
name?: string;
|
||||||
|
sets?: number;
|
||||||
|
reps?: number;
|
||||||
|
durationSec?: number;
|
||||||
|
restSec?: number;
|
||||||
|
note?: string;
|
||||||
|
itemType?: 'exercise' | 'rest' | 'note';
|
||||||
|
completed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成状态统计
|
||||||
|
export interface CompletionStats {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新排序请求体
|
||||||
|
export interface ReorderExercisesDto {
|
||||||
|
exerciseIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScheduleExerciseApi {
|
||||||
|
// 获取训练计划的所有项目
|
||||||
|
async list(planId: string): Promise<ScheduleExercise[]> {
|
||||||
|
return api.get<ScheduleExercise[]>(`/training-plans/${planId}/exercises`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取训练项目详情
|
||||||
|
async detail(planId: string, exerciseId: string): Promise<ScheduleExercise> {
|
||||||
|
return api.get<ScheduleExercise>(`/training-plans/${planId}/exercises/${exerciseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加训练项目
|
||||||
|
async create(planId: string, dto: CreateScheduleExerciseDto): Promise<ScheduleExercise> {
|
||||||
|
return api.post<ScheduleExercise>(`/training-plans/${planId}/exercises`, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新训练项目
|
||||||
|
async update(planId: string, exerciseId: string, dto: UpdateScheduleExerciseDto): Promise<ScheduleExercise> {
|
||||||
|
return api.put<ScheduleExercise>(`/training-plans/${planId}/exercises/${exerciseId}`, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除训练项目
|
||||||
|
async delete(planId: string, exerciseId: string): Promise<{ success: boolean }> {
|
||||||
|
return api.delete<{ success: boolean }>(`/training-plans/${planId}/exercises/${exerciseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新训练项目排序
|
||||||
|
async reorder(planId: string, dto: ReorderExercisesDto): Promise<{ success: boolean }> {
|
||||||
|
return api.put<{ success: boolean }>(`/training-plans/${planId}/exercises/order`, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记训练项目完成状态
|
||||||
|
async updateCompletion(planId: string, exerciseId: string, completed: boolean): Promise<ScheduleExercise> {
|
||||||
|
return api.put<ScheduleExercise>(`/training-plans/${planId}/exercises/${exerciseId}/complete`, { completed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scheduleExerciseApi = new ScheduleExerciseApi();
|
||||||
@@ -70,6 +70,15 @@ class TrainingPlanApi {
|
|||||||
async activate(id: string): Promise<{ success: boolean }> {
|
async activate(id: string): Promise<{ success: boolean }> {
|
||||||
return api.post<{ success: boolean }>(`/training-plans/${id}/activate`);
|
return api.post<{ success: boolean }>(`/training-plans/${id}/activate`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getActivePlan(): Promise<TrainingPlanResponse | null> {
|
||||||
|
try {
|
||||||
|
return api.get<TrainingPlanResponse>('/training-plans/active');
|
||||||
|
} catch (error) {
|
||||||
|
// 如果没有激活的计划,返回null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const trainingPlanApi = new TrainingPlanApi();
|
export const trainingPlanApi = new TrainingPlanApi();
|
||||||
204
services/workoutsApi.ts
Normal file
204
services/workoutsApi.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
// ==================== 数据类型定义 ====================
|
||||||
|
|
||||||
|
export interface WorkoutSession {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
trainingPlanId?: string;
|
||||||
|
name: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
status: 'planned' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
totalDurationSec?: number;
|
||||||
|
caloriesBurned?: number;
|
||||||
|
stats?: {
|
||||||
|
totalExercises: number;
|
||||||
|
completedExercises: number;
|
||||||
|
totalSets: number;
|
||||||
|
completedSets: number;
|
||||||
|
totalReps: number;
|
||||||
|
completedReps: number;
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deleted: boolean;
|
||||||
|
|
||||||
|
// 关联数据
|
||||||
|
trainingPlan?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
goal: string;
|
||||||
|
};
|
||||||
|
exercises?: WorkoutExercise[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkoutExercise {
|
||||||
|
id: string;
|
||||||
|
workoutSessionId: string;
|
||||||
|
userId: string;
|
||||||
|
exerciseKey?: string;
|
||||||
|
name: string;
|
||||||
|
plannedSets?: number;
|
||||||
|
plannedReps?: number;
|
||||||
|
plannedDurationSec?: number;
|
||||||
|
completedSets?: number;
|
||||||
|
completedReps?: number;
|
||||||
|
actualDurationSec?: number;
|
||||||
|
restSec?: number;
|
||||||
|
note?: string;
|
||||||
|
itemType: 'exercise' | 'rest' | 'note';
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'skipped';
|
||||||
|
sortOrder: number;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
performanceData?: Record<string, any>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deleted: boolean;
|
||||||
|
|
||||||
|
// 关联的动作信息
|
||||||
|
exercise?: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
categoryKey: string;
|
||||||
|
categoryName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DTO 类型定义 ====================
|
||||||
|
|
||||||
|
export interface StartWorkoutDto {
|
||||||
|
startedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartWorkoutExerciseDto {
|
||||||
|
startedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteWorkoutExerciseDto {
|
||||||
|
completedAt?: string;
|
||||||
|
completedSets?: number;
|
||||||
|
completedReps?: number;
|
||||||
|
actualDurationSec?: number;
|
||||||
|
performanceData?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddWorkoutExerciseDto {
|
||||||
|
exerciseKey?: string;
|
||||||
|
name: string;
|
||||||
|
plannedSets?: number;
|
||||||
|
plannedReps?: number;
|
||||||
|
plannedDurationSec?: number;
|
||||||
|
restSec?: number;
|
||||||
|
note?: string;
|
||||||
|
itemType?: 'exercise' | 'rest' | 'note';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWorkoutExerciseDto {
|
||||||
|
name?: string;
|
||||||
|
plannedSets?: number;
|
||||||
|
plannedReps?: number;
|
||||||
|
plannedDurationSec?: number;
|
||||||
|
restSec?: number;
|
||||||
|
note?: string;
|
||||||
|
itemType?: 'exercise' | 'rest' | 'note';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkoutSessionListResponse {
|
||||||
|
sessions: WorkoutSession[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkoutSessionStatsResponse {
|
||||||
|
status: string;
|
||||||
|
duration?: number;
|
||||||
|
calories?: number;
|
||||||
|
stats?: {
|
||||||
|
totalExercises: number;
|
||||||
|
completedExercises: number;
|
||||||
|
totalSets: number;
|
||||||
|
completedSets: number;
|
||||||
|
totalReps: number;
|
||||||
|
completedReps: number;
|
||||||
|
};
|
||||||
|
exerciseCount: number;
|
||||||
|
completedExercises: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API 服务类 ====================
|
||||||
|
|
||||||
|
class WorkoutsApi {
|
||||||
|
// ==================== 训练会话管理 ====================
|
||||||
|
|
||||||
|
async getSessions(page: number = 1, limit: number = 10): Promise<WorkoutSessionListResponse> {
|
||||||
|
return api.get<WorkoutSessionListResponse>(`/workouts/sessions?page=${page}&limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionDetail(sessionId: string): Promise<WorkoutSession> {
|
||||||
|
return api.get<WorkoutSession>(`/workouts/sessions/${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSession(sessionId: string, dto: StartWorkoutDto = {}): Promise<WorkoutSession> {
|
||||||
|
return api.post<WorkoutSession>(`/workouts/sessions/${sessionId}/start`, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSession(sessionId: string): Promise<{ success: boolean }> {
|
||||||
|
return api.delete<{ success: boolean }>(`/workouts/sessions/${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 训练动作管理 ====================
|
||||||
|
|
||||||
|
async getSessionExercises(sessionId: string): Promise<WorkoutExercise[]> {
|
||||||
|
return api.get<WorkoutExercise[]>(`/workouts/sessions/${sessionId}/exercises`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExerciseDetail(sessionId: string, exerciseId: string): Promise<WorkoutExercise> {
|
||||||
|
return api.get<WorkoutExercise>(`/workouts/sessions/${sessionId}/exercises/${exerciseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startExercise(sessionId: string, exerciseId: string, dto: StartWorkoutExerciseDto = {}): Promise<WorkoutExercise> {
|
||||||
|
return api.post<WorkoutExercise>(`/workouts/sessions/${sessionId}/exercises/${exerciseId}/start`, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeExercise(sessionId: string, exerciseId: string, dto: CompleteWorkoutExerciseDto): Promise<WorkoutExercise> {
|
||||||
|
return api.post<WorkoutExercise>(`/workouts/sessions/${sessionId}/exercises/${exerciseId}/complete`, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async skipExercise(sessionId: string, exerciseId: string): Promise<WorkoutExercise> {
|
||||||
|
return api.post<WorkoutExercise>(`/workouts/sessions/${sessionId}/exercises/${exerciseId}/skip`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateExercise(sessionId: string, exerciseId: string, dto: UpdateWorkoutExerciseDto): Promise<WorkoutExercise> {
|
||||||
|
return api.put<WorkoutExercise>(`/workouts/sessions/${sessionId}/exercises/${exerciseId}`, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addExercise(sessionId: string, dto: AddWorkoutExerciseDto): Promise<WorkoutExercise> {
|
||||||
|
return api.post<WorkoutExercise>(`/workouts/sessions/${sessionId}/exercises`, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 统计和分析 ====================
|
||||||
|
|
||||||
|
async getSessionStats(sessionId: string): Promise<WorkoutSessionStatsResponse> {
|
||||||
|
return api.get<WorkoutSessionStatsResponse>(`/workouts/sessions/${sessionId}/stats`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 快捷操作 ====================
|
||||||
|
|
||||||
|
async getTodayWorkout(): Promise<WorkoutSession> {
|
||||||
|
return api.get<WorkoutSession>('/workouts/today');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentWorkouts(days: number = 7, limit: number = 10): Promise<{ sessions: WorkoutSession[]; period: string }> {
|
||||||
|
return api.get<{ sessions: WorkoutSession[]; period: string }>(`/workouts/recent?days=${days}&limit=${limit}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workoutsApi = new WorkoutsApi();
|
||||||
126
store/exerciseLibrarySlice.ts
Normal file
126
store/exerciseLibrarySlice.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
fetchExerciseConfig,
|
||||||
|
normalizeToLibraryItems,
|
||||||
|
type ExerciseCategoryDto,
|
||||||
|
type ExerciseConfigResponse,
|
||||||
|
type ExerciseLibraryItem,
|
||||||
|
} from '@/services/exercises';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export interface ExerciseLibraryState {
|
||||||
|
categories: ExerciseCategoryDto[];
|
||||||
|
exercises: ExerciseLibraryItem[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdatedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_KEY = '@exercise_config_v2';
|
||||||
|
|
||||||
|
const initialState: ExerciseLibraryState = {
|
||||||
|
categories: [],
|
||||||
|
exercises: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdatedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadExerciseLibrary = createAsyncThunk(
|
||||||
|
'exerciseLibrary/load',
|
||||||
|
async (_: void, { rejectWithValue }) => {
|
||||||
|
// 先读本地缓存(最佳体验),随后静默刷新远端
|
||||||
|
try {
|
||||||
|
const cached = await AsyncStorage.getItem(CACHE_KEY);
|
||||||
|
if (cached) {
|
||||||
|
const data = JSON.parse(cached) as ExerciseConfigResponse;
|
||||||
|
return { source: 'cache' as const, data };
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fresh = await fetchExerciseConfig();
|
||||||
|
try { await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(fresh)); } catch { }
|
||||||
|
return { source: 'network' as const, data: fresh };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '加载动作库失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const refreshExerciseLibrary = createAsyncThunk(
|
||||||
|
'exerciseLibrary/refresh',
|
||||||
|
async (_: void, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const fresh = await fetchExerciseConfig();
|
||||||
|
try { await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(fresh)); } catch { }
|
||||||
|
return fresh;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '刷新动作库失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const exerciseLibrarySlice = createSlice({
|
||||||
|
name: 'exerciseLibrary',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
clearExerciseLibraryError(state) {
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
setExerciseLibraryFromData(
|
||||||
|
state,
|
||||||
|
action: PayloadAction<ExerciseConfigResponse>
|
||||||
|
) {
|
||||||
|
const data = action.payload;
|
||||||
|
state.categories = (data.categories || []).slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||||
|
const sorted: ExerciseConfigResponse = {
|
||||||
|
...data,
|
||||||
|
exercises: (data.exercises || []).slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)),
|
||||||
|
};
|
||||||
|
state.exercises = normalizeToLibraryItems(sorted);
|
||||||
|
state.lastUpdatedAt = Date.now();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(loadExerciseLibrary.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(loadExerciseLibrary.fulfilled, (state, action) => {
|
||||||
|
const { data } = action.payload as { source: 'cache' | 'network'; data: ExerciseConfigResponse };
|
||||||
|
state.loading = false;
|
||||||
|
state.categories = (data.categories || []).slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||||
|
const sorted: ExerciseConfigResponse = {
|
||||||
|
...data,
|
||||||
|
exercises: (data.exercises || []).slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)),
|
||||||
|
};
|
||||||
|
state.exercises = normalizeToLibraryItems(sorted);
|
||||||
|
state.lastUpdatedAt = Date.now();
|
||||||
|
})
|
||||||
|
.addCase(loadExerciseLibrary.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
.addCase(refreshExerciseLibrary.fulfilled, (state, action) => {
|
||||||
|
const data = action.payload as ExerciseConfigResponse;
|
||||||
|
state.categories = (data.categories || []).slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||||
|
const sorted: ExerciseConfigResponse = {
|
||||||
|
...data,
|
||||||
|
exercises: (data.exercises || []).slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)),
|
||||||
|
};
|
||||||
|
state.exercises = normalizeToLibraryItems(sorted);
|
||||||
|
state.lastUpdatedAt = Date.now();
|
||||||
|
})
|
||||||
|
.addCase(refreshExerciseLibrary.rejected, (state, action) => {
|
||||||
|
state.error = action.payload as string;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { clearExerciseLibraryError, setExerciseLibraryFromData } = exerciseLibrarySlice.actions;
|
||||||
|
export default exerciseLibrarySlice.reducer;
|
||||||
|
|
||||||
|
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||||
import challengeReducer from './challengeSlice';
|
import challengeReducer from './challengeSlice';
|
||||||
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||||
|
import exerciseLibraryReducer from './exerciseLibrarySlice';
|
||||||
|
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
||||||
import trainingPlanReducer from './trainingPlanSlice';
|
import trainingPlanReducer from './trainingPlanSlice';
|
||||||
import userReducer from './userSlice';
|
import userReducer from './userSlice';
|
||||||
|
import workoutReducer from './workoutSlice';
|
||||||
|
|
||||||
// 创建监听器中间件来处理自动同步
|
// 创建监听器中间件来处理自动同步
|
||||||
const listenerMiddleware = createListenerMiddleware();
|
const listenerMiddleware = createListenerMiddleware();
|
||||||
@@ -38,6 +41,9 @@ export const store = configureStore({
|
|||||||
challenge: challengeReducer,
|
challenge: challengeReducer,
|
||||||
checkin: checkinReducer,
|
checkin: checkinReducer,
|
||||||
trainingPlan: trainingPlanReducer,
|
trainingPlan: trainingPlanReducer,
|
||||||
|
scheduleExercise: scheduleExerciseReducer,
|
||||||
|
exerciseLibrary: exerciseLibraryReducer,
|
||||||
|
workout: workoutReducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
||||||
|
|||||||
233
store/scheduleExerciseSlice.ts
Normal file
233
store/scheduleExerciseSlice.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import {
|
||||||
|
scheduleExerciseApi,
|
||||||
|
type CreateScheduleExerciseDto,
|
||||||
|
type ReorderExercisesDto,
|
||||||
|
type ScheduleExercise,
|
||||||
|
type UpdateScheduleExerciseDto
|
||||||
|
} from '@/services/scheduleExerciseApi';
|
||||||
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export type ScheduleExerciseState = {
|
||||||
|
exercises: ScheduleExercise[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
currentPlanId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: ScheduleExerciseState = {
|
||||||
|
exercises: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
currentPlanId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载训练计划的所有项目
|
||||||
|
export const loadExercises = createAsyncThunk(
|
||||||
|
'scheduleExercise/loadExercises',
|
||||||
|
async (planId: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const exercises = await scheduleExerciseApi.list(planId);
|
||||||
|
return { exercises, planId };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '加载训练项目失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 添加训练项目
|
||||||
|
export const addExercise = createAsyncThunk(
|
||||||
|
'scheduleExercise/addExercise',
|
||||||
|
async ({ planId, dto }: { planId: string; dto: CreateScheduleExerciseDto }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const exercise = await scheduleExerciseApi.create(planId, dto);
|
||||||
|
return { exercise };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '添加训练项目失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新训练项目
|
||||||
|
export const updateExercise = createAsyncThunk(
|
||||||
|
'scheduleExercise/updateExercise',
|
||||||
|
async ({
|
||||||
|
planId,
|
||||||
|
exerciseId,
|
||||||
|
dto
|
||||||
|
}: {
|
||||||
|
planId: string;
|
||||||
|
exerciseId: string;
|
||||||
|
dto: UpdateScheduleExerciseDto;
|
||||||
|
}, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const exercise = await scheduleExerciseApi.update(planId, exerciseId, dto);
|
||||||
|
return { exercise };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '更新训练项目失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 删除训练项目
|
||||||
|
export const deleteExercise = createAsyncThunk(
|
||||||
|
'scheduleExercise/deleteExercise',
|
||||||
|
async ({ planId, exerciseId }: { planId: string; exerciseId: string }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await scheduleExerciseApi.delete(planId, exerciseId);
|
||||||
|
return { exerciseId };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '删除训练项目失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 重新排序训练项目
|
||||||
|
export const reorderExercises = createAsyncThunk(
|
||||||
|
'scheduleExercise/reorderExercises',
|
||||||
|
async ({ planId, dto }: { planId: string; dto: ReorderExercisesDto }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await scheduleExerciseApi.reorder(planId, dto);
|
||||||
|
// 重新加载排序后的列表
|
||||||
|
const exercises = await scheduleExerciseApi.list(planId);
|
||||||
|
return { exercises };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '重新排序失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新完成状态
|
||||||
|
export const toggleCompletion = createAsyncThunk(
|
||||||
|
'scheduleExercise/toggleCompletion',
|
||||||
|
async ({
|
||||||
|
planId,
|
||||||
|
exerciseId,
|
||||||
|
completed
|
||||||
|
}: {
|
||||||
|
planId: string;
|
||||||
|
exerciseId: string;
|
||||||
|
completed: boolean;
|
||||||
|
}, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const exercise = await scheduleExerciseApi.updateCompletion(planId, exerciseId, completed);
|
||||||
|
return { exercise };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '更新完成状态失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const scheduleExerciseSlice = createSlice({
|
||||||
|
name: 'scheduleExercise',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
clearError(state) {
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
clearExercises(state) {
|
||||||
|
state.exercises = [];
|
||||||
|
state.currentPlanId = null;
|
||||||
|
},
|
||||||
|
// 本地更新排序(用于拖拽等即时反馈)
|
||||||
|
updateLocalOrder(state, action: PayloadAction<string[]>) {
|
||||||
|
const newOrder = action.payload;
|
||||||
|
const orderedExercises = newOrder.map(id =>
|
||||||
|
state.exercises.find(ex => ex.id === id)
|
||||||
|
).filter(Boolean) as ScheduleExercise[];
|
||||||
|
state.exercises = orderedExercises;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
// loadExercises
|
||||||
|
.addCase(loadExercises.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(loadExercises.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.exercises = action.payload.exercises;
|
||||||
|
state.currentPlanId = action.payload.planId;
|
||||||
|
})
|
||||||
|
.addCase(loadExercises.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// addExercise
|
||||||
|
.addCase(addExercise.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(addExercise.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.exercises.push(action.payload.exercise);
|
||||||
|
})
|
||||||
|
.addCase(addExercise.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// updateExercise
|
||||||
|
.addCase(updateExercise.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(updateExercise.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
const index = state.exercises.findIndex(ex => ex.id === action.payload.exercise.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.exercises[index] = action.payload.exercise;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(updateExercise.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// deleteExercise
|
||||||
|
.addCase(deleteExercise.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(deleteExercise.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.exercises = state.exercises.filter(ex => ex.id !== action.payload.exerciseId);
|
||||||
|
})
|
||||||
|
.addCase(deleteExercise.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// reorderExercises
|
||||||
|
.addCase(reorderExercises.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(reorderExercises.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.exercises = action.payload.exercises;
|
||||||
|
})
|
||||||
|
.addCase(reorderExercises.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// toggleCompletion
|
||||||
|
.addCase(toggleCompletion.pending, (state) => {
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(toggleCompletion.fulfilled, (state, action) => {
|
||||||
|
const index = state.exercises.findIndex(ex => ex.id === action.payload.exercise.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.exercises[index] = action.payload.exercise;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(toggleCompletion.rejected, (state, action) => {
|
||||||
|
state.error = action.payload as string;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { clearError, clearExercises, updateLocalOrder } = scheduleExerciseSlice.actions;
|
||||||
|
export default scheduleExerciseSlice.reducer;
|
||||||
395
store/workoutSlice.ts
Normal file
395
store/workoutSlice.ts
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
import {
|
||||||
|
workoutsApi,
|
||||||
|
type AddWorkoutExerciseDto,
|
||||||
|
type CompleteWorkoutExerciseDto,
|
||||||
|
type StartWorkoutDto,
|
||||||
|
type StartWorkoutExerciseDto,
|
||||||
|
type UpdateWorkoutExerciseDto,
|
||||||
|
type WorkoutExercise,
|
||||||
|
type WorkoutSession,
|
||||||
|
type WorkoutSessionStatsResponse,
|
||||||
|
} from '@/services/workoutsApi';
|
||||||
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export interface WorkoutState {
|
||||||
|
// 当前训练会话
|
||||||
|
currentSession: WorkoutSession | null;
|
||||||
|
exercises: WorkoutExercise[];
|
||||||
|
stats: WorkoutSessionStatsResponse | null;
|
||||||
|
|
||||||
|
// 历史训练会话
|
||||||
|
sessions: WorkoutSession[];
|
||||||
|
sessionsPagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
loading: boolean;
|
||||||
|
exerciseLoading: string | null; // 正在操作的exercise ID
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: WorkoutState = {
|
||||||
|
currentSession: null,
|
||||||
|
exercises: [],
|
||||||
|
stats: null,
|
||||||
|
sessions: [],
|
||||||
|
sessionsPagination: null,
|
||||||
|
loading: false,
|
||||||
|
exerciseLoading: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 异步Action定义 ====================
|
||||||
|
|
||||||
|
// 获取今日训练
|
||||||
|
export const loadTodayWorkout = createAsyncThunk(
|
||||||
|
'workout/loadTodayWorkout',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const session = await workoutsApi.getTodayWorkout();
|
||||||
|
return session;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '获取今日训练失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 开始训练会话
|
||||||
|
export const startWorkoutSession = createAsyncThunk(
|
||||||
|
'workout/startSession',
|
||||||
|
async ({ sessionId, dto }: { sessionId: string; dto?: StartWorkoutDto }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const session = await workoutsApi.startSession(sessionId, dto);
|
||||||
|
return session;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '开始训练失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 开始训练动作
|
||||||
|
export const startWorkoutExercise = createAsyncThunk(
|
||||||
|
'workout/startExercise',
|
||||||
|
async ({ sessionId, exerciseId, dto }: {
|
||||||
|
sessionId: string;
|
||||||
|
exerciseId: string;
|
||||||
|
dto?: StartWorkoutExerciseDto
|
||||||
|
}, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const exercise = await workoutsApi.startExercise(sessionId, exerciseId, dto);
|
||||||
|
return exercise;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '开始动作失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 完成训练动作
|
||||||
|
export const completeWorkoutExercise = createAsyncThunk(
|
||||||
|
'workout/completeExercise',
|
||||||
|
async ({ sessionId, exerciseId, dto }: {
|
||||||
|
sessionId: string;
|
||||||
|
exerciseId: string;
|
||||||
|
dto: CompleteWorkoutExerciseDto
|
||||||
|
}, { rejectWithValue, getState }) => {
|
||||||
|
try {
|
||||||
|
const exercise = await workoutsApi.completeExercise(sessionId, exerciseId, dto);
|
||||||
|
|
||||||
|
// 完成动作后重新获取会话详情(检查是否自动完成)
|
||||||
|
const updatedSession = await workoutsApi.getSessionDetail(sessionId);
|
||||||
|
|
||||||
|
return { exercise, updatedSession };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '完成动作失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 跳过训练动作
|
||||||
|
export const skipWorkoutExercise = createAsyncThunk(
|
||||||
|
'workout/skipExercise',
|
||||||
|
async ({ sessionId, exerciseId }: { sessionId: string; exerciseId: string }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const exercise = await workoutsApi.skipExercise(sessionId, exerciseId);
|
||||||
|
|
||||||
|
// 跳过动作后重新获取会话详情(检查是否自动完成)
|
||||||
|
const updatedSession = await workoutsApi.getSessionDetail(sessionId);
|
||||||
|
|
||||||
|
return { exercise, updatedSession };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '跳过动作失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新训练动作
|
||||||
|
export const updateWorkoutExercise = createAsyncThunk(
|
||||||
|
'workout/updateExercise',
|
||||||
|
async ({ sessionId, exerciseId, dto }: {
|
||||||
|
sessionId: string;
|
||||||
|
exerciseId: string;
|
||||||
|
dto: UpdateWorkoutExerciseDto
|
||||||
|
}, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const exercise = await workoutsApi.updateExercise(sessionId, exerciseId, dto);
|
||||||
|
return exercise;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '更新动作失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取训练统计
|
||||||
|
export const loadWorkoutStats = createAsyncThunk(
|
||||||
|
'workout/loadStats',
|
||||||
|
async (sessionId: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const stats = await workoutsApi.getSessionStats(sessionId);
|
||||||
|
return stats;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '获取统计数据失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取训练会话列表
|
||||||
|
export const loadWorkoutSessions = createAsyncThunk(
|
||||||
|
'workout/loadSessions',
|
||||||
|
async ({ page = 1, limit = 10 }: { page?: number; limit?: number } = {}, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const result = await workoutsApi.getSessions(page, limit);
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '获取训练列表失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 添加动作到训练会话
|
||||||
|
export const addWorkoutExercise = createAsyncThunk(
|
||||||
|
'workout/addExercise',
|
||||||
|
async ({ sessionId, dto }: { sessionId: string; dto: AddWorkoutExerciseDto }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const exercise = await workoutsApi.addExercise(sessionId, dto);
|
||||||
|
return exercise;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '添加动作失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 删除训练会话
|
||||||
|
export const deleteWorkoutSession = createAsyncThunk(
|
||||||
|
'workout/deleteSession',
|
||||||
|
async (sessionId: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await workoutsApi.deleteSession(sessionId);
|
||||||
|
return sessionId;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '删除训练会话失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== Slice定义 ====================
|
||||||
|
|
||||||
|
const workoutSlice = createSlice({
|
||||||
|
name: 'workout',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
clearWorkoutError(state) {
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
clearCurrentWorkout(state) {
|
||||||
|
state.currentSession = null;
|
||||||
|
state.exercises = [];
|
||||||
|
state.stats = null;
|
||||||
|
},
|
||||||
|
// 本地更新exercise状态(用于乐观更新)
|
||||||
|
updateExerciseLocally(state, action: PayloadAction<Partial<WorkoutExercise> & { id: string }>) {
|
||||||
|
const { id, ...updates } = action.payload;
|
||||||
|
const exerciseIndex = state.exercises.findIndex(ex => ex.id === id);
|
||||||
|
if (exerciseIndex !== -1) {
|
||||||
|
Object.assign(state.exercises[exerciseIndex], updates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
// loadTodayWorkout
|
||||||
|
.addCase(loadTodayWorkout.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(loadTodayWorkout.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.currentSession = action.payload;
|
||||||
|
state.exercises = action.payload.exercises || [];
|
||||||
|
})
|
||||||
|
.addCase(loadTodayWorkout.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// startWorkoutSession
|
||||||
|
.addCase(startWorkoutSession.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(startWorkoutSession.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.currentSession = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(startWorkoutSession.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// startWorkoutExercise
|
||||||
|
.addCase(startWorkoutExercise.pending, (state, action) => {
|
||||||
|
state.exerciseLoading = action.meta.arg.exerciseId;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(startWorkoutExercise.fulfilled, (state, action) => {
|
||||||
|
state.exerciseLoading = null;
|
||||||
|
const exerciseIndex = state.exercises.findIndex(ex => ex.id === action.payload.id);
|
||||||
|
if (exerciseIndex !== -1) {
|
||||||
|
state.exercises[exerciseIndex] = action.payload;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(startWorkoutExercise.rejected, (state, action) => {
|
||||||
|
state.exerciseLoading = null;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// completeWorkoutExercise
|
||||||
|
.addCase(completeWorkoutExercise.pending, (state, action) => {
|
||||||
|
state.exerciseLoading = action.meta.arg.exerciseId;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(completeWorkoutExercise.fulfilled, (state, action) => {
|
||||||
|
state.exerciseLoading = null;
|
||||||
|
const { exercise, updatedSession } = action.payload;
|
||||||
|
|
||||||
|
// 更新exercise
|
||||||
|
const exerciseIndex = state.exercises.findIndex(ex => ex.id === exercise.id);
|
||||||
|
if (exerciseIndex !== -1) {
|
||||||
|
state.exercises[exerciseIndex] = exercise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新session(可能已自动完成)
|
||||||
|
state.currentSession = updatedSession;
|
||||||
|
})
|
||||||
|
.addCase(completeWorkoutExercise.rejected, (state, action) => {
|
||||||
|
state.exerciseLoading = null;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// skipWorkoutExercise
|
||||||
|
.addCase(skipWorkoutExercise.pending, (state, action) => {
|
||||||
|
state.exerciseLoading = action.meta.arg.exerciseId;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(skipWorkoutExercise.fulfilled, (state, action) => {
|
||||||
|
state.exerciseLoading = null;
|
||||||
|
const { exercise, updatedSession } = action.payload;
|
||||||
|
|
||||||
|
// 更新exercise
|
||||||
|
const exerciseIndex = state.exercises.findIndex(ex => ex.id === exercise.id);
|
||||||
|
if (exerciseIndex !== -1) {
|
||||||
|
state.exercises[exerciseIndex] = exercise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新session(可能已自动完成)
|
||||||
|
state.currentSession = updatedSession;
|
||||||
|
})
|
||||||
|
.addCase(skipWorkoutExercise.rejected, (state, action) => {
|
||||||
|
state.exerciseLoading = null;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// updateWorkoutExercise
|
||||||
|
.addCase(updateWorkoutExercise.pending, (state, action) => {
|
||||||
|
state.exerciseLoading = action.meta.arg.exerciseId;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(updateWorkoutExercise.fulfilled, (state, action) => {
|
||||||
|
state.exerciseLoading = null;
|
||||||
|
const exerciseIndex = state.exercises.findIndex(ex => ex.id === action.payload.id);
|
||||||
|
if (exerciseIndex !== -1) {
|
||||||
|
state.exercises[exerciseIndex] = action.payload;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(updateWorkoutExercise.rejected, (state, action) => {
|
||||||
|
state.exerciseLoading = null;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// loadWorkoutStats
|
||||||
|
.addCase(loadWorkoutStats.fulfilled, (state, action) => {
|
||||||
|
state.stats = action.payload;
|
||||||
|
})
|
||||||
|
|
||||||
|
// loadWorkoutSessions
|
||||||
|
.addCase(loadWorkoutSessions.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(loadWorkoutSessions.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.sessions = action.payload.sessions;
|
||||||
|
state.sessionsPagination = action.payload.pagination;
|
||||||
|
})
|
||||||
|
.addCase(loadWorkoutSessions.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// addWorkoutExercise
|
||||||
|
.addCase(addWorkoutExercise.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(addWorkoutExercise.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
// 将新添加的动作添加到exercises列表末尾
|
||||||
|
state.exercises.push(action.payload);
|
||||||
|
})
|
||||||
|
.addCase(addWorkoutExercise.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// deleteWorkoutSession
|
||||||
|
.addCase(deleteWorkoutSession.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(deleteWorkoutSession.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
const deletedSessionId = action.payload;
|
||||||
|
|
||||||
|
// 如果删除的是当前会话,清空当前会话数据
|
||||||
|
if (state.currentSession?.id === deletedSessionId) {
|
||||||
|
state.currentSession = null;
|
||||||
|
state.exercises = [];
|
||||||
|
state.stats = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从会话列表中移除已删除的会话
|
||||||
|
state.sessions = state.sessions.filter(session => session.id !== deletedSessionId);
|
||||||
|
})
|
||||||
|
.addCase(deleteWorkoutSession.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { clearWorkoutError, clearCurrentWorkout, updateExerciseLocally } = workoutSlice.actions;
|
||||||
|
export { addWorkoutExercise, deleteWorkoutSession };
|
||||||
|
export default workoutSlice.reducer;
|
||||||
Reference in New Issue
Block a user