Files
digital-pilates/components/workout/WorkoutDetailModal.tsx
richarjiang 5e11da34ee feat(app): 新增HRV压力提醒设置与锻炼记录分享功能
- 通知设置页面新增 HRV 压力提醒开关,支持自定义开启或关闭压力监测推送
- 锻炼详情页集成分享功能,支持将运动数据生成精美长图并分享
- 优化 HRV 监测服务逻辑,在发送通知前检查用户偏好设置
- 更新多语言配置文件,添加相关文案翻译
- 将应用版本号更新至 1.1.5
2025-12-16 11:27:11 +08:00

1334 lines
44 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 { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Dimensions,
Modal,
Platform,
Pressable,
ScrollView,
Share,
StyleSheet,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native';
import ViewShot from 'react-native-view-shot';
import { useI18n } from '@/hooks/useI18n';
import {
HeartRateZoneStat,
WorkoutDetailMetrics,
} from '@/services/workoutDetail';
import {
getWorkoutTypeDisplayName,
WorkoutActivityType,
WorkoutData,
} from '@/utils/health';
import { Toast } from '@/utils/toast.utils';
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 [sharing, setSharing] = useState(false);
const shareContentRef = useRef<ViewShot | null>(null);
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();
}
};
const handleShare = useCallback(async () => {
if (!shareContentRef.current || !workout || sharing) {
return;
}
setSharing(true);
try {
Toast.show({
type: 'info',
text1: t('workoutDetail.share.generating', '正在生成分享卡片…'),
});
const uri = await shareContentRef.current.capture?.({
format: 'png',
quality: 0.95,
snapshotContentContainer: true,
});
if (!uri) {
throw new Error('share-capture-failed');
}
const shareTitle = t('workoutDetail.share.title', { defaultValue: activityName || t('workoutDetail.title', '锻炼详情') });
const caloriesLabel = metrics?.calories != null
? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}`
: '--';
const shareMessage = t('workoutDetail.share.message', {
activity: activityName || t('workoutDetail.share.activityFallback', '锻炼'),
duration: metrics?.durationLabel ?? '--',
calories: caloriesLabel,
date: dateInfo.subtitle,
defaultValue: `我的${activityName || '锻炼'}${dateInfo.subtitle},持续${metrics?.durationLabel ?? '--'},消耗${caloriesLabel}`,
});
await Share.share({
title: shareTitle,
message: shareMessage,
url: Platform.OS === 'ios' ? uri : `file://${uri}`,
});
} catch (error) {
console.warn('workout-detail-share-failed', error);
Toast.error(t('workoutDetail.share.failed', '分享失败,请稍后再试'));
} finally {
setSharing(false);
}
}, [activityName, dateInfo.subtitle, metrics?.calories, metrics?.durationLabel, sharing, t, workout]);
if (!isMounted) {
return null;
}
return (
<Modal
transparent
visible={visible}
animationType='slide'
onRequestClose={onClose}
>
<View style={styles.modalContainer}>
<TouchableWithoutFeedback onPress={handleBackdropPress}>
<View style={styles.backdrop} />
</TouchableWithoutFeedback>
<View style={styles.sheetContainer}>
<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>
{isLiquidGlassAvailable() ? (
<Pressable
onPress={handleShare}
disabled={loading || sharing || !workout}
style={({ pressed }) => [
styles.headerIconButton,
styles.glassButtonWrapper,
pressed && styles.headerIconPressed,
(loading || sharing || !workout) && styles.headerIconDisabled,
]}
accessibilityRole="button"
accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')}
>
<GlassView glassEffectStyle="regular" tintColor="rgba(255,255,255,0.9)" isInteractive style={styles.glassButton}>
<View style={styles.glassButtonInner}>
<Ionicons name="share-outline" size={20} color="#1E2148" />
</View>
</GlassView>
</Pressable>
) : (
<Pressable
onPress={handleShare}
disabled={loading || sharing || !workout}
style={({ pressed }) => [
styles.headerIconButton,
styles.headerIconFallback,
pressed && styles.headerIconPressed,
(loading || sharing || !workout) && styles.headerIconDisabled,
]}
accessibilityRole="button"
accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')}
>
<LinearGradient
colors={['#EEF2FF', '#E0E7FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.glassButtonInner}
>
<Ionicons name="share-outline" size={20} color="#1E2148" />
</LinearGradient>
</Pressable>
)}
</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, loading ? styles.summaryCardLoading : null]}>
<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)
.locale(locale)
.format(locale === 'en' ? 'dddd, MMM D, YYYY HH:mm' : 'YYYY年M月D日 dddd HH:mm')}
</Text>
{loading ? (
<View style={styles.loadingBlock}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.loadingLabel}>{t('workoutDetail.loading')}</Text>
</View>
) : metrics ? (
<>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.duration')}</Text>
<Text style={styles.metricValue}>{metrics.durationLabel}</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.calories')}</Text>
<Text style={styles.metricValue}>
{metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'}
</Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<View style={styles.metricTitleRow}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.intensity')}</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}>{t('workoutDetail.metrics.averageHeartRate')}</Text>
<Text style={styles.metricValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
{monthOccurrenceText ? (
<Text style={styles.monthOccurrenceText}>{monthOccurrenceText}</Text>
) : null}
</>
) : (
<View style={styles.errorBlock}>
<Text style={styles.errorText}>
{errorMessage || t('workoutDetail.errors.loadFailed')}
</Text>
{onRetry ? (
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
<Text style={styles.retryButtonText}>{t('workoutDetail.retry')}</Text>
</TouchableOpacity>
) : null}
</View>
)}
</View>
<View style={[styles.section, loading ? styles.sectionHeartRateLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
</View>
{loading ? (
<View style={styles.sectionLoading}>
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
<>
<View style={styles.heartRateSummaryRow}>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.averageHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.maximumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.minimumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
{heartRateChart ? (
LineChart ? (
<View style={styles.chartWrapper}>
{shouldRenderChart ? (
/* @ts-ignore - react-native-chart-kit types are outdated */
<LineChart
data={{
labels: heartRateChart.labels,
datasets: [
{
data: heartRateChart.data,
color: () => '#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}
/>
) : (
<View style={[styles.chartLoading, { width: chartWidth }]}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.chartLoadingText}>{t('workoutDetail.loading')}</Text>
</View>
)}
</View>
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.unavailable')}</Text>
</View>
)
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.noData')}</Text>
</View>
)}
</>
) : (
<View style={styles.sectionError}>
<Text style={styles.errorTextSmall}>
{errorMessage || t('workoutDetail.errors.noHeartRateData')}
</Text>
</View>
)}
</View>
<View style={[styles.section, loading ? styles.sectionZonesLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
</View>
{loading ? (
<View style={styles.sectionLoading}>
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t))
) : (
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
)}
</View>
<View style={styles.homeIndicatorSpacer} />
</ScrollView>
</View>
{/* Hidden share capture renders full content height for complete screenshots */}
<ViewShot
ref={shareContentRef}
style={[styles.sheetContainer, styles.shareCaptureContainer]}
collapsable={false}
options={{ format: 'png', quality: 0.95, snapshotContentContainer: true }}
>
<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}>
<View style={styles.headerIconButton} />
<View style={styles.headerTitleWrapper}>
<Text style={styles.headerTitle}>{dateInfo.title}</Text>
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
</View>
<View style={styles.headerIconButton} />
</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, loading ? styles.summaryCardLoading : null]}>
<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)
.locale(locale)
.format(locale === 'en' ? 'dddd, MMM D, YYYY HH:mm' : 'YYYY年M月D日 dddd HH:mm')}
</Text>
{loading ? (
<View style={styles.loadingBlock}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.loadingLabel}>{t('workoutDetail.loading')}</Text>
</View>
) : metrics ? (
<>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.duration')}</Text>
<Text style={styles.metricValue}>{metrics.durationLabel}</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.calories')}</Text>
<Text style={styles.metricValue}>
{metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'}
</Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<View style={styles.metricTitleRow}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.intensity')}</Text>
<View style={styles.metricInfoButton}>
<MaterialCommunityIcons name="information-outline" size={16} color="#7780AA" />
</View>
</View>
<Text style={styles.metricValue}>
{formatMetsValue(metrics.mets)}
</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.averageHeartRate')}</Text>
<Text style={styles.metricValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
{monthOccurrenceText ? (
<Text style={styles.monthOccurrenceText}>{monthOccurrenceText}</Text>
) : null}
</>
) : (
<View style={styles.errorBlock}>
<Text style={styles.errorText}>
{errorMessage || t('workoutDetail.errors.loadFailed')}
</Text>
{onRetry ? (
<View style={[styles.retryButton, styles.retryButtonDisabled]}>
<Text style={styles.retryButtonText}>{t('workoutDetail.retry')}</Text>
</View>
) : null}
</View>
)}
</View>
<View style={[styles.section, loading ? styles.sectionHeartRateLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
</View>
{loading ? (
<View style={styles.sectionLoading}>
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
<>
<View style={styles.heartRateSummaryRow}>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.averageHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.maximumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.minimumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
{heartRateChart ? (
LineChart ? (
<View style={styles.chartWrapper}>
{shouldRenderChart ? (
/* @ts-ignore - react-native-chart-kit types are outdated */
<LineChart
data={{
labels: heartRateChart.labels,
datasets: [
{
data: heartRateChart.data,
color: () => '#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}
/>
) : (
<View style={[styles.chartLoading, { width: chartWidth }]}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.chartLoadingText}>{t('workoutDetail.loading')}</Text>
</View>
)}
</View>
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.unavailable')}</Text>
</View>
)
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.noData')}</Text>
</View>
)}
</>
) : (
<View style={styles.sectionError}>
<Text style={styles.errorTextSmall}>
{errorMessage || t('workoutDetail.errors.noHeartRateData')}
</Text>
</View>
)}
</View>
<View style={[styles.section, loading ? styles.sectionZonesLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
</View>
{loading ? (
<View style={styles.sectionLoading}>
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t))
) : (
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
)}
</View>
<View style={styles.homeIndicatorSpacer} />
</ScrollView>
</ViewShot>
{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}>{t('workoutDetail.intensityInfo.title')}</Text>
<Text style={styles.intensityInfoText}>
{t('workoutDetail.intensityInfo.description1')}
</Text>
<Text style={styles.intensityInfoText}>
{t('workoutDetail.intensityInfo.description2')}
</Text>
<Text style={styles.intensityInfoText}>
{t('workoutDetail.intensityInfo.description3')}
</Text>
<Text style={styles.intensityInfoText}>
{t('workoutDetail.intensityInfo.description4')}
</Text>
<View style={styles.intensityFormula}>
<Text style={styles.intensityFormulaLabel}>{t('workoutDetail.intensityInfo.formula.title')}</Text>
<Text style={styles.intensityFormulaValue}>{t('workoutDetail.intensityInfo.formula.value')}</Text>
</View>
<View style={styles.intensityLegend}>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.low')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityLow]}>{t('workoutDetail.intensityInfo.legend.lowLabel')}</Text>
</View>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.medium')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityMedium]}>{t('workoutDetail.intensityInfo.legend.mediumLabel')}</Text>
</View>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.high')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityHigh]}>{t('workoutDetail.intensityInfo.legend.highLabel')}</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));
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, any>) => 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 (
<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}>{label}</Text>
<Text style={styles.zoneMeta}>{meta}</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',
},
headerIconFallback: {
overflow: 'hidden',
},
headerIconPressed: {
opacity: 0.75,
},
headerIconDisabled: {
opacity: 0.4,
},
glassButtonWrapper: {
overflow: 'hidden',
},
glassButton: {
borderRadius: 20,
width: '100%',
height: '100%',
},
glassButtonInner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
borderRadius: 20,
},
shareCaptureContainer: {
position: 'absolute',
top: -9999,
left: 0,
right: 0,
opacity: 0,
zIndex: -1,
},
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,
},
retryButtonDisabled: {
opacity: 0.4,
},
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',
},
});