Files
digital-pilates/components/workout/WorkoutDetailModal.tsx
richarjiang d43d8c692f feat(workout): 重构锻炼模块并新增详细数据展示
- 移除旧的锻炼会话页面和布局文件
- 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示
- 优化锻炼历史页面,增加月度统计卡片和交互式详情查看
- 新增锻炼详情服务,提供心率分析、METs计算等功能
- 更新应用版本至1.0.17并调整iOS后台任务配置
- 添加项目规则文档,明确React Native开发规范
2025-10-11 17:20:51 +08:00

882 lines
25 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 = 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,
},
});