diff --git a/app/sleep-detail.tsx b/app/sleep-detail.tsx index c02e406..7bd90d3 100644 --- a/app/sleep-detail.tsx +++ b/app/sleep-detail.tsx @@ -22,6 +22,7 @@ import { import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal'; import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal'; +import { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline'; import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; @@ -309,6 +310,14 @@ export default function SleepDetailScreen() { onInfoPress={() => setSleepStagesModal({ visible: true })} /> + {/* 苹果健康风格的睡眠阶段时间轴图表 */} + setSleepStagesModal({ visible: true })} + /> + {/* 睡眠阶段统计 - 2x2网格布局 */} {/* 使用真实数据或默认数据,确保包含所有4个阶段 */} diff --git a/components/sleep/SleepStageTimeline.tsx b/components/sleep/SleepStageTimeline.tsx new file mode 100644 index 0000000..372151d --- /dev/null +++ b/components/sleep/SleepStageTimeline.tsx @@ -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 ( + + + 睡眠阶段图 + {onInfoPress && ( + + + + )} + + + + 暂无睡眠阶段数据 + + + + ); + } + + return ( + + {/* 标题栏 */} + + 睡眠阶段图 + {onInfoPress && ( + + + + )} + + + {/* 睡眠时间范围 */} + + + 入睡 + + {formatTime(bedtime)} + + + + 起床 + + {formatTime(wakeupTime)} + + + + + {/* SVG 图表 */} + + + {/* 绘制睡眠阶段条形图 */} + {timelineData.map((segment, index) => ( + + ))} + + {/* 绘制时间刻度标签 */} + {timeLabels.map((label, index) => ( + + {/* 刻度线 */} + + {/* 时间标签 */} + + {label.time} + + + ))} + + + + {/* 图例 */} + + + + + 深度睡眠 + + + + 核心睡眠 + + + + + + 快速眼动 + + + + 清醒时间 + + + + + ); +}; + +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', + }, +}); \ No newline at end of file diff --git a/ios/digitalpilates/Info.plist b/ios/digitalpilates/Info.plist index d5f6333..c747a16 100644 --- a/ios/digitalpilates/Info.plist +++ b/ios/digitalpilates/Info.plist @@ -4,7 +4,7 @@ BGTaskSchedulerPermittedIdentifiers - com.expo.modules.backgroundtask.processing + com.expo.modules.backgroundtask.processing CADisableMinimumFrameDurationOnPhone @@ -23,7 +23,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.9 + 1.0.10 CFBundleSignature ???? CFBundleURLTypes @@ -37,7 +37,7 @@ CFBundleVersion - 3 + 1 ITSAppUsesNonExemptEncryption LSMinimumSystemVersion diff --git a/utils/sleepHealthKit.ts b/utils/sleepHealthKit.ts index c781a6f..3c06cd1 100644 --- a/utils/sleepHealthKit.ts +++ b/utils/sleepHealthKit.ts @@ -177,7 +177,7 @@ export const fetchSleepSamples = async (date: Date): Promise => { value: sample.value, sourceName: sample.sourceName }); - + return { startDate: sample.startDate, endDate: sample.endDate, @@ -251,7 +251,7 @@ export const fetchSleepHeartRateData = async (bedtime: string, wakeupTime: strin */ export const calculateSleepStageStats = (samples: SleepSample[]): SleepStageStats[] => { console.log('[Sleep] 开始计算睡眠阶段统计,原始样本数:', samples.length); - + const stageMap = new Map(); // 统计各阶段持续时间 @@ -327,7 +327,7 @@ export const calculateSleepStageStats = (samples: SleepSample[]): SleepStageStat const sortedStats = stats.sort((a, b) => b.duration - a.duration); console.log('[Sleep] 最终睡眠阶段统计:', sortedStats); - + return sortedStats; }; @@ -441,16 +441,16 @@ export const fetchCompleteSleepData = async (date: Date): Promise - stage.stage !== SleepStage.InBed && stage.stage !== SleepStage.Awake + const actualSleepStages = sleepStages.filter(stage => + stage.stage !== SleepStage.InBed ); const totalSleepTime = actualSleepStages.reduce((total, stage) => total + stage.duration, 0); - + // 重新计算睡眠效率 const sleepEfficiency = timeInBed > 0 ? Math.round((totalSleepTime / timeInBed) * 100) : 0; - + console.log('[Sleep] 睡眠效率计算:'); console.log('- 总睡眠时间(不含醒来):', totalSleepTime, '分钟'); console.log('- 在床时间:', timeInBed, '分钟');