feat: 添加睡眠阶段时间轴组件,优化睡眠数据可视化
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
|||||||
|
|
||||||
import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal';
|
import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal';
|
||||||
import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal';
|
import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal';
|
||||||
|
import { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline';
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
@@ -309,6 +310,14 @@ export default function SleepDetailScreen() {
|
|||||||
onInfoPress={() => setSleepStagesModal({ visible: true })}
|
onInfoPress={() => setSleepStagesModal({ visible: true })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 苹果健康风格的睡眠阶段时间轴图表 */}
|
||||||
|
<SleepStageTimeline
|
||||||
|
sleepSamples={displayData.rawSleepSamples}
|
||||||
|
bedtime={displayData.bedtime}
|
||||||
|
wakeupTime={displayData.wakeupTime}
|
||||||
|
onInfoPress={() => setSleepStagesModal({ visible: true })}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 睡眠阶段统计 - 2x2网格布局 */}
|
{/* 睡眠阶段统计 - 2x2网格布局 */}
|
||||||
<View style={styles.stagesGridContainer}>
|
<View style={styles.stagesGridContainer}>
|
||||||
{/* 使用真实数据或默认数据,确保包含所有4个阶段 */}
|
{/* 使用真实数据或默认数据,确保包含所有4个阶段 */}
|
||||||
|
|||||||
323
components/sleep/SleepStageTimeline.tsx
Normal file
323
components/sleep/SleepStageTimeline.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { formatTime, getSleepStageColor, SleepStage, type SleepSample } from '@/utils/sleepHealthKit';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
|
||||||
|
|
||||||
|
export type SleepStageTimelineProps = {
|
||||||
|
sleepSamples: SleepSample[];
|
||||||
|
bedtime: string;
|
||||||
|
wakeupTime: string;
|
||||||
|
onInfoPress?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SleepStageTimeline = ({
|
||||||
|
sleepSamples,
|
||||||
|
bedtime,
|
||||||
|
wakeupTime,
|
||||||
|
onInfoPress
|
||||||
|
}: SleepStageTimelineProps) => {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
|
||||||
|
// 图表尺寸参数
|
||||||
|
const chartWidth = 320;
|
||||||
|
const chartHeight = 80;
|
||||||
|
const timelineHeight = 32;
|
||||||
|
const timelineY = 24;
|
||||||
|
const timeScaleY = timelineY + timelineHeight + 16;
|
||||||
|
|
||||||
|
// 计算时间范围和刻度
|
||||||
|
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);
|
||||||
|
const width = Math.max(2, (duration / totalMinutes) * chartWidth);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
width,
|
||||||
|
stage: sample.value,
|
||||||
|
color: getSleepStageColor(sample.value)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 智能生成时间标签,避免重合
|
||||||
|
const labels = [];
|
||||||
|
const minLabelSpacing = 50; // 最小标签间距(像素)
|
||||||
|
|
||||||
|
// 总是显示起始时间
|
||||||
|
labels.push({
|
||||||
|
time: startTime.format('HH:mm'),
|
||||||
|
x: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// 根据睡眠总时长动态调整时间间隔
|
||||||
|
const sleepDurationHours = totalMinutes / 60;
|
||||||
|
let timeStepMinutes;
|
||||||
|
|
||||||
|
if (sleepDurationHours <= 4) {
|
||||||
|
timeStepMinutes = 60; // 1小时间隔
|
||||||
|
} else if (sleepDurationHours <= 8) {
|
||||||
|
timeStepMinutes = 120; // 2小时间隔
|
||||||
|
} else {
|
||||||
|
timeStepMinutes = 180; // 3小时间隔
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加中间时间标签,确保不重合
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 检查与前一个标签的间距
|
||||||
|
const lastLabel = labels[labels.length - 1];
|
||||||
|
if (x - lastLabel.x >= minLabelSpacing && x <= chartWidth - 30) {
|
||||||
|
labels.push({
|
||||||
|
time: stepTime.format('HH:mm'),
|
||||||
|
x: x
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stepCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 总是显示结束时间,但要确保与前一个标签有足够间距
|
||||||
|
const endX = chartWidth - 25;
|
||||||
|
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 }]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: colorTokens.text }]}>睡眠阶段图</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 }]}>
|
||||||
|
暂无睡眠阶段数据
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: colorTokens.text }]}>睡眠阶段图</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}>
|
||||||
|
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>入睡</Text>
|
||||||
|
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
|
||||||
|
{formatTime(bedtime)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.timePoint}>
|
||||||
|
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>起床</Text>
|
||||||
|
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
|
||||||
|
{formatTime(wakeupTime)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* SVG 图表 */}
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<Svg width={chartWidth} height={chartHeight}>
|
||||||
|
{/* 绘制睡眠阶段条形图 */}
|
||||||
|
{timelineData.map((segment, index) => (
|
||||||
|
<Rect
|
||||||
|
key={index}
|
||||||
|
x={segment.x}
|
||||||
|
y={timelineY}
|
||||||
|
width={segment.width}
|
||||||
|
height={timelineHeight}
|
||||||
|
fill={segment.color}
|
||||||
|
rx={2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 绘制时间刻度标签 */}
|
||||||
|
{timeLabels.map((label, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{/* 刻度线 */}
|
||||||
|
<Rect
|
||||||
|
x={label.x}
|
||||||
|
y={timelineY + timelineHeight}
|
||||||
|
width={1}
|
||||||
|
height={6}
|
||||||
|
fill={colorTokens.border}
|
||||||
|
/>
|
||||||
|
{/* 时间标签 */}
|
||||||
|
<SvgText
|
||||||
|
x={label.x}
|
||||||
|
y={timeScaleY}
|
||||||
|
fontSize={11}
|
||||||
|
fill={colorTokens.textSecondary}
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
{label.time}
|
||||||
|
</SvgText>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 图例 */}
|
||||||
|
<View style={styles.legend}>
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
|
||||||
|
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>深度睡眠</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
|
||||||
|
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>核心睡眠</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
|
||||||
|
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>快速眼动</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
|
||||||
|
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>清醒时间</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
infoButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
timeRange: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
timePoint: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
timeLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
timeValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
},
|
||||||
|
chartContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 32,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
legendRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 24,
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
legendDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
legendText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.9</string>
|
<string>1.0.10</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>3</string>
|
<string>1</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ export const fetchCompleteSleepData = async (date: Date): Promise<CompleteSleepD
|
|||||||
|
|
||||||
// 计算总睡眠时间(排除在床时间和醒来时间)
|
// 计算总睡眠时间(排除在床时间和醒来时间)
|
||||||
const actualSleepStages = sleepStages.filter(stage =>
|
const actualSleepStages = sleepStages.filter(stage =>
|
||||||
stage.stage !== SleepStage.InBed && stage.stage !== SleepStage.Awake
|
stage.stage !== SleepStage.InBed
|
||||||
);
|
);
|
||||||
const totalSleepTime = actualSleepStages.reduce((total, stage) => total + stage.duration, 0);
|
const totalSleepTime = actualSleepStages.reduce((total, stage) => total + stage.duration, 0);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user