feat(workout): 重构锻炼模块并新增详细数据展示

- 移除旧的锻炼会话页面和布局文件
- 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示
- 优化锻炼历史页面,增加月度统计卡片和交互式详情查看
- 新增锻炼详情服务,提供心率分析、METs计算等功能
- 更新应用版本至1.0.17并调整iOS后台任务配置
- 添加项目规则文档,明确React Native开发规范
This commit is contained in:
richarjiang
2025-10-11 17:20:51 +08:00
parent 79ddd41a49
commit d43d8c692f
13 changed files with 1605 additions and 2417 deletions

View File

@@ -0,0 +1,7 @@
# kilo-rule.md
永远记得你是一名专业的 reac native 工程师,并且当前项目是一个 prebuild 之后的 expo react native 项目,应用场景永远是 ios不要考虑 android
## 指导原则
- 遇到比较复杂的页面,尽量使用可以复用的组件
- 不要尝试使用 `npm run ios` 命令

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Out Live", "name": "Out Live",
"slug": "digital-pilates", "slug": "digital-pilates",
"version": "1.0.16", "version": "1.0.17",
"orientation": "portrait", "orientation": "portrait",
"scheme": "digitalpilates", "scheme": "digitalpilates",
"userInterfaceStyle": "light", "userInterfaceStyle": "light",

View File

@@ -330,9 +330,11 @@ export default function FitnessRingsDetailScreen() {
style={[ style={[
styles.chartBar, styles.chartBar,
{ {
flex: 1,
height: value > 0 ? height : 2, // 没有数据时显示最小高度的灰色条 height: value > 0 ? height : 2, // 没有数据时显示最小高度的灰色条
backgroundColor: value > 0 ? color : '#E5E5EA', 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() {
})} })}
</View> </View>
<View style={styles.chartLabels}> <View style={styles.chartLabels}>
<Text style={styles.chartLabel}>00:00</Text> {chartData.map((_, index) => {
<Text style={styles.chartLabel}>06:00</Text> // 只在关键时间点显示标签0点、6点、12点、18点
<Text style={styles.chartLabel}>12:00</Text> if (index === 0 || index === 6 || index === 12 || index === 18) {
<Text style={styles.chartLabel}>18:00</Text> const hour = index;
return (
<Text key={index} style={styles.chartLabel}>
{hour.toString().padStart(2, '0')}:00
</Text>
);
}
// 对于不显示标签的小时返回一个占位的View
return <View key={index} style={styles.chartLabelSpacer} />;
})}
</View> </View>
</View> </View>
); );
@@ -731,23 +742,25 @@ const styles = StyleSheet.create({
alignItems: 'flex-end', alignItems: 'flex-end',
height: 60, height: 60,
marginBottom: 8, marginBottom: 8,
paddingHorizontal: 4, paddingHorizontal: 2,
justifyContent: 'space-between',
}, },
chartBar: { chartBar: {
width: 3,
borderRadius: 1.5, borderRadius: 1.5,
marginHorizontal: 0.5,
}, },
chartLabels: { chartLabels: {
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: 2,
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: 4,
}, },
chartLabel: { chartLabel: {
fontSize: 12, fontSize: 10,
color: '#8E8E93', color: '#8E8E93',
fontWeight: '500', fontWeight: '500',
textAlign: 'center',
flex: 6, // 给显示标签的元素更多空间
},
chartLabelSpacer: {
flex: 1, // 占位元素使用较少空间
}, },
// 锻炼信息样式 // 锻炼信息样式
exerciseInfo: { exerciseInfo: {

View File

@@ -1,36 +0,0 @@
import { Stack } from 'expo-router';
export default function WorkoutLayout() {
return (
<Stack>
<Stack.Screen
name="today"
options={{
headerShown: false,
presentation: 'card',
}}
/>
<Stack.Screen
name="create-session"
options={{
headerShown: false,
presentation: 'modal',
}}
/>
<Stack.Screen
name="history"
options={{
headerShown: false,
presentation: 'card',
}}
/>
<Stack.Screen
name="session/[id]"
options={{
headerShown: false,
presentation: 'card',
}}
/>
</Stack>
);
}

View File

@@ -1,6 +1,7 @@
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import isBetween from 'dayjs/plugin/isBetween';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { import {
@@ -13,7 +14,9 @@ import {
} from 'react-native'; } from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
import { import {
addHealthPermissionListener, addHealthPermissionListener,
checkHealthPermissionStatus, checkHealthPermissionStatus,
@@ -138,6 +141,97 @@ const ICON_MAP: Partial<Record<WorkoutActivityType, keyof typeof MaterialCommuni
[WorkoutActivityType.Curling]: 'target', [WorkoutActivityType.Curling]: 'target',
}; };
type ActivitySummary = {
type: WorkoutActivityType;
duration: number;
count: number;
displayName: string;
iconName: keyof typeof MaterialCommunityIcons.glyphMap;
};
type MonthlyStatsInfo = {
items: ActivitySummary[];
totalDuration: number;
totalCount: number;
monthStart: string;
monthEnd: string;
snapshotDate: string;
};
const MONTHLY_STAT_COLORS = [
{ background: '#FDE9F4', pill: '#F8CDE2', bar: '#F9CFE3', icon: '#2F2965', label: '#5A648C' },
{ background: '#FFF3D6', pill: '#FFE3A4', bar: '#FFE0A6', icon: '#2F2965', label: '#5A648C' },
{ background: '#E3F5F3', pill: '#CBEAE4', bar: '#D7EEE8', icon: '#2F2965', label: '#5A648C' },
];
function formatDurationShort(durationInSeconds: number): string {
const totalMinutes = Math.max(Math.round(durationInSeconds / 60), 1);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 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<Record<string, ActivitySummary>>((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) { function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) {
if (!totalCalories || !durationInSeconds) { if (!totalCalories || !durationInSeconds) {
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' }; return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
@@ -180,6 +274,14 @@ export default function WorkoutHistoryScreen() {
const [sections, setSections] = useState<WorkoutSection[]>([]); const [sections, setSections] = useState<WorkoutSection[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedWorkout, setSelectedWorkout] = useState<WorkoutData | null>(null);
const [isDetailVisible, setIsDetailVisible] = useState(false);
const [detailMetrics, setDetailMetrics] = useState<WorkoutDetailMetrics | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [selectedIntensity, setSelectedIntensity] = useState<IntensityBadge | null>(null);
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
const loadHistory = useCallback(async () => { const loadHistory = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@@ -198,6 +300,7 @@ export default function WorkoutHistoryScreen() {
if (!hasPermission) { if (!hasPermission) {
setSections([]); setSections([]);
setError('尚未授予健康数据权限'); setError('尚未授予健康数据权限');
setMonthlyStats(null);
return; return;
} }
@@ -206,11 +309,13 @@ export default function WorkoutHistoryScreen() {
const workouts = await fetchWorkoutsForDateRange(start.toDate(), end.toDate(), 200); const workouts = await fetchWorkoutsForDateRange(start.toDate(), end.toDate(), 200);
const filteredWorkouts = workouts.filter((workout) => workout.duration && workout.duration > 0); const filteredWorkouts = workouts.filter((workout) => workout.duration && workout.duration > 0);
setMonthlyStats(computeMonthlyStats(filteredWorkouts));
setSections(groupWorkouts(filteredWorkouts)); setSections(groupWorkouts(filteredWorkouts));
} catch (err) { } catch (err) {
console.error('加载锻炼历史失败:', err); console.error('加载锻炼历史失败:', err);
setError('加载锻炼记录失败,请稍后再试'); setError('加载锻炼记录失败,请稍后再试');
setSections([]); setSections([]);
setMonthlyStats(null);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -233,12 +338,77 @@ export default function WorkoutHistoryScreen() {
}; };
}, [loadHistory]); }, [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 (
<View style={styles.headerContainer}> <View style={styles.headerContainer}>
<Text style={styles.headerTitle}></Text> {/* <Text style={styles.headerTitle}>历史</Text>
<Text style={styles.headerSubtitle}></Text> <Text style={styles.headerSubtitle}>最近一个月的锻炼记录</Text> */}
<View style={styles.monthlyStatsWrapper}>
{/* <Text style={styles.monthlyStatsTitle}>统计</Text> */}
<View style={styles.monthlyStatsCardShell}>
<LinearGradient
colors={['#FFFFFF', '#F5F6FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.monthlyStatsCard}
>
<Text style={styles.statSectionLabel}></Text>
<Text style={styles.statPeriodText}>{periodText}</Text>
<Text style={styles.statDescription}>{overviewText}</Text>
{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 (
<View key={String(item.type)} style={styles.summaryRowWrapper}>
<View style={[styles.summaryRowBackground, { backgroundColor: palette.background }]}>
<View
style={[
styles.summaryRowFill,
{ width: `${ratio * 100}%`, backgroundColor: palette.bar },
]}
/>
<View style={styles.summaryRowInner}>
<View style={[styles.summaryBadge, { backgroundColor: palette.pill }]}>
<MaterialCommunityIcons name={item.iconName} size={20} color={palette.icon} />
<Text style={[styles.summaryCount, { color: palette.icon }]}>X{item.count}</Text>
</View> </View>
), []); <View style={styles.summaryRowContent}>
<Text style={[styles.summaryDuration, { color: palette.icon }]}>{formatDurationShort(item.duration)}</Text>
<Text style={[styles.summaryActivity, { color: palette.label }]}>{item.displayName}</Text>
</View>
</View>
</View>
</View>
);
})
) : (
<View style={styles.statEmptyState}>
<MaterialCommunityIcons name="calendar-blank" size={20} color="#7C85A3" />
<Text style={styles.statEmptyText}></Text>
</View>
)}
</LinearGradient>
</View>
</View>
</View>
);
}, [monthlyStats]);
const emptyComponent = useMemo(() => ( const emptyComponent = useMemo(() => (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
@@ -248,6 +418,77 @@ export default function WorkoutHistoryScreen() {
</View> </View>
), []); ), []);
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 renderItem = useCallback(({ item }: { item: WorkoutData }) => {
const calories = Math.round(item.totalEnergyBurned || 0); const calories = Math.round(item.totalEnergyBurned || 0);
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1); const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
@@ -257,7 +498,11 @@ export default function WorkoutHistoryScreen() {
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType); const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
return ( return (
<TouchableOpacity style={styles.historyCard} activeOpacity={0.85} onPress={() => { }}> <TouchableOpacity
style={styles.historyCard}
activeOpacity={0.85}
onPress={() => handleWorkoutPress(item)}
>
<View style={styles.cardIconWrapper}> <View style={styles.cardIconWrapper}>
<MaterialCommunityIcons name={iconName} size={28} color="#5C55FF" /> <MaterialCommunityIcons name={iconName} size={28} color="#5C55FF" />
</View> </View>
@@ -275,7 +520,7 @@ export default function WorkoutHistoryScreen() {
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */} {/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
</TouchableOpacity> </TouchableOpacity>
); );
}, []); }, [handleWorkoutPress]);
const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => ( const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => (
<Text style={styles.sectionHeader}>{section.title}</Text> <Text style={styles.sectionHeader}>{section.title}</Text>
@@ -315,6 +560,17 @@ export default function WorkoutHistoryScreen() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
/> />
)} )}
<WorkoutDetailModal
visible={isDetailVisible}
onClose={handleCloseDetail}
workout={selectedWorkout}
metrics={detailMetrics}
loading={detailLoading}
intensityBadge={selectedIntensity || undefined}
monthOccurrenceText={monthOccurrenceText || undefined}
onRetry={detailError ? handleRetryDetail : undefined}
errorMessage={detailError}
/>
</View> </View>
); );
} }
@@ -342,6 +598,103 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
color: '#677086', 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: { listContent: {
paddingBottom: 40, paddingBottom: 40,
}, },

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<Modal
transparent
visible={isMounted}
animationType="none"
onRequestClose={onClose}
>
<View style={styles.modalContainer}>
<TouchableWithoutFeedback onPress={handleBackdropPress}>
<Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]} />
</TouchableWithoutFeedback>
<Animated.View
style={[
styles.sheetContainer,
{
transform: [{ translateY }],
},
]}
>
<LinearGradient
colors={['#FFFFFF', '#F3F5FF']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.gradientBackground}
/>
<View style={styles.handleWrapper}>
<View style={styles.handle} />
</View>
<View style={styles.headerRow}>
<TouchableOpacity onPress={onClose} style={styles.headerIconButton} disabled={loading}>
<MaterialCommunityIcons name="chevron-down" size={26} color="#262A5D" />
</TouchableOpacity>
<View style={styles.headerTitleWrapper}>
<Text style={styles.headerTitle}>{dateInfo.title}</Text>
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
</View>
<View style={styles.headerSpacer} />
</View>
<View style={styles.heroIconWrapper}>
<MaterialCommunityIcons name="run" size={160} color="#E8EAFE" />
</View>
<ScrollView
bounces={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
>
<View style={styles.summaryCard}>
<View style={styles.summaryHeader}>
<Text style={styles.activityName}>{activityName}</Text>
{intensityBadge ? (
<View
style={[
styles.intensityPill,
{ backgroundColor: intensityBadge.background },
]}
>
<Text style={[styles.intensityPillText, { color: intensityBadge.color }]}>
{intensityBadge.label}
</Text>
</View>
) : null}
</View>
<Text style={styles.summarySubtitle}>
{dayjs(workout?.startDate || workout?.endDate).format('YYYY年M月D日 dddd HH:mm')}
</Text>
{loading ? (
<View style={styles.loadingBlock}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.loadingLabel}>...</Text>
</View>
) : metrics ? (
<>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricValue}>{metrics.durationLabel}</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricValue}>
{metrics.calories != null ? `${metrics.calories} 千卡` : '--'}
</Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<View style={styles.metricTitleRow}>
<Text style={styles.metricTitle}></Text>
<TouchableOpacity
onPress={() => setShowIntensityInfo(true)}
style={styles.metricInfoButton}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<MaterialCommunityIcons name="information-outline" size={16} color="#7780AA" />
</TouchableOpacity>
</View>
<Text style={styles.metricValue}>
{formatMetsValue(metrics.mets)}
</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} 次/分` : '--'}
</Text>
</View>
</View>
{monthOccurrenceText ? (
<Text style={styles.monthOccurrenceText}>{monthOccurrenceText}</Text>
) : null}
</>
) : (
<View style={styles.errorBlock}>
<Text style={styles.errorText}>
{errorMessage || '未能获取到完整的锻炼详情'}
</Text>
{onRetry ? (
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
<Text style={styles.retryButtonText}></Text>
</TouchableOpacity>
) : null}
</View>
)}
</View>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<MaterialCommunityIcons name="help-circle-outline" size={16} color="#A0A8C8" />
</View>
{loading ? (
<View style={styles.sectionLoading}>
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
<>
<View style={styles.heartRateSummaryRow}>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text>
<Text style={styles.statValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate}次/分` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text>
<Text style={styles.statValue}>
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate}次/分` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text>
<Text style={styles.statValue}>
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate}次/分` : '--'}
</Text>
</View>
</View>
{heartRateChart ? (
LineChart ? (
<View style={styles.chartWrapper}>
{/* @ts-ignore - react-native-chart-kit types are outdated */}
<LineChart
data={{
labels: heartRateChart.labels,
datasets: [
{
data: heartRateChart.data,
color: () => '#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}
/>
</View>
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>线</Text>
</View>
)
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}></Text>
</View>
)}
</>
) : (
<View style={styles.sectionError}>
<Text style={styles.errorTextSmall}>
{errorMessage || '未获取到心率数据'}
</Text>
</View>
)}
</View>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
</View>
{loading ? (
<View style={styles.sectionLoading}>
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
metrics.heartRateZones.map(renderHeartRateZone)
) : (
<Text style={styles.errorTextSmall}></Text>
)}
</View>
<View style={styles.homeIndicatorSpacer} />
</ScrollView>
</Animated.View>
{showIntensityInfo ? (
<Modal
transparent
visible={showIntensityInfo}
animationType="fade"
onRequestClose={() => setShowIntensityInfo(false)}
>
<TouchableWithoutFeedback onPress={() => setShowIntensityInfo(false)}>
<View style={styles.infoBackdrop}>
<TouchableWithoutFeedback onPress={() => { }}>
<View style={styles.intensityInfoSheet}>
<View style={styles.intensityHandle} />
<Text style={styles.intensityInfoTitle}></Text>
<Text style={styles.intensityInfoText}>
MET/·
</Text>
<Text style={styles.intensityInfoText}>
MET 便
</Text>
<Text style={styles.intensityInfoText}>
3 km/h 2 METs 2
</Text>
<Text style={styles.intensityInfoText}>
METs 使70
</Text>
<View style={styles.intensityFormula}>
<Text style={styles.intensityFormulaLabel}></Text>
<Text style={styles.intensityFormulaValue}>METs = / ÷ 1 /</Text>
</View>
<View style={styles.intensityLegend}>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{'< 3'}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityLow]}></Text>
</View>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>3 - 6</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityMedium]}></Text>
</View>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{'≥ 6'}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityHigh]}></Text>
</View>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
) : null}
</View>
</Modal>
);
}
// 格式化 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 (
<View key={zone.key} style={styles.zoneRow}>
<View style={[styles.zoneBar, { backgroundColor: `${zone.color}33` }]}>
<View
style={[
styles.zoneBarFill,
{
width: `${Math.min(zone.percentage, 100)}%`,
backgroundColor: zone.color,
},
]}
/>
</View>
<View style={styles.zoneInfo}>
<Text style={styles.zoneLabel}>{zone.label}</Text>
<Text style={styles.zoneMeta}>
{zone.durationMinutes} · {zone.rangeText}
</Text>
</View>
</View>
);
}
// 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,
},
});

View File

@@ -25,7 +25,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.16</string> <string>1.0.17</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -78,8 +78,6 @@
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>processing</string> <string>processing</string>
<string>fetch</string>
<string>remote-notification</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>SplashScreen</string> <string>SplashScreen</string>

View File

@@ -4,11 +4,8 @@
"version": "1.0.2", "version": "1.0.2",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"ios-device": "expo run:ios --device", "ios-device": "expo run:ios --device",
"web": "expo start --web",
"lint": "expo lint" "lint": "expo lint"
}, },
"dependencies": { "dependencies": {

View File

@@ -274,9 +274,7 @@ export class BackgroundTaskManager {
log.info('[BackgroundTask] 任务未注册, 开始注册...'); log.info('[BackgroundTask] 任务未注册, 开始注册...');
// 注册后台任务 // 注册后台任务
await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER, { await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER);
minimumInterval: 15 * 2,
});
this.isInitialized = true; this.isInitialized = true;

279
services/workoutDetail.ts Normal file
View File

@@ -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<WorkoutDetailMetrics> {
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<string, number> = HEART_RATE_ZONES.reduce((acc, zone) => {
acc[zone.key] = 0;
return acc;
}, {} as Record<string, number>);
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<string, any>): 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;
}

View File

@@ -26,6 +26,18 @@ export interface WorkoutData {
metadata: Record<string, any>; metadata: Record<string, any>;
} }
export interface HeartRateSample {
id: string;
startDate: string;
endDate: string;
value: number;
source?: {
name: string;
bundleIdentifier: string;
};
metadata?: Record<string, any>;
}
// 锻炼记录查询选项 // 锻炼记录查询选项
export interface WorkoutOptions extends HealthDataOptions { export interface WorkoutOptions extends HealthDataOptions {
limit?: number; // 默认10条 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<HeartRateSample[]> {
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<number | null> { async function fetchHeartRate(options: HealthDataOptions): Promise<number | null> {
try { try {
const result = await HealthKitManager.getHeartRateSamples(options); const result = await HealthKitManager.getHeartRateSamples(options);