Refactor iOS dependencies and update HealthKit integration
- Removed NitroModules and ReactNativeHealthkit from Podfile.lock and package files. - Updated Info.plist to increment app version from 2 to 3. - Refactored background task manager to define background tasks within the class. - Added new utility file for sleep data management, including fetching sleep samples, calculating sleep statistics, and generating sleep quality scores.
This commit is contained in:
5
app.json
5
app.json
@@ -84,10 +84,7 @@
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-background-task",
|
||||
{
|
||||
"minimumInterval": 15
|
||||
}
|
||||
"expo-background-task"
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import {
|
||||
AuthorizationRequestStatus,
|
||||
queryCategorySamplesWithAnchor,
|
||||
queryQuantitySamplesWithAnchor,
|
||||
useHealthkitAuthorization
|
||||
} from '@kingstinct/react-native-healthkit';
|
||||
fetchCompleteSleepData,
|
||||
formatSleepTime,
|
||||
formatTime,
|
||||
getSleepStageColor,
|
||||
SleepStage,
|
||||
type CompleteSleepData
|
||||
} from '@/utils/sleepHealthKit';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
@@ -18,425 +20,13 @@ import {
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal';
|
||||
import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
|
||||
|
||||
// 睡眠阶段枚举
|
||||
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;
|
||||
};
|
||||
|
||||
// 睡眠详情数据类型从 InfoModal 组件导入,但需要扩展以包含其他字段
|
||||
type ExtendedSleepDetailData = SleepDetailData & {
|
||||
sleepStages: SleepStageStats[];
|
||||
rawSleepSamples: SleepSample[];
|
||||
sleepHeartRateData: HeartRateData[];
|
||||
};
|
||||
|
||||
// 工具函数
|
||||
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<ExtendedSleepDetailData | 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: ExtendedSleepDetailData = {
|
||||
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 = ({
|
||||
@@ -552,7 +142,7 @@ const SleepStageChart = ({
|
||||
export default function SleepDetailScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const [sleepData, setSleepData] = useState<ExtendedSleepDetailData | null>(null);
|
||||
const [sleepData, setSleepData] = useState<CompleteSleepData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedDate] = useState(dayjs().toDate());
|
||||
|
||||
@@ -566,39 +156,26 @@ export default function SleepDetailScreen() {
|
||||
visible: false
|
||||
});
|
||||
|
||||
// 使用 HealthKit 权限 hook
|
||||
const [authorizationStatus, requestAuthorization] = useHealthkitAuthorization([
|
||||
'HKCategoryTypeIdentifierSleepAnalysis',
|
||||
'HKQuantityTypeIdentifierHeartRate'
|
||||
]);
|
||||
|
||||
const loadSleepData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log('开始加载睡眠数据...');
|
||||
|
||||
// 如果需要请求权限,先请求权限
|
||||
if (authorizationStatus === AuthorizationRequestStatus.shouldRequest) {
|
||||
console.log('请求 HealthKit 权限..., 当前状态:', authorizationStatus);
|
||||
try {
|
||||
await requestAuthorization();
|
||||
// 请求权限后,等待状态更新
|
||||
return;
|
||||
} catch (permissionError) {
|
||||
console.error('权限请求失败:', permissionError);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果权限不需要或已经处理,尝试获取数据
|
||||
console.log('尝试获取睡眠数据,权限状态:', authorizationStatus);
|
||||
const data = await fetchSleepDetailData(selectedDate);
|
||||
const data = await fetchCompleteSleepData(selectedDate);
|
||||
setSleepData(data);
|
||||
|
||||
if (data) {
|
||||
console.log('睡眠数据加载成功,得分:', data.sleepScore);
|
||||
} else {
|
||||
console.log('未找到睡眠数据');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载睡眠数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedDate, authorizationStatus, requestAuthorization]);
|
||||
}, [selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSleepData();
|
||||
@@ -614,7 +191,7 @@ export default function SleepDetailScreen() {
|
||||
}
|
||||
|
||||
// 如果没有数据,使用默认数据结构
|
||||
const displayData: ExtendedSleepDetailData = sleepData || {
|
||||
const displayData: CompleteSleepData = sleepData || {
|
||||
sleepScore: 0,
|
||||
totalSleepTime: 0,
|
||||
sleepQualityPercentage: 0,
|
||||
@@ -622,7 +199,7 @@ export default function SleepDetailScreen() {
|
||||
wakeupTime: '',
|
||||
timeInBed: 0,
|
||||
sleepStages: [],
|
||||
rawSleepSamples: [], // 添加空的原始睡眠样本数据
|
||||
rawSleepSamples: [],
|
||||
averageHeartRate: null,
|
||||
sleepHeartRateData: [],
|
||||
sleepEfficiency: 0,
|
||||
@@ -831,31 +408,18 @@ export default function SleepDetailScreen() {
|
||||
const duration = Math.round((new Date(sample.endDate).getTime() - new Date(sample.startDate).getTime()) / (1000 * 60));
|
||||
|
||||
// 获取睡眠阶段中文名称
|
||||
const getStageName = (value: string) => {
|
||||
const getStageName = (value: SleepStage) => {
|
||||
switch (value) {
|
||||
case 'HKCategoryValueSleepAnalysisInBed': return '在床上';
|
||||
case 'HKCategoryValueSleepAnalysisAwake': return '清醒';
|
||||
case 'HKCategoryValueSleepAnalysisAsleepCore': return '核心睡眠';
|
||||
case 'HKCategoryValueSleepAnalysisAsleepDeep': return '深度睡眠';
|
||||
case 'HKCategoryValueSleepAnalysisAsleepREM': return 'REM睡眠';
|
||||
case 'HKCategoryValueSleepAnalysisAsleepUnspecified': return '未指定睡眠';
|
||||
case SleepStage.InBed: return '在床上';
|
||||
case SleepStage.Awake: return '清醒';
|
||||
case SleepStage.Core: return '核心睡眠';
|
||||
case SleepStage.Deep: return '深度睡眠';
|
||||
case SleepStage.REM: return 'REM睡眠';
|
||||
case SleepStage.Asleep: 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 (
|
||||
<View key={index}>
|
||||
{/* 显示数据间隔 */}
|
||||
@@ -875,7 +439,7 @@ export default function SleepDetailScreen() {
|
||||
<View
|
||||
style={[
|
||||
styles.stageDot,
|
||||
{ backgroundColor: getStageColor(sample.value) }
|
||||
{ backgroundColor: getSleepStageColor(sample.value) }
|
||||
]}
|
||||
/>
|
||||
<Text style={[styles.sampleStage, { color: colorTokens.text }]}>
|
||||
@@ -910,7 +474,7 @@ export default function SleepDetailScreen() {
|
||||
onClose={() => setInfoModal({ ...infoModal, visible: false })}
|
||||
title={infoModal.title}
|
||||
type={infoModal.type}
|
||||
sleepData={displayData}
|
||||
sleepData={displayData as SleepDetailData}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1012,8 +576,6 @@ const styles = StyleSheet.create({
|
||||
statCardIcon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(120, 120, 128, 0.08)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
|
||||
@@ -153,30 +153,6 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- 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):
|
||||
- RevenueCat (= 5.34.0)
|
||||
- QCloudCore (6.5.1):
|
||||
@@ -1767,31 +1743,6 @@ PODS:
|
||||
- React-logger (= 0.79.5)
|
||||
- React-perflogger (= 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)
|
||||
- RNAppleHealthKit (1.7.0):
|
||||
- React
|
||||
@@ -2078,7 +2029,6 @@ DEPENDENCIES:
|
||||
- fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`)
|
||||
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
|
||||
- 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/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
|
||||
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
|
||||
@@ -2146,7 +2096,6 @@ DEPENDENCIES:
|
||||
- ReactAppDependencyProvider (from `build/generated/ios`)
|
||||
- ReactCodegen (from `build/generated/ios`)
|
||||
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
|
||||
- "ReactNativeHealthkit (from `../node_modules/@kingstinct/react-native-healthkit`)"
|
||||
- RNAppleHealthKit (from `../node_modules/react-native-health`)
|
||||
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||
- "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)"
|
||||
@@ -2249,8 +2198,6 @@ EXTERNAL SOURCES:
|
||||
:podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec"
|
||||
lottie-react-native:
|
||||
:path: "../node_modules/lottie-react-native"
|
||||
NitroModules:
|
||||
:path: "../node_modules/react-native-nitro-modules"
|
||||
RCT-Folly:
|
||||
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
|
||||
RCTDeprecation:
|
||||
@@ -2381,8 +2328,6 @@ EXTERNAL SOURCES:
|
||||
:path: build/generated/ios
|
||||
ReactCommon:
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
ReactNativeHealthkit:
|
||||
:path: "../node_modules/@kingstinct/react-native-healthkit"
|
||||
RNAppleHealthKit:
|
||||
:path: "../node_modules/react-native-health"
|
||||
RNCAsyncStorage:
|
||||
@@ -2452,7 +2397,6 @@ SPEC CHECKSUMS:
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||
lottie-react-native: 4969af5ac8f2ed2c562d35b6d3d9fe7d34a1add1
|
||||
NitroModules: c99da4ad8dd1f8cf270770fbcd5d7659743bc010
|
||||
PurchasesHybridCommon: 62f852419aae7041792217593998f7ac3f8b567d
|
||||
QCloudCore: 6f8c67b96448472d2c6a92b9cfe1bdb5abbb1798
|
||||
QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7
|
||||
@@ -2522,7 +2466,6 @@ SPEC CHECKSUMS:
|
||||
ReactAppDependencyProvider: f3e842e6cb5a825b6918a74a38402ba1409411f8
|
||||
ReactCodegen: 272c9bc1a8a917bf557bd9d032a4b3e181c6abfe
|
||||
ReactCommon: 7eb76fcd5133313d8c6a138a5c7dd89f80f189d5
|
||||
ReactNativeHealthkit: 1b94bc11acc67035a878c3b9baedb6977b39a835
|
||||
RevenueCat: eb2aa042789d9c99ad5172bd96e28b96286d6ada
|
||||
RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9
|
||||
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<string>3</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
|
||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -9,7 +9,6 @@
|
||||
"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",
|
||||
@@ -56,7 +55,6 @@
|
||||
"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",
|
||||
@@ -2730,21 +2728,6 @@
|
||||
"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",
|
||||
@@ -11749,17 +11732,6 @@
|
||||
"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",
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
},
|
||||
"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",
|
||||
@@ -60,7 +59,6 @@
|
||||
"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",
|
||||
|
||||
@@ -8,17 +8,6 @@ import { TaskManagerTaskBody } from 'expo-task-manager';
|
||||
const BACKGROUND_TASK_IDENTIFIER = 'background-task';
|
||||
|
||||
|
||||
// 定义后台任务
|
||||
TaskManager.defineTask(BACKGROUND_TASK_IDENTIFIER, async (body: TaskManagerTaskBody) => {
|
||||
try {
|
||||
console.log('[BackgroundTask] 后台任务执行');
|
||||
await executeBackgroundTasks();
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
} catch (error) {
|
||||
console.error('[BackgroundTask] 任务执行失败:', error);
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
}
|
||||
});
|
||||
|
||||
// 检查通知权限
|
||||
async function checkNotificationPermissions(): Promise<boolean> {
|
||||
@@ -201,6 +190,20 @@ export class BackgroundTaskManager {
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// 定义后台任务
|
||||
TaskManager.defineTask(BACKGROUND_TASK_IDENTIFIER, async (body: TaskManagerTaskBody) => {
|
||||
try {
|
||||
console.log('[BackgroundTask] 后台任务执行');
|
||||
await executeBackgroundTasks();
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
} catch (error) {
|
||||
console.error('[BackgroundTask] 任务执行失败:', error);
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 注册后台任务
|
||||
const status = await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER, {
|
||||
minimumInterval: 15,
|
||||
|
||||
537
utils/sleepHealthKit.ts
Normal file
537
utils/sleepHealthKit.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import dayjs from 'dayjs';
|
||||
import HealthKit from 'react-native-health';
|
||||
|
||||
// 睡眠阶段枚举
|
||||
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;
|
||||
};
|
||||
|
||||
// 完整睡眠详情数据
|
||||
export type CompleteSleepData = {
|
||||
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;
|
||||
};
|
||||
|
||||
// HealthKit 权限配置
|
||||
const permissions = {
|
||||
permissions: {
|
||||
read: [
|
||||
HealthKit.Constants.Permissions.SleepAnalysis,
|
||||
HealthKit.Constants.Permissions.HeartRate,
|
||||
],
|
||||
write: [], // 我们只读取数据,不写入
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化 HealthKit 权限
|
||||
*/
|
||||
export const initializeHealthKit = (): Promise<boolean> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
HealthKit.initHealthKit(permissions, (error: string) => {
|
||||
if (error) {
|
||||
console.error('[HealthKit] 权限初始化失败:', error);
|
||||
reject(new Error(error));
|
||||
} else {
|
||||
console.log('[HealthKit] 权限初始化成功');
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建睡眠日期范围 (从前一天 18:00 到当天 12:00)
|
||||
*/
|
||||
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()
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 映射 HealthKit 睡眠值到自定义枚举
|
||||
* react-native-health 库返回的睡眠分析值是字符串:
|
||||
* "INBED" = InBed (在床时间)
|
||||
* "ASLEEP" = Asleep (睡着但未分阶段)
|
||||
* "AWAKE" = Awake (醒来时间)
|
||||
* "CORE" = Core (核心睡眠/浅度睡眠)
|
||||
* "DEEP" = Deep (深度睡眠)
|
||||
* "REM" = REM (快速眼动睡眠)
|
||||
*/
|
||||
const mapHealthKitSleepValue = (value: string): SleepStage => {
|
||||
switch (value) {
|
||||
case 'INBED':
|
||||
return SleepStage.InBed;
|
||||
case 'ASLEEP':
|
||||
return SleepStage.Asleep;
|
||||
case 'AWAKE':
|
||||
return SleepStage.Awake;
|
||||
case 'CORE':
|
||||
return SleepStage.Core;
|
||||
case 'DEEP':
|
||||
return SleepStage.Deep;
|
||||
case 'REM':
|
||||
return SleepStage.REM;
|
||||
default:
|
||||
console.warn(`[Sleep] 未识别的睡眠阶段值: ${value}`);
|
||||
return SleepStage.Asleep;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取睡眠样本数据
|
||||
*/
|
||||
export const fetchSleepSamples = async (date: Date): Promise<SleepSample[]> => {
|
||||
try {
|
||||
const dateRange = createSleepDateRange(date);
|
||||
console.log('[Sleep] 查询睡眠数据范围:', {
|
||||
startDate: dayjs(dateRange.startDate).format('YYYY-MM-DD HH:mm:ss'),
|
||||
endDate: dayjs(dateRange.endDate).format('YYYY-MM-DD HH:mm:ss')
|
||||
});
|
||||
|
||||
const options = {
|
||||
startDate: dateRange.startDate.toISOString(),
|
||||
endDate: dateRange.endDate.toISOString(),
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
HealthKit.getSleepSamples(options, (error: string, results: any[]) => {
|
||||
if (error) {
|
||||
console.error('[Sleep] 获取睡眠数据失败:', error);
|
||||
resolve([]); // 返回空数组而非拒绝,以便于处理
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
console.warn('[Sleep] 未找到睡眠数据');
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Sleep] 获取到原始睡眠样本:', results.length, '条');
|
||||
|
||||
// 过滤并转换数据格式
|
||||
const sleepSamples: SleepSample[] = results
|
||||
.filter(sample => {
|
||||
const sampleStart = new Date(sample.startDate).getTime();
|
||||
const sampleEnd = new Date(sample.endDate).getTime();
|
||||
const rangeStart = dateRange.startDate.getTime();
|
||||
const rangeEnd = dateRange.endDate.getTime();
|
||||
|
||||
return (sampleStart >= rangeStart && sampleStart < rangeEnd) ||
|
||||
(sampleStart < rangeEnd && sampleEnd > rangeStart);
|
||||
})
|
||||
.map(sample => {
|
||||
console.log('[Sleep] 原始睡眠样本:', {
|
||||
startDate: sample.startDate,
|
||||
endDate: sample.endDate,
|
||||
value: sample.value,
|
||||
sourceName: sample.sourceName
|
||||
});
|
||||
|
||||
return {
|
||||
startDate: sample.startDate,
|
||||
endDate: sample.endDate,
|
||||
value: mapHealthKitSleepValue(sample.value),
|
||||
sourceName: sample.sourceName,
|
||||
sourceId: sample.sourceId || sample.uuid
|
||||
};
|
||||
});
|
||||
|
||||
console.log('[Sleep] 过滤后的睡眠样本:', sleepSamples.length, '条');
|
||||
resolve(sleepSamples);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Sleep] 获取睡眠样本失败:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取睡眠期间心率数据
|
||||
*/
|
||||
export const fetchSleepHeartRateData = async (bedtime: string, wakeupTime: string): Promise<HeartRateData[]> => {
|
||||
try {
|
||||
const options = {
|
||||
startDate: bedtime,
|
||||
endDate: wakeupTime,
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
HealthKit.getHeartRateSamples(options, (error: string, results: any[]) => {
|
||||
if (error) {
|
||||
console.error('[Sleep] 获取心率数据失败:', error);
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
console.log('[Sleep] 未找到心率数据');
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const heartRateData: HeartRateData[] = results
|
||||
.filter(sample => {
|
||||
const sampleTime = new Date(sample.startDate).getTime();
|
||||
const bedtimeMs = new Date(bedtime).getTime();
|
||||
const wakeupTimeMs = new Date(wakeupTime).getTime();
|
||||
return sampleTime >= bedtimeMs && sampleTime <= wakeupTimeMs;
|
||||
})
|
||||
.map(sample => ({
|
||||
timestamp: sample.startDate,
|
||||
value: Math.round(sample.value)
|
||||
}))
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
|
||||
console.log('[Sleep] 获取到睡眠心率数据:', heartRateData.length, '个样本');
|
||||
resolve(heartRateData);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Sleep] 获取睡眠心率数据失败:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算睡眠阶段统计
|
||||
*/
|
||||
export const calculateSleepStageStats = (samples: SleepSample[]): SleepStageStats[] => {
|
||||
console.log('[Sleep] 开始计算睡眠阶段统计,原始样本数:', samples.length);
|
||||
|
||||
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');
|
||||
|
||||
console.log(`[Sleep] 阶段: ${sample.value}, 持续时间: ${duration}分钟`);
|
||||
|
||||
const currentDuration = stageMap.get(sample.value) || 0;
|
||||
stageMap.set(sample.value, currentDuration + duration);
|
||||
});
|
||||
|
||||
console.log('[Sleep] 阶段时间统计:', Array.from(stageMap.entries()));
|
||||
|
||||
// 计算实际睡眠时间(排除在床时间,但包含醒来时间)
|
||||
const actualSleepTime = Array.from(stageMap.entries())
|
||||
.filter(([stage]) => stage !== SleepStage.InBed)
|
||||
.reduce((total, [, duration]) => total + duration, 0);
|
||||
|
||||
console.log('[Sleep] 实际睡眠时间(包含醒来):', actualSleepTime, '分钟');
|
||||
|
||||
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.Asleep:
|
||||
// 未分阶段的睡眠时间,按中等质量处理
|
||||
quality = SleepQuality.Fair;
|
||||
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
|
||||
});
|
||||
|
||||
console.log(`[Sleep] 阶段统计: ${stage}, 时长: ${duration}分钟, 百分比: ${Math.round(percentage)}%, 质量: ${quality}`);
|
||||
});
|
||||
|
||||
const sortedStats = stats.sort((a, b) => b.duration - a.duration);
|
||||
console.log('[Sleep] 最终睡眠阶段统计:', sortedStats);
|
||||
|
||||
return sortedStats;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算睡眠得分
|
||||
*/
|
||||
export const calculateSleepScore = (
|
||||
sleepStages: SleepStageStats[],
|
||||
sleepEfficiency: number,
|
||||
totalSleepTime: number
|
||||
): number => {
|
||||
let score = 0;
|
||||
|
||||
// 睡眠时长得分 (30%)
|
||||
const idealSleepHours = 8 * 60;
|
||||
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));
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取睡眠质量描述和建议
|
||||
*/
|
||||
export 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: '你的睡眠质量需要严重关注。建议咨询医生或睡眠专家,检查是否有睡眠障碍,同时改善睡眠环境和习惯,避免睡前刺激性活动。'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 主函数:获取完整的睡眠详情数据
|
||||
*/
|
||||
export const fetchCompleteSleepData = async (date: Date): Promise<CompleteSleepData | null> => {
|
||||
try {
|
||||
console.log('[Sleep] 开始获取完整睡眠数据...', dayjs(date).format('YYYY-MM-DD'));
|
||||
|
||||
// 确保 HealthKit 已初始化
|
||||
await initializeHealthKit();
|
||||
|
||||
// 获取睡眠样本
|
||||
const sleepSamples = await fetchSleepSamples(date);
|
||||
|
||||
if (sleepSamples.length === 0) {
|
||||
console.warn('[Sleep] 没有找到睡眠数据');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算入睡和起床时间
|
||||
const sortedSamples = sleepSamples.sort((a, b) =>
|
||||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||
);
|
||||
|
||||
const bedtime = sortedSamples[0].startDate;
|
||||
const wakeupTime = sortedSamples[sortedSamples.length - 1].endDate;
|
||||
|
||||
console.log('[Sleep] 计算睡眠时间范围:');
|
||||
console.log('- 入睡时间:', dayjs(bedtime).format('YYYY-MM-DD HH:mm:ss'));
|
||||
console.log('- 起床时间:', dayjs(wakeupTime).format('YYYY-MM-DD HH:mm:ss'));
|
||||
|
||||
// 计算在床时间
|
||||
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('[Sleep] 在床时间:', timeInBed, '分钟');
|
||||
} else {
|
||||
timeInBed = dayjs(wakeupTime).diff(dayjs(bedtime), 'minute');
|
||||
console.log('[Sleep] 使用睡眠时间作为在床时间:', timeInBed, '分钟');
|
||||
}
|
||||
|
||||
// 计算睡眠阶段统计
|
||||
const sleepStages = calculateSleepStageStats(sleepSamples);
|
||||
|
||||
// 计算总睡眠时间(排除在床时间和醒来时间)
|
||||
const actualSleepStages = sleepStages.filter(stage =>
|
||||
stage.stage !== SleepStage.InBed && stage.stage !== SleepStage.Awake
|
||||
);
|
||||
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, '分钟');
|
||||
console.log('- 睡眠效率:', sleepEfficiency, '%');
|
||||
|
||||
// 获取睡眠期间心率数据
|
||||
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('[Sleep] 睡眠数据处理完成:');
|
||||
console.log('- 总睡眠时间:', totalSleepTime, '分钟');
|
||||
console.log('- 睡眠效率:', sleepEfficiency, '%');
|
||||
console.log('- 睡眠得分:', sleepScore);
|
||||
|
||||
return {
|
||||
sleepScore,
|
||||
totalSleepTime,
|
||||
sleepQualityPercentage: sleepScore,
|
||||
bedtime,
|
||||
wakeupTime,
|
||||
timeInBed,
|
||||
sleepStages,
|
||||
rawSleepSamples: sleepSamples,
|
||||
averageHeartRate,
|
||||
sleepHeartRateData,
|
||||
sleepEfficiency,
|
||||
qualityDescription: qualityInfo.description,
|
||||
recommendation: qualityInfo.recommendation
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Sleep] 获取完整睡眠数据失败:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具函数:格式化睡眠时间显示
|
||||
*/
|
||||
export 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`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具函数:格式化时间显示
|
||||
*/
|
||||
export const formatTime = (dateString: string): string => {
|
||||
return dayjs(dateString).format('HH:mm');
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具函数:获取睡眠阶段颜色
|
||||
*/
|
||||
export 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';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user