feat: 添加睡眠阶段时间轴组件,优化睡眠数据可视化

This commit is contained in:
richarjiang
2025-09-10 19:03:34 +08:00
parent 98176ee988
commit 6fbdbafa3e
4 changed files with 343 additions and 11 deletions

View File

@@ -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个阶段 */}

View 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',
},
});

View File

@@ -4,7 +4,7 @@
<dict> <dict>
<key>BGTaskSchedulerPermittedIdentifiers</key> <key>BGTaskSchedulerPermittedIdentifiers</key>
<array> <array>
<string>com.expo.modules.backgroundtask.processing</string> <string>com.expo.modules.backgroundtask.processing</string>
</array> </array>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
@@ -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>

View File

@@ -177,7 +177,7 @@ export const fetchSleepSamples = async (date: Date): Promise<SleepSample[]> => {
value: sample.value, value: sample.value,
sourceName: sample.sourceName sourceName: sample.sourceName
}); });
return { return {
startDate: sample.startDate, startDate: sample.startDate,
endDate: sample.endDate, endDate: sample.endDate,
@@ -251,7 +251,7 @@ export const fetchSleepHeartRateData = async (bedtime: string, wakeupTime: strin
*/ */
export const calculateSleepStageStats = (samples: SleepSample[]): SleepStageStats[] => { export const calculateSleepStageStats = (samples: SleepSample[]): SleepStageStats[] => {
console.log('[Sleep] 开始计算睡眠阶段统计,原始样本数:', samples.length); console.log('[Sleep] 开始计算睡眠阶段统计,原始样本数:', samples.length);
const stageMap = new Map<SleepStage, number>(); const stageMap = new Map<SleepStage, number>();
// 统计各阶段持续时间 // 统计各阶段持续时间
@@ -327,7 +327,7 @@ export const calculateSleepStageStats = (samples: SleepSample[]): SleepStageStat
const sortedStats = stats.sort((a, b) => b.duration - a.duration); const sortedStats = stats.sort((a, b) => b.duration - a.duration);
console.log('[Sleep] 最终睡眠阶段统计:', sortedStats); console.log('[Sleep] 最终睡眠阶段统计:', sortedStats);
return sortedStats; return sortedStats;
}; };
@@ -441,16 +441,16 @@ export const fetchCompleteSleepData = async (date: Date): Promise<CompleteSleepD
// 计算睡眠阶段统计 // 计算睡眠阶段统计
const sleepStages = calculateSleepStageStats(sleepSamples); const sleepStages = calculateSleepStageStats(sleepSamples);
// 计算总睡眠时间(排除在床时间和醒来时间) // 计算总睡眠时间(排除在床时间和醒来时间)
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);
// 重新计算睡眠效率 // 重新计算睡眠效率
const sleepEfficiency = timeInBed > 0 ? Math.round((totalSleepTime / timeInBed) * 100) : 0; const sleepEfficiency = timeInBed > 0 ? Math.round((totalSleepTime / timeInBed) * 100) : 0;
console.log('[Sleep] 睡眠效率计算:'); console.log('[Sleep] 睡眠效率计算:');
console.log('- 总睡眠时间(不含醒来):', totalSleepTime, '分钟'); console.log('- 总睡眠时间(不含醒来):', totalSleepTime, '分钟');
console.log('- 在床时间:', timeInBed, '分钟'); console.log('- 在床时间:', timeInBed, '分钟');