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 ( {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={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} /> ) : ( 图表组件不可用,无法展示心率曲线 ) ) : ( 暂无心率采样数据 )} ) : ( {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 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 ( {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, 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, }, });