feat: 更新训练计划和打卡功能

- 在训练计划中新增训练项目的添加、更新和删除功能,支持用户灵活管理训练内容
- 优化训练计划排课界面,提升用户体验
- 更新打卡功能,支持按日期加载和展示打卡记录
- 删除不再使用的打卡相关页面,简化代码结构
- 新增今日训练页面,集成今日训练计划和动作展示
- 更新样式以适应新功能的展示和交互
This commit is contained in:
richarjiang
2025-08-15 17:01:33 +08:00
parent f95401c1ce
commit dacbee197c
19 changed files with 3052 additions and 1197 deletions

View File

@@ -4,13 +4,14 @@ import { SearchBox } from '@/components/SearchBox';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { listRecommendedArticles } from '@/services/articles';
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
import { loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice';
// Removed WorkoutCard import since we no longer use the horizontal carousel
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { getChineseGreeting } from '@/utils/date';
import dayjs from 'dayjs';
import { useRouter } from 'expo-router';
import React from 'react';
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() {
const router = useRouter();
const dispatch = useAppDispatch();
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
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
const pan = React.useRef(new Animated.ValueXY()).current;
const [coachSize, setCoachSize] = React.useState({ width: 0, height: 0 });
@@ -101,6 +107,17 @@ export default function HomeScreen() {
// 打底数据(接口不可用时)
const getFallbackItems = React.useCallback((): RecommendItem[] => {
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',
key: 'assess',
@@ -122,10 +139,27 @@ export default function HomeScreen() {
readCount: a.readCount,
})),
];
}, [router]);
}, [router, pushIfAuthedElseLogin]);
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(() => {
let canceled = false;
@@ -158,10 +192,10 @@ export default function HomeScreen() {
type: 'plan',
key: c.id || 'checkin',
image: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
title: c.title || '今日打卡',
title: c.title || '今日训练',
subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持',
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; };
}, [isLoggedIn, pushIfAuthedElseLogin, getFallbackItems]);
// 处理点击训练计划卡片跳转到锻炼tab
const handlePlanCardPress = () => {
if (activePlan) {
// 跳转到训练计划页面的锻炼tab并传递planId参数
router.push(`/training-plan?planId=${activePlan.id}&tab=schedule` as any);
}
};
return (
<SafeAreaView style={[styles.safeArea, { 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]}
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.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}> · 11</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
style={[styles.featureCard, styles.featureCardQuaternary]}
onPress={() => pushIfAuthedElseLogin('/training-plan')}
>
<ThemedText style={styles.featureTitle}></ThemedText>
<ThemedText style={styles.featureSubtitle}> · </ThemedText>
<View style={styles.featureIconWrapper}>
<View style={styles.featureIconPlaceholder}>
<ThemedText style={styles.featureIconText}>💪</ThemedText>
</View>
</View>
<ThemedText style={styles.featureTitle}></ThemedText>
</Pressable>
</View>
</View>
{/* My Plan Section - 显示激活的训练计划 */}
{/* {activePlan && (
<MyPlanCard
plan={activePlan}
onPress={handlePlanCardPress}
/>
)} */}
{/* Today Plan Section */}
<View style={styles.sectionContainer}>
<ThemedText style={styles.sectionTitle}></ThemedText>
@@ -408,48 +451,89 @@ const styles = StyleSheet.create({
featureGrid: {
paddingHorizontal: 24,
flexDirection: 'row',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 12,
},
featureCard: {
width: '48%',
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
padding: 12,
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#FFFFFF',
marginBottom: 12,
// 轻量阴影,减少臃肿感
// 精致的阴影效果
shadowColor: '#000',
shadowOpacity: 0.04,
shadowOpacity: 0.06,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 2,
shadowOffset: { width: 0, height: 2 },
elevation: 3,
// 渐变边框效果
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.8)',
// 添加微妙的内阴影效果
position: 'relative',
minHeight: 48,
},
featureCardPrimary: {
backgroundColor: '#EEF2FF', // 柔和的靛蓝背景
// 由于RN不支持CSS渐变使用渐变色背景
backgroundColor: '#667eea',
},
featureCardSecondary: {
backgroundColor: '#F0FDFA', // 柔和的青绿背景
backgroundColor: '#4facfe',
},
featureCardTertiary: {
backgroundColor: '#FFF7ED', // 柔和的橙色背景
backgroundColor: '#43e97b',
},
featureCardQuaternary: {
backgroundColor: '#F5F3FF', // 柔和的紫色背景
backgroundColor: '#fa709a',
},
featureIcon: {
fontSize: 28,
marginBottom: 8,
featureIconWrapper: {
width: 32,
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: {
fontSize: 16,
fontSize: 14,
fontWeight: '700',
color: '#0F172A',
marginBottom: 4,
color: '#FFFFFF',
textAlign: 'left',
letterSpacing: 0.2,
flex: 1,
},
featureSubtitle: {
fontSize: 11,
color: '#6B7280',
lineHeight: 15,
fontSize: 12,
color: 'rgba(255, 255, 255, 0.85)',
lineHeight: 16,
textAlign: 'center',
fontWeight: '500',
},
planList: {
paddingHorizontal: 24,

View File

@@ -557,7 +557,7 @@ export default function AICoachChatScreen() {
style={styles.weightInput}
onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text)}
returnKeyType="done"
blurOnSubmit
submitBehavior="blurAndSubmit"
/>
<Text style={styles.weightUnit}>kg</Text>
<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);
} catch (e: any) {
Alert.alert('保存失败', e?.message || '请稍后重试');

View File

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

View File

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

View File

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

View File

@@ -21,31 +21,17 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors, palette } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
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 { 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 类型定义
type TabType = 'list' | 'schedule';
@@ -258,18 +244,15 @@ function BottomTabs({ activeTab, onTabChange, selectedPlan }: {
export default function TrainingPlanScreen() {
const router = useRouter();
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 { exercises, error: scheduleError } = useAppSelector((s) => s.scheduleExercise);
// Tab 状态管理
const [activeTab, setActiveTab] = useState<TabType>('list');
// Tab 状态管理 - 支持从URL参数设置初始tab
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 [exercises, setExercises] = useState<ScheduleExercise[]>([]);
const [scheduleNote, setScheduleNote] = useState('');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// 一键排课配置
const [genVisible, setGenVisible] = useState(false);
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 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(() => {
const loadSchedule = async () => {
if (selectedPlan) {
try {
const scheduleData = await loadScheduleData(selectedPlan.id);
if (scheduleData) {
setExercises(scheduleData.exercises);
setScheduleNote(scheduleData.note || '');
} else {
// 如果没有保存的排课数据,重置为默认空状态
setExercises([]);
setScheduleNote('');
}
} catch (error) {
console.error('加载排课数据失败:', error);
// 出错时重置为默认空状态
setExercises([]);
setScheduleNote('');
}
} else {
// 没有选中计划时,重置为默认空状态
setExercises([]);
setScheduleNote('');
}
};
loadSchedule();
}, [selectedPlan]);
if (selectedPlanId) {
dispatch(loadExercises(selectedPlanId));
} else {
dispatch(clearExercises());
}
}, [selectedPlanId, dispatch]);
useEffect(() => {
dispatch(loadPlans());
@@ -342,19 +285,15 @@ export default function TrainingPlanScreen() {
}
}, [error, dispatch]);
// 处理从选择页面传回的新动作
useEffect(() => {
if (params.newExercise) {
try {
const newExercise: ScheduleExercise = JSON.parse(params.newExercise);
setExercises(prev => [...prev, newExercise]);
setHasUnsavedChanges(true);
router.setParams({ newExercise: undefined } as any);
} catch (error) {
console.error('解析新动作数据失败:', error);
}
if (scheduleError) {
console.error('排课错误:', scheduleError);
const timer = setTimeout(() => {
dispatch(clearScheduleError());
}, 3000);
return () => clearTimeout(timer);
}
}, [params.newExercise]);
}, [scheduleError, dispatch]);
const handleActivate = async (planId: string) => {
try {
@@ -367,7 +306,6 @@ export default function TrainingPlanScreen() {
const handlePlanSelect = (plan: TrainingPlan) => {
setSelectedPlanId(plan.id);
setActiveTab('schedule');
// TODO: 加载该计划的排课数据
}
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 = () => {
router.push(`/training-plan/schedule/select?planId=${selectedPlanId}` as any);
};
const handleRemoveExercise = (key: string) => {
const handleRemoveExercise = (exerciseId: string) => {
if (!selectedPlanId) return;
Alert.alert('确认移除', '确定要移除该动作吗?', [
{ text: '取消', style: 'cancel' },
{
text: '移除',
style: 'destructive',
onPress: () => {
setExercises(prev => prev.filter(ex => ex.key !== key));
setHasUnsavedChanges(true);
dispatch(deleteExercise({ planId: selectedPlanId, exerciseId }));
},
},
]);
};
const handleToggleCompleted = (key: string) => {
setExercises(prev => prev.map(ex =>
ex.key === key ? { ...ex, completed: !ex.completed } : ex
));
setHasUnsavedChanges(true);
const handleToggleCompleted = (exerciseId: string, currentCompleted: boolean) => {
if (!selectedPlanId) return;
dispatch(toggleCompletion({
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 { items, note } = buildClassicalSession({
const { items } = buildClassicalSession({
withSectionRests: genWithRests,
restSeconds: restSec,
withNotes: genWithNotes,
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);
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>
</Animated.View>
{error && (
{(error || scheduleError) && (
<Animated.View entering={FadeInUp.duration(400)} style={styles.errorContainer}>
<ThemedText style={styles.errorText}> {error}</ThemedText>
<ThemedText style={styles.errorText}> {error || scheduleError}</ThemedText>
</Animated.View>
)}
@@ -567,7 +497,7 @@ export default function TrainingPlanScreen() {
{/* 动作列表 */}
<FlatList
data={exercises}
keyExtractor={(item) => item.key}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.scheduleListContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
@@ -607,7 +537,7 @@ export default function TrainingPlanScreen() {
</View>
<TouchableOpacity
style={styles.inlineRemoveBtn}
onPress={() => handleRemoveExercise(item.key)}
onPress={() => handleRemoveExercise(item.id)}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Ionicons name="close-outline" size={16} color="#888F92" />
@@ -624,9 +554,9 @@ export default function TrainingPlanScreen() {
<View style={styles.exerciseContent}>
<View style={styles.exerciseInfo}>
<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}>
{item.sets}
{item.sets || 1}
{item.reps ? ` · 每组 ${item.reps}` : ''}
{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''}
</ThemedText>
@@ -635,7 +565,7 @@ export default function TrainingPlanScreen() {
<View style={styles.exerciseActions}>
<TouchableOpacity
style={styles.completeBtn}
onPress={() => handleToggleCompleted(item.key)}
onPress={() => handleToggleCompleted(item.id, item.completed)}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Ionicons
@@ -647,7 +577,7 @@ export default function TrainingPlanScreen() {
<TouchableOpacity
style={styles.removeBtn}
onPress={() => handleRemoveExercise(item.key)}
onPress={() => handleRemoveExercise(item.id)}
>
<Text style={styles.removeBtnText}></Text>
</TouchableOpacity>
@@ -678,10 +608,6 @@ export default function TrainingPlanScreen() {
<TouchableOpacity onPress={() => router.push('/training-plan/create' as any)} style={styles.headerRightBtn}>
<ThemedText style={styles.headerRightBtnText}>+ </ThemedText>
</TouchableOpacity>
) : hasUnsavedChanges ? (
<TouchableOpacity onPress={handleSave} style={styles.headerRightBtn}>
<ThemedText style={styles.headerRightBtnText}></ThemedText>
</TouchableOpacity>
) : undefined
}
/>
@@ -1462,4 +1388,17 @@ const styles = StyleSheet.create({
fontWeight: '800',
fontSize: 10,
},
// 统计显示
statsContainer: {
paddingHorizontal: 12,
paddingVertical: 4,
backgroundColor: 'rgba(187,242,70,0.2)',
borderRadius: 16,
},
statsText: {
fontSize: 12,
fontWeight: '800',
color: palette.ink,
},
});

View File

@@ -1,18 +1,18 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { palette } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { fetchExerciseConfig, normalizeToLibraryItems } from '@/services/exercises';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { loadExerciseLibrary } from '@/store/exerciseLibrarySlice';
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 { LinearGradient } from 'expo-linear-gradient';
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';
import { Alert, Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native';
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 }> = {
postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' },
@@ -40,12 +40,20 @@ function DynamicBackground({ color }: { color: string }) {
export default function SelectExerciseForScheduleScreen() {
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 { currentSession } = useAppSelector((s) => s.workout);
const planId = params.planId;
const sessionId = params.sessionId;
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 [category, setCategory] = useState<string>('全部');
@@ -55,8 +63,12 @@ export default function SelectExerciseForScheduleScreen() {
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 [showRestModal, setShowRestModal] = useState(false);
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;
@@ -67,39 +79,16 @@ export default function SelectExerciseForScheduleScreen() {
}, []);
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; };
}, []);
dispatch(loadExerciseLibrary());
}, [dispatch]);
const categories = useMemo(() => {
const base = serverCategories ?? getCategories();
return ['全部', ...base];
}, [serverCategories]);
const base = serverCategoryDtos && serverCategoryDtos.length
? serverCategoryDtos.map((c) => c.name)
: getCategories();
const unique = Array.from(new Set(base));
return ['全部', ...unique];
}, [serverCategoryDtos]);
const mainCategories = useMemo(() => {
const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑'];
@@ -110,7 +99,7 @@ export default function SelectExerciseForScheduleScreen() {
return picked;
}, [categories]);
const library = useMemo(() => serverLibrary ?? EXERCISE_LIBRARY, [serverLibrary]);
const library = useMemo(() => (serverExercises && serverExercises.length ? serverExercises : EXERCISE_LIBRARY), [serverExercises]);
const filtered = useMemo(() => {
const kw = keyword.trim().toLowerCase();
@@ -131,29 +120,126 @@ export default function SelectExerciseForScheduleScreen() {
}).start();
}, [selected, controlsOpacity]);
const handleAdd = () => {
if (!selected || !plan) return;
const handleAdd = async () => {
if (!selected || adding) return;
const exerciseData: ScheduleExercise = {
key: `${selected.key}_${Date.now()}`,
console.log('选择动作:', selected);
const newExerciseDto = {
exerciseKey: selected.key,
name: selected.name,
category: selected.category,
sets: Math.max(1, sets),
reps: reps && reps > 0 ? reps : undefined,
itemType: 'exercise',
completed: false,
plannedSets: sets,
plannedReps: reps,
itemType: 'exercise' as const,
note: `${selected.category}训练`,
};
console.log('添加动作到排课:', exerciseData);
// 通过路由参数传递数据回到排课页面
router.push({
pathname: '/training-plan/schedule',
params: {
planId: planId,
newExercise: JSON.stringify(exerciseData)
setAdding(true);
try {
if (isSessionMode && sessionId) {
// 训练会话模式:添加到训练会话
await dispatch(addWorkoutExercise({ sessionId, dto: newExerciseDto })).unwrap();
} else if (plan) {
// 训练计划模式:添加到训练计划
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) => {
@@ -162,19 +248,22 @@ export default function SelectExerciseForScheduleScreen() {
setSelectedKey(null);
return;
}
setSets(3);
setReps(undefined);
const sel = library.find((e) => e.key === key) as any;
setSets(sel?.beginnerSets ?? 3);
setReps(sel?.beginnerReps);
setShowCustomReps(false);
setCustomRepsInput('');
setSelectedKey(key);
};
if (!plan || !goalConfig) {
if (!goalConfig || (!plan && !isSessionMode)) {
return (
<SafeAreaView style={styles.safeArea}>
<HeaderBar title="选择动作" onBack={() => router.back()} />
<View style={styles.errorContainer}>
<ThemedText style={styles.errorText}></ThemedText>
<ThemedText style={styles.errorText}>
{isSessionMode ? '找不到指定的训练会话' : '找不到指定的训练计划'}
</ThemedText>
</View>
</SafeAreaView>
);
@@ -187,7 +276,7 @@ export default function SelectExerciseForScheduleScreen() {
<SafeAreaView style={styles.contentWrapper}>
<HeaderBar
title="选择动作"
title={isSessionMode ? "添加动作" : "选择动作"}
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
@@ -200,10 +289,31 @@ export default function SelectExerciseForScheduleScreen() {
<View style={[styles.planColorIndicator, { backgroundColor: goalConfig.color }]} />
<View style={styles.planInfo}>
<ThemedText style={styles.planTitle}>{goalConfig.title}</ThemedText>
<ThemedText style={styles.planDescription}></ThemedText>
<ThemedText style={styles.planDescription}>
{isSessionMode ? '为当前训练会话添加动作' : '选择动作或添加休息、备注项目'}
</ThemedText>
</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}>
{[...mainCategories, '更多'].map((item) => {
@@ -327,6 +437,16 @@ export default function SelectExerciseForScheduleScreen() {
<View style={{ flex: 1 }}>
<Text style={styles.itemTitle}>{item.name}</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>
</View>
{isSelected && <Ionicons name="chevron-down" size={20} color={goalConfig.color} />}
@@ -414,12 +534,14 @@ export default function SelectExerciseForScheduleScreen() {
style={[
styles.addBtn,
{ 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}
>
<Text style={styles.addBtnText}></Text>
<Text style={styles.addBtnText}>
{adding ? '添加中...' : (isSessionMode ? '添加到训练会话' : '添加到训练计划')}
</Text>
</TouchableOpacity>
</Animated.View>
)}
@@ -429,6 +551,104 @@ export default function SelectExerciseForScheduleScreen() {
/>
</View>
</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>
);
}
@@ -492,6 +712,28 @@ const styles = StyleSheet.create({
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: {
paddingTop: 10,
@@ -671,7 +913,104 @@ const styles = StyleSheet.create({
color: '#FFFFFF',
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: {
marginTop: 20,
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',

15
app/workout/_layout.tsx Normal file
View 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

File diff suppressed because it is too large Load Diff