Files
digital-pilates/components/sleep/SleepStageTimeline.tsx

413 lines
13 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 { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { formatTime, getSleepStageColor, SleepStage, type SleepSample } from '@/utils/sleepHealthKit';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useMemo } from 'react';
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop, Text as SvgText } from 'react-native-svg';
import { StyleProp, ViewStyle } from 'react-native';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
export type SleepStageTimelineProps = {
sleepSamples: SleepSample[];
bedtime: string;
wakeupTime: string;
onInfoPress?: () => void;
hideHeader?: boolean;
style?: StyleProp<ViewStyle>;
};
export const SleepStageTimeline = ({
sleepSamples,
bedtime,
wakeupTime,
onInfoPress,
hideHeader = false,
style
}: SleepStageTimelineProps) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
// 图表尺寸参数 - 更宽更现代的设计
const containerWidth = SCREEN_WIDTH - 64; // 留出左右边距
const chartPadding = 24; // 增加左右内边距,避免时间轴和标签被截断
const chartWidth = containerWidth - chartPadding * 2;
const chartHeight = 140; // 增加高度以容纳更高的条形图
const timelineHeight = 48; // 更高的条形图
const timelineY = 16;
const timeScaleY = timelineY + timelineHeight + 24;
// 计算时间范围和刻度
const { timelineData, timeLabels } = useMemo(() => {
if (!bedtime || !wakeupTime || sleepSamples.length === 0) {
return { timelineData: [], timeLabels: [] };
}
const startTime = dayjs(bedtime);
const endTime = dayjs(wakeupTime);
const totalMinutes = endTime.diff(startTime, 'minute');
// 过滤相关睡眠阶段排除InBed
const relevantSamples = sleepSamples.filter(sample =>
sample.value !== SleepStage.InBed
);
// 转换样本数据为时间轴数据
const data = relevantSamples.map(sample => {
const sampleStart = dayjs(sample.startDate);
const sampleEnd = dayjs(sample.endDate);
const startOffset = sampleStart.diff(startTime, 'minute');
const duration = sampleEnd.diff(sampleStart, 'minute');
const x = Math.max(0, (startOffset / totalMinutes) * chartWidth) + chartPadding;
const width = Math.max(3, (duration / totalMinutes) * chartWidth);
return {
x,
width,
stage: sample.value,
color: getSleepStageColor(sample.value)
};
});
// 智能生成时间标签
const labels = [];
const minLabelSpacing = 60;
// 起始时间标签
labels.push({
time: startTime.format('HH:mm'),
x: chartPadding
});
const sleepDurationHours = totalMinutes / 60;
let timeStepMinutes;
if (sleepDurationHours <= 4) {
timeStepMinutes = 60;
} else if (sleepDurationHours <= 8) {
timeStepMinutes = 120;
} else {
timeStepMinutes = 180;
}
let currentTime = startTime;
let stepCount = 1;
while (currentTime.add(timeStepMinutes * stepCount, 'minute').isBefore(endTime)) {
const stepTime = currentTime.add(timeStepMinutes * stepCount, 'minute');
const x = (stepTime.diff(startTime, 'minute') / totalMinutes) * chartWidth + chartPadding;
const lastLabel = labels[labels.length - 1];
if (x - lastLabel.x >= minLabelSpacing && x <= containerWidth - chartPadding) {
labels.push({
time: stepTime.format('HH:mm'),
x: x
});
}
stepCount++;
}
// 结束时间标签
const endX = containerWidth - chartPadding;
const lastLabel = labels[labels.length - 1];
if (endX - lastLabel.x >= minLabelSpacing) {
labels.push({
time: endTime.format('HH:mm'),
x: endX
});
} else {
labels[labels.length - 1] = {
time: endTime.format('HH:mm'),
x: endX
};
}
return { timelineData: data, timeLabels: labels };
}, [sleepSamples, bedtime, wakeupTime, chartWidth]);
// 如果没有数据,显示空状态
if (timelineData.length === 0) {
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
{!hideHeader && (
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}>
{t('sleepDetail.sleepStages')}
</Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
)}
</View>
)}
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.noData')}
</Text>
</View>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: 'transparent' }, style]}>
{/* 标题栏 */}
{!hideHeader && (
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}>
{t('sleepDetail.sleepStages')}
</Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
)}
</View>
)}
{/* 睡眠时间范围 - 更简洁的设计 */}
<View style={styles.timeRange}>
<View style={styles.timePoint}>
<Ionicons name="moon" size={16} color="#8B9DC3" style={{ marginBottom: 4 }} />
<Text style={[styles.timeValue, { color: '#1c1f3a' }]}>
{formatTime(bedtime)}
</Text>
<Text style={[styles.timeLabel, { color: '#8B9DC3' }]}>
{t('sleepDetail.infoModalTitles.sleepTime')}
</Text>
</View>
<View style={styles.timePoint}>
<Ionicons name="sunny" size={16} color="#F59E0B" style={{ marginBottom: 4 }} />
<Text style={[styles.timeValue, { color: '#1c1f3a' }]}>
{formatTime(wakeupTime)}
</Text>
<Text style={[styles.timeLabel, { color: '#8B9DC3' }]}>
{t('sleepDetail.sleepDuration')}
</Text>
</View>
</View>
{/* SVG 图表 - iOS 健康风格 */}
<View style={styles.chartContainer}>
{/* 背景轨道 */}
<View style={[styles.trackBackground, {
left: chartPadding,
right: chartPadding,
width: chartWidth
}]} />
<Svg width={containerWidth} height={chartHeight}>
<Defs>
{/* 为每种睡眠阶段定义渐变 */}
<SvgLinearGradient id="gradDeep" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#60A5FA" stopOpacity="1" />
<Stop offset="1" stopColor="#3B82F6" stopOpacity="0.85" />
</SvgLinearGradient>
<SvgLinearGradient id="gradCore" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#A78BFA" stopOpacity="1" />
<Stop offset="1" stopColor="#8B5CF6" stopOpacity="0.85" />
</SvgLinearGradient>
<SvgLinearGradient id="gradREM" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#F472B6" stopOpacity="1" />
<Stop offset="1" stopColor="#EC4899" stopOpacity="0.85" />
</SvgLinearGradient>
<SvgLinearGradient id="gradAwake" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#FCD34D" stopOpacity="1" />
<Stop offset="1" stopColor="#F59E0B" stopOpacity="0.85" />
</SvgLinearGradient>
<SvgLinearGradient id="gradAsleep" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#F472B6" stopOpacity="1" />
<Stop offset="1" stopColor="#EC4899" stopOpacity="0.85" />
</SvgLinearGradient>
</Defs>
{/* 绘制睡眠阶段条形图 - 使用渐变和圆角 */}
{timelineData.map((segment, index) => {
const gradientId =
segment.stage === SleepStage.Deep ? 'gradDeep' :
segment.stage === SleepStage.Core ? 'gradCore' :
segment.stage === SleepStage.REM || segment.stage === SleepStage.Asleep ? 'gradREM' :
segment.stage === SleepStage.Awake ? 'gradAwake' : 'gradAsleep';
return (
<Rect
key={index}
x={segment.x}
y={timelineY}
width={segment.width}
height={timelineHeight}
fill={`url(#${gradientId})`}
rx={8}
opacity={0.95}
/>
);
})}
{/* 绘制时间刻度标签 - 更细腻的设计 */}
{timeLabels.map((label, index) => (
<React.Fragment key={index}>
{/* 刻度线 */}
<Rect
x={label.x - 0.5}
y={timelineY + timelineHeight + 4}
width={1}
height={4}
fill="#D1D5DB"
opacity={0.4}
/>
{/* 时间标签 */}
<SvgText
x={label.x}
y={timeScaleY}
fontSize={11}
fill="#8B9DC3"
textAnchor="middle"
fontWeight="500"
>
{label.time}
</SvgText>
</React.Fragment>
))}
</Svg>
</View>
{/* 图例 - iOS 风格的标签 */}
<View style={styles.legend}>
<View style={styles.legendItem}>
<LinearGradient
colors={['#60A5FA', '#3B82F6']}
style={styles.legendPill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.legendText}>{t('sleepDetail.deep')}</Text>
</View>
<View style={styles.legendItem}>
<LinearGradient
colors={['#A78BFA', '#8B5CF6']}
style={styles.legendPill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.legendText}>{t('sleepDetail.core')}</Text>
</View>
<View style={styles.legendItem}>
<LinearGradient
colors={['#F472B6', '#EC4899']}
style={styles.legendPill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.legendText}>{t('sleepDetail.rem')}</Text>
</View>
<View style={styles.legendItem}>
<LinearGradient
colors={['#FCD34D', '#F59E0B']}
style={styles.legendPill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.legendText}>{t('sleepDetail.awake')}</Text>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
borderRadius: 16,
paddingVertical: 20,
paddingHorizontal: 16,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
title: {
fontSize: 16,
fontWeight: '600',
fontFamily: 'AliBold',
},
infoButton: {
padding: 4,
},
timeRange: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: 28,
paddingHorizontal: 20,
},
timePoint: {
alignItems: 'center',
gap: 2,
},
timeLabel: {
fontSize: 11,
fontWeight: '500',
fontFamily: 'AliRegular',
},
timeValue: {
fontSize: 20,
fontWeight: '700',
fontFamily: 'AliBold',
letterSpacing: -0.5,
},
chartContainer: {
alignItems: 'center',
marginBottom: 20,
position: 'relative',
},
trackBackground: {
position: 'absolute',
top: 16,
height: 48,
backgroundColor: '#F0F2F9',
borderRadius: 24,
opacity: 0.5,
},
emptyState: {
alignItems: 'center',
paddingVertical: 32,
},
emptyText: {
fontSize: 14,
fontStyle: 'italic',
fontFamily: 'AliRegular',
},
legend: {
flexDirection: 'row',
justifyContent: 'center',
flexWrap: 'wrap',
gap: 16,
paddingTop: 8,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
legendPill: {
width: 20,
height: 10,
borderRadius: 5,
},
legendText: {
fontSize: 12,
fontWeight: '500',
color: '#6B7280',
fontFamily: 'AliRegular',
},
});