import { MaterialCommunityIcons } from '@expo/vector-icons'; import dayjs from 'dayjs'; import 'dayjs/locale/en'; import 'dayjs/locale/zh-cn'; import { LinearGradient } from 'expo-linear-gradient'; import React, { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Dimensions, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, TouchableWithoutFeedback, View, } from 'react-native'; import { useI18n } from '@/hooks/useI18n'; 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 { t, i18n } = useI18n(); const [isMounted, setIsMounted] = useState(visible); const [shouldRenderChart, setShouldRenderChart] = useState(visible); const [showIntensityInfo, setShowIntensityInfo] = useState(false); const locale = useMemo(() => (i18n.language?.startsWith('en') ? 'en' : 'zh-cn'), [i18n.language]); useEffect(() => { if (visible) { setIsMounted(true); setShouldRenderChart(true); } else { setShouldRenderChart(false); setIsMounted(false); setShowIntensityInfo(false); } }, [visible]); const activityName = workout ? getWorkoutTypeDisplayName(workout.workoutActivityType as WorkoutActivityType) : ''; const chartWidth = useMemo( () => Math.max(Dimensions.get('window').width - 96, 240), [] ); const dateInfo = useMemo(() => { if (!workout) { return { title: '', subtitle: '' }; } const date = dayjs(workout.startDate || workout.endDate).locale(locale); if (!date.isValid()) { return { title: '', subtitle: '' }; } return { title: locale === 'en' ? date.format('MMM D') : date.format('M月D日'), subtitle: locale === 'en' ? date.format('dddd, MMM D, YYYY HH:mm') : date.format('YYYY年M月D日 dddd HH:mm'), }; }, [locale, 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) .locale(locale) .format(locale === 'en' ? 'dddd, MMM D, YYYY HH:mm' : 'YYYY年M月D日 dddd HH:mm')} {loading ? ( {t('workoutDetail.loading')} ) : metrics ? ( <> {t('workoutDetail.metrics.duration')} {metrics.durationLabel} {t('workoutDetail.metrics.calories')} {metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'} {t('workoutDetail.metrics.intensity')} setShowIntensityInfo(true)} style={styles.metricInfoButton} hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} > {formatMetsValue(metrics.mets)} {t('workoutDetail.metrics.averageHeartRate')} {metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'} {monthOccurrenceText ? ( {monthOccurrenceText} ) : null} ) : ( {errorMessage || t('workoutDetail.errors.loadFailed')} {onRetry ? ( {t('workoutDetail.retry')} ) : null} )} {t('workoutDetail.sections.heartRateRange')} {loading ? ( ) : metrics ? ( <> {t('workoutDetail.sections.averageHeartRate')} {metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'} {t('workoutDetail.sections.maximumHeartRate')} {metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'} {t('workoutDetail.sections.minimumHeartRate')} {metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'} {heartRateChart ? ( LineChart ? ( {shouldRenderChart ? ( /* @ts-ignore - react-native-chart-kit types are outdated */ '#5C55FF', strokeWidth: 2, }, ], }} width={chartWidth} height={220} fromZero={false} yAxisSuffix={t('workoutDetail.sections.heartRateUnit')} withInnerLines={false} bezier paddingRight={48} 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} /> ) : ( {t('workoutDetail.loading')} )} ) : ( {t('workoutDetail.chart.unavailable')} ) ) : ( {t('workoutDetail.chart.noData')} )} ) : ( {errorMessage || t('workoutDetail.errors.noHeartRateData')} )} {t('workoutDetail.sections.heartRateZones')} {loading ? ( ) : metrics ? ( metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t)) ) : ( {t('workoutDetail.errors.noZoneStats')} )} {showIntensityInfo ? ( setShowIntensityInfo(false)} > setShowIntensityInfo(false)}> { }}> {t('workoutDetail.intensityInfo.title')} {t('workoutDetail.intensityInfo.description1')} {t('workoutDetail.intensityInfo.description2')} {t('workoutDetail.intensityInfo.description3')} {t('workoutDetail.intensityInfo.description4')} {t('workoutDetail.intensityInfo.formula.title')} {t('workoutDetail.intensityInfo.formula.value')} {t('workoutDetail.intensityInfo.legend.low')} {t('workoutDetail.intensityInfo.legend.lowLabel')} {t('workoutDetail.intensityInfo.legend.medium')} {t('workoutDetail.intensityInfo.legend.mediumLabel')} {t('workoutDetail.intensityInfo.legend.high')} {t('workoutDetail.intensityInfo.legend.highLabel')} ) : 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)); let lastSelectedIndex = 0; 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) { // 检查与上一个选中点的距离,避免过于密集 if (i - lastSelectedIndex >= minDistance || isLocalExtremum(i)) { result.push(series[i]); lastSelectedIndex = 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, t: (key: string, options?: Record) => string ) { const label = t(`workoutDetail.zones.labels.${zone.key}`, { defaultValue: zone.label, }); const range = t(`workoutDetail.zones.ranges.${zone.key}`, { defaultValue: zone.rangeText, }); const meta = t('workoutDetail.zones.summary', { minutes: zone.durationMinutes, range, }); return ( {label} {meta} ); } // 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, }, summaryCardLoading: { minHeight: 240, }, summaryHeader: { flexDirection: 'row', alignItems: 'flex-start', flexWrap: 'wrap', gap: 10, }, activityName: { fontSize: 24, fontWeight: '700', color: '#1E2148', flex: 1, flexShrink: 1, lineHeight: 30, }, intensityPill: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: 999, alignSelf: 'flex-start', }, 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, }, sectionHeartRateLoading: { minHeight: 360, }, sectionZonesLoading: { minHeight: 200, }, 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: 'flex-start', overflow: 'visible', }, chartLoading: { height: 220, alignItems: 'center', justifyContent: 'center', }, chartLoadingText: { marginTop: 8, fontSize: 12, color: '#7E86A7', }, chartStyle: { marginLeft: 0, marginRight: 0, }, 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, }, });