feat: Update Podfile.lock to include NitroModules and ReactNativeHealthkit dependencies
fix: Adjust objectVersion in project.pbxproj and improve WaterWidget folder exception handling refactor: Remove sleepService.ts as part of code cleanup chore: Comment out HealthKit initialization in health.ts and clean up fetchSleepDuration function
This commit is contained in:
@@ -1,8 +1,14 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import {
|
||||||
|
AuthorizationRequestStatus,
|
||||||
|
queryCategorySamplesWithAnchor,
|
||||||
|
queryQuantitySamplesWithAnchor,
|
||||||
|
useHealthkitAuthorization
|
||||||
|
} from '@kingstinct/react-native-healthkit';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
@@ -18,17 +24,431 @@ import {
|
|||||||
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';
|
||||||
import {
|
|
||||||
fetchSleepDetailForDate,
|
|
||||||
formatSleepTime,
|
|
||||||
formatTime,
|
|
||||||
getSleepStageColor,
|
|
||||||
SleepDetailData,
|
|
||||||
SleepStage
|
|
||||||
} from '@/services/sleepService';
|
|
||||||
import { ensureHealthPermissions } from '@/utils/health';
|
|
||||||
|
|
||||||
|
|
||||||
|
// 睡眠阶段枚举
|
||||||
|
enum SleepStage {
|
||||||
|
InBed = 'INBED',
|
||||||
|
Asleep = 'ASLEEP',
|
||||||
|
Awake = 'AWAKE',
|
||||||
|
Core = 'CORE',
|
||||||
|
Deep = 'DEEP',
|
||||||
|
REM = 'REM'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 睡眠质量评级
|
||||||
|
enum SleepQuality {
|
||||||
|
Poor = 'poor',
|
||||||
|
Fair = 'fair',
|
||||||
|
Good = 'good',
|
||||||
|
Excellent = 'excellent'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 睡眠样本数据类型
|
||||||
|
type SleepSample = {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
value: SleepStage;
|
||||||
|
sourceName?: string;
|
||||||
|
sourceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 睡眠阶段统计
|
||||||
|
type SleepStageStats = {
|
||||||
|
stage: SleepStage;
|
||||||
|
duration: number;
|
||||||
|
percentage: number;
|
||||||
|
quality: SleepQuality;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 心率数据类型
|
||||||
|
type HeartRateData = {
|
||||||
|
timestamp: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 睡眠详情数据类型
|
||||||
|
type SleepDetailData = {
|
||||||
|
sleepScore: number;
|
||||||
|
totalSleepTime: number;
|
||||||
|
sleepQualityPercentage: number;
|
||||||
|
bedtime: string;
|
||||||
|
wakeupTime: string;
|
||||||
|
timeInBed: number;
|
||||||
|
sleepStages: SleepStageStats[];
|
||||||
|
rawSleepSamples: SleepSample[];
|
||||||
|
averageHeartRate: number | null;
|
||||||
|
sleepHeartRateData: HeartRateData[];
|
||||||
|
sleepEfficiency: number;
|
||||||
|
qualityDescription: string;
|
||||||
|
recommendation: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 工具函数
|
||||||
|
const formatSleepTime = (minutes: number): string => {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours > 0 && mins > 0) {
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours}h`;
|
||||||
|
} else {
|
||||||
|
return `${mins}m`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateString: string): string => {
|
||||||
|
return dayjs(dateString).format('HH:mm');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSleepStageColor = (stage: SleepStage): string => {
|
||||||
|
switch (stage) {
|
||||||
|
case SleepStage.Deep:
|
||||||
|
return '#3B82F6';
|
||||||
|
case SleepStage.Core:
|
||||||
|
return '#8B5CF6';
|
||||||
|
case SleepStage.REM:
|
||||||
|
case SleepStage.Asleep:
|
||||||
|
return '#EC4899';
|
||||||
|
case SleepStage.Awake:
|
||||||
|
return '#F59E0B';
|
||||||
|
case SleepStage.InBed:
|
||||||
|
return '#6B7280';
|
||||||
|
default:
|
||||||
|
return '#9CA3AF';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建睡眠日期范围
|
||||||
|
const createSleepDateRange = (date: Date): { startDate: Date; endDate: Date } => {
|
||||||
|
return {
|
||||||
|
startDate: dayjs(date).subtract(1, 'day').hour(18).minute(0).second(0).millisecond(0).toDate(),
|
||||||
|
endDate: dayjs(date).hour(12).minute(0).second(0).millisecond(0).toDate()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取睡眠样本数据
|
||||||
|
const fetchSleepSamples = async (date: Date): Promise<SleepSample[]> => {
|
||||||
|
try {
|
||||||
|
const options = createSleepDateRange(date);
|
||||||
|
|
||||||
|
// 使用 queryCategorySamplesWithAnchor 查询睡眠分析数据
|
||||||
|
const { samples } = await queryCategorySamplesWithAnchor('HKCategoryTypeIdentifierSleepAnalysis', {
|
||||||
|
limit: 0,
|
||||||
|
filter: {
|
||||||
|
startDate: options.startDate,
|
||||||
|
endDate: options.endDate,
|
||||||
|
strictStartDate: true,
|
||||||
|
strictEndDate: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!samples || !Array.isArray(samples)) {
|
||||||
|
console.warn('睡眠样本数据为空');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤指定日期范围内的样本
|
||||||
|
const startTime = new Date(options.startDate).getTime();
|
||||||
|
const endTime = new Date(options.endDate).getTime();
|
||||||
|
|
||||||
|
const filteredSamples = samples.filter(sample => {
|
||||||
|
const sampleStart = new Date(sample.startDate).getTime();
|
||||||
|
const sampleEnd = new Date(sample.endDate).getTime();
|
||||||
|
return (sampleStart >= startTime && sampleStart < endTime) ||
|
||||||
|
(sampleStart < endTime && sampleEnd > startTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`过滤前样本数量: ${samples.length}, 过滤后样本数量: ${filteredSamples.length}`);
|
||||||
|
|
||||||
|
// 转换数据格式
|
||||||
|
const sleepSamples: SleepSample[] = filteredSamples.map(sample => {
|
||||||
|
let mappedValue: SleepStage;
|
||||||
|
|
||||||
|
// HealthKit 睡眠分析值映射
|
||||||
|
switch (sample.value) {
|
||||||
|
case 0:
|
||||||
|
mappedValue = SleepStage.InBed;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
mappedValue = SleepStage.Asleep;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
mappedValue = SleepStage.Awake;
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
mappedValue = SleepStage.Core;
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
mappedValue = SleepStage.Deep;
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
mappedValue = SleepStage.REM;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
mappedValue = SleepStage.Asleep;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: sample.startDate,
|
||||||
|
endDate: sample.endDate,
|
||||||
|
value: mappedValue,
|
||||||
|
sourceName: sample.sourceName,
|
||||||
|
sourceId: sample.uuid
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('获取到睡眠样本:', sleepSamples.length);
|
||||||
|
return sleepSamples;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取睡眠样本失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取睡眠期间心率数据
|
||||||
|
const fetchSleepHeartRateData = async (bedtime: string, wakeupTime: string): Promise<HeartRateData[]> => {
|
||||||
|
try {
|
||||||
|
const { samples } = await queryQuantitySamplesWithAnchor('HKQuantityTypeIdentifierHeartRate', {
|
||||||
|
limit: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!samples || !Array.isArray(samples)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bedtimeMs = new Date(bedtime).getTime();
|
||||||
|
const wakeupTimeMs = new Date(wakeupTime).getTime();
|
||||||
|
|
||||||
|
const sleepHeartRateData: HeartRateData[] = samples
|
||||||
|
.filter(sample => {
|
||||||
|
const sampleTime = new Date(sample.startDate).getTime();
|
||||||
|
return sampleTime >= bedtimeMs && sampleTime <= wakeupTimeMs;
|
||||||
|
})
|
||||||
|
.map(sample => ({
|
||||||
|
timestamp: sample.startDate,
|
||||||
|
value: Math.round(sample.quantity)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||||
|
|
||||||
|
console.log('获取到睡眠心率数据:', sleepHeartRateData.length, '个样本');
|
||||||
|
return sleepHeartRateData;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取睡眠心率数据失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算睡眠阶段统计
|
||||||
|
const calculateSleepStageStats = (samples: SleepSample[]): SleepStageStats[] => {
|
||||||
|
const stageMap = new Map<SleepStage, number>();
|
||||||
|
|
||||||
|
samples.forEach(sample => {
|
||||||
|
const startTime = dayjs(sample.startDate);
|
||||||
|
const endTime = dayjs(sample.endDate);
|
||||||
|
const duration = endTime.diff(startTime, 'minute');
|
||||||
|
|
||||||
|
const currentDuration = stageMap.get(sample.value) || 0;
|
||||||
|
stageMap.set(sample.value, currentDuration + duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
const actualSleepTime = Array.from(stageMap.entries())
|
||||||
|
.reduce((total, [, duration]) => total + duration, 0);
|
||||||
|
|
||||||
|
const stats: SleepStageStats[] = [];
|
||||||
|
|
||||||
|
stageMap.forEach((duration, stage) => {
|
||||||
|
if (stage === SleepStage.InBed) return;
|
||||||
|
|
||||||
|
const percentage = actualSleepTime > 0 ? (duration / actualSleepTime) * 100 : 0;
|
||||||
|
let quality: SleepQuality;
|
||||||
|
|
||||||
|
switch (stage) {
|
||||||
|
case SleepStage.Deep:
|
||||||
|
quality = percentage >= 15 ? SleepQuality.Excellent :
|
||||||
|
percentage >= 10 ? SleepQuality.Good :
|
||||||
|
percentage >= 5 ? SleepQuality.Fair : SleepQuality.Poor;
|
||||||
|
break;
|
||||||
|
case SleepStage.REM:
|
||||||
|
quality = percentage >= 20 ? SleepQuality.Excellent :
|
||||||
|
percentage >= 15 ? SleepQuality.Good :
|
||||||
|
percentage >= 10 ? SleepQuality.Fair : SleepQuality.Poor;
|
||||||
|
break;
|
||||||
|
case SleepStage.Core:
|
||||||
|
quality = percentage >= 45 ? SleepQuality.Excellent :
|
||||||
|
percentage >= 35 ? SleepQuality.Good :
|
||||||
|
percentage >= 25 ? SleepQuality.Fair : SleepQuality.Poor;
|
||||||
|
break;
|
||||||
|
case SleepStage.Awake:
|
||||||
|
quality = percentage <= 5 ? SleepQuality.Excellent :
|
||||||
|
percentage <= 10 ? SleepQuality.Good :
|
||||||
|
percentage <= 15 ? SleepQuality.Fair : SleepQuality.Poor;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
quality = SleepQuality.Fair;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.push({
|
||||||
|
stage,
|
||||||
|
duration,
|
||||||
|
percentage: Math.round(percentage),
|
||||||
|
quality
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats.sort((a, b) => b.duration - a.duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算睡眠得分
|
||||||
|
const calculateSleepScore = (sleepStages: SleepStageStats[], sleepEfficiency: number, totalSleepTime: number): number => {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
const idealSleepHours = 8 * 60;
|
||||||
|
const sleepDurationScore = Math.min(30, (totalSleepTime / idealSleepHours) * 30);
|
||||||
|
score += sleepDurationScore;
|
||||||
|
|
||||||
|
const efficiencyScore = (sleepEfficiency / 100) * 25;
|
||||||
|
score += efficiencyScore;
|
||||||
|
|
||||||
|
const deepSleepStage = sleepStages.find(stage => stage.stage === SleepStage.Deep);
|
||||||
|
const deepSleepScore = deepSleepStage ? Math.min(25, (deepSleepStage.percentage / 20) * 25) : 0;
|
||||||
|
score += deepSleepScore;
|
||||||
|
|
||||||
|
const remSleepStage = sleepStages.find(stage => stage.stage === SleepStage.REM);
|
||||||
|
const remSleepScore = remSleepStage ? Math.min(20, (remSleepStage.percentage / 25) * 20) : 0;
|
||||||
|
score += remSleepScore;
|
||||||
|
|
||||||
|
return Math.round(Math.min(100, score));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取睡眠质量描述和建议
|
||||||
|
const getSleepQualityInfo = (sleepScore: number): { description: string; recommendation: string } => {
|
||||||
|
if (sleepScore >= 85) {
|
||||||
|
return {
|
||||||
|
description: '你身心愉悦并且精力充沛',
|
||||||
|
recommendation: '恭喜你获得优质的睡眠!如果你感到精力充沛,可以考虑中等强度的运动,以维持健康的生活方式,并进一步减轻压力,以获得最佳睡眠。'
|
||||||
|
};
|
||||||
|
} else if (sleepScore >= 70) {
|
||||||
|
return {
|
||||||
|
description: '睡眠质量良好,精神状态不错',
|
||||||
|
recommendation: '你的睡眠质量还不错,但还有改善空间。建议保持规律的睡眠时间,睡前避免使用电子设备,营造安静舒适的睡眠环境。'
|
||||||
|
};
|
||||||
|
} else if (sleepScore >= 50) {
|
||||||
|
return {
|
||||||
|
description: '睡眠质量一般,可能影响日间表现',
|
||||||
|
recommendation: '你的睡眠需要改善。建议制定固定的睡前例行程序,限制咖啡因摄入,确保卧室温度适宜,考虑进行轻度运动来改善睡眠质量。'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
description: '睡眠质量较差,建议重视睡眠健康',
|
||||||
|
recommendation: '你的睡眠质量需要严重关注。建议咨询医生或睡眠专家,检查是否有睡眠障碍,同时改善睡眠环境和习惯,避免睡前刺激性活动。'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 主函数:获取完整的睡眠详情数据
|
||||||
|
const fetchSleepDetailData = async (date: Date): Promise<SleepDetailData | null> => {
|
||||||
|
try {
|
||||||
|
console.log('开始获取睡眠详情数据...', date);
|
||||||
|
|
||||||
|
const sleepSamples = await fetchSleepSamples(date);
|
||||||
|
|
||||||
|
if (sleepSamples.length === 0) {
|
||||||
|
console.warn('没有找到睡眠数据');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bedtime: string;
|
||||||
|
let wakeupTime: string;
|
||||||
|
|
||||||
|
if (sleepSamples.length > 0) {
|
||||||
|
const sortedSamples = sleepSamples.sort((a, b) =>
|
||||||
|
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
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('没有找到睡眠样本数据');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算在床时间
|
||||||
|
let timeInBed: number;
|
||||||
|
const inBedSamples = sleepSamples.filter(sample => sample.value === SleepStage.InBed);
|
||||||
|
|
||||||
|
if (inBedSamples.length > 0) {
|
||||||
|
const sortedInBedSamples = inBedSamples.sort((a, b) =>
|
||||||
|
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const inBedStart = sortedInBedSamples[0].startDate;
|
||||||
|
const inBedEnd = sortedInBedSamples[sortedInBedSamples.length - 1].endDate;
|
||||||
|
timeInBed = dayjs(inBedEnd).diff(dayjs(inBedStart), 'minute');
|
||||||
|
|
||||||
|
console.log('在床时间计算:');
|
||||||
|
console.log('- 上床时间:', dayjs(inBedStart).format('YYYY-MM-DD HH:mm:ss'));
|
||||||
|
console.log('- 离床时间:', dayjs(inBedEnd).format('YYYY-MM-DD HH:mm:ss'));
|
||||||
|
console.log('- 在床时长:', timeInBed, '分钟');
|
||||||
|
} else {
|
||||||
|
timeInBed = dayjs(wakeupTime).diff(dayjs(bedtime), 'minute');
|
||||||
|
console.log('没有INBED数据,使用睡眠时间作为在床时间:', timeInBed, '分钟');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算睡眠阶段统计
|
||||||
|
const sleepStages = calculateSleepStageStats(sleepSamples);
|
||||||
|
const totalSleepTime = sleepStages.reduce((total, stage) => total + stage.duration, 0);
|
||||||
|
const sleepEfficiency = timeInBed > 0 ? Math.round((totalSleepTime / timeInBed) * 100) : 0;
|
||||||
|
|
||||||
|
// 获取睡眠期间心率数据
|
||||||
|
const sleepHeartRateData = await fetchSleepHeartRateData(bedtime, wakeupTime);
|
||||||
|
const averageHeartRate = sleepHeartRateData.length > 0 ?
|
||||||
|
Math.round(sleepHeartRateData.reduce((sum, data) => sum + data.value, 0) / sleepHeartRateData.length) :
|
||||||
|
null;
|
||||||
|
|
||||||
|
// 计算睡眠得分
|
||||||
|
const sleepScore = calculateSleepScore(sleepStages, sleepEfficiency, totalSleepTime);
|
||||||
|
const qualityInfo = getSleepQualityInfo(sleepScore);
|
||||||
|
|
||||||
|
console.log('=== 睡眠数据处理结果 ===');
|
||||||
|
console.log('时间范围:', dayjs(bedtime).format('HH:mm'), '-', dayjs(wakeupTime).format('HH:mm'));
|
||||||
|
console.log('在床时间:', timeInBed, '分钟');
|
||||||
|
console.log('总睡眠时间:', totalSleepTime, '分钟');
|
||||||
|
console.log('睡眠效率:', sleepEfficiency, '%');
|
||||||
|
console.log('睡眠得分:', sleepScore);
|
||||||
|
console.log('========================');
|
||||||
|
|
||||||
|
const sleepDetailData: SleepDetailData = {
|
||||||
|
sleepScore,
|
||||||
|
totalSleepTime,
|
||||||
|
sleepQualityPercentage: sleepScore,
|
||||||
|
bedtime,
|
||||||
|
wakeupTime,
|
||||||
|
timeInBed,
|
||||||
|
sleepStages,
|
||||||
|
rawSleepSamples: sleepSamples,
|
||||||
|
averageHeartRate,
|
||||||
|
sleepHeartRateData,
|
||||||
|
sleepEfficiency,
|
||||||
|
qualityDescription: qualityInfo.description,
|
||||||
|
recommendation: qualityInfo.recommendation
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('睡眠详情数据获取完成,睡眠得分:', sleepScore);
|
||||||
|
return sleepDetailData;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取睡眠详情数据失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 简化的睡眠阶段图表组件
|
// 简化的睡眠阶段图表组件
|
||||||
const SleepStageChart = ({
|
const SleepStageChart = ({
|
||||||
sleepData,
|
sleepData,
|
||||||
@@ -43,12 +463,12 @@ const SleepStageChart = ({
|
|||||||
// 使用真实的睡眠阶段数据,如果没有则使用默认数据
|
// 使用真实的睡眠阶段数据,如果没有则使用默认数据
|
||||||
const stages = sleepData.sleepStages.length > 0
|
const stages = sleepData.sleepStages.length > 0
|
||||||
? sleepData.sleepStages
|
? sleepData.sleepStages
|
||||||
.filter(stage => stage.percentage > 0) // 只显示有数据的阶段
|
.filter(stage => stage.percentage > 0) // 只显示有数据的阶段
|
||||||
.map(stage => ({
|
.map(stage => ({
|
||||||
stage: stage.stage,
|
stage: stage.stage,
|
||||||
percentage: stage.percentage,
|
percentage: stage.percentage,
|
||||||
duration: stage.duration
|
duration: stage.duration
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{ stage: SleepStage.Awake, percentage: 1, duration: 3 },
|
{ stage: SleepStage.Awake, percentage: 1, duration: 3 },
|
||||||
{ stage: SleepStage.REM, percentage: 20, duration: 89 },
|
{ stage: SleepStage.REM, percentage: 20, duration: 89 },
|
||||||
@@ -59,7 +479,7 @@ const SleepStageChart = ({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.simplifiedChartContainer}>
|
<View style={styles.simplifiedChartContainer}>
|
||||||
<View style={styles.chartTitleContainer}>
|
<View style={styles.chartTitleContainer}>
|
||||||
<Text style={styles.chartTitle}>睡眠阶段分析</Text>
|
<Text style={styles.chartTitle}>阶段分析</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.chartInfoButton}
|
style={styles.chartInfoButton}
|
||||||
onPress={onInfoPress}
|
onPress={onInfoPress}
|
||||||
@@ -75,7 +495,7 @@ const SleepStageChart = ({
|
|||||||
入睡时间
|
入睡时间
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.sleepTimeValue, { color: colorTokens.text }]}>
|
<Text style={[styles.sleepTimeValue, { color: colorTokens.text }]}>
|
||||||
{sleepData.bedtime ? formatTime(sleepData.bedtime) : '23:15'}
|
{sleepData.bedtime ? formatTime(sleepData.bedtime) : '--:--'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.sleepTimeLabel}>
|
<View style={styles.sleepTimeLabel}>
|
||||||
@@ -83,7 +503,7 @@ const SleepStageChart = ({
|
|||||||
起床时间
|
起床时间
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.sleepTimeValue, { color: colorTokens.text }]}>
|
<Text style={[styles.sleepTimeValue, { color: colorTokens.text }]}>
|
||||||
{sleepData.wakeupTime ? formatTime(sleepData.wakeupTime) : '06:52'}
|
{sleepData.wakeupTime ? formatTime(sleepData.wakeupTime) : '--:--'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -92,6 +512,8 @@ const SleepStageChart = ({
|
|||||||
<View style={styles.simplifiedChartBar}>
|
<View style={styles.simplifiedChartBar}>
|
||||||
{stages.map((stageData, index) => {
|
{stages.map((stageData, index) => {
|
||||||
const color = getSleepStageColor(stageData.stage);
|
const color = getSleepStageColor(stageData.stage);
|
||||||
|
// 确保最小宽度,避免清醒阶段等小比例的阶段完全不可见
|
||||||
|
const flexValue = Math.max(stageData.percentage || 1, 3);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
key={index}
|
key={index}
|
||||||
@@ -99,7 +521,7 @@ const SleepStageChart = ({
|
|||||||
styles.stageSegment,
|
styles.stageSegment,
|
||||||
{
|
{
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
flex: stageData.percentage || 1,
|
flex: flexValue,
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -118,8 +540,6 @@ const SleepStageChart = ({
|
|||||||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
|
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
|
||||||
<Text style={styles.legendText}>快速眼动</Text>
|
<Text style={styles.legendText}>快速眼动</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
<View style={styles.legendRow}>
|
|
||||||
<View style={styles.legendItem}>
|
<View style={styles.legendItem}>
|
||||||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
|
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
|
||||||
<Text style={styles.legendText}>核心睡眠</Text>
|
<Text style={styles.legendText}>核心睡眠</Text>
|
||||||
@@ -495,23 +915,31 @@ export default function SleepDetailScreen() {
|
|||||||
visible: false
|
visible: false
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
// 使用 HealthKit 权限 hook
|
||||||
loadSleepData();
|
const [authorizationStatus, requestAuthorization] = useHealthkitAuthorization([
|
||||||
}, [selectedDate]);
|
'HKCategoryTypeIdentifierSleepAnalysis',
|
||||||
|
'HKQuantityTypeIdentifierHeartRate'
|
||||||
|
]);
|
||||||
|
|
||||||
const loadSleepData = async () => {
|
const loadSleepData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 确保有健康权限
|
// 如果需要请求权限,先请求权限
|
||||||
const hasPermission = await ensureHealthPermissions();
|
if (authorizationStatus === AuthorizationRequestStatus.shouldRequest) {
|
||||||
if (!hasPermission) {
|
console.log('请求 HealthKit 权限..., 当前状态:', authorizationStatus);
|
||||||
console.warn('没有健康数据权限');
|
try {
|
||||||
return;
|
await requestAuthorization();
|
||||||
|
// 请求权限后,等待状态更新
|
||||||
|
return;
|
||||||
|
} catch (permissionError) {
|
||||||
|
console.error('权限请求失败:', permissionError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取睡眠详情数据
|
// 如果权限不需要或已经处理,尝试获取数据
|
||||||
const data = await fetchSleepDetailForDate(selectedDate);
|
console.log('尝试获取睡眠数据,权限状态:', authorizationStatus);
|
||||||
|
const data = await fetchSleepDetailData(selectedDate);
|
||||||
setSleepData(data);
|
setSleepData(data);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -519,7 +947,11 @@ export default function SleepDetailScreen() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [selectedDate, authorizationStatus, requestAuthorization]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSleepData();
|
||||||
|
}, [loadSleepData]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -535,8 +967,8 @@ export default function SleepDetailScreen() {
|
|||||||
sleepScore: 0,
|
sleepScore: 0,
|
||||||
totalSleepTime: 0,
|
totalSleepTime: 0,
|
||||||
sleepQualityPercentage: 0,
|
sleepQualityPercentage: 0,
|
||||||
bedtime: new Date().toISOString(),
|
bedtime: '',
|
||||||
wakeupTime: new Date().toISOString(),
|
wakeupTime: '',
|
||||||
timeInBed: 0,
|
timeInBed: 0,
|
||||||
sleepStages: [],
|
sleepStages: [],
|
||||||
rawSleepSamples: [], // 添加空的原始睡眠样本数据
|
rawSleepSamples: [], // 添加空的原始睡眠样本数据
|
||||||
@@ -586,26 +1018,6 @@ export default function SleepDetailScreen() {
|
|||||||
{/* 建议文本 */}
|
{/* 建议文本 */}
|
||||||
<Text style={styles.recommendationText}>{displayData.recommendation}</Text>
|
<Text style={styles.recommendationText}>{displayData.recommendation}</Text>
|
||||||
|
|
||||||
{/* 调试信息 - 仅在开发模式下显示 */}
|
|
||||||
{__DEV__ && sleepData && sleepData.rawSleepSamples.length > 0 && (
|
|
||||||
<View style={[styles.debugContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
|
||||||
<Text style={[styles.debugTitle, { color: colorTokens.text }]}>
|
|
||||||
调试信息 ({sleepData.rawSleepSamples.length} 个睡眠样本)
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.debugText, { color: colorTokens.textSecondary }]}>
|
|
||||||
原始睡眠样本类型: {[...new Set(sleepData.rawSleepSamples.map(s => s.value))].join(', ')}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.debugText, { color: colorTokens.textSecondary }]}>
|
|
||||||
时间范围: {sleepData.rawSleepSamples.length > 0 ?
|
|
||||||
`${formatTime(sleepData.rawSleepSamples[0].startDate)} - ${formatTime(sleepData.rawSleepSamples[sleepData.rawSleepSamples.length - 1].endDate)}` :
|
|
||||||
'无数据'}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.debugText, { color: colorTokens.textSecondary }]}>
|
|
||||||
在床时长: {displayData.timeInBed > 0 ? formatSleepTime(displayData.timeInBed) : '未知'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 睡眠统计卡片 */}
|
{/* 睡眠统计卡片 */}
|
||||||
<View style={styles.statsContainer}>
|
<View style={styles.statsContainer}>
|
||||||
<View style={[styles.newStatCard, { backgroundColor: colorTokens.background }]}>
|
<View style={[styles.newStatCard, { backgroundColor: colorTokens.background }]}>
|
||||||
@@ -738,7 +1150,7 @@ export default function SleepDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Raw Sleep Samples List - 显示所有原始睡眠数据 */}
|
{/* Raw Sleep Samples List - 显示所有原始睡眠数据 */}
|
||||||
{sleepData && sleepData.rawSleepSamples && sleepData.rawSleepSamples.length > 0 && (
|
{sleepData && sleepData.rawSleepSamples && sleepData.rawSleepSamples.length > 1 && (
|
||||||
<View style={[styles.rawSamplesContainer, { backgroundColor: colorTokens.background }]}>
|
<View style={[styles.rawSamplesContainer, { backgroundColor: colorTokens.background }]}>
|
||||||
<View style={styles.rawSamplesHeader}>
|
<View style={styles.rawSamplesHeader}>
|
||||||
<Text style={[styles.rawSamplesTitle, { color: colorTokens.text }]}>
|
<Text style={[styles.rawSamplesTitle, { color: colorTokens.text }]}>
|
||||||
@@ -748,14 +1160,14 @@ export default function SleepDetailScreen() {
|
|||||||
查看数据间隔和可能的gap
|
查看数据间隔和可能的gap
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView style={styles.rawSamplesScrollView} nestedScrollEnabled={true}>
|
<ScrollView style={styles.rawSamplesScrollView} nestedScrollEnabled={true}>
|
||||||
{sleepData.rawSleepSamples.map((sample, index) => {
|
{sleepData.rawSleepSamples.map((sample, index) => {
|
||||||
// 计算与前一个样本的时间间隔
|
// 计算与前一个样本的时间间隔
|
||||||
const prevSample = index > 0 ? sleepData.rawSleepSamples[index - 1] : null;
|
const prevSample = index > 0 ? sleepData.rawSleepSamples[index - 1] : null;
|
||||||
let gapMinutes = 0;
|
let gapMinutes = 0;
|
||||||
let hasGap = false;
|
let hasGap = false;
|
||||||
|
|
||||||
if (prevSample) {
|
if (prevSample) {
|
||||||
const prevEndTime = new Date(prevSample.endDate).getTime();
|
const prevEndTime = new Date(prevSample.endDate).getTime();
|
||||||
const currentStartTime = new Date(sample.startDate).getTime();
|
const currentStartTime = new Date(sample.startDate).getTime();
|
||||||
@@ -766,14 +1178,14 @@ export default function SleepDetailScreen() {
|
|||||||
const startTime = formatTime(sample.startDate);
|
const startTime = formatTime(sample.startDate);
|
||||||
const endTime = formatTime(sample.endDate);
|
const endTime = formatTime(sample.endDate);
|
||||||
const duration = Math.round((new Date(sample.endDate).getTime() - new Date(sample.startDate).getTime()) / (1000 * 60));
|
const duration = Math.round((new Date(sample.endDate).getTime() - new Date(sample.startDate).getTime()) / (1000 * 60));
|
||||||
|
|
||||||
// 获取睡眠阶段中文名称
|
// 获取睡眠阶段中文名称
|
||||||
const getStageName = (value: string) => {
|
const getStageName = (value: string) => {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'HKCategoryValueSleepAnalysisInBed': return '在床上';
|
case 'HKCategoryValueSleepAnalysisInBed': return '在床上';
|
||||||
case 'HKCategoryValueSleepAnalysisAwake': return '清醒';
|
case 'HKCategoryValueSleepAnalysisAwake': return '清醒';
|
||||||
case 'HKCategoryValueSleepAnalysisAsleepCore': return '核心睡眠';
|
case 'HKCategoryValueSleepAnalysisAsleepCore': return '核心睡眠';
|
||||||
case 'HKCategoryValueSleepAnalysisAsleepDeep': return '深度睡眠';
|
case 'HKCategoryValueSleepAnalysisAsleepDeep': return '深度睡眠';
|
||||||
case 'HKCategoryValueSleepAnalysisAsleepREM': return 'REM睡眠';
|
case 'HKCategoryValueSleepAnalysisAsleepREM': return 'REM睡眠';
|
||||||
case 'HKCategoryValueSleepAnalysisAsleepUnspecified': return '未指定睡眠';
|
case 'HKCategoryValueSleepAnalysisAsleepUnspecified': return '未指定睡眠';
|
||||||
default: return value;
|
default: return value;
|
||||||
@@ -804,16 +1216,16 @@ export default function SleepDetailScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 睡眠样本条目 */}
|
{/* 睡眠样本条目 */}
|
||||||
<View style={[styles.rawSampleItem, { borderColor: colorTokens.border }]}>
|
<View style={[styles.rawSampleItem, { borderColor: colorTokens.border }]}>
|
||||||
<View style={styles.sampleHeader}>
|
<View style={styles.sampleHeader}>
|
||||||
<View style={styles.sampleLeft}>
|
<View style={styles.sampleLeft}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.stageDot,
|
styles.stageDot,
|
||||||
{ backgroundColor: getStageColor(sample.value) }
|
{ backgroundColor: getStageColor(sample.value) }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.sampleStage, { color: colorTokens.text }]}>
|
<Text style={[styles.sampleStage, { color: colorTokens.text }]}>
|
||||||
{getStageName(sample.value)}
|
{getStageName(sample.value)}
|
||||||
@@ -823,7 +1235,7 @@ export default function SleepDetailScreen() {
|
|||||||
{duration}分钟
|
{duration}分钟
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.sampleTimeRange}>
|
<View style={styles.sampleTimeRange}>
|
||||||
<Text style={[styles.sampleTime, { color: colorTokens.textSecondary }]}>
|
<Text style={[styles.sampleTime, { color: colorTokens.textSecondary }]}>
|
||||||
{startTime} - {endTime}
|
{startTime} - {endTime}
|
||||||
@@ -1302,7 +1714,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
legendRow: {
|
legendRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
legendItem: {
|
legendItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -153,6 +153,30 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- Yoga
|
- Yoga
|
||||||
|
- NitroModules (0.29.3):
|
||||||
|
- DoubleConversion
|
||||||
|
- glog
|
||||||
|
- RCT-Folly (= 2024.11.18.00)
|
||||||
|
- RCTRequired
|
||||||
|
- RCTTypeSafety
|
||||||
|
- React-callinvoker
|
||||||
|
- React-Core
|
||||||
|
- React-debug
|
||||||
|
- React-Fabric
|
||||||
|
- React-featureflags
|
||||||
|
- React-graphics
|
||||||
|
- React-ImageManager
|
||||||
|
- React-jsc
|
||||||
|
- React-jsi
|
||||||
|
- React-NativeModulesApple
|
||||||
|
- React-RCTFabric
|
||||||
|
- React-renderercss
|
||||||
|
- React-rendererdebug
|
||||||
|
- React-utils
|
||||||
|
- ReactCodegen
|
||||||
|
- ReactCommon/turbomodule/bridging
|
||||||
|
- ReactCommon/turbomodule/core
|
||||||
|
- Yoga
|
||||||
- PurchasesHybridCommon (16.2.2):
|
- PurchasesHybridCommon (16.2.2):
|
||||||
- RevenueCat (= 5.34.0)
|
- RevenueCat (= 5.34.0)
|
||||||
- QCloudCore (6.5.1):
|
- QCloudCore (6.5.1):
|
||||||
@@ -1743,6 +1767,31 @@ PODS:
|
|||||||
- React-logger (= 0.79.5)
|
- React-logger (= 0.79.5)
|
||||||
- React-perflogger (= 0.79.5)
|
- React-perflogger (= 0.79.5)
|
||||||
- React-utils (= 0.79.5)
|
- React-utils (= 0.79.5)
|
||||||
|
- ReactNativeHealthkit (10.1.0):
|
||||||
|
- DoubleConversion
|
||||||
|
- glog
|
||||||
|
- NitroModules
|
||||||
|
- RCT-Folly (= 2024.11.18.00)
|
||||||
|
- RCTRequired
|
||||||
|
- RCTTypeSafety
|
||||||
|
- React-callinvoker
|
||||||
|
- React-Core
|
||||||
|
- React-debug
|
||||||
|
- React-Fabric
|
||||||
|
- React-featureflags
|
||||||
|
- React-graphics
|
||||||
|
- React-ImageManager
|
||||||
|
- React-jsc
|
||||||
|
- React-jsi
|
||||||
|
- React-NativeModulesApple
|
||||||
|
- React-RCTFabric
|
||||||
|
- React-renderercss
|
||||||
|
- React-rendererdebug
|
||||||
|
- React-utils
|
||||||
|
- ReactCodegen
|
||||||
|
- ReactCommon/turbomodule/bridging
|
||||||
|
- ReactCommon/turbomodule/core
|
||||||
|
- Yoga
|
||||||
- RevenueCat (5.34.0)
|
- RevenueCat (5.34.0)
|
||||||
- RNAppleHealthKit (1.7.0):
|
- RNAppleHealthKit (1.7.0):
|
||||||
- React
|
- React
|
||||||
@@ -2029,6 +2078,7 @@ DEPENDENCIES:
|
|||||||
- fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`)
|
- fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`)
|
||||||
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
|
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
|
||||||
- lottie-react-native (from `../node_modules/lottie-react-native`)
|
- lottie-react-native (from `../node_modules/lottie-react-native`)
|
||||||
|
- NitroModules (from `../node_modules/react-native-nitro-modules`)
|
||||||
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
|
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
|
||||||
- RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
|
- RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
|
||||||
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
|
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
|
||||||
@@ -2096,6 +2146,7 @@ DEPENDENCIES:
|
|||||||
- ReactAppDependencyProvider (from `build/generated/ios`)
|
- ReactAppDependencyProvider (from `build/generated/ios`)
|
||||||
- ReactCodegen (from `build/generated/ios`)
|
- ReactCodegen (from `build/generated/ios`)
|
||||||
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
|
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
|
||||||
|
- "ReactNativeHealthkit (from `../node_modules/@kingstinct/react-native-healthkit`)"
|
||||||
- RNAppleHealthKit (from `../node_modules/react-native-health`)
|
- RNAppleHealthKit (from `../node_modules/react-native-health`)
|
||||||
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||||
- "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)"
|
- "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)"
|
||||||
@@ -2198,6 +2249,8 @@ EXTERNAL SOURCES:
|
|||||||
:podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec"
|
:podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec"
|
||||||
lottie-react-native:
|
lottie-react-native:
|
||||||
:path: "../node_modules/lottie-react-native"
|
:path: "../node_modules/lottie-react-native"
|
||||||
|
NitroModules:
|
||||||
|
:path: "../node_modules/react-native-nitro-modules"
|
||||||
RCT-Folly:
|
RCT-Folly:
|
||||||
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
|
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
|
||||||
RCTDeprecation:
|
RCTDeprecation:
|
||||||
@@ -2328,6 +2381,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: build/generated/ios
|
:path: build/generated/ios
|
||||||
ReactCommon:
|
ReactCommon:
|
||||||
:path: "../node_modules/react-native/ReactCommon"
|
:path: "../node_modules/react-native/ReactCommon"
|
||||||
|
ReactNativeHealthkit:
|
||||||
|
:path: "../node_modules/@kingstinct/react-native-healthkit"
|
||||||
RNAppleHealthKit:
|
RNAppleHealthKit:
|
||||||
:path: "../node_modules/react-native-health"
|
:path: "../node_modules/react-native-health"
|
||||||
RNCAsyncStorage:
|
RNCAsyncStorage:
|
||||||
@@ -2397,6 +2452,7 @@ SPEC CHECKSUMS:
|
|||||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||||
lottie-react-native: 4969af5ac8f2ed2c562d35b6d3d9fe7d34a1add1
|
lottie-react-native: 4969af5ac8f2ed2c562d35b6d3d9fe7d34a1add1
|
||||||
|
NitroModules: c99da4ad8dd1f8cf270770fbcd5d7659743bc010
|
||||||
PurchasesHybridCommon: 62f852419aae7041792217593998f7ac3f8b567d
|
PurchasesHybridCommon: 62f852419aae7041792217593998f7ac3f8b567d
|
||||||
QCloudCore: 6f8c67b96448472d2c6a92b9cfe1bdb5abbb1798
|
QCloudCore: 6f8c67b96448472d2c6a92b9cfe1bdb5abbb1798
|
||||||
QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7
|
QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7
|
||||||
@@ -2466,6 +2522,7 @@ SPEC CHECKSUMS:
|
|||||||
ReactAppDependencyProvider: f3e842e6cb5a825b6918a74a38402ba1409411f8
|
ReactAppDependencyProvider: f3e842e6cb5a825b6918a74a38402ba1409411f8
|
||||||
ReactCodegen: 272c9bc1a8a917bf557bd9d032a4b3e181c6abfe
|
ReactCodegen: 272c9bc1a8a917bf557bd9d032a4b3e181c6abfe
|
||||||
ReactCommon: 7eb76fcd5133313d8c6a138a5c7dd89f80f189d5
|
ReactCommon: 7eb76fcd5133313d8c6a138a5c7dd89f80f189d5
|
||||||
|
ReactNativeHealthkit: 1b94bc11acc67035a878c3b9baedb6977b39a835
|
||||||
RevenueCat: eb2aa042789d9c99ad5172bd96e28b96286d6ada
|
RevenueCat: eb2aa042789d9c99ad5172bd96e28b96286d6ada
|
||||||
RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9
|
RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9
|
||||||
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
|
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 70;
|
objectVersion = 60;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
7996A1302E6FB82300371142 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
7996A1302E6FB82300371142 /* Exceptions for "WaterWidget" folder in "WaterWidgetExtension" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Info.plist,
|
Info.plist,
|
||||||
@@ -74,7 +74,18 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
7996A11C2E6FB82300371142 /* WaterWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7996A1302E6FB82300371142 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WaterWidget; sourceTree = "<group>"; };
|
7996A11C2E6FB82300371142 /* WaterWidget */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
7996A1302E6FB82300371142 /* Exceptions for "WaterWidget" folder in "WaterWidgetExtension" target */,
|
||||||
|
);
|
||||||
|
explicitFileTypes = {
|
||||||
|
};
|
||||||
|
explicitFolders = (
|
||||||
|
);
|
||||||
|
path = WaterWidget;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -232,8 +243,6 @@
|
|||||||
7996A11C2E6FB82300371142 /* WaterWidget */,
|
7996A11C2E6FB82300371142 /* WaterWidget */,
|
||||||
);
|
);
|
||||||
name = WaterWidgetExtension;
|
name = WaterWidgetExtension;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = WaterWidgetExtension;
|
productName = WaterWidgetExtension;
|
||||||
productReference = 7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */;
|
productReference = 7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
@@ -556,6 +565,7 @@
|
|||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates.WaterWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates.WaterWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -601,6 +611,7 @@
|
|||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates.WaterWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates.WaterWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -666,7 +677,10 @@
|
|||||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
OTHER_LDFLAGS = "$(inherited) ";
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
" ",
|
||||||
|
);
|
||||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||||
@@ -721,7 +735,10 @@
|
|||||||
);
|
);
|
||||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
OTHER_LDFLAGS = "$(inherited) ";
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
" ",
|
||||||
|
);
|
||||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
USE_HERMES = false;
|
USE_HERMES = false;
|
||||||
|
|||||||
@@ -1,508 +0,0 @@
|
|||||||
import dayjs from 'dayjs';
|
|
||||||
import AppleHealthKit from 'react-native-health';
|
|
||||||
|
|
||||||
// 睡眠阶段枚举(与 HealthKit 保持一致)
|
|
||||||
export enum SleepStage {
|
|
||||||
InBed = 'INBED',
|
|
||||||
Asleep = 'ASLEEP',
|
|
||||||
Awake = 'AWAKE',
|
|
||||||
Core = 'CORE',
|
|
||||||
Deep = 'DEEP',
|
|
||||||
REM = 'REM'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 睡眠质量评级
|
|
||||||
export enum SleepQuality {
|
|
||||||
Poor = 'poor', // 差
|
|
||||||
Fair = 'fair', // 一般
|
|
||||||
Good = 'good', // 良好
|
|
||||||
Excellent = 'excellent' // 优秀
|
|
||||||
}
|
|
||||||
|
|
||||||
// 睡眠样本数据类型
|
|
||||||
export type SleepSample = {
|
|
||||||
startDate: string;
|
|
||||||
endDate: string;
|
|
||||||
value: SleepStage;
|
|
||||||
sourceName?: string;
|
|
||||||
sourceId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 睡眠阶段统计
|
|
||||||
export type SleepStageStats = {
|
|
||||||
stage: SleepStage;
|
|
||||||
duration: number; // 分钟
|
|
||||||
percentage: number; // 百分比
|
|
||||||
quality: SleepQuality;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 心率数据类型
|
|
||||||
export type HeartRateData = {
|
|
||||||
timestamp: string;
|
|
||||||
value: number; // BPM
|
|
||||||
};
|
|
||||||
|
|
||||||
// 睡眠详情数据类型
|
|
||||||
export type SleepDetailData = {
|
|
||||||
// 基础睡眠信息
|
|
||||||
sleepScore: number; // 睡眠得分 0-100
|
|
||||||
totalSleepTime: number; // 总睡眠时间(分钟)
|
|
||||||
sleepQualityPercentage: number; // 睡眠质量百分比
|
|
||||||
|
|
||||||
// 睡眠时间信息
|
|
||||||
bedtime: string; // 上床时间
|
|
||||||
wakeupTime: string; // 起床时间
|
|
||||||
timeInBed: number; // 在床时间(分钟)
|
|
||||||
|
|
||||||
// 睡眠阶段统计
|
|
||||||
sleepStages: SleepStageStats[];
|
|
||||||
|
|
||||||
// 原始睡眠样本数据(用于图表显示)
|
|
||||||
rawSleepSamples: SleepSample[];
|
|
||||||
|
|
||||||
// 心率数据
|
|
||||||
averageHeartRate: number | null; // 平均心率
|
|
||||||
sleepHeartRateData: HeartRateData[]; // 睡眠期间心率数据
|
|
||||||
|
|
||||||
// 睡眠效率
|
|
||||||
sleepEfficiency: number; // 睡眠效率百分比 (总睡眠时间/在床时间)
|
|
||||||
|
|
||||||
// 建议和评价
|
|
||||||
qualityDescription: string; // 睡眠质量描述
|
|
||||||
recommendation: string; // 睡眠建议
|
|
||||||
};
|
|
||||||
|
|
||||||
// 日期范围工具函数
|
|
||||||
function createSleepDateRange(date: Date): { startDate: string; endDate: string } {
|
|
||||||
// 睡眠数据通常跨越两天,从前一天18:00到当天12:00
|
|
||||||
return {
|
|
||||||
startDate: dayjs(date).subtract(1, 'day').hour(18).minute(0).second(0).millisecond(0).toISOString(),
|
|
||||||
endDate: dayjs(date).hour(12).minute(0).second(0).millisecond(0).toISOString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取睡眠样本数据
|
|
||||||
async function fetchSleepSamples(date: Date): Promise<SleepSample[]> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const options = createSleepDateRange(date);
|
|
||||||
|
|
||||||
AppleHealthKit.getSleepSamples(options, (err, results) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('获取睡眠样本失败:', err);
|
|
||||||
resolve([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!results || !Array.isArray(results)) {
|
|
||||||
console.warn('睡眠样本数据为空');
|
|
||||||
resolve([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加详细日志,了解实际获取到的数据类型
|
|
||||||
console.log('获取到睡眠样本:', results.length);
|
|
||||||
console.log('睡眠样本详情:', results.map(r => ({
|
|
||||||
value: r.value,
|
|
||||||
start: r.startDate?.substring(11, 16),
|
|
||||||
end: r.endDate?.substring(11, 16),
|
|
||||||
duration: `${Math.round((new Date(r.endDate).getTime() - new Date(r.startDate).getTime()) / 60000)}min`
|
|
||||||
})));
|
|
||||||
|
|
||||||
// 检查可用的睡眠阶段类型
|
|
||||||
const uniqueValues = [...new Set(results.map(r => r.value))];
|
|
||||||
console.log('可用的睡眠阶段类型:', uniqueValues);
|
|
||||||
|
|
||||||
resolve(results as unknown as SleepSample[]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取睡眠期间心率数据
|
|
||||||
async function fetchSleepHeartRateData(bedtime: string, wakeupTime: string): Promise<HeartRateData[]> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const options = {
|
|
||||||
startDate: bedtime,
|
|
||||||
endDate: wakeupTime,
|
|
||||||
ascending: true
|
|
||||||
};
|
|
||||||
|
|
||||||
AppleHealthKit.getHeartRateSamples(options, (err, results) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('获取睡眠心率数据失败:', err);
|
|
||||||
resolve([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!results || !Array.isArray(results)) {
|
|
||||||
resolve([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const heartRateData: HeartRateData[] = results.map(sample => ({
|
|
||||||
timestamp: sample.startDate,
|
|
||||||
value: Math.round(sample.value)
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log('获取到睡眠心率数据:', heartRateData.length, '个样本');
|
|
||||||
resolve(heartRateData);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算睡眠阶段统计
|
|
||||||
function calculateSleepStageStats(samples: SleepSample[]): SleepStageStats[] {
|
|
||||||
const stageMap = new Map<SleepStage, number>();
|
|
||||||
|
|
||||||
// 计算每个阶段的总时长
|
|
||||||
samples.forEach(sample => {
|
|
||||||
const startTime = dayjs(sample.startDate);
|
|
||||||
const endTime = dayjs(sample.endDate);
|
|
||||||
const duration = endTime.diff(startTime, 'minute');
|
|
||||||
|
|
||||||
const currentDuration = stageMap.get(sample.value) || 0;
|
|
||||||
stageMap.set(sample.value, currentDuration + duration);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 计算实际睡眠时间(包括所有睡眠阶段,排除在床时间)
|
|
||||||
const actualSleepTime = Array.from(stageMap.entries())
|
|
||||||
.reduce((total, [, duration]) => total + duration, 0);
|
|
||||||
|
|
||||||
// 生成统计数据,包含所有睡眠阶段(包括清醒时间)
|
|
||||||
const stats: SleepStageStats[] = [];
|
|
||||||
|
|
||||||
stageMap.forEach((duration, stage) => {
|
|
||||||
// 只排除在床时间,保留清醒时间
|
|
||||||
if (stage === SleepStage.InBed) return;
|
|
||||||
|
|
||||||
const percentage = actualSleepTime > 0 ? (duration / actualSleepTime) * 100 : 0;
|
|
||||||
let quality: SleepQuality;
|
|
||||||
|
|
||||||
// 根据睡眠阶段和比例判断质量
|
|
||||||
switch (stage) {
|
|
||||||
case SleepStage.Deep:
|
|
||||||
quality = percentage >= 15 ? SleepQuality.Excellent :
|
|
||||||
percentage >= 10 ? SleepQuality.Good :
|
|
||||||
percentage >= 5 ? SleepQuality.Fair : SleepQuality.Poor;
|
|
||||||
break;
|
|
||||||
case SleepStage.REM:
|
|
||||||
quality = percentage >= 20 ? SleepQuality.Excellent :
|
|
||||||
percentage >= 15 ? SleepQuality.Good :
|
|
||||||
percentage >= 10 ? SleepQuality.Fair : SleepQuality.Poor;
|
|
||||||
break;
|
|
||||||
case SleepStage.Core:
|
|
||||||
quality = percentage >= 45 ? SleepQuality.Excellent :
|
|
||||||
percentage >= 35 ? SleepQuality.Good :
|
|
||||||
percentage >= 25 ? SleepQuality.Fair : SleepQuality.Poor;
|
|
||||||
break;
|
|
||||||
case SleepStage.Awake:
|
|
||||||
// 清醒时间越少越好
|
|
||||||
quality = percentage <= 5 ? SleepQuality.Excellent :
|
|
||||||
percentage <= 10 ? SleepQuality.Good :
|
|
||||||
percentage <= 15 ? SleepQuality.Fair : SleepQuality.Poor;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
quality = SleepQuality.Fair;
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.push({
|
|
||||||
stage,
|
|
||||||
duration,
|
|
||||||
percentage: Math.round(percentage),
|
|
||||||
quality
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 按持续时间排序
|
|
||||||
return stats.sort((a, b) => b.duration - a.duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算睡眠得分
|
|
||||||
function calculateSleepScore(sleepStages: SleepStageStats[], sleepEfficiency: number, totalSleepTime: number): number {
|
|
||||||
let score = 0;
|
|
||||||
|
|
||||||
// 睡眠时长得分 (30分)
|
|
||||||
const idealSleepHours = 8 * 60; // 8小时
|
|
||||||
const sleepDurationScore = Math.min(30, (totalSleepTime / idealSleepHours) * 30);
|
|
||||||
score += sleepDurationScore;
|
|
||||||
|
|
||||||
// 睡眠效率得分 (25分)
|
|
||||||
const efficiencyScore = (sleepEfficiency / 100) * 25;
|
|
||||||
score += efficiencyScore;
|
|
||||||
|
|
||||||
// 深度睡眠得分 (25分)
|
|
||||||
const deepSleepStage = sleepStages.find(stage => stage.stage === SleepStage.Deep);
|
|
||||||
const deepSleepScore = deepSleepStage ? Math.min(25, (deepSleepStage.percentage / 20) * 25) : 0;
|
|
||||||
score += deepSleepScore;
|
|
||||||
|
|
||||||
// REM睡眠得分 (20分)
|
|
||||||
const remSleepStage = sleepStages.find(stage => stage.stage === SleepStage.REM);
|
|
||||||
const remSleepScore = remSleepStage ? Math.min(20, (remSleepStage.percentage / 25) * 20) : 0;
|
|
||||||
score += remSleepScore;
|
|
||||||
|
|
||||||
return Math.round(Math.min(100, score));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取睡眠质量描述和建议
|
|
||||||
function getSleepQualityInfo(sleepScore: number): { description: string; recommendation: string } {
|
|
||||||
if (sleepScore >= 85) {
|
|
||||||
return {
|
|
||||||
description: '你身心愉悦并且精力充沛',
|
|
||||||
recommendation: '恭喜你获得优质的睡眠!如果你感到精力充沛,可以考虑中等强度的运动,以维持健康的生活方式,并进一步减轻压力,以获得最佳睡眠。'
|
|
||||||
};
|
|
||||||
} else if (sleepScore >= 70) {
|
|
||||||
return {
|
|
||||||
description: '睡眠质量良好,精神状态不错',
|
|
||||||
recommendation: '你的睡眠质量还不错,但还有改善空间。建议保持规律的睡眠时间,睡前避免使用电子设备,营造安静舒适的睡眠环境。'
|
|
||||||
};
|
|
||||||
} else if (sleepScore >= 50) {
|
|
||||||
return {
|
|
||||||
description: '睡眠质量一般,可能影响日间表现',
|
|
||||||
recommendation: '你的睡眠需要改善。建议制定固定的睡前例行程序,限制咖啡因摄入,确保卧室温度适宜,考虑进行轻度运动来改善睡眠质量。'
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
description: '睡眠质量较差,建议重视睡眠健康',
|
|
||||||
recommendation: '你的睡眠质量需要严重关注。建议咨询医生或睡眠专家,检查是否有睡眠障碍,同时改善睡眠环境和习惯,避免睡前刺激性活动。'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取睡眠阶段中文名称
|
|
||||||
export function getSleepStageDisplayName(stage: SleepStage): string {
|
|
||||||
switch (stage) {
|
|
||||||
case SleepStage.Deep:
|
|
||||||
return '深度';
|
|
||||||
case SleepStage.Core:
|
|
||||||
return '核心';
|
|
||||||
case SleepStage.REM:
|
|
||||||
return '快速眼动';
|
|
||||||
case SleepStage.Asleep:
|
|
||||||
return '浅睡';
|
|
||||||
case SleepStage.Awake:
|
|
||||||
return '清醒';
|
|
||||||
case SleepStage.InBed:
|
|
||||||
return '在床';
|
|
||||||
default:
|
|
||||||
return '未知';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取睡眠质量颜色
|
|
||||||
export function getSleepStageColor(stage: SleepStage): string {
|
|
||||||
switch (stage) {
|
|
||||||
case SleepStage.Deep:
|
|
||||||
return '#3B82F6'; // 蓝色
|
|
||||||
case SleepStage.Core:
|
|
||||||
return '#8B5CF6'; // 紫色
|
|
||||||
case SleepStage.REM:
|
|
||||||
case SleepStage.Asleep:
|
|
||||||
return '#EC4899'; // 粉色
|
|
||||||
case SleepStage.Awake:
|
|
||||||
return '#F59E0B'; // 橙色
|
|
||||||
case SleepStage.InBed:
|
|
||||||
return '#6B7280'; // 灰色
|
|
||||||
default:
|
|
||||||
return '#9CA3AF';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 主函数:获取完整的睡眠详情数据
|
|
||||||
export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailData | null> {
|
|
||||||
try {
|
|
||||||
console.log('开始获取睡眠详情数据...', date);
|
|
||||||
|
|
||||||
// 获取睡眠样本数据
|
|
||||||
const sleepSamples = await fetchSleepSamples(date);
|
|
||||||
|
|
||||||
if (sleepSamples.length === 0) {
|
|
||||||
console.warn('没有找到睡眠数据');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 找到入睡时间和起床时间
|
|
||||||
// 使用所有样本数据来确定完整的睡眠周期(包含清醒时间)
|
|
||||||
let bedtime: string;
|
|
||||||
let wakeupTime: string;
|
|
||||||
|
|
||||||
if (sleepSamples.length > 0) {
|
|
||||||
// 按开始时间排序
|
|
||||||
const sortedSamples = sleepSamples.sort((a, b) =>
|
|
||||||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 入睡时间:第一个样本的开始时间(包含清醒时间,确保完整性)
|
|
||||||
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('没有找到睡眠样本数据');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算在床时间 - 使用 INBED 样本数据
|
|
||||||
let timeInBed: number;
|
|
||||||
const inBedSamples = sleepSamples.filter(sample => sample.value === SleepStage.InBed);
|
|
||||||
|
|
||||||
if (inBedSamples.length > 0) {
|
|
||||||
// 使用 INBED 样本计算在床时间
|
|
||||||
const sortedInBedSamples = inBedSamples.sort((a, b) =>
|
|
||||||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
const inBedStart = sortedInBedSamples[0].startDate;
|
|
||||||
const inBedEnd = sortedInBedSamples[sortedInBedSamples.length - 1].endDate;
|
|
||||||
timeInBed = dayjs(inBedEnd).diff(dayjs(inBedStart), 'minute');
|
|
||||||
|
|
||||||
console.log('在床时间计算:');
|
|
||||||
console.log('- 上床时间:', dayjs(inBedStart).format('YYYY-MM-DD HH:mm:ss'));
|
|
||||||
console.log('- 离床时间:', dayjs(inBedEnd).format('YYYY-MM-DD HH:mm:ss'));
|
|
||||||
console.log('- 在床时长:', timeInBed, '分钟');
|
|
||||||
} else {
|
|
||||||
// 如果没有 INBED 数据,使用睡眠时间作为在床时间
|
|
||||||
timeInBed = dayjs(wakeupTime).diff(dayjs(bedtime), 'minute');
|
|
||||||
console.log('没有INBED数据,使用睡眠时间作为在床时间:', timeInBed, '分钟');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算睡眠阶段统计
|
|
||||||
const sleepStages = calculateSleepStageStats(sleepSamples);
|
|
||||||
|
|
||||||
const totalSleepTime = sleepStages
|
|
||||||
.reduce((total, stage) => total + stage.duration, 0);
|
|
||||||
|
|
||||||
// 计算睡眠效率
|
|
||||||
const sleepEfficiency = timeInBed > 0 ? Math.round((totalSleepTime / timeInBed) * 100) : 0;
|
|
||||||
|
|
||||||
// 获取睡眠期间心率数据
|
|
||||||
const sleepHeartRateData = await fetchSleepHeartRateData(bedtime, wakeupTime);
|
|
||||||
|
|
||||||
// 计算平均心率
|
|
||||||
const averageHeartRate = sleepHeartRateData.length > 0 ?
|
|
||||||
Math.round(sleepHeartRateData.reduce((sum, data) => sum + data.value, 0) / sleepHeartRateData.length) :
|
|
||||||
null;
|
|
||||||
|
|
||||||
// 计算睡眠得分
|
|
||||||
const sleepScore = calculateSleepScore(sleepStages, sleepEfficiency, totalSleepTime);
|
|
||||||
|
|
||||||
// 获取质量描述和建议
|
|
||||||
const qualityInfo = getSleepQualityInfo(sleepScore);
|
|
||||||
|
|
||||||
// 详细的调试信息
|
|
||||||
console.log('=== 睡眠数据处理结果 ===');
|
|
||||||
console.log('时间范围:', dayjs(bedtime).format('HH:mm'), '-', dayjs(wakeupTime).format('HH:mm'));
|
|
||||||
console.log('在床时间:', timeInBed, '分钟');
|
|
||||||
console.log('总睡眠时间:', totalSleepTime, '分钟');
|
|
||||||
console.log('睡眠效率:', sleepEfficiency, '%');
|
|
||||||
console.log('睡眠阶段统计:');
|
|
||||||
sleepStages.forEach(stage => {
|
|
||||||
console.log(` ${getSleepStageDisplayName(stage.stage)}: ${stage.duration}分钟 (${stage.percentage}%)`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('========================');
|
|
||||||
|
|
||||||
const sleepDetailData: SleepDetailData = {
|
|
||||||
sleepScore,
|
|
||||||
totalSleepTime,
|
|
||||||
sleepQualityPercentage: sleepScore, // 使用睡眠得分作为质量百分比
|
|
||||||
bedtime,
|
|
||||||
wakeupTime,
|
|
||||||
timeInBed,
|
|
||||||
sleepStages,
|
|
||||||
rawSleepSamples: sleepSamples, // 保存原始睡眠样本数据
|
|
||||||
averageHeartRate,
|
|
||||||
sleepHeartRateData,
|
|
||||||
sleepEfficiency,
|
|
||||||
qualityDescription: qualityInfo.description,
|
|
||||||
recommendation: qualityInfo.recommendation
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('睡眠详情数据获取完成,睡眠得分:', sleepScore);
|
|
||||||
return sleepDetailData;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取睡眠详情数据失败:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化睡眠时间显示
|
|
||||||
export function formatSleepTime(minutes: number): string {
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const mins = minutes % 60;
|
|
||||||
|
|
||||||
if (hours > 0 && mins > 0) {
|
|
||||||
return `${hours}h ${mins}m`;
|
|
||||||
} else if (hours > 0) {
|
|
||||||
return `${hours}h`;
|
|
||||||
} else {
|
|
||||||
return `${mins}m`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化时间显示 (HH:MM)
|
|
||||||
export function formatTime(dateString: string): string {
|
|
||||||
return dayjs(dateString).format('HH:mm');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将睡眠样本数据转换为15分钟间隔的睡眠阶段数据
|
|
||||||
export function convertSleepSamplesToIntervals(sleepSamples: SleepSample[], bedtime: string, wakeupTime: string): { time: string; stage: SleepStage }[] {
|
|
||||||
const data: { time: string; stage: SleepStage }[] = [];
|
|
||||||
|
|
||||||
if (sleepSamples.length === 0) {
|
|
||||||
console.log('没有睡眠样本数据可用于图表显示');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤掉InBed阶段,只保留实际睡眠阶段
|
|
||||||
const sleepOnlySamples = sleepSamples.filter(sample =>
|
|
||||||
sample.value !== SleepStage.InBed
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sleepOnlySamples.length === 0) {
|
|
||||||
console.log('只有InBed数据,没有详细睡眠阶段数据');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('处理睡眠阶段数据 - 样本数量:', sleepOnlySamples.length);
|
|
||||||
console.log('时间范围:', formatTime(bedtime), '-', formatTime(wakeupTime));
|
|
||||||
|
|
||||||
const startTime = dayjs(bedtime);
|
|
||||||
const endTime = dayjs(wakeupTime);
|
|
||||||
let currentTime = startTime.clone();
|
|
||||||
|
|
||||||
// 创建一个映射,用于快速查找每个时间点的睡眠阶段
|
|
||||||
while (currentTime.isBefore(endTime)) {
|
|
||||||
const currentTimestamp = currentTime.toDate().getTime();
|
|
||||||
|
|
||||||
// 找到当前时间点对应的睡眠阶段
|
|
||||||
let currentStage = SleepStage.Awake; // 默认为清醒
|
|
||||||
|
|
||||||
for (const sample of sleepOnlySamples) {
|
|
||||||
const sampleStart = new Date(sample.startDate).getTime();
|
|
||||||
const sampleEnd = new Date(sample.endDate).getTime();
|
|
||||||
|
|
||||||
// 如果当前时间在这个样本的时间范围内
|
|
||||||
if (currentTimestamp >= sampleStart && currentTimestamp < sampleEnd) {
|
|
||||||
currentStage = sample.value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeStr = currentTime.format('HH:mm');
|
|
||||||
data.push({ time: timeStr, stage: currentStage });
|
|
||||||
|
|
||||||
// 移动到下一个15分钟间隔
|
|
||||||
currentTime = currentTime.add(15, 'minute');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('生成的睡眠阶段间隔数据点数量:', data.length);
|
|
||||||
console.log('阶段分布:', data.reduce((acc, curr) => {
|
|
||||||
acc[curr.stage] = (acc[curr.stage] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, number>));
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
@@ -77,21 +77,22 @@ export async function ensureHealthPermissions(): Promise<boolean> {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
console.log('开始初始化HealthKit...');
|
console.log('开始初始化HealthKit...');
|
||||||
|
|
||||||
AppleHealthKit.initHealthKit(PERMISSIONS, (error) => {
|
resolve(true)
|
||||||
if (error) {
|
// AppleHealthKit.initHealthKit(PERMISSIONS, (error) => {
|
||||||
console.error('HealthKit初始化失败:', error);
|
// if (error) {
|
||||||
// 常见错误处理
|
// console.error('HealthKit初始化失败:', error);
|
||||||
if (typeof error === 'string') {
|
// // 常见错误处理
|
||||||
if (error.includes('not available')) {
|
// if (typeof error === 'string') {
|
||||||
console.warn('HealthKit不可用 - 可能在模拟器上运行或非iOS设备');
|
// if (error.includes('not available')) {
|
||||||
}
|
// console.warn('HealthKit不可用 - 可能在模拟器上运行或非iOS设备');
|
||||||
}
|
// }
|
||||||
resolve(false);
|
// }
|
||||||
return;
|
// resolve(false);
|
||||||
}
|
// return;
|
||||||
console.log('HealthKit初始化成功');
|
// }
|
||||||
resolve(true);
|
// console.log('HealthKit初始化成功');
|
||||||
});
|
// resolve(true);
|
||||||
|
// });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +446,7 @@ async function fetchSleepDuration(date: Date): Promise<number> {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// 使用睡眠专用的日期范围,包含前一天晚上的睡眠数据
|
// 使用睡眠专用的日期范围,包含前一天晚上的睡眠数据
|
||||||
const sleepOptions = createSleepDateRange(date);
|
const sleepOptions = createSleepDateRange(date);
|
||||||
|
|
||||||
AppleHealthKit.getSleepSamples(sleepOptions, (err, res) => {
|
AppleHealthKit.getSleepSamples(sleepOptions, (err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logError('睡眠数据', err);
|
logError('睡眠数据', err);
|
||||||
@@ -456,23 +457,23 @@ async function fetchSleepDuration(date: Date): Promise<number> {
|
|||||||
return resolve(0);
|
return resolve(0);
|
||||||
}
|
}
|
||||||
logSuccess('睡眠', res);
|
logSuccess('睡眠', res);
|
||||||
|
|
||||||
// 过滤睡眠数据,只计算主睡眠时间段
|
// 过滤睡眠数据,只计算主睡眠时间段
|
||||||
const filteredSamples = res.filter(sample => {
|
const filteredSamples = res.filter(sample => {
|
||||||
if (!sample || !sample.startDate || !sample.endDate) return false;
|
if (!sample || !sample.startDate || !sample.endDate) return false;
|
||||||
|
|
||||||
const startDate = dayjs(sample.startDate);
|
const startDate = dayjs(sample.startDate);
|
||||||
const endDate = dayjs(sample.endDate);
|
const endDate = dayjs(sample.endDate);
|
||||||
const targetDate = dayjs(date);
|
const targetDate = dayjs(date);
|
||||||
|
|
||||||
// 判断这个睡眠段是否属于当天的主睡眠
|
// 判断这个睡眠段是否属于当天的主睡眠
|
||||||
// 睡眠段的结束时间应该在当天,或者睡眠段跨越了前一天晚上到当天早上
|
// 睡眠段的结束时间应该在当天,或者睡眠段跨越了前一天晚上到当天早上
|
||||||
const isMainSleepPeriod = endDate.isSame(targetDate, 'day') ||
|
const isMainSleepPeriod = endDate.isSame(targetDate, 'day') ||
|
||||||
(startDate.isBefore(targetDate, 'day') && endDate.isAfter(targetDate.startOf('day')));
|
(startDate.isBefore(targetDate, 'day') && endDate.isAfter(targetDate.startOf('day')));
|
||||||
|
|
||||||
return isMainSleepPeriod;
|
return isMainSleepPeriod;
|
||||||
});
|
});
|
||||||
|
|
||||||
resolve(calculateSleepDuration(filteredSamples));
|
resolve(calculateSleepDuration(filteredSamples));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user