- 移除旧的锻炼会话页面和布局文件 - 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示 - 优化锻炼历史页面,增加月度统计卡片和交互式详情查看 - 新增锻炼详情服务,提供心率分析、METs计算等功能 - 更新应用版本至1.0.17并调整iOS后台任务配置 - 添加项目规则文档,明确React Native开发规范
882 lines
25 KiB
TypeScript
882 lines
25 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 = 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,
|
||
},
|
||
});
|
||
|