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:
2025-09-09 23:16:54 +08:00
parent b0c572c1d4
commit 98176ee988
8 changed files with 584 additions and 572 deletions

View File

@@ -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',