diff --git a/app/sleep-detail.tsx b/app/sleep-detail.tsx index b81c589..8a18dcf 100644 --- a/app/sleep-detail.tsx +++ b/app/sleep-detail.tsx @@ -736,6 +736,109 @@ export default function SleepDetailScreen() { ); })} + + {/* Raw Sleep Samples List - 显示所有原始睡眠数据 */} + {sleepData && sleepData.rawSleepSamples && sleepData.rawSleepSamples.length > 0 && ( + + + + 原始睡眠数据 ({sleepData.rawSleepSamples.length} 条记录) + + + 查看数据间隔和可能的gap + + + + + {sleepData.rawSleepSamples.map((sample, index) => { + // 计算与前一个样本的时间间隔 + const prevSample = index > 0 ? sleepData.rawSleepSamples[index - 1] : null; + let gapMinutes = 0; + let hasGap = false; + + if (prevSample) { + const prevEndTime = new Date(prevSample.endDate).getTime(); + const currentStartTime = new Date(sample.startDate).getTime(); + gapMinutes = (currentStartTime - prevEndTime) / (1000 * 60); + hasGap = gapMinutes > 1; // 大于1分钟视为有间隔 + } + + const startTime = formatTime(sample.startDate); + const endTime = formatTime(sample.endDate); + const duration = Math.round((new Date(sample.endDate).getTime() - new Date(sample.startDate).getTime()) / (1000 * 60)); + + // 获取睡眠阶段中文名称 + const getStageName = (value: string) => { + switch (value) { + case 'HKCategoryValueSleepAnalysisInBed': return '在床上'; + case 'HKCategoryValueSleepAnalysisAwake': return '清醒'; + case 'HKCategoryValueSleepAnalysisAsleepCore': return '核心睡眠'; + case 'HKCategoryValueSleepAnalysisAsleepDeep': return '深度睡眠'; + case 'HKCategoryValueSleepAnalysisAsleepREM': return 'REM睡眠'; + case 'HKCategoryValueSleepAnalysisAsleepUnspecified': return '未指定睡眠'; + default: return value; + } + }; + + // 获取状态颜色 + const getStageColor = (value: string) => { + switch (value) { + case 'HKCategoryValueSleepAnalysisInBed': return '#9CA3AF'; + case 'HKCategoryValueSleepAnalysisAwake': return '#F59E0B'; + case 'HKCategoryValueSleepAnalysisAsleepCore': return '#8B5CF6'; + case 'HKCategoryValueSleepAnalysisAsleepDeep': return '#3B82F6'; + case 'HKCategoryValueSleepAnalysisAsleepREM': return '#EC4899'; + case 'HKCategoryValueSleepAnalysisAsleepUnspecified': return '#6B7280'; + default: return '#6B7280'; + } + }; + + return ( + + {/* 显示数据间隔 */} + {hasGap && ( + + + + 数据间隔: {Math.round(gapMinutes)}分钟 + + + )} + + {/* 睡眠样本条目 */} + + + + + + {getStageName(sample.value)} + + + + {duration}分钟 + + + + + + {startTime} - {endTime} + + + #{index + 1} + + + + + ); + })} + + + )} {infoModal.type && ( @@ -1388,4 +1491,92 @@ const styles = StyleSheet.create({ lineHeight: 16, marginBottom: 4, }, + // Raw Sleep Samples List 样式 + rawSamplesContainer: { + borderRadius: 16, + padding: 16, + marginBottom: 24, + marginHorizontal: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 3, + }, + rawSamplesHeader: { + marginBottom: 16, + }, + rawSamplesTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 4, + }, + rawSamplesSubtitle: { + fontSize: 12, + fontWeight: '500', + }, + rawSamplesScrollView: { + maxHeight: 400, // 限制高度,避免列表过长 + }, + rawSampleItem: { + paddingVertical: 12, + paddingHorizontal: 16, + borderLeftWidth: 3, + borderLeftColor: 'transparent', + marginBottom: 8, + borderRadius: 8, + backgroundColor: 'rgba(248, 250, 252, 0.5)', + }, + sampleHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + sampleLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + stageDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 8, + }, + sampleStage: { + fontSize: 14, + fontWeight: '500', + flex: 1, + }, + sampleDuration: { + fontSize: 12, + fontWeight: '600', + }, + sampleTimeRange: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + sampleTime: { + fontSize: 12, + }, + sampleIndex: { + fontSize: 10, + fontWeight: '500', + }, + gapIndicator: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 8, + paddingHorizontal: 12, + marginVertical: 4, + borderRadius: 8, + gap: 6, + }, + gapText: { + fontSize: 12, + fontWeight: '600', + }, }); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index bf32659..edc25d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.2", "dependencies": { "@expo/vector-icons": "^14.1.0", + "@kingstinct/react-native-healthkit": "^10.1.0", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/datetimepicker": "^8.4.4", "@react-native-masked-view/masked-view": "^0.3.2", @@ -55,6 +56,7 @@ "react-native-image-viewing": "^0.2.2", "react-native-markdown-display": "^7.0.2", "react-native-modal-datetime-picker": "^18.0.0", + "react-native-nitro-modules": "^0.29.3", "react-native-popover-view": "^6.1.0", "react-native-purchases": "^9.2.2", "react-native-reanimated": "~3.17.4", @@ -2728,6 +2730,21 @@ "react-native": "*" } }, + "node_modules/@kingstinct/react-native-healthkit": { + "version": "10.1.0", + "resolved": "https://mirrors.tencent.com/npm/@kingstinct/react-native-healthkit/-/react-native-healthkit-10.1.0.tgz", + "integrity": "sha512-p6f3Uf4p6GXs+8xIc5NHu8DPnNJC9kxGvI+4qmgGk5U24hVZBZFAwFT53jkQMoIHZIoQmtuXJDp8jMJ7WzeZ+Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kingstinct" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -11732,6 +11749,17 @@ "react-native": ">=0.65.0" } }, + "node_modules/react-native-nitro-modules": { + "version": "0.29.3", + "resolved": "https://mirrors.tencent.com/npm/react-native-nitro-modules/-/react-native-nitro-modules-0.29.3.tgz", + "integrity": "sha512-gGaCueHKaZSw2rlrKrPgMZE6O6qvsnTJwNysJgk4ZEHMwnVe6Auk5hc4+sJPQLOVd6o+HMHdVhVQhZZv1u19Eg==", + "hasInstallScript": true, + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-popover-view": { "version": "6.1.0", "resolved": "https://mirrors.tencent.com/npm/react-native-popover-view/-/react-native-popover-view-6.1.0.tgz", diff --git a/package.json b/package.json index 0647ddc..5232473 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@expo/vector-icons": "^14.1.0", + "@kingstinct/react-native-healthkit": "^10.1.0", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/datetimepicker": "^8.4.4", "@react-native-masked-view/masked-view": "^0.3.2", @@ -27,6 +28,7 @@ "dayjs": "^1.11.13", "expo": "53.0.22", "expo-apple-authentication": "~7.2.4", + "expo-background-task": "~0.2.8", "expo-blur": "~14.1.5", "expo-camera": "^16.1.11", "expo-constants": "~17.1.7", @@ -58,6 +60,7 @@ "react-native-image-viewing": "^0.2.2", "react-native-markdown-display": "^7.0.2", "react-native-modal-datetime-picker": "^18.0.0", + "react-native-nitro-modules": "^0.29.3", "react-native-popover-view": "^6.1.0", "react-native-purchases": "^9.2.2", "react-native-reanimated": "~3.17.4", @@ -69,8 +72,7 @@ "react-native-web": "~0.20.0", "react-native-webview": "13.13.5", "react-native-wheel-picker-expo": "^0.5.4", - "react-redux": "^9.2.0", - "expo-background-task": "~0.2.8" + "react-redux": "^9.2.0" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -80,4 +82,4 @@ "typescript": "~5.8.3" }, "private": true -} \ No newline at end of file +} diff --git a/services/sleepService.ts b/services/sleepService.ts index 4adaae9..c56c624 100644 --- a/services/sleepService.ts +++ b/services/sleepService.ts @@ -320,37 +320,27 @@ export async function fetchSleepDetailForDate(date: Date): Promise - sample.value !== SleepStage.InBed && sample.value !== SleepStage.Awake - ); - - // 入睡时间:第一个实际睡眠阶段的开始时间 - // 起床时间:最后一个实际睡眠阶段的结束时间 + // 使用所有样本数据来确定完整的睡眠周期(包含清醒时间) let bedtime: string; let wakeupTime: string; - if (actualSleepSamples.length > 0) { + if (sleepSamples.length > 0) { // 按开始时间排序 - const sortedSleepSamples = actualSleepSamples.sort((a, b) => + const sortedSamples = sleepSamples.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime() ); - bedtime = sortedSleepSamples[0].startDate; - wakeupTime = sortedSleepSamples[sortedSleepSamples.length - 1].endDate; + // 入睡时间:第一个样本的开始时间(包含清醒时间,确保完整性) + bedtime = sortedSamples[0].startDate; + // 起床时间:最后一个样本的结束时间 + wakeupTime = sortedSamples[sortedSamples.length - 1].endDate; console.log('计算入睡和起床时间:'); console.log('- 入睡时间:', dayjs(bedtime).format('YYYY-MM-DD HH:mm:ss')); console.log('- 起床时间:', dayjs(wakeupTime).format('YYYY-MM-DD HH:mm:ss')); } else { - // 如果没有实际睡眠数据,回退到使用所有样本数据 - console.warn('没有找到实际睡眠阶段数据,使用所有样本数据'); - const sortedAllSamples = sleepSamples.sort((a, b) => - new Date(a.startDate).getTime() - new Date(b.startDate).getTime() - ); - - bedtime = sortedAllSamples[0].startDate; - wakeupTime = sortedAllSamples[sortedAllSamples.length - 1].endDate; + console.warn('没有找到睡眠样本数据'); + return null; } // 计算在床时间 - 使用 INBED 样本数据 @@ -380,7 +370,6 @@ export async function fetchSleepDetailForDate(date: Date): Promise total + stage.duration, 0); @@ -401,6 +390,19 @@ export async function fetchSleepDetailForDate(date: Date): Promise { + console.log(` ${getSleepStageDisplayName(stage.stage)}: ${stage.duration}分钟 (${stage.percentage}%)`); + }); + + console.log('========================'); + const sleepDetailData: SleepDetailData = { sleepScore, totalSleepTime, @@ -417,7 +419,7 @@ export async function fetchSleepDetailForDate(date: Date): Promise