951 lines
27 KiB
TypeScript
951 lines
27 KiB
TypeScript
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>
|
||
</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,
|
||
},
|
||
});
|
||
|