413 lines
13 KiB
TypeScript
413 lines
13 KiB
TypeScript
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',
|
||
},
|
||
}); |