feat(workout): 重构锻炼模块并新增详细数据展示
- 移除旧的锻炼会话页面和布局文件 - 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示 - 优化锻炼历史页面,增加月度统计卡片和交互式详情查看 - 新增锻炼详情服务,提供心率分析、METs计算等功能 - 更新应用版本至1.0.17并调整iOS后台任务配置 - 添加项目规则文档,明确React Native开发规范
This commit is contained in:
881
components/workout/WorkoutDetailModal.tsx
Normal file
881
components/workout/WorkoutDetailModal.tsx
Normal file
@@ -0,0 +1,881 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user