diff --git a/.kilocode/rules/kilo-rule.md b/.kilocode/rules/kilo-rule.md
new file mode 100644
index 0000000..4866c6b
--- /dev/null
+++ b/.kilocode/rules/kilo-rule.md
@@ -0,0 +1,7 @@
+# kilo-rule.md
+永远记得你是一名专业的 reac native 工程师,并且当前项目是一个 prebuild 之后的 expo react native 项目,应用场景永远是 ios,不要考虑 android
+
+## 指导原则
+
+- 遇到比较复杂的页面,尽量使用可以复用的组件
+- 不要尝试使用 `npm run ios` 命令
diff --git a/app.json b/app.json
index 226db94..e75e74d 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Out Live",
"slug": "digital-pilates",
- "version": "1.0.16",
+ "version": "1.0.17",
"orientation": "portrait",
"scheme": "digitalpilates",
"userInterfaceStyle": "light",
diff --git a/app/fitness-rings-detail.tsx b/app/fitness-rings-detail.tsx
index 171a9a0..375cb1a 100644
--- a/app/fitness-rings-detail.tsx
+++ b/app/fitness-rings-detail.tsx
@@ -330,9 +330,11 @@ export default function FitnessRingsDetailScreen() {
style={[
styles.chartBar,
{
+ flex: 1,
height: value > 0 ? height : 2, // 没有数据时显示最小高度的灰色条
backgroundColor: value > 0 ? color : '#E5E5EA',
- opacity: value > 0 ? 1 : 0.5
+ opacity: value > 0 ? 1 : 0.5,
+ marginHorizontal: 0.5
}
]}
/>
@@ -340,10 +342,19 @@ export default function FitnessRingsDetailScreen() {
})}
- 00:00
- 06:00
- 12:00
- 18:00
+ {chartData.map((_, index) => {
+ // 只在关键时间点显示标签:0点、6点、12点、18点
+ if (index === 0 || index === 6 || index === 12 || index === 18) {
+ const hour = index;
+ return (
+
+ {hour.toString().padStart(2, '0')}:00
+
+ );
+ }
+ // 对于不显示标签的小时,返回一个占位的View
+ return ;
+ })}
);
@@ -731,23 +742,25 @@ const styles = StyleSheet.create({
alignItems: 'flex-end',
height: 60,
marginBottom: 8,
- paddingHorizontal: 4,
- justifyContent: 'space-between',
+ paddingHorizontal: 2,
},
chartBar: {
- width: 3,
borderRadius: 1.5,
- marginHorizontal: 0.5,
},
chartLabels: {
flexDirection: 'row',
+ paddingHorizontal: 2,
justifyContent: 'space-between',
- paddingHorizontal: 4,
},
chartLabel: {
- fontSize: 12,
+ fontSize: 10,
color: '#8E8E93',
fontWeight: '500',
+ textAlign: 'center',
+ flex: 6, // 给显示标签的元素更多空间
+ },
+ chartLabelSpacer: {
+ flex: 1, // 占位元素使用较少空间
},
// 锻炼信息样式
exerciseInfo: {
diff --git a/app/workout/_layout.tsx b/app/workout/_layout.tsx
deleted file mode 100644
index ad4b3a6..0000000
--- a/app/workout/_layout.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Stack } from 'expo-router';
-
-export default function WorkoutLayout() {
- return (
-
-
-
-
-
-
- );
-}
diff --git a/app/workout/history.tsx b/app/workout/history.tsx
index 5ac4df8..6112822 100644
--- a/app/workout/history.tsx
+++ b/app/workout/history.tsx
@@ -1,6 +1,7 @@
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
+import isBetween from 'dayjs/plugin/isBetween';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useMemo, useState } from 'react';
import {
@@ -13,7 +14,9 @@ import {
} from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar';
+import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
+import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
import {
addHealthPermissionListener,
checkHealthPermissionStatus,
@@ -138,6 +141,97 @@ const ICON_MAP: Partial 0) {
+ return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`;
+ }
+
+ return `${totalMinutes}m`;
+}
+
+// 扩展 dayjs 以支持 isBetween 插件
+dayjs.extend(isBetween);
+
+function computeMonthlyStats(workouts: WorkoutData[]): MonthlyStatsInfo | null {
+ const now = dayjs();
+ const monthStart = now.startOf('month');
+ const monthEnd = now.endOf('month');
+
+ const monthlyEntries = workouts.filter((workout) => {
+ const workoutDate = dayjs(workout.startDate || workout.endDate);
+ if (!workoutDate.isValid()) {
+ return false;
+ }
+ return workoutDate.isBetween(monthStart, monthEnd, 'day', '[]');
+ });
+
+ if (monthlyEntries.length === 0) {
+ return null;
+ }
+
+ const summaryMap = monthlyEntries.reduce>((acc, workout) => {
+ const type = workout.workoutActivityType;
+ if (type === undefined || type === null) {
+ return acc;
+ }
+
+ const mapKey = String(type);
+
+ if (!acc[mapKey]) {
+ acc[mapKey] = {
+ type,
+ duration: 0,
+ count: 0,
+ displayName: getWorkoutTypeDisplayName(type),
+ iconName: ICON_MAP[type as WorkoutActivityType] || 'run',
+ };
+ }
+
+ acc[mapKey].duration += workout.duration || 0;
+ acc[mapKey].count += 1;
+ return acc;
+ }, {});
+
+ const items = Object.values(summaryMap).sort((a, b) => b.duration - a.duration);
+ const totalDuration = monthlyEntries.reduce((sum, workout) => sum + (workout.duration || 0), 0);
+
+ return {
+ items,
+ totalDuration,
+ totalCount: monthlyEntries.length,
+ monthStart: monthStart.format('YYYY-MM-DD'),
+ monthEnd: monthEnd.format('YYYY-MM-DD'),
+ snapshotDate: now.format('YYYY-MM-DD'),
+ };
+}
+
function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) {
if (!totalCalories || !durationInSeconds) {
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
@@ -180,6 +274,14 @@ export default function WorkoutHistoryScreen() {
const [sections, setSections] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
+ const [selectedWorkout, setSelectedWorkout] = useState(null);
+ const [isDetailVisible, setIsDetailVisible] = useState(false);
+ const [detailMetrics, setDetailMetrics] = useState(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [detailError, setDetailError] = useState(null);
+ const [selectedIntensity, setSelectedIntensity] = useState(null);
+ const [monthOccurrenceText, setMonthOccurrenceText] = useState(null);
+ const [monthlyStats, setMonthlyStats] = useState(null);
const loadHistory = useCallback(async () => {
setIsLoading(true);
@@ -198,6 +300,7 @@ export default function WorkoutHistoryScreen() {
if (!hasPermission) {
setSections([]);
setError('尚未授予健康数据权限');
+ setMonthlyStats(null);
return;
}
@@ -206,11 +309,13 @@ export default function WorkoutHistoryScreen() {
const workouts = await fetchWorkoutsForDateRange(start.toDate(), end.toDate(), 200);
const filteredWorkouts = workouts.filter((workout) => workout.duration && workout.duration > 0);
+ setMonthlyStats(computeMonthlyStats(filteredWorkouts));
setSections(groupWorkouts(filteredWorkouts));
} catch (err) {
console.error('加载锻炼历史失败:', err);
setError('加载锻炼记录失败,请稍后再试');
setSections([]);
+ setMonthlyStats(null);
} finally {
setIsLoading(false);
}
@@ -233,12 +338,77 @@ export default function WorkoutHistoryScreen() {
};
}, [loadHistory]);
- const headerComponent = useMemo(() => (
-
- 历史
- 最近一个月的锻炼记录
-
- ), []);
+ const headerComponent = useMemo(() => {
+ const statsItems = monthlyStats?.items.slice(0, 3) ?? [];
+ const monthEndDay = monthlyStats
+ ? dayjs(monthlyStats.monthEnd).date()
+ : dayjs().endOf('month').date();
+ const snapshotLabel = monthlyStats
+ ? dayjs(monthlyStats.snapshotDate).format('M月D日')
+ : dayjs().format('M月D日');
+ const overviewText = monthlyStats
+ ? `截至${snapshotLabel},你已完成${monthlyStats.totalCount}次锻炼,累计${formatDurationShort(monthlyStats.totalDuration)}。`
+ : '本月还没有锻炼记录,动起来收集第一条吧!';
+ const periodText = `统计周期:1日 - ${monthEndDay}日(本月)`;
+ const maxDuration = statsItems[0]?.duration || 1;
+
+ return (
+
+ {/* 历史
+ 最近一个月的锻炼记录 */}
+
+
+ {/* 统计 */}
+
+
+ 锻炼时间
+ {periodText}
+ {overviewText}
+
+ {statsItems.length > 0 ? (
+ statsItems.map((item, index) => {
+ const palette = MONTHLY_STAT_COLORS[index % MONTHLY_STAT_COLORS.length];
+ const ratio = Math.max(Math.min(item.duration / maxDuration, 1), 0.18);
+ return (
+
+
+
+
+
+
+ X{item.count}
+
+
+ {formatDurationShort(item.duration)}
+ {item.displayName}
+
+
+
+
+ );
+ })
+ ) : (
+
+
+ 本月还没有锻炼数据
+
+ )}
+
+
+
+
+ );
+ }, [monthlyStats]);
const emptyComponent = useMemo(() => (
@@ -248,6 +418,77 @@ export default function WorkoutHistoryScreen() {
), []);
+ const computeMonthlyOccurrenceText = useCallback((workout: WorkoutData): string | null => {
+ const workoutDate = dayjs(workout.startDate || workout.endDate);
+ if (!workoutDate.isValid() || sections.length === 0) {
+ return null;
+ }
+
+ const sameMonthWorkouts = sections
+ .flatMap((section) => section.data)
+ .filter((entry) => {
+ const entryDate = dayjs(entry.startDate || entry.endDate);
+ return (
+ entryDate.isValid() &&
+ entryDate.isSame(workoutDate, 'month') &&
+ entry.workoutActivityType === workout.workoutActivityType
+ );
+ });
+
+ const ascending = sameMonthWorkouts.some((entry) => entry.id === workout.id)
+ ? sameMonthWorkouts
+ : [...sameMonthWorkouts, workout];
+
+ ascending.sort(
+ (a, b) =>
+ dayjs(a.startDate || a.endDate).valueOf() - dayjs(b.startDate || b.endDate).valueOf()
+ );
+
+ const index = ascending.findIndex((entry) => entry.id === workout.id);
+ if (index === -1) {
+ return null;
+ }
+
+ const activityLabel = getWorkoutTypeDisplayName(workout.workoutActivityType);
+ return `这是你${workoutDate.format('M月')}的第 ${index + 1} 次${activityLabel}。`;
+ }, [sections]);
+
+ const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => {
+ setDetailLoading(true);
+ setDetailError(null);
+ try {
+ const metrics = await getWorkoutDetailMetrics(workout);
+ setDetailMetrics(metrics);
+ } catch (err) {
+ console.error('加载锻炼详情失败:', err);
+ setDetailMetrics(null);
+ setDetailError('加载锻炼详情失败,请稍后再试');
+ } finally {
+ setDetailLoading(false);
+ }
+ }, []);
+
+ const handleWorkoutPress = useCallback((workout: WorkoutData) => {
+ const intensity = getIntensityBadge(workout.totalEnergyBurned, workout.duration || 0);
+ setSelectedIntensity(intensity);
+ setSelectedWorkout(workout);
+ setDetailMetrics(null);
+ setDetailError(null);
+ setMonthOccurrenceText(computeMonthlyOccurrenceText(workout));
+ setIsDetailVisible(true);
+ loadWorkoutDetail(workout);
+ }, [computeMonthlyOccurrenceText, loadWorkoutDetail]);
+
+ const handleRetryDetail = useCallback(() => {
+ if (selectedWorkout) {
+ loadWorkoutDetail(selectedWorkout);
+ }
+ }, [selectedWorkout, loadWorkoutDetail]);
+
+ const handleCloseDetail = useCallback(() => {
+ setIsDetailVisible(false);
+ }, []);
+
const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
const calories = Math.round(item.totalEnergyBurned || 0);
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
@@ -257,7 +498,11 @@ export default function WorkoutHistoryScreen() {
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
return (
- { }}>
+ handleWorkoutPress(item)}
+ >
@@ -275,7 +520,7 @@ export default function WorkoutHistoryScreen() {
{/* */}
);
- }, []);
+ }, [handleWorkoutPress]);
const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => (
{section.title}
@@ -315,6 +560,17 @@ export default function WorkoutHistoryScreen() {
showsVerticalScrollIndicator={false}
/>
)}
+
);
}
@@ -342,6 +598,103 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#677086',
},
+ monthlyStatsWrapper: {
+ },
+ monthlyStatsTitle: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: '#1F2355',
+ marginBottom: 14,
+ },
+ monthlyStatsCardShell: {
+ borderRadius: 28,
+ shadowColor: '#5460E54D',
+ shadowOffset: { width: 0, height: 8 },
+ shadowOpacity: 0.12,
+ shadowRadius: 18,
+ elevation: 8,
+ },
+ monthlyStatsCard: {
+ paddingHorizontal: 20,
+ paddingVertical: 22,
+ borderRadius: 28,
+ overflow: 'hidden',
+ gap: 12,
+ },
+ statSectionLabel: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: '#8289A9',
+ },
+ statPeriodText: {
+ fontSize: 12,
+ color: '#8C95B0',
+ },
+ statDescription: {
+ marginTop: 2,
+ fontSize: 13,
+ color: '#525A7A',
+ lineHeight: 18,
+ },
+ summaryRowWrapper: {
+ marginTop: 12,
+ },
+ summaryRowBackground: {
+ borderRadius: 24,
+ overflow: 'hidden',
+ position: 'relative',
+ },
+ summaryRowFill: {
+ position: 'absolute',
+ top: 0,
+ bottom: 0,
+ left: 0,
+ },
+ summaryRowInner: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 16,
+ paddingHorizontal: 18,
+ gap: 14,
+ },
+ summaryBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: 14,
+ paddingVertical: 9,
+ borderRadius: 999,
+ gap: 6,
+ },
+ summaryRowContent: {
+ flex: 1,
+ gap: 4,
+ },
+ summaryCount: {
+ fontSize: 14,
+ fontWeight: '700',
+ color: '#2F2965',
+ },
+ summaryDuration: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: '#2F2965',
+ },
+ summaryActivity: {
+ fontSize: 13,
+ fontWeight: '500',
+ color: '#565F7F',
+ },
+ statEmptyState: {
+ marginTop: 14,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ statEmptyText: {
+ fontSize: 13,
+ color: '#7C85A3',
+ },
listContent: {
paddingBottom: 40,
},
diff --git a/app/workout/session/[id].tsx b/app/workout/session/[id].tsx
deleted file mode 100644
index d270f79..0000000
--- a/app/workout/session/[id].tsx
+++ /dev/null
@@ -1,1115 +0,0 @@
-import { Ionicons } from '@expo/vector-icons';
-import * as Haptics from 'expo-haptics';
-import { LinearGradient } from 'expo-linear-gradient';
-import { useLocalSearchParams, useRouter } from 'expo-router';
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
-import Animated, { FadeInUp } from 'react-native-reanimated';
-
-import { CircularRing } from '@/components/CircularRing';
-import { ThemedText } from '@/components/ThemedText';
-import { HeaderBar } from '@/components/ui/HeaderBar';
-import { palette } from '@/constants/Colors';
-import { useAppDispatch, useAppSelector } from '@/hooks/redux';
-import type { WorkoutExercise, WorkoutSession } from '@/services/workoutsApi';
-import { workoutsApi } from '@/services/workoutsApi';
-import {
- clearWorkoutError,
- completeWorkoutExercise,
- deleteWorkoutSession,
- skipWorkoutExercise,
- startWorkoutExercise,
- startWorkoutSession
-} from '@/store/workoutSlice';
-import { useFocusEffect } from '@react-navigation/native';
-
-// ==================== 工具函数 ====================
-
-// 计算两个时间之间的耗时(秒)
-const calculateDuration = (startTime: string, endTime: string): number => {
- const start = new Date(startTime);
- const end = new Date(endTime);
- return Math.floor((end.getTime() - start.getTime()) / 1000);
-};
-
-// 格式化耗时显示(分钟:秒)
-const formatDuration = (seconds: number): string => {
- const minutes = Math.floor(seconds / 60);
- const remainingSeconds = seconds % 60;
- return `${minutes}分${remainingSeconds.toString().padStart(2, '0')}秒`;
-};
-
-// 获取动作的耗时信息
-const getExerciseDuration = (exercise: WorkoutExercise): { duration: number; formatted: string } | null => {
- if (exercise.status === 'completed' && exercise.startedAt && exercise.completedAt) {
- const duration = calculateDuration(exercise.startedAt, exercise.completedAt);
- return {
- duration,
- formatted: formatDuration(duration)
- };
- }
- return null;
-};
-
-const GOAL_TEXT: Record = {
- postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' },
- fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' },
- posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' },
- core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' },
- flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' },
- rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' },
- stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' },
-};
-
-// 动态背景组件
-function DynamicBackground({ color }: { color: string }) {
- return (
-
-
-
-
-
- );
-}
-
-export default function WorkoutSessionDetailScreen() {
- const router = useRouter();
- const dispatch = useAppDispatch();
- const params = useLocalSearchParams<{ id: string }>();
- const { exerciseLoading, error } = useAppSelector((s) => s.workout);
-
- const [session, setSession] = useState(null);
- const [exercises, setExercises] = useState([]);
- const [loading, setLoading] = useState(true);
-
- // 本地状态
- const [completionModal, setCompletionModal] = useState<{
- visible: boolean;
- exercise: WorkoutExercise | null;
- sets: number;
- reps: number;
- }>({
- visible: false,
- exercise: null,
- sets: 0,
- reps: 0,
- });
-
- const sessionId = params.id;
-
- // 加载会话详情 - 每次页面展示时都拉取最新数据
- useFocusEffect(
- useCallback(() => {
- if (!sessionId) return;
-
- const loadSessionDetail = async () => {
- try {
- setLoading(true);
- const [sessionDetail, sessionExercises] = await Promise.all([
- workoutsApi.getSessionDetail(sessionId),
- workoutsApi.getSessionExercises(sessionId)
- ]);
- setSession(sessionDetail);
- setExercises(sessionExercises);
- } catch (error) {
- console.error('加载会话详情失败:', error);
- Alert.alert('加载失败', '无法加载训练会话详情');
- } finally {
- setLoading(false);
- }
- };
-
- loadSessionDetail();
- }, [sessionId])
- );
-
- const goalConfig = session?.trainingPlan
- ? (GOAL_TEXT[session.trainingPlan.goal] || { title: '训练会话', color: palette.primary, description: '开始你的训练之旅' })
- : { title: session?.name, color: palette.primary, description: '开始你的训练之旅' };
-
- // 错误处理
- useEffect(() => {
- if (error) {
- Alert.alert('错误', error, [
- { text: '确定', onPress: () => dispatch(clearWorkoutError()) }
- ]);
- }
- }, [error, dispatch]);
-
- // 训练状态统计
- const workoutStats = useMemo(() => {
- const exerciseItems = exercises.filter(ex => ex.itemType === 'exercise');
- return {
- total: exerciseItems.length,
- completed: exerciseItems.filter(ex => ex.status === 'completed').length,
- inProgress: exerciseItems.filter(ex => ex.status === 'in_progress').length,
- pending: exerciseItems.filter(ex => ex.status === 'pending').length,
- skipped: exerciseItems.filter(ex => ex.status === 'skipped').length,
- };
- }, [exercises]);
-
- const completionPercentage = workoutStats.total > 0
- ? Math.round((workoutStats.completed / workoutStats.total) * 100)
- : 0;
-
- // 开始训练会话
- const handleStartWorkout = () => {
- if (!session) return;
-
- Alert.alert(
- '开始训练',
- '准备好开始训练了吗?',
- [
- { text: '取消', style: 'cancel' },
- {
- text: '开始',
- onPress: async () => {
- try {
- await dispatch(startWorkoutSession({ sessionId: session.id })).unwrap();
- // 重新加载会话详情
- const updatedSession = await workoutsApi.getSessionDetail(session.id);
- setSession(updatedSession);
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
- } catch (error) {
- console.error('开始训练失败:', error);
- }
- }
- }
- ]
- );
- };
-
- // 开始动作
- const handleStartExercise = async (exercise: WorkoutExercise) => {
- if (!session || exercise.status !== 'pending') return;
-
- try {
- const updatedExercise = await dispatch(startWorkoutExercise({
- sessionId: session.id,
- exerciseId: exercise.id
- })).unwrap();
-
- // 更新本地exercises列表
- setExercises(prev => prev.map(ex => ex.id === exercise.id ? updatedExercise : ex));
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
- } catch (error) {
- console.error('开始动作失败:', error);
- }
- };
-
- // 显示完成动作模态框
- const handleShowCompleteModal = (exercise: WorkoutExercise) => {
- setCompletionModal({
- visible: true,
- exercise,
- sets: exercise.completedSets || exercise.plannedSets || 0,
- reps: exercise.completedReps || exercise.plannedReps || 0,
- });
- };
-
- // 完成动作
- const handleCompleteExercise = async () => {
- const { exercise, sets, reps } = completionModal;
- if (!session || !exercise) return;
-
- try {
- const result = await dispatch(completeWorkoutExercise({
- sessionId: session.id,
- exerciseId: exercise.id,
- dto: {
- completedSets: sets,
- completedReps: reps,
- }
- })).unwrap();
-
- // 更新本地exercises列表和session
- setExercises(prev => prev.map(ex => ex.id === exercise.id ? result.exercise : ex));
- setSession(result.updatedSession);
-
- setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 });
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
- } catch (error) {
- console.error('完成动作失败:', error);
- }
- };
-
- // 跳过动作
- const handleSkipExercise = (exercise: WorkoutExercise) => {
- if (!session) return;
-
- Alert.alert(
- '跳过动作',
- `确定要跳过"${exercise.name}"吗?`,
- [
- { text: '取消', style: 'cancel' },
- {
- text: '跳过',
- style: 'destructive',
- onPress: async () => {
- try {
- const result = await dispatch(skipWorkoutExercise({
- sessionId: session.id,
- exerciseId: exercise.id
- })).unwrap();
-
- // 更新本地exercises列表和session
- setExercises(prev => prev.map(ex => ex.id === exercise.id ? result.exercise : ex));
- setSession(result.updatedSession);
-
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
- } catch (error) {
- console.error('跳过动作失败:', error);
- }
- }
- }
- ]
- );
- };
-
- // 删除训练会话
- const handleDeleteSession = () => {
- if (!session) return;
-
- Alert.alert(
- '删除训练会话',
- '确定要删除这个训练会话吗?删除后无法恢复。',
- [
- { text: '取消', style: 'cancel' },
- {
- text: '删除',
- style: 'destructive',
- onPress: async () => {
- try {
- await dispatch(deleteWorkoutSession(session.id)).unwrap();
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
- router.back();
- } catch (error) {
- console.error('删除会话失败:', error);
- }
- }
- }
- ]
- );
- };
-
- // 获取动作状态文本和颜色
- const getExerciseStatusConfig = (exercise: WorkoutExercise) => {
- switch (exercise.status) {
- case 'completed':
- return { text: '已完成', color: '#22C55E', backgroundColor: '#22C55E15' };
- case 'in_progress':
- return { text: '进行中', color: '#F59E0B', backgroundColor: '#F59E0B15' };
- case 'skipped':
- return { text: '已跳过', color: '#6B7280', backgroundColor: '#6B728015' };
- default:
- return { text: '待开始', color: '#6B7280', backgroundColor: '#6B728015' };
- }
- };
-
- // 渲染动作卡片
- const renderExerciseItem = ({ item, index }: { item: WorkoutExercise; index: number }) => {
- const statusConfig = getExerciseStatusConfig(item);
- const isLoading = exerciseLoading === item.id;
-
- if (item.itemType === 'rest') {
- return (
-
-
-
-
-
- {item.name}
- {item.restSec}秒
-
-
- );
- }
-
- if (item.itemType === 'note') {
- return (
-
-
-
-
-
- {item.name}
- {item.note && {item.note}}
-
-
- );
- }
-
- return (
-
-
-
- {item.name}
- {item.exercise && (
- {item.exercise.categoryName}
- )}
-
-
-
- {statusConfig.text}
-
-
-
-
-
- {item.plannedSets && (
-
- {item.plannedSets} 组 × {item.plannedReps || '-'} 次
-
- )}
- {item.plannedDurationSec && (
-
- 持续 {item.plannedDurationSec} 秒
-
- )}
-
-
- {item.status === 'completed' && (
-
-
-
- 实际完成: {item.completedSets || '-'} 组 × {item.completedReps || '-'} 次
-
- {(() => {
- const durationInfo = getExerciseDuration(item);
- return durationInfo ? (
-
-
-
- {durationInfo.formatted}
-
-
- ) : null;
- })()}
-
-
- )}
-
-
- {item.status === 'pending' && session?.status === 'in_progress' && (
- handleStartExercise(item)}
- disabled={isLoading}
- >
-
- {isLoading ? '开始中...' : '开始'}
-
-
- )}
-
- {item.status === 'in_progress' && (
- <>
- handleShowCompleteModal(item)}
- disabled={isLoading}
- >
-
- {isLoading ? '完成中...' : '完成'}
-
-
- handleSkipExercise(item)}
- disabled={isLoading}
- >
- 跳过
-
- >
- )}
-
- {item.status === 'pending' && (
- handleSkipExercise(item)}
- disabled={isLoading}
- >
- 跳过
-
- )}
-
-
- );
- };
-
- if (loading) {
- return (
-
- router.back()} />
-
- 加载中...
-
-
- );
- }
-
- if (!session) {
- return (
-
- router.back()} />
-
-
- 训练会话不存在
- 该训练会话可能已被删除
-
-
- );
- }
-
- return (
-
- {/* 动态背景 */}
-
-
-
- router.back()}
- withSafeTop={false}
- transparent={true}
- tone="light"
- right={
- session?.status === 'planned' ? (
- router.push(`/training-plan/schedule/select?sessionId=${session.id}`)}
- disabled={loading}
- >
-
-
- ) : null
- }
- />
-
-
- {/* 训练计划信息头部 */}
-
- {/* 删除按钮 - 右上角 */}
-
-
-
-
-
-
- {goalConfig.title}
-
- {session.name}
-
- {/* 进度统计文字 */}
- {session.status !== 'planned' && (
-
- {workoutStats.completed}/{workoutStats.total} 个动作已完成
-
- )}
-
-
- {/* 右侧区域:圆环进度或开始按钮 */}
- {session.status === 'planned' ? (
-
-
-
- ) : (
-
-
-
-
- {completionPercentage}%
-
-
-
- )}
-
-
- {/* 训练完成提示 */}
- {session.status === 'completed' && (
-
-
- 训练已完成!
-
- )}
-
- {/* 动作列表 */}
- item.id}
- renderItem={renderExerciseItem}
- contentContainerStyle={styles.listContent}
- showsVerticalScrollIndicator={false}
- />
-
-
-
- {/* 完成动作模态框 */}
- setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 })}
- >
- setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 })}
- >
- e.stopPropagation()}
- >
- 完成动作
- {completionModal.exercise?.name}
-
-
-
- 完成组数
-
- setCompletionModal(prev => ({
- ...prev,
- sets: Math.max(0, prev.sets - 1)
- }))}
- >
- -
-
- {completionModal.sets}
- setCompletionModal(prev => ({
- ...prev,
- sets: Math.min(20, prev.sets + 1)
- }))}
- >
- +
-
-
-
-
-
- 每组次数
-
- setCompletionModal(prev => ({
- ...prev,
- reps: Math.max(0, prev.reps - 1)
- }))}
- >
- -
-
- {completionModal.reps}
- setCompletionModal(prev => ({
- ...prev,
- reps: Math.min(50, prev.reps + 1)
- }))}
- >
- +
-
-
-
-
-
-
- 确认完成
-
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- safeArea: {
- flex: 1,
- },
- contentWrapper: {
- flex: 1,
- },
- content: {
- flex: 1,
- paddingHorizontal: 20,
- },
-
- // 动态背景
- backgroundOrb: {
- position: 'absolute',
- width: 300,
- height: 300,
- borderRadius: 150,
- top: -150,
- right: -100,
- },
- backgroundOrb2: {
- position: 'absolute',
- width: 400,
- height: 400,
- borderRadius: 200,
- bottom: -200,
- left: -150,
- },
-
- // 计划信息头部
- planHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- padding: 16,
- borderRadius: 16,
- marginBottom: 16,
- },
- planColorIndicator: {
- width: 4,
- height: 40,
- borderRadius: 2,
- marginRight: 12,
- },
- planInfo: {
- flex: 1,
- },
- planTitle: {
- fontSize: 18,
- fontWeight: '800',
- color: '#192126',
- marginBottom: 4,
- },
- planDescription: {
- fontSize: 13,
- color: '#5E6468',
- opacity: 0.8,
- marginBottom: 4,
- },
- planProgressStats: {
- fontSize: 12,
- color: '#6B7280',
- marginTop: 4,
- },
-
- // 圆环进度容器
- circularProgressContainer: {
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: 32,
- position: 'relative',
- },
- circularProgressText: {
- position: 'absolute',
- alignItems: 'center',
- justifyContent: 'center',
- width: 60,
- height: 60,
- },
- circularProgressPercentage: {
- fontSize: 14,
- fontWeight: '800',
- textAlign: 'center',
- },
-
- // 删除按钮
- deleteBtn: {
- position: 'absolute',
- top: 8,
- right: 8,
- width: 32,
- height: 32,
- borderRadius: 16,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: 'rgba(255, 255, 255, 0.9)',
- zIndex: 10,
- shadowColor: '#000',
- shadowOpacity: 0.1,
- shadowRadius: 4,
- shadowOffset: { width: 0, height: 2 },
- elevation: 2,
- },
-
- // 开始训练按钮
- planStartBtn: {
- width: 44,
- height: 44,
- borderRadius: 22,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: 32,
- },
-
- // 完成提示
- completedBanner: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: '#F0FDF4',
- paddingVertical: 12,
- paddingHorizontal: 16,
- borderRadius: 12,
- marginBottom: 16,
- gap: 8,
- },
- completedBannerText: {
- color: '#22C55E',
- fontSize: 16,
- fontWeight: '700',
- },
-
- // 列表
- listContent: {
- paddingBottom: 40,
- },
-
- // 动作卡片
- exerciseCard: {
- backgroundColor: '#FFFFFF',
- borderRadius: 16,
- padding: 16,
- marginBottom: 12,
- shadowColor: '#000',
- shadowOpacity: 0.06,
- shadowRadius: 12,
- shadowOffset: { width: 0, height: 6 },
- elevation: 3,
- },
- exerciseHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'flex-start',
- marginBottom: 8,
- },
- exerciseInfo: {
- flex: 1,
- },
- exerciseName: {
- fontSize: 16,
- fontWeight: '800',
- color: '#192126',
- marginBottom: 4,
- },
- exerciseCategory: {
- fontSize: 12,
- color: '#888F92',
- },
- statusBadge: {
- paddingHorizontal: 8,
- paddingVertical: 4,
- borderRadius: 8,
- },
- statusText: {
- fontSize: 12,
- fontWeight: '600',
- },
- exerciseDetails: {
- marginBottom: 12,
- },
- exerciseParams: {
- fontSize: 14,
- color: '#5E6468',
- marginBottom: 2,
- },
- completedInfo: {
- backgroundColor: '#F0FDF4',
- borderRadius: 8,
- padding: 12,
- marginBottom: 12,
- },
- completedRow: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- },
- completedText: {
- fontSize: 12,
- color: '#22C55E',
- fontWeight: '600',
- flex: 1,
- },
- durationBadge: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 6,
- paddingVertical: 3,
- borderRadius: 6,
- gap: 3,
- },
- durationText: {
- fontSize: 11,
- fontWeight: '600',
- },
- exerciseActions: {
- flexDirection: 'row',
- gap: 8,
- },
- actionBtn: {
- flex: 1,
- paddingVertical: 10,
- paddingHorizontal: 16,
- borderRadius: 8,
- alignItems: 'center',
- },
- startBtn: {
- backgroundColor: '#22C55E',
- },
- startBtnText: {
- color: '#FFFFFF',
- fontSize: 14,
- fontWeight: '700',
- },
- completeBtn: {
- backgroundColor: '#22C55E',
- },
- completeBtnText: {
- color: '#FFFFFF',
- fontSize: 14,
- fontWeight: '700',
- },
- skipBtn: {
- backgroundColor: '#F3F4F6',
- borderWidth: 1,
- borderColor: '#E5E7EB',
- },
- skipBtnText: {
- color: '#6B7280',
- fontSize: 14,
- fontWeight: '600',
- },
-
- // 休息卡片
- restCard: {
- backgroundColor: '#FFFFFF',
- borderRadius: 16,
- padding: 16,
- marginBottom: 12,
- borderLeftWidth: 4,
- flexDirection: 'row',
- alignItems: 'center',
- shadowColor: '#000',
- shadowOpacity: 0.06,
- shadowRadius: 8,
- shadowOffset: { width: 0, height: 4 },
- elevation: 2,
- },
- restIconContainer: {
- marginRight: 12,
- },
- restContent: {
- flex: 1,
- },
- restTitle: {
- fontSize: 16,
- fontWeight: '700',
- color: '#192126',
- marginBottom: 4,
- },
- restDuration: {
- fontSize: 14,
- color: '#5E6468',
- },
-
- // 备注卡片
- noteCard: {
- backgroundColor: '#FFFFFF',
- borderRadius: 16,
- padding: 16,
- marginBottom: 12,
- borderLeftWidth: 4,
- flexDirection: 'row',
- alignItems: 'flex-start',
- shadowColor: '#000',
- shadowOpacity: 0.06,
- shadowRadius: 8,
- shadowOffset: { width: 0, height: 4 },
- elevation: 2,
- },
- noteIconContainer: {
- marginRight: 12,
- marginTop: 2,
- },
- noteContent: {
- flex: 1,
- },
- noteTitle: {
- fontSize: 16,
- fontWeight: '700',
- color: '#192126',
- marginBottom: 4,
- },
- noteText: {
- fontSize: 14,
- color: '#5E6468',
- lineHeight: 20,
- },
-
- // 空状态
- emptyContainer: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'flex-start',
- paddingTop: 40,
- padding: 20,
- },
- emptyTitle: {
- fontSize: 18,
- fontWeight: '700',
- color: '#192126',
- marginTop: 16,
- marginBottom: 8,
- },
- emptyText: {
- fontSize: 14,
- color: '#6B7280',
- textAlign: 'center',
- marginBottom: 24,
- },
-
- // 加载状态
- loadingContainer: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- },
- loadingText: {
- fontSize: 16,
- color: '#6B7280',
- },
-
- // 模态框
- modalOverlay: {
- flex: 1,
- backgroundColor: 'rgba(0,0,0,0.35)',
- alignItems: 'center',
- justifyContent: 'flex-end',
- },
- modalSheet: {
- width: '100%',
- backgroundColor: '#FFFFFF',
- borderTopLeftRadius: 16,
- borderTopRightRadius: 16,
- paddingHorizontal: 16,
- paddingTop: 14,
- paddingBottom: 24,
- },
- modalTitle: {
- fontSize: 18,
- fontWeight: '800',
- marginBottom: 8,
- color: '#192126',
- textAlign: 'center',
- },
- modalSubtitle: {
- fontSize: 14,
- color: '#6B7280',
- textAlign: 'center',
- marginBottom: 24,
- },
- inputRow: {
- flexDirection: 'row',
- gap: 16,
- marginBottom: 24,
- },
- inputBox: {
- flex: 1,
- },
- inputLabel: {
- fontSize: 14,
- fontWeight: '600',
- color: '#192126',
- marginBottom: 12,
- },
- counterRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- backgroundColor: '#F3F4F6',
- borderRadius: 8,
- padding: 4,
- },
- counterBtn: {
- backgroundColor: '#FFFFFF',
- width: 32,
- height: 32,
- borderRadius: 6,
- alignItems: 'center',
- justifyContent: 'center',
- shadowColor: '#000',
- shadowOpacity: 0.05,
- shadowRadius: 2,
- shadowOffset: { width: 0, height: 1 },
- elevation: 1,
- },
- counterBtnText: {
- fontWeight: '800',
- color: '#192126',
- fontSize: 16,
- },
- counterValue: {
- fontWeight: '700',
- color: '#192126',
- fontSize: 16,
- minWidth: 40,
- textAlign: 'center',
- },
- confirmBtn: {
- paddingVertical: 16,
- borderRadius: 12,
- alignItems: 'center',
- },
- confirmBtnText: {
- color: '#FFFFFF',
- fontWeight: '800',
- fontSize: 16,
- },
-
- // 添加动作按钮
- addExerciseBtn: {
- width: 28,
- height: 28,
- borderRadius: 14,
- alignItems: 'center',
- justifyContent: 'center',
- shadowColor: '#000',
- shadowOpacity: 0.1,
- shadowRadius: 4,
- shadowOffset: { width: 0, height: 2 },
- elevation: 2,
- },
-});
diff --git a/app/workout/today.tsx b/app/workout/today.tsx
deleted file mode 100644
index adae40e..0000000
--- a/app/workout/today.tsx
+++ /dev/null
@@ -1,1237 +0,0 @@
-import { Ionicons } from '@expo/vector-icons';
-import * as Haptics from 'expo-haptics';
-import { LinearGradient } from 'expo-linear-gradient';
-import { useRouter } from 'expo-router';
-import React, { useEffect, useMemo, useState } from 'react';
-import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
-import Animated, { FadeInUp } from 'react-native-reanimated';
-
-import { useGlobalDialog } from '@/components/ui/DialogProvider';
-import { HeaderBar } from '@/components/ui/HeaderBar';
-import { palette } from '@/constants/Colors';
-import { useAppDispatch, useAppSelector } from '@/hooks/redux';
-import type { WorkoutExercise } from '@/services/workoutsApi';
-import {
- clearWorkoutError,
- completeWorkoutExercise,
- createWorkoutSession,
- deleteWorkoutSession,
- loadWorkoutSessions,
- skipWorkoutExercise,
- startWorkoutExercise,
- startWorkoutSession
-} from '@/store/workoutSlice';
-import dayjs from 'dayjs';
-import { ROUTES } from '@/constants/Routes';
-
-// ==================== 工具函数 ====================
-
-// 计算两个时间之间的耗时(秒)
-const calculateDuration = (startTime: string, endTime: string): number => {
- const start = dayjs(startTime);
- const end = dayjs(endTime);
- return Math.floor((end.valueOf() - start.valueOf()) / 1000);
-};
-
-// 格式化耗时显示(分钟:秒)
-const formatDuration = (seconds: number): string => {
- const minutes = Math.floor(seconds / 60);
- const remainingSeconds = seconds % 60;
- return `${minutes}分${remainingSeconds.toString().padStart(2, '0')}秒`;
-};
-
-// 获取动作的耗时信息
-const getExerciseDuration = (exercise: WorkoutExercise): { duration: number; formatted: string } | null => {
- if (exercise.status === 'completed' && exercise.startedAt && exercise.completedAt) {
- const duration = calculateDuration(exercise.startedAt, exercise.completedAt);
- return {
- duration,
- formatted: formatDuration(duration)
- };
- }
- return null;
-};
-
-const GOAL_TEXT: Record = {
- postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' },
- fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' },
- posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' },
- core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' },
- flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' },
- rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' },
- stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' },
-};
-
-// 动态背景组件
-function DynamicBackground({ color }: { color: string }) {
- return (
-
-
-
-
-
- );
-}
-
-export default function TodayWorkoutScreen() {
- const router = useRouter();
- const dispatch = useAppDispatch();
- const { currentSession, exercises, sessions, sessionsPagination, loading, exerciseLoading, error } = useAppSelector((s) => s.workout);
- const { showConfirm, showActionSheet } = useGlobalDialog();
-
- const [refreshing, setRefreshing] = useState(false);
- const [loadingMore, setLoadingMore] = useState(false);
-
- // 本地状态
- const [completionModal, setCompletionModal] = useState<{
- visible: boolean;
- exercise: WorkoutExercise | null;
- sets: number;
- reps: number;
- }>({
- visible: false,
- exercise: null,
- sets: 0,
- reps: 0,
- });
-
-
-
- const goalConfig = currentSession?.trainingPlan
- ? (GOAL_TEXT[currentSession.trainingPlan.goal] || { title: '今日训练', color: palette.primary, description: '开始你的训练之旅' })
- : { title: '今日训练', color: palette.primary, description: '开始你的训练之旅' };
-
- // 加载训练会话列表
- useEffect(() => {
- dispatch(loadWorkoutSessions({ page: 1, limit: 10 }));
- }, [dispatch]);
-
- // 刷新数据
- const handleRefresh = async () => {
- setRefreshing(true);
- try {
- await dispatch(loadWorkoutSessions({ page: 1, limit: 10 })).unwrap();
- } catch (error) {
- console.error('刷新失败:', error);
- } finally {
- setRefreshing(false);
- }
- };
-
- // 加载更多数据
- const handleLoadMore = async () => {
- if (loadingMore || loading || !sessionsPagination) return;
-
- const currentPage = sessionsPagination.page;
- const totalPages = sessionsPagination.totalPages;
-
- if (currentPage >= totalPages) return; // 已经是最后一页
-
- setLoadingMore(true);
- try {
- await dispatch(loadWorkoutSessions({
- page: currentPage + 1,
- limit: 10,
- append: true // 追加数据而不是替换
- })).unwrap();
- } catch (error) {
- console.error('加载更多失败:', error);
- } finally {
- setLoadingMore(false);
- }
- };
-
- // 错误处理
- useEffect(() => {
- if (error) {
- Alert.alert('错误', error, [
- { text: '确定', onPress: () => dispatch(clearWorkoutError()) }
- ]);
- }
- }, [error, dispatch]);
-
- // 训练状态统计
- const workoutStats = useMemo(() => {
- const exerciseItems = exercises.filter(ex => ex.itemType === 'exercise');
- return {
- total: exerciseItems.length,
- completed: exerciseItems.filter(ex => ex.status === 'completed').length,
- inProgress: exerciseItems.filter(ex => ex.status === 'in_progress').length,
- pending: exerciseItems.filter(ex => ex.status === 'pending').length,
- skipped: exerciseItems.filter(ex => ex.status === 'skipped').length,
- };
- }, [exercises]);
-
- const completionPercentage = workoutStats.total > 0
- ? Math.round((workoutStats.completed / workoutStats.total) * 100)
- : 0;
-
- // 开始训练会话
- const handleStartWorkout = () => {
- if (!currentSession) return;
-
- showConfirm(
- {
- title: '开始训练',
- message: '准备好开始今日的训练了吗?',
- icon: 'fitness-outline',
- },
- () => {
- dispatch(startWorkoutSession({ sessionId: currentSession.id }));
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
- }
- );
- };
-
- // 开始动作
- const handleStartExercise = (exercise: WorkoutExercise) => {
- if (!currentSession || exercise.status !== 'pending') return;
-
- dispatch(startWorkoutExercise({
- sessionId: currentSession.id,
- exerciseId: exercise.id
- }));
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
- };
-
- // 显示完成动作模态框
- const handleShowCompleteModal = (exercise: WorkoutExercise) => {
- setCompletionModal({
- visible: true,
- exercise,
- sets: exercise.completedSets || exercise.plannedSets || 0,
- reps: exercise.completedReps || exercise.plannedReps || 0,
- });
- };
-
- // 完成动作
- const handleCompleteExercise = () => {
- const { exercise, sets, reps } = completionModal;
- if (!currentSession || !exercise) return;
-
- dispatch(completeWorkoutExercise({
- sessionId: currentSession.id,
- exerciseId: exercise.id,
- dto: {
- completedSets: sets,
- completedReps: reps,
- }
- }));
-
- setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 });
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
- };
-
- // 跳过动作
- const handleSkipExercise = (exercise: WorkoutExercise) => {
- if (!currentSession) return;
-
- showConfirm(
- {
- title: '跳过动作',
- message: `确定要跳过"${exercise.name}"吗?`,
- icon: 'play-skip-forward-outline',
- destructive: true,
- },
- () => {
- dispatch(skipWorkoutExercise({
- sessionId: currentSession.id,
- exerciseId: exercise.id
- }));
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
- }
- );
- };
-
- // 创建新的训练会话
- const handleCreateSession = () => {
- showActionSheet(
- {
- title: '新建训练',
- subtitle: '选择创建方式开始你的训练',
- },
- [
- {
- id: 'custom',
- title: '自定义训练',
- subtitle: '创建一个空的训练,可以自由添加动作',
- icon: 'create-outline',
- iconColor: '#3B82F6',
- onPress: () => {
- // 创建空的自定义会话
- const sessionName = `训练 ${dayjs().format('YYYY年MM月DD日')}`;
- dispatch(createWorkoutSession({
- name: sessionName,
- scheduledDate: dayjs().format('YYYY-MM-DD')
- }));
- }
- },
- {
- id: 'from-plan',
- title: '从训练计划导入',
- subtitle: '基于现有训练计划创建训练',
- icon: 'library-outline',
- iconColor: '#10B981',
- onPress: () => {
- // 跳转到创建页面选择训练计划
- router.push(ROUTES.WORKOUT_CREATE_SESSION);
- }
- }
- ]
- );
- };
-
- // 删除训练会话
- const handleDeleteSession = () => {
- if (!currentSession) return;
-
- showConfirm(
- {
- title: '删除训练会话',
- message: '确定要删除这个训练会话吗?删除后无法恢复。',
- icon: 'trash-outline',
- destructive: true,
- },
- () => {
- dispatch(deleteWorkoutSession(currentSession.id));
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
- // 删除成功后清空当前会话
- // 不需要返回上一页,因为现在显示的是会话列表
- }
- );
- };
-
- // 获取动作状态文本和颜色
- const getExerciseStatusConfig = (exercise: WorkoutExercise) => {
- switch (exercise.status) {
- case 'completed':
- return { text: '已完成', color: '#22C55E', backgroundColor: '#22C55E15' };
- case 'in_progress':
- return { text: '进行中', color: '#F59E0B', backgroundColor: '#F59E0B15' };
- case 'skipped':
- return { text: '已跳过', color: '#6B7280', backgroundColor: '#6B728015' };
- default:
- return { text: '待开始', color: '#6B7280', backgroundColor: '#6B728015' };
- }
- };
-
- // 渲染动作卡片
- const renderExerciseItem = ({ item, index }: { item: WorkoutExercise; index: number }) => {
- const statusConfig = getExerciseStatusConfig(item);
- const isLoading = exerciseLoading === item.id;
-
- if (item.itemType === 'rest') {
- return (
-
-
-
-
-
- {item.name}
- {item.restSec}秒
-
-
- );
- }
-
- if (item.itemType === 'note') {
- return (
-
-
-
-
-
- {item.name}
- {item.note && {item.note}}
-
-
- );
- }
-
- return (
-
-
-
- {item.name}
- {item.exercise && (
- {item.exercise.categoryName}
- )}
-
-
-
- {statusConfig.text}
-
-
-
-
-
- {item.plannedSets && (
-
- {item.plannedSets} 组 × {item.plannedReps || '-'} 次
-
- )}
- {item.plannedDurationSec && (
-
- 持续 {item.plannedDurationSec} 秒
-
- )}
-
-
- {item.status === 'completed' && (
-
-
-
- 实际完成: {item.completedSets || '-'} 组 × {item.completedReps || '-'} 次
-
- {(() => {
- const durationInfo = getExerciseDuration(item);
- return durationInfo ? (
-
-
-
- {durationInfo.formatted}
-
-
- ) : null;
- })()}
-
-
- )}
-
-
- {item.status === 'pending' && currentSession?.status === 'in_progress' && (
- handleStartExercise(item)}
- disabled={isLoading}
- >
-
- {isLoading ? '开始中...' : '开始'}
-
-
- )}
-
- {item.status === 'in_progress' && (
- <>
- handleShowCompleteModal(item)}
- disabled={isLoading}
- >
-
- {isLoading ? '完成中...' : '完成'}
-
-
- handleSkipExercise(item)}
- disabled={isLoading}
- >
- 跳过
-
- >
- )}
-
- {item.status === 'pending' && (
- handleSkipExercise(item)}
- disabled={isLoading}
- >
- 跳过
-
- )}
-
-
- );
- };
-
- // 渲染训练会话卡片
- const renderSessionItem = ({ item, index }: { item: any; index: number }) => {
- const goalConfig = item.trainingPlan?.goal
- ? (GOAL_TEXT[item.trainingPlan.goal] || { title: '训练会话', color: palette.primary, description: '开始你的训练之旅' })
- : { title: '训练会话', color: palette.primary, description: '开始你的训练之旅' };
-
- const exerciseCount = item.exercises?.length || 0;
- const completedCount = item.exercises?.filter((ex: any) => ex.status === 'completed').length || 0;
-
- return (
-
- {
- router.push(`${ROUTES.WORKOUT_SESSION}/${item.id}`);
- }}
- activeOpacity={0.9}
- >
-
-
- {item.name}
- {item.trainingPlan && (
- {item.trainingPlan.name}
- )}
-
- 创建时间 {dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}
-
-
- 计划时间 {dayjs(item.scheduledDate ).format('YYYY-MM-DD HH:mm:ss')}
-
-
-
-
- {item.status === 'completed' ? (
-
- 已完成
-
- ) : item.status === 'in_progress' ? (
-
- 进行中
-
- ) : (
-
- 待开始
-
- )}
-
-
-
- {exerciseCount > 0 && (
-
-
- {completedCount}/{exerciseCount} 个动作已完成
-
-
- 0 ? (completedCount / exerciseCount) * 100 : 0}%`,
- backgroundColor: goalConfig.color
- }
- ]}
- />
-
-
- )}
-
-
- );
- };
-
- if (loading && sessions.length === 0) {
- return (
-
- router.back()} />
-
- 加载中...
-
-
- );
- }
-
- if (sessions.length === 0 && !loading) {
- return (
-
- router.back()} />
-
-
- 暂无训练记录
- 开始你的第一次训练吧
-
- 新建训练会话
-
-
-
- );
- }
-
- return (
-
- {/* 动态背景 */}
-
-
-
- router.back()}
- withSafeTop={false}
- transparent={true}
- tone="light"
- right={
-
-
-
- }
- />
-
-
- {/* 会话列表 */}
- item.id}
- renderItem={renderSessionItem}
- contentContainerStyle={styles.listContent}
- showsVerticalScrollIndicator={false}
- refreshing={refreshing}
- onRefresh={handleRefresh}
- onEndReached={handleLoadMore}
- onEndReachedThreshold={0.1}
- ListFooterComponent={
- loadingMore ? (
-
- 加载更多...
-
- ) : sessionsPagination && sessionsPagination.page >= sessionsPagination.totalPages ? (
-
- 已加载全部数据
-
- ) : null
- }
- />
-
-
-
- {/* 完成动作模态框 */}
- setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 })}
- >
- setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 })}
- >
- e.stopPropagation()}
- >
- 完成动作
- {completionModal.exercise?.name}
-
-
-
- 完成组数
-
- setCompletionModal(prev => ({
- ...prev,
- sets: Math.max(0, prev.sets - 1)
- }))}
- >
- -
-
- {completionModal.sets}
- setCompletionModal(prev => ({
- ...prev,
- sets: Math.min(20, prev.sets + 1)
- }))}
- >
- +
-
-
-
-
-
- 每组次数
-
- setCompletionModal(prev => ({
- ...prev,
- reps: Math.max(0, prev.reps - 1)
- }))}
- >
- -
-
- {completionModal.reps}
- setCompletionModal(prev => ({
- ...prev,
- reps: Math.min(50, prev.reps + 1)
- }))}
- >
- +
-
-
-
-
-
-
- 确认完成
-
-
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- safeArea: {
- flex: 1,
- },
- contentWrapper: {
- flex: 1,
- },
- content: {
- flex: 1,
- paddingHorizontal: 20,
- },
-
- // 动态背景
- backgroundOrb: {
- position: 'absolute',
- width: 300,
- height: 300,
- borderRadius: 150,
- top: -150,
- right: -100,
- },
- backgroundOrb2: {
- position: 'absolute',
- width: 400,
- height: 400,
- borderRadius: 200,
- bottom: -200,
- left: -150,
- },
-
- // 计划信息头部
- planHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- padding: 16,
- borderRadius: 16,
- marginBottom: 16,
- },
- planColorIndicator: {
- width: 4,
- height: 40,
- borderRadius: 2,
- marginRight: 12,
- },
- planInfo: {
- flex: 1,
- },
- planTitle: {
- fontSize: 18,
- fontWeight: '800',
- color: '#192126',
- marginBottom: 4,
- },
- planDescription: {
- fontSize: 13,
- color: '#5E6468',
- opacity: 0.8,
- marginBottom: 4,
- },
- planProgressStats: {
- fontSize: 12,
- color: '#6B7280',
- marginTop: 4,
- },
-
- // 圆环进度容器
- circularProgressContainer: {
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: 32,
- position: 'relative',
- },
- circularProgressText: {
- position: 'absolute',
- alignItems: 'center',
- justifyContent: 'center',
- width: 60,
- height: 60,
- },
- circularProgressPercentage: {
- fontSize: 14,
- fontWeight: '800',
- textAlign: 'center',
- },
-
- // 删除按钮
- deleteBtn: {
- position: 'absolute',
- top: 8,
- right: 8,
- width: 32,
- height: 32,
- borderRadius: 16,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: 'rgba(255, 255, 255, 0.9)',
- zIndex: 10,
- shadowColor: '#000',
- shadowOpacity: 0.1,
- shadowRadius: 4,
- shadowOffset: { width: 0, height: 2 },
- elevation: 2,
- },
-
- // 开始训练按钮
- planStartBtn: {
- width: 44,
- height: 44,
- borderRadius: 22,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: 32,
- },
-
- // 完成提示
- completedBanner: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: '#F0FDF4',
- paddingVertical: 12,
- paddingHorizontal: 16,
- borderRadius: 12,
- marginBottom: 16,
- gap: 8,
- },
- completedBannerText: {
- color: '#22C55E',
- fontSize: 16,
- fontWeight: '700',
- },
-
- // 列表
- listContent: {
- paddingBottom: 40,
- },
-
- // 动作卡片
- exerciseCard: {
- backgroundColor: '#FFFFFF',
- borderRadius: 16,
- padding: 16,
- marginBottom: 12,
- shadowColor: '#000',
- shadowOpacity: 0.06,
- shadowRadius: 12,
- shadowOffset: { width: 0, height: 6 },
- elevation: 3,
- },
- exerciseHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'flex-start',
- marginBottom: 8,
- },
- exerciseInfo: {
- flex: 1,
- },
- exerciseName: {
- fontSize: 16,
- fontWeight: '800',
- color: '#192126',
- marginBottom: 4,
- },
- exerciseCategory: {
- fontSize: 12,
- color: '#888F92',
- },
- statusBadge: {
- paddingHorizontal: 8,
- paddingVertical: 4,
- borderRadius: 8,
- },
- statusText: {
- fontSize: 12,
- fontWeight: '600',
- },
- exerciseDetails: {
- marginBottom: 12,
- },
- exerciseParams: {
- fontSize: 14,
- color: '#5E6468',
- marginBottom: 2,
- },
- completedInfo: {
- backgroundColor: '#F0FDF4',
- borderRadius: 8,
- },
- completedRow: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- },
- completedText: {
- fontSize: 12,
- color: '#22C55E',
- fontWeight: '600',
- flex: 1,
- },
- durationBadge: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 6,
- paddingVertical: 3,
- borderRadius: 6,
- gap: 3,
- },
- durationText: {
- fontSize: 11,
- fontWeight: '600',
- },
- exerciseActions: {
- flexDirection: 'row',
- gap: 8,
- },
- actionBtn: {
- flex: 1,
- paddingVertical: 10,
- paddingHorizontal: 16,
- borderRadius: 8,
- alignItems: 'center',
- },
- startBtn: {
- backgroundColor: '#22C55E',
- },
- startBtnText: {
- color: '#FFFFFF',
- fontSize: 14,
- fontWeight: '700',
- },
- completeBtn: {
- backgroundColor: '#22C55E',
- },
- completeBtnText: {
- color: '#FFFFFF',
- fontSize: 14,
- fontWeight: '700',
- },
- skipBtn: {
- backgroundColor: '#F3F4F6',
- borderWidth: 1,
- borderColor: '#E5E7EB',
- },
- skipBtnText: {
- color: '#6B7280',
- fontSize: 14,
- fontWeight: '600',
- },
-
- // 休息卡片
- restCard: {
- backgroundColor: '#FFFFFF',
- borderRadius: 16,
- padding: 16,
- marginBottom: 12,
- borderLeftWidth: 4,
- flexDirection: 'row',
- alignItems: 'center',
- shadowColor: '#000',
- shadowOpacity: 0.06,
- shadowRadius: 8,
- shadowOffset: { width: 0, height: 4 },
- elevation: 2,
- },
- restIconContainer: {
- marginRight: 12,
- },
- restContent: {
- flex: 1,
- },
- restTitle: {
- fontSize: 16,
- fontWeight: '700',
- color: '#192126',
- marginBottom: 4,
- },
- restDuration: {
- fontSize: 14,
- color: '#5E6468',
- },
-
- // 备注卡片
- noteCard: {
- backgroundColor: '#FFFFFF',
- borderRadius: 16,
- padding: 16,
- marginBottom: 12,
- borderLeftWidth: 4,
- flexDirection: 'row',
- alignItems: 'flex-start',
- shadowColor: '#000',
- shadowOpacity: 0.06,
- shadowRadius: 8,
- shadowOffset: { width: 0, height: 4 },
- elevation: 2,
- },
- noteIconContainer: {
- marginRight: 12,
- marginTop: 2,
- },
- noteContent: {
- flex: 1,
- },
- noteTitle: {
- fontSize: 16,
- fontWeight: '700',
- color: '#192126',
- marginBottom: 4,
- },
- noteText: {
- fontSize: 14,
- color: '#5E6468',
- lineHeight: 20,
- },
-
- // 空状态
- emptyContainer: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'flex-start',
- paddingTop: 40,
- padding: 20,
- },
- emptyTitle: {
- fontSize: 18,
- fontWeight: '700',
- color: '#192126',
- marginTop: 16,
- marginBottom: 8,
- },
- emptyText: {
- fontSize: 14,
- color: '#6B7280',
- textAlign: 'center',
- marginBottom: 24,
- },
- createSessionBtn: {
- backgroundColor: '#22C55E',
- paddingVertical: 12,
- paddingHorizontal: 24,
- borderRadius: 8,
- },
- createSessionBtnText: {
- color: '#FFFFFF',
- fontSize: 14,
- fontWeight: '700',
- },
-
- // 加载状态
- loadingContainer: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- },
- loadingText: {
- fontSize: 16,
- color: '#6B7280',
- },
-
- // 模态框
- modalOverlay: {
- flex: 1,
- backgroundColor: 'rgba(0,0,0,0.35)',
- alignItems: 'center',
- justifyContent: 'flex-end',
- },
- modalSheet: {
- width: '100%',
- backgroundColor: '#FFFFFF',
- borderTopLeftRadius: 16,
- borderTopRightRadius: 16,
- paddingHorizontal: 16,
- paddingTop: 14,
- paddingBottom: 24,
- },
- modalTitle: {
- fontSize: 18,
- fontWeight: '800',
- marginBottom: 8,
- color: '#192126',
- textAlign: 'center',
- },
- modalSubtitle: {
- fontSize: 14,
- color: '#6B7280',
- textAlign: 'center',
- marginBottom: 24,
- },
- inputRow: {
- flexDirection: 'row',
- gap: 16,
- marginBottom: 24,
- },
- inputBox: {
- flex: 1,
- },
- inputLabel: {
- fontSize: 14,
- fontWeight: '600',
- color: '#192126',
- marginBottom: 12,
- },
- counterRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- backgroundColor: '#F3F4F6',
- borderRadius: 8,
- padding: 4,
- },
- counterBtn: {
- backgroundColor: '#FFFFFF',
- width: 32,
- height: 32,
- borderRadius: 6,
- alignItems: 'center',
- justifyContent: 'center',
- shadowColor: '#000',
- shadowOpacity: 0.05,
- shadowRadius: 2,
- shadowOffset: { width: 0, height: 1 },
- elevation: 1,
- },
- counterBtnText: {
- fontWeight: '800',
- color: '#192126',
- fontSize: 16,
- },
- counterValue: {
- fontWeight: '700',
- color: '#192126',
- fontSize: 16,
- minWidth: 40,
- textAlign: 'center',
- },
- confirmBtn: {
- paddingVertical: 16,
- borderRadius: 12,
- alignItems: 'center',
- },
- confirmBtnText: {
- color: '#FFFFFF',
- fontWeight: '800',
- fontSize: 16,
- },
-
- // 添加会话按钮
- addSessionBtn: {
- width: 32,
- height: 32,
- borderRadius: 16,
- alignItems: 'center',
- justifyContent: 'center',
- shadowColor: '#000',
- shadowOpacity: 0.1,
- shadowRadius: 4,
- shadowOffset: { width: 0, height: 2 },
- elevation: 2,
- },
-
- // 会话卡片
- sessionCard: {
- backgroundColor: '#FFFFFF',
- borderRadius: 16,
- marginBottom: 12,
- borderLeftWidth: 4,
- shadowColor: '#000',
- shadowOpacity: 0.06,
- shadowRadius: 12,
- shadowOffset: { width: 0, height: 6 },
- elevation: 3,
- },
- sessionCardContent: {
- padding: 16,
- },
- sessionHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'flex-start',
- marginBottom: 12,
- },
- sessionInfo: {
- flex: 1,
- },
- sessionName: {
- fontSize: 16,
- fontWeight: '800',
- color: '#192126',
- marginBottom: 4,
- },
- sessionPlan: {
- fontSize: 12,
- color: '#888F92',
- marginBottom: 2,
- },
- sessionDate: {
- fontSize: 11,
- color: '#6B7280',
- },
- sessionStats: {
- alignItems: 'flex-end',
- },
- sessionProgress: {
- marginTop: 8,
- },
- progressText: {
- fontSize: 12,
- color: '#6B7280',
- marginTop: 6
- },
- progressBar: {
- height: 4,
- backgroundColor: '#F3F4F6',
- borderRadius: 2,
- overflow: 'hidden',
- },
- progressFill: {
- height: '100%',
- borderRadius: 2,
- },
-
- // 加载更多
- loadMoreContainer: {
- paddingVertical: 16,
- alignItems: 'center',
- },
- loadMoreText: {
- fontSize: 12,
- color: '#6B7280',
- },
-});
diff --git a/components/workout/WorkoutDetailModal.tsx b/components/workout/WorkoutDetailModal.tsx
new file mode 100644
index 0000000..b9460da
--- /dev/null
+++ b/components/workout/WorkoutDetailModal.tsx
@@ -0,0 +1,881 @@
+import { MaterialCommunityIcons } from '@expo/vector-icons';
+import dayjs from 'dayjs';
+import { LinearGradient } from 'expo-linear-gradient';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import {
+ ActivityIndicator,
+ Animated,
+ Dimensions,
+ Modal,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ TouchableWithoutFeedback,
+ View,
+} from 'react-native';
+
+import {
+ HeartRateZoneStat,
+ WorkoutDetailMetrics,
+} from '@/services/workoutDetail';
+import {
+ getWorkoutTypeDisplayName,
+ WorkoutActivityType,
+ WorkoutData,
+} from '@/utils/health';
+
+export interface IntensityBadge {
+ label: string;
+ color: string;
+ background: string;
+}
+
+interface WorkoutDetailModalProps {
+ visible: boolean;
+ onClose: () => void;
+ workout: WorkoutData | null;
+ metrics: WorkoutDetailMetrics | null;
+ loading: boolean;
+ intensityBadge?: IntensityBadge;
+ monthOccurrenceText?: string;
+ onRetry?: () => void;
+ errorMessage?: string | null;
+}
+
+const SCREEN_HEIGHT = Dimensions.get('window').height;
+const SHEET_MAX_HEIGHT = SCREEN_HEIGHT * 0.9;
+
+const HEART_RATE_CHART_MAX_POINTS = 120;
+
+export function WorkoutDetailModal({
+ visible,
+ onClose,
+ workout,
+ metrics,
+ loading,
+ intensityBadge,
+ monthOccurrenceText,
+ onRetry,
+ errorMessage,
+}: WorkoutDetailModalProps) {
+ const animation = useRef(new Animated.Value(visible ? 1 : 0)).current;
+ const [isMounted, setIsMounted] = useState(visible);
+ const [showIntensityInfo, setShowIntensityInfo] = useState(false);
+
+ useEffect(() => {
+ if (visible) {
+ setIsMounted(true);
+ Animated.timing(animation, {
+ toValue: 1,
+ duration: 280,
+ useNativeDriver: true,
+ }).start();
+ } else {
+ Animated.timing(animation, {
+ toValue: 0,
+ duration: 240,
+ useNativeDriver: true,
+ }).start(({ finished }) => {
+ if (finished) {
+ setIsMounted(false);
+ }
+ });
+
+ setShowIntensityInfo(false);
+ }
+ }, [visible, animation]);
+
+ const translateY = animation.interpolate({
+ inputRange: [0, 1],
+ outputRange: [SHEET_MAX_HEIGHT, 0],
+ });
+
+ const backdropOpacity = animation.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0, 1],
+ });
+
+ const activityName = workout
+ ? getWorkoutTypeDisplayName(workout.workoutActivityType as WorkoutActivityType)
+ : '';
+
+ const dateInfo = useMemo(() => {
+ if (!workout) {
+ return { title: '', subtitle: '' };
+ }
+
+ const date = dayjs(workout.startDate || workout.endDate);
+ if (!date.isValid()) {
+ return { title: '', subtitle: '' };
+ }
+
+ return {
+ title: date.format('M月D日'),
+ subtitle: date.format('YYYY年M月D日 dddd HH:mm'),
+ };
+ }, [workout]);
+
+ const heartRateChart = useMemo(() => {
+ if (!metrics?.heartRateSeries?.length) {
+ return null;
+ }
+
+ const sortedSeries = metrics.heartRateSeries;
+ const trimmed = trimHeartRateSeries(sortedSeries);
+
+ const labels = trimmed.map((point, index) => {
+ if (
+ index === 0 ||
+ index === trimmed.length - 1 ||
+ index === Math.floor(trimmed.length / 2)
+ ) {
+ return dayjs(point.timestamp).format('HH:mm');
+ }
+ return '';
+ });
+
+ const data = trimmed.map((point) => Math.round(point.value));
+
+ return {
+ labels,
+ data,
+ };
+ }, [metrics?.heartRateSeries]);
+
+ const handleBackdropPress = () => {
+ if (!loading) {
+ onClose();
+ }
+ };
+
+ if (!isMounted) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {dateInfo.title}
+ {dateInfo.subtitle}
+
+
+
+
+
+
+
+
+
+
+
+
+ {activityName}
+ {intensityBadge ? (
+
+
+ {intensityBadge.label}
+
+
+ ) : null}
+
+
+ {dayjs(workout?.startDate || workout?.endDate).format('YYYY年M月D日 dddd HH:mm')}
+
+
+ {loading ? (
+
+
+ 正在加载锻炼详情...
+
+ ) : metrics ? (
+ <>
+
+
+ 体能训练时间
+ {metrics.durationLabel}
+
+
+ 运动热量
+
+ {metrics.calories != null ? `${metrics.calories} 千卡` : '--'}
+
+
+
+
+
+
+ 运动强度
+ setShowIntensityInfo(true)}
+ style={styles.metricInfoButton}
+ hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
+ >
+
+
+
+
+ {formatMetsValue(metrics.mets)}
+
+
+
+ 平均心率
+
+ {metrics.averageHeartRate != null ? `${metrics.averageHeartRate} 次/分` : '--'}
+
+
+
+ {monthOccurrenceText ? (
+ {monthOccurrenceText}
+ ) : null}
+ >
+ ) : (
+
+
+ {errorMessage || '未能获取到完整的锻炼详情'}
+
+ {onRetry ? (
+
+ 重新加载
+
+ ) : null}
+
+ )}
+
+
+
+
+ 心率范围
+
+
+
+ {loading ? (
+
+
+
+ ) : metrics ? (
+ <>
+
+
+ 平均心率
+
+ {metrics.averageHeartRate != null ? `${metrics.averageHeartRate}次/分` : '--'}
+
+
+
+ 最高心率
+
+ {metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate}次/分` : '--'}
+
+
+
+ 最低心率
+
+ {metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate}次/分` : '--'}
+
+
+
+
+ {heartRateChart ? (
+ LineChart ? (
+
+ {/* @ts-ignore - react-native-chart-kit types are outdated */}
+ '#5C55FF',
+ strokeWidth: 2,
+ },
+ ],
+ }}
+ width={Dimensions.get('window').width - 72}
+ height={220}
+ fromZero={false}
+ yAxisSuffix="次/分"
+ withInnerLines
+ bezier
+ chartConfig={{
+ backgroundColor: '#FFFFFF',
+ backgroundGradientFrom: '#FFFFFF',
+ backgroundGradientTo: '#FFFFFF',
+ decimalPlaces: 0,
+ color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`,
+ labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`,
+ propsForDots: {
+ r: '3',
+ strokeWidth: '2',
+ stroke: '#FFFFFF',
+ },
+ propsForBackgroundLines: {
+ strokeDasharray: '3,3',
+ stroke: '#E3E6F4',
+ strokeWidth: 1,
+ },
+ fillShadowGradientFromOpacity: 0.1,
+ fillShadowGradientToOpacity: 0.02,
+ }}
+ style={styles.chartStyle}
+ />
+
+ ) : (
+
+
+ 图表组件不可用,无法展示心率曲线
+
+ )
+ ) : (
+
+
+ 暂无心率采样数据
+
+ )}
+ >
+ ) : (
+
+
+ {errorMessage || '未获取到心率数据'}
+
+
+ )}
+
+
+
+
+ 心率训练区间
+
+
+ {loading ? (
+
+
+
+ ) : metrics ? (
+ metrics.heartRateZones.map(renderHeartRateZone)
+ ) : (
+ 暂无区间统计
+ )}
+
+
+
+
+
+ {showIntensityInfo ? (
+ setShowIntensityInfo(false)}
+ >
+ setShowIntensityInfo(false)}>
+
+ { }}>
+
+
+ 什么是运动强度?
+
+ 运动强度是你完成一项任务所用的能量估算,是衡量锻炼和其他日常活动能耗强度的指标,单位为 MET(千卡/(千克·小时))。
+
+
+ 因为每个人的代谢状况不同,MET 以身体的静息能耗作为参考,便于衡量不同活动的强度。
+
+
+ 例如:散步(约 3 km/h)相当于 2 METs,意味着它需要消耗静息状态 2 倍的能量。
+
+
+ 注:当设备未提供 METs 值时,系统会根据您的卡路里消耗和锻炼时长自动计算(使用70公斤估算体重)。
+
+
+ 运动强度计算公式
+ METs = 活动能耗(千卡/小时) ÷ 静息能耗(1 千卡/小时)
+
+
+
+
+ {'< 3'}
+ 低强度活动
+
+
+ 3 - 6
+ 中强度活动
+
+
+ {'≥ 6'}
+ 高强度活动
+
+
+
+
+
+
+
+ ) : null}
+
+
+ );
+}
+
+// 格式化 METs 值显示
+function formatMetsValue(mets: number | null): string {
+ if (mets == null) {
+ return '—';
+ }
+
+ // 保留一位小数
+ const formattedMets = mets.toFixed(1);
+ return `${formattedMets} METs`;
+}
+
+function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
+ if (series.length <= HEART_RATE_CHART_MAX_POINTS) {
+ return series;
+ }
+
+ const step = Math.ceil(series.length / HEART_RATE_CHART_MAX_POINTS);
+ const reduced = series.filter((_, index) => index % step === 0);
+
+ if (reduced[reduced.length - 1] !== series[series.length - 1]) {
+ reduced.push(series[series.length - 1]);
+ }
+
+ return reduced;
+}
+
+function renderHeartRateZone(zone: HeartRateZoneStat) {
+ return (
+
+
+
+
+
+ {zone.label}
+
+ {zone.durationMinutes} 分钟 · {zone.rangeText}
+
+
+
+ );
+}
+
+// Lazy import to avoid circular dependency
+let LineChart: any;
+
+try {
+ // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
+ LineChart = require('react-native-chart-kit').LineChart;
+} catch (error) {
+ console.warn('未安装 react-native-chart-kit,心率图表将不会显示:', error);
+}
+
+const styles = StyleSheet.create({
+ modalContainer: {
+ flex: 1,
+ justifyContent: 'flex-end',
+ backgroundColor: 'transparent',
+ },
+ backdrop: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: '#10122599',
+ },
+ sheetContainer: {
+ maxHeight: SHEET_MAX_HEIGHT,
+ backgroundColor: '#FFFFFF',
+ borderTopLeftRadius: 32,
+ borderTopRightRadius: 32,
+ overflow: 'hidden',
+ },
+ gradientBackground: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ handleWrapper: {
+ alignItems: 'center',
+ paddingTop: 16,
+ paddingBottom: 8,
+ },
+ handle: {
+ width: 42,
+ height: 5,
+ borderRadius: 3,
+ backgroundColor: '#D5D9EB',
+ },
+ headerRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 20,
+ paddingBottom: 12,
+ },
+ headerIconButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ headerTitleWrapper: {
+ flex: 1,
+ alignItems: 'center',
+ },
+ headerTitle: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: '#1E2148',
+ },
+ headerSubtitle: {
+ marginTop: 4,
+ fontSize: 12,
+ color: '#7E86A7',
+ },
+ heroIconWrapper: {
+ position: 'absolute',
+ right: -20,
+ top: 60,
+ },
+ contentContainer: {
+ paddingBottom: 40,
+ paddingHorizontal: 24,
+ paddingTop: 8,
+ },
+ summaryCard: {
+ backgroundColor: '#FFFFFF',
+ borderRadius: 28,
+ padding: 20,
+ marginBottom: 22,
+ shadowColor: '#646CFF33',
+ shadowOffset: { width: 0, height: 10 },
+ shadowOpacity: 0.18,
+ shadowRadius: 22,
+ elevation: 8,
+ },
+ summaryHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ activityName: {
+ fontSize: 24,
+ fontWeight: '700',
+ color: '#1E2148',
+ },
+ intensityPill: {
+ paddingHorizontal: 12,
+ paddingVertical: 4,
+ borderRadius: 999,
+ },
+ intensityPillText: {
+ fontSize: 12,
+ fontWeight: '600',
+ },
+ summarySubtitle: {
+ marginTop: 8,
+ fontSize: 13,
+ color: '#848BA9',
+ },
+ metricsRow: {
+ flexDirection: 'row',
+ marginTop: 20,
+ gap: 12,
+ },
+ metricItem: {
+ flex: 1,
+ borderRadius: 18,
+ backgroundColor: '#F5F6FF',
+ paddingVertical: 14,
+ paddingHorizontal: 12,
+ },
+ metricTitle: {
+ fontSize: 12,
+ color: '#7A81A3',
+ marginBottom: 6,
+ },
+ metricTitleRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ marginBottom: 6,
+ },
+ metricInfoButton: {
+ padding: 2,
+ },
+ metricValue: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#1E2148',
+ },
+ monthOccurrenceText: {
+ marginTop: 16,
+ fontSize: 13,
+ color: '#4B4F75',
+ },
+ loadingBlock: {
+ marginTop: 32,
+ alignItems: 'center',
+ gap: 10,
+ },
+ loadingLabel: {
+ fontSize: 13,
+ color: '#7E86A7',
+ },
+ errorBlock: {
+ marginTop: 24,
+ alignItems: 'center',
+ gap: 12,
+ },
+ errorText: {
+ fontSize: 13,
+ color: '#F65858',
+ },
+ retryButton: {
+ paddingHorizontal: 18,
+ paddingVertical: 8,
+ backgroundColor: '#5C55FF',
+ borderRadius: 16,
+ },
+ retryButtonText: {
+ color: '#FFFFFF',
+ fontWeight: '600',
+ fontSize: 13,
+ },
+ section: {
+ backgroundColor: '#FFFFFF',
+ borderRadius: 24,
+ padding: 20,
+ marginBottom: 20,
+ shadowColor: '#10122514',
+ shadowOffset: { width: 0, height: 12 },
+ shadowOpacity: 0.08,
+ shadowRadius: 20,
+ elevation: 4,
+ },
+ sectionHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginBottom: 16,
+ },
+ sectionTitle: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#1E2148',
+ },
+ sectionLoading: {
+ paddingVertical: 40,
+ alignItems: 'center',
+ },
+ sectionError: {
+ alignItems: 'center',
+ paddingVertical: 18,
+ },
+ errorTextSmall: {
+ fontSize: 12,
+ color: '#7E86A7',
+ },
+ heartRateSummaryRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: 18,
+ },
+ heartRateStat: {
+ flex: 1,
+ alignItems: 'center',
+ },
+ statLabel: {
+ fontSize: 12,
+ color: '#7E86A7',
+ marginBottom: 4,
+ },
+ statValue: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#1E2148',
+ },
+ chartWrapper: {
+ alignItems: 'center',
+ },
+ chartStyle: {
+ marginLeft: -10,
+ },
+ chartEmpty: {
+ paddingVertical: 32,
+ alignItems: 'center',
+ gap: 8,
+ },
+ chartEmptyText: {
+ fontSize: 13,
+ color: '#9CA3C6',
+ },
+ zoneRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 14,
+ gap: 12,
+ },
+ zoneBar: {
+ width: 110,
+ height: 12,
+ borderRadius: 999,
+ overflow: 'hidden',
+ },
+ zoneBarFill: {
+ height: '100%',
+ borderRadius: 999,
+ },
+ zoneInfo: {
+ flex: 1,
+ },
+ zoneLabel: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#1E2148',
+ },
+ zoneMeta: {
+ marginTop: 4,
+ fontSize: 12,
+ color: '#7E86A7',
+ },
+ homeIndicatorSpacer: {
+ height: 28,
+ },
+ infoBackdrop: {
+ flex: 1,
+ backgroundColor: '#0F122080',
+ justifyContent: 'flex-end',
+ },
+ intensityInfoSheet: {
+ margin: 20,
+ marginBottom: 34,
+ backgroundColor: '#FFFFFF',
+ borderRadius: 28,
+ paddingHorizontal: 24,
+ paddingTop: 20,
+ paddingBottom: 28,
+ shadowColor: '#1F265933',
+ shadowOffset: { width: 0, height: 16 },
+ shadowOpacity: 0.25,
+ shadowRadius: 24,
+ },
+ intensityHandle: {
+ alignSelf: 'center',
+ width: 44,
+ height: 4,
+ borderRadius: 999,
+ backgroundColor: '#E1E4F3',
+ marginBottom: 16,
+ },
+ intensityInfoTitle: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: '#1E2148',
+ marginBottom: 12,
+ },
+ intensityInfoText: {
+ fontSize: 13,
+ color: '#4C5074',
+ lineHeight: 20,
+ marginBottom: 10,
+ },
+ intensityFormula: {
+ marginTop: 12,
+ marginBottom: 18,
+ backgroundColor: '#F4F6FE',
+ borderRadius: 18,
+ paddingVertical: 14,
+ paddingHorizontal: 16,
+ },
+ intensityFormulaLabel: {
+ fontSize: 12,
+ color: '#7E86A7',
+ marginBottom: 6,
+ },
+ intensityFormulaValue: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#1F2355',
+ lineHeight: 20,
+ },
+ intensityLegend: {
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: '#E3E6F4',
+ paddingTop: 16,
+ gap: 14,
+ },
+ intensityLegendRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ intensityLegendRange: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#1E2148',
+ },
+ intensityLegendLabel: {
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ intensityLow: {
+ color: '#5C84FF',
+ },
+ intensityMedium: {
+ color: '#2CCAA0',
+ },
+ intensityHigh: {
+ color: '#FF6767',
+ },
+ headerSpacer: {
+ width: 40,
+ height: 40,
+ },
+});
+
diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist
index 02ded79..94bc391 100644
--- a/ios/OutLive/Info.plist
+++ b/ios/OutLive/Info.plist
@@ -25,7 +25,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 1.0.16
+ 1.0.17
CFBundleSignature
????
CFBundleURLTypes
@@ -78,8 +78,6 @@
UIBackgroundModes
processing
- fetch
- remote-notification
UILaunchStoryboardName
SplashScreen
diff --git a/package.json b/package.json
index 750fc8d..ab971a8 100644
--- a/package.json
+++ b/package.json
@@ -4,11 +4,8 @@
"version": "1.0.2",
"scripts": {
"start": "expo start",
- "reset-project": "node ./scripts/reset-project.js",
- "android": "expo run:android",
"ios": "expo run:ios",
"ios-device": "expo run:ios --device",
- "web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
diff --git a/services/backgroundTaskManager.ts b/services/backgroundTaskManager.ts
index 5512fc3..29dfc46 100644
--- a/services/backgroundTaskManager.ts
+++ b/services/backgroundTaskManager.ts
@@ -274,9 +274,7 @@ export class BackgroundTaskManager {
log.info('[BackgroundTask] 任务未注册, 开始注册...');
// 注册后台任务
- await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER, {
- minimumInterval: 15 * 2,
- });
+ await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER);
this.isInitialized = true;
diff --git a/services/workoutDetail.ts b/services/workoutDetail.ts
new file mode 100644
index 0000000..6373542
--- /dev/null
+++ b/services/workoutDetail.ts
@@ -0,0 +1,279 @@
+import dayjs from 'dayjs';
+
+import {
+ fetchHeartRateSamplesForRange,
+ HeartRateSample,
+ WorkoutData,
+} from '@/utils/health';
+
+export interface WorkoutHeartRatePoint {
+ timestamp: string;
+ value: number;
+}
+
+export interface HeartRateZoneStat {
+ key: string;
+ label: string;
+ color: string;
+ rangeText: string;
+ durationMinutes: number;
+ percentage: number;
+}
+
+export interface WorkoutDetailMetrics {
+ durationLabel: string;
+ durationSeconds: number;
+ calories: number | null;
+ mets: number | null;
+ averageHeartRate: number | null;
+ maximumHeartRate: number | null;
+ minimumHeartRate: number | null;
+ heartRateSeries: WorkoutHeartRatePoint[];
+ heartRateZones: HeartRateZoneStat[];
+}
+
+const DEFAULT_SAMPLE_GAP_SECONDS = 5;
+
+const HEART_RATE_ZONES = [
+ {
+ key: 'warmup',
+ label: '热身放松',
+ color: '#9FC7FF',
+ rangeText: '<100次/分',
+ min: 0,
+ max: 100,
+ },
+ {
+ key: 'fatburn',
+ label: '燃烧脂肪',
+ color: '#5ED7B0',
+ rangeText: '100-119次/分',
+ min: 100,
+ max: 120,
+ },
+ {
+ key: 'aerobic',
+ label: '有氧运动',
+ color: '#FFB74D',
+ rangeText: '120-149次/分',
+ min: 120,
+ max: 150,
+ },
+ {
+ key: 'anaerobic',
+ label: '无氧运动',
+ color: '#FF826E',
+ rangeText: '150-169次/分',
+ min: 150,
+ max: 170,
+ },
+ {
+ key: 'max',
+ label: '身体极限',
+ color: '#F2A4D8',
+ rangeText: '≥170次/分',
+ min: 170,
+ max: Infinity,
+ },
+];
+
+export async function getWorkoutDetailMetrics(
+ workout: WorkoutData
+): Promise {
+ const start = dayjs(workout.startDate || workout.endDate);
+ const end = dayjs(workout.endDate || workout.startDate);
+
+ const safeStart = start.isValid() ? start : dayjs();
+ const safeEnd = end.isValid() ? end : safeStart.add(workout.duration || 0, 'second');
+
+ const heartRateSamples = await fetchHeartRateSamplesForRange(
+ safeStart.toDate(),
+ safeEnd.toDate()
+ );
+
+ const heartRateSeries = normalizeHeartRateSeries(heartRateSamples);
+ const {
+ average: averageHeartRate,
+ max: maximumHeartRate,
+ min: minimumHeartRate,
+ } = calculateHeartRateStats(heartRateSeries, workout);
+
+ const heartRateZones = calculateHeartRateZones(heartRateSeries);
+ const durationSeconds = Math.max(Math.round(workout.duration || 0), 0);
+ const durationLabel = formatDuration(durationSeconds);
+ const calories = workout.totalEnergyBurned != null
+ ? Math.round(workout.totalEnergyBurned)
+ : null;
+ const mets = extractMetsFromMetadata(workout.metadata) || calculateMetsFromWorkoutData(workout);
+
+ return {
+ durationLabel,
+ durationSeconds,
+ calories,
+ mets,
+ averageHeartRate,
+ maximumHeartRate,
+ minimumHeartRate,
+ heartRateSeries,
+ heartRateZones,
+ };
+}
+
+function formatDuration(durationSeconds: number): string {
+ const hours = Math.floor(durationSeconds / 3600);
+ const minutes = Math.floor((durationSeconds % 3600) / 60);
+ const seconds = durationSeconds % 60;
+
+ const parts = [hours, minutes, seconds].map((unit) =>
+ unit.toString().padStart(2, '0')
+ );
+
+ return parts.join(':');
+}
+
+function normalizeHeartRateSeries(samples: HeartRateSample[]): WorkoutHeartRatePoint[] {
+ return samples
+ .map((sample) => ({
+ timestamp: sample.endDate || sample.startDate,
+ value: Number(sample.value),
+ }))
+ .filter((point) => dayjs(point.timestamp).isValid() && Number.isFinite(point.value))
+ .sort((a, b) => dayjs(a.timestamp).valueOf() - dayjs(b.timestamp).valueOf());
+}
+
+function calculateHeartRateStats(
+ series: WorkoutHeartRatePoint[],
+ workout: WorkoutData
+) {
+ if (!series.length) {
+ const fallback = workout.averageHeartRate ?? null;
+ return {
+ average: fallback,
+ max: fallback,
+ min: fallback,
+ };
+ }
+
+ const values = series.map((point) => point.value);
+ const sum = values.reduce((acc, value) => acc + value, 0);
+
+ return {
+ average: Math.round(sum / values.length),
+ max: Math.round(Math.max(...values)),
+ min: Math.round(Math.min(...values)),
+ };
+}
+
+function calculateHeartRateZones(series: WorkoutHeartRatePoint[]): HeartRateZoneStat[] {
+ if (!series.length) {
+ return HEART_RATE_ZONES.map((zone) => ({
+ key: zone.key,
+ label: zone.label,
+ color: zone.color,
+ rangeText: zone.rangeText,
+ durationMinutes: 0,
+ percentage: 0,
+ }));
+ }
+
+ const durations: Record = HEART_RATE_ZONES.reduce((acc, zone) => {
+ acc[zone.key] = 0;
+ return acc;
+ }, {} as Record);
+
+ for (let i = 0; i < series.length; i++) {
+ const current = series[i];
+ const next = series[i + 1];
+
+ const currentTime = dayjs(current.timestamp);
+ let nextTime = next ? dayjs(next.timestamp) : currentTime.add(DEFAULT_SAMPLE_GAP_SECONDS, 'second');
+
+ if (!nextTime.isValid() || nextTime.isBefore(currentTime)) {
+ nextTime = currentTime.add(DEFAULT_SAMPLE_GAP_SECONDS, 'second');
+ }
+
+ const durationSeconds = Math.max(nextTime.diff(currentTime, 'second'), DEFAULT_SAMPLE_GAP_SECONDS);
+ const zone = getZoneForValue(current.value);
+ durations[zone.key] += durationSeconds;
+ }
+
+ const totalSeconds = Object.values(durations).reduce((acc, value) => acc + value, 0) || 1;
+
+ return HEART_RATE_ZONES.map((zone) => {
+ const zoneSeconds = durations[zone.key] || 0;
+ const minutes = Math.round(zoneSeconds / 60);
+ const percentage = Math.round((zoneSeconds / totalSeconds) * 100);
+
+ return {
+ key: zone.key,
+ label: zone.label,
+ color: zone.color,
+ rangeText: zone.rangeText,
+ durationMinutes: minutes,
+ percentage,
+ };
+ });
+}
+
+function getZoneForValue(value: number) {
+ return (
+ HEART_RATE_ZONES.find((zone) => value >= zone.min && value < zone.max) ||
+ HEART_RATE_ZONES[HEART_RATE_ZONES.length - 1]
+ );
+}
+
+function extractMetsFromMetadata(metadata: Record): number | null {
+ if (!metadata) {
+ return null;
+ }
+
+ const candidates = [
+ metadata.HKAverageMETs,
+ metadata.averageMETs,
+ metadata.mets,
+ metadata.METs,
+ ];
+
+ for (const candidate of candidates) {
+ if (candidate !== undefined && candidate !== null && Number.isFinite(Number(candidate))) {
+ return Math.round(Number(candidate) * 10) / 10;
+ }
+ }
+
+ return null;
+}
+
+function calculateMetsFromWorkoutData(workout: WorkoutData): number | null {
+ // 如果没有卡路里消耗或持续时间数据,无法计算 METs
+ if (!workout.totalEnergyBurned || !workout.duration || workout.duration <= 0) {
+ return null;
+ }
+
+ // 计算活动能耗(千卡/小时)
+ const durationInHours = workout.duration / 3600; // 将秒转换为小时
+ const activeEnergyBurnedPerHour = workout.totalEnergyBurned / durationInHours;
+
+ // 使用估算的平均体重(70公斤)来计算 METs
+ // METs = 活动能量消耗(千卡/小时) ÷ 体重(千克)
+ const estimatedWeightKg = 70; // 成年人平均估算体重
+ const mets = activeEnergyBurnedPerHour / estimatedWeightKg;
+
+ // 验证计算结果的合理性
+ // 一般成年人的静息代谢率约为 1 MET,日常活动通常在 1-12 METs 范围内
+ // 高强度运动可能超过 12 METs,但很少超过 20 METs
+ if (mets < 0.5 || mets > 25) {
+ console.warn('计算出的 METs 值可能不合理:', {
+ mets,
+ totalEnergyBurned: workout.totalEnergyBurned,
+ duration: workout.duration,
+ durationInHours,
+ activeEnergyBurnedPerHour,
+ estimatedWeightKg
+ });
+ // 即使值可能不合理,也返回计算结果,但记录警告
+ }
+
+ // 保留一位小数
+ return Math.round(mets * 10) / 10;
+}
+
diff --git a/utils/health.ts b/utils/health.ts
index d65be41..f6b4c54 100644
--- a/utils/health.ts
+++ b/utils/health.ts
@@ -26,6 +26,18 @@ export interface WorkoutData {
metadata: Record;
}
+export interface HeartRateSample {
+ id: string;
+ startDate: string;
+ endDate: string;
+ value: number;
+ source?: {
+ name: string;
+ bundleIdentifier: string;
+ };
+ metadata?: Record;
+}
+
// 锻炼记录查询选项
export interface WorkoutOptions extends HealthDataOptions {
limit?: number; // 默认10条
@@ -770,6 +782,44 @@ export async function fetchOxygenSaturation(options: HealthDataOptions): Promise
}
}
+export async function fetchHeartRateSamplesForRange(
+ startDate: Date,
+ endDate: Date,
+ limit: number = 2000
+): Promise {
+ try {
+ const options = {
+ startDate: dayjs(startDate).toISOString(),
+ endDate: dayjs(endDate).toISOString(),
+ limit,
+ };
+
+ const result = await HealthKitManager.getHeartRateSamples(options);
+
+ if (result && Array.isArray(result.data)) {
+ const samples: HeartRateSample[] = result.data
+ .filter((sample: any) => sample && typeof sample.value === 'number' && !Number.isNaN(sample.value))
+ .map((sample: any) => ({
+ id: sample.id,
+ startDate: sample.startDate,
+ endDate: sample.endDate,
+ value: Number(sample.value),
+ source: sample.source,
+ metadata: sample.metadata,
+ }));
+
+ logSuccess('锻炼心率采样', { count: samples.length, startDate: options.startDate, endDate: options.endDate });
+ return samples;
+ }
+
+ logWarning('锻炼心率采样', '为空或格式错误');
+ return [];
+ } catch (error) {
+ logError('锻炼心率采样', error);
+ return [];
+ }
+}
+
async function fetchHeartRate(options: HealthDataOptions): Promise {
try {
const result = await HealthKitManager.getHeartRateSamples(options);