Files
digital-pilates/components/workout/WorkoutDetailModal.tsx
richarjiang fbe0c92f0f feat(i18n): 全面实现应用核心功能模块的国际化支持
- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块
- 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本
- 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示
- 完善登录页、注销流程及权限申请弹窗的双语提示信息
- 优化部分页面的 UI 细节与字体样式以适配多语言显示
2025-11-27 17:54:36 +08:00

953 lines
28 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 { useI18n } from '@/hooks/useI18n';
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 { t } = useI18n();
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}>{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}>
<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}>
{/* @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={t('workoutDetail.sections.heartRateUnit')}
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}>{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}>
<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(renderHeartRateZone)
) : (
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</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}>{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));
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,
},
});