Files
digital-pilates/components/workout/WorkoutDetailModal.tsx
richarjiang ed3a178aa0 feat(workout): 优化心率图表性能并移除每日总结通知功能
- 重构心率数据采样算法,采用智能采样保留峰值、谷值和变化率大的点
- 减少心率图表最大数据点数和查询限制,提升渲染性能
- 移除图表背景线样式,简化视觉呈现
- 完全移除每日总结通知功能相关代码和调用
2025-10-11 21:53:18 +08:00

952 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = 80;
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={false}
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',
},
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 result: typeof series = [];
const n = series.length;
// 总是保留第一个和最后一个点
result.push(series[0]);
// 计算心率变化率
const changeRates: number[] = [];
for (let i = 1; i < n; i++) {
const prevValue = series[i - 1].value;
const currValue = series[i].value;
const prevTime = dayjs(series[i - 1].timestamp).valueOf();
const currTime = dayjs(series[i].timestamp).valueOf();
const timeDiff = Math.max(currTime - prevTime, 1000); // 至少1秒避免除零
const valueDiff = Math.abs(currValue - prevValue);
changeRates.push(valueDiff / timeDiff * 1000); // 变化率:每秒变化量
}
// 计算变化率的阈值前75%的分位数)
const sortedRates = [...changeRates].sort((a, b) => a - b);
const thresholdIndex = Math.floor(sortedRates.length * 0.75);
const changeThreshold = sortedRates[thresholdIndex] || 0;
// 识别局部极值点
const isLocalExtremum = (index: number): boolean => {
if (index === 0 || index === n - 1) return false;
const prev = series[index - 1].value;
const curr = series[index].value;
const next = series[index + 1].value;
// 局部最大值
if (curr > prev && curr > next) return true;
// 局部最小值
if (curr < prev && curr < next) return true;
return false;
};
// 遍历所有点,选择重要点
let minDistance = Math.max(1, Math.floor(n / HEART_RATE_CHART_MAX_POINTS));
for (let i = 1; i < n - 1; i++) {
const shouldKeep =
// 1. 是局部极值点
isLocalExtremum(i) ||
// 2. 变化率超过阈值
(i > 0 && changeRates[i - 1] > changeThreshold) ||
// 3. 均匀分布的点(确保基本覆盖)
(i % minDistance === 0);
if (shouldKeep) {
// 检查与上一个选中点的距离,避免过于密集
const lastSelectedIndex = result.length > 0 ?
series.findIndex(p => p.timestamp === result[result.length - 1].timestamp) : 0;
if (i - lastSelectedIndex >= minDistance || isLocalExtremum(i)) {
result.push(series[i]);
}
}
}
// 确保最后一个点被包含
if (result[result.length - 1].timestamp !== series[n - 1].timestamp) {
result.push(series[n - 1]);
}
// 如果结果仍然太多,进行二次采样
if (result.length > HEART_RATE_CHART_MAX_POINTS) {
const finalStep = Math.ceil(result.length / HEART_RATE_CHART_MAX_POINTS);
const finalResult = result.filter((_, index) => index % finalStep === 0);
// 确保最后一个点被包含
if (finalResult[finalResult.length - 1] !== result[result.length - 1]) {
finalResult.push(result[result.length - 1]);
}
return finalResult;
}
return result;
}
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,
marginRight: -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,
},
});