feat(health): 完善HealthKit权限管理和数据获取系统

- 重构权限管理,新增SimpleEventEmitter实现状态监听
- 实现完整的健身圆环数据获取(活动热量、锻炼时间、站立小时)
- 优化组件状态管理,支持实时数据刷新和权限状态响应
- 新增useHealthPermissions Hook,简化权限状态管理
- 完善iOS原生代码,支持按小时统计健身数据
- 优化应用启动时权限初始化流程,避免启动弹窗

BREAKING CHANGE: FitnessRingsCard组件API变更,移除手动传参改为自动获取数据
This commit is contained in:
richarjiang
2025-09-19 14:16:11 +08:00
parent 184fb672b7
commit ccfccca7bc
11 changed files with 1044 additions and 360 deletions

View File

@@ -221,9 +221,7 @@ export default function PersonalScreen() {
<Button
variant='default'
onPress={() => {
console.log(111111);
// pushIfAuthedElseLogin('/profile/edit')
pushIfAuthedElseLogin('/profile/edit')
}}
modifiers={[
frame({
@@ -237,10 +235,10 @@ export default function PersonalScreen() {
}
})
]} >
<SwiftText size={14} color='black' weight={'medium'}></SwiftText>
<SwiftText size={14} color='black' weight={'medium'}>{isLoggedIn ? '编辑' : '登录'}</SwiftText>
</Button>
</Host> : <TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<Text style={styles.editButtonText}></Text>
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
</TouchableOpacity>}

View File

@@ -95,28 +95,6 @@ export default function ExploreScreen() {
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
const fitnessRingsData = useMockData ? {
activeCalories: mockData?.activeCalories ?? 0,
activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350,
exerciseMinutes: mockData?.exerciseMinutes ?? 0,
exerciseMinutesGoal: mockData?.exerciseMinutesGoal ?? 30,
standHours: mockData?.standHours ?? 0,
standHoursGoal: mockData?.standHoursGoal ?? 12,
} : (healthData ? {
activeCalories: healthData.activeEnergyBurned,
activeCaloriesGoal: healthData.activeCaloriesGoal,
exerciseMinutes: healthData.exerciseMinutes,
exerciseMinutesGoal: healthData.exerciseMinutesGoal,
standHours: healthData.standHours,
standHoursGoal: healthData.standHoursGoal,
} : {
activeCalories: 0,
activeCaloriesGoal: 350,
exerciseMinutes: 0,
exerciseMinutesGoal: 30,
standHours: 0,
standHoursGoal: 12,
});
// 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0);
@@ -564,7 +542,6 @@ export default function ExploreScreen() {
<FloatingCard style={styles.masonryCard}>
<SleepCard
selectedDate={currentSelectedDate}
onPress={() => pushIfAuthedElseLogin(`/sleep-detail?date=${dayjs(currentSelectedDate).format('YYYY-MM-DD')}`)}
/>
</FloatingCard>
</View>
@@ -573,12 +550,7 @@ export default function ExploreScreen() {
<View style={styles.masonryColumn}>
<FloatingCard style={styles.masonryCard} delay={250}>
<FitnessRingsCard
activeCalories={fitnessRingsData.activeCalories}
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
exerciseMinutes={fitnessRingsData.exerciseMinutes}
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
standHours={fitnessRingsData.standHours}
standHoursGoal={fitnessRingsData.standHoursGoal}
selectedDate={currentSelectedDate}
resetToken={animToken}
/>
</FloatingCard>

View File

@@ -16,7 +16,7 @@ import { WaterRecordSource } from '@/services/waterRecords';
import { store } from '@/store';
import { rehydrateUserSync, setPrivacyAgreed } from '@/store/userSlice';
import { createWaterRecordAction } from '@/store/waterSlice';
import { ensureHealthPermissions } from '@/utils/health';
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import React from 'react';
@@ -44,13 +44,24 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
};
const initHealthPermissions = async () => {
// 初始化 HealthKit 权限
// 初始化 HealthKit 权限管理系统
try {
console.log('初始化 HealthKit 权限管理系统...');
initializeHealthPermissions();
// 延迟请求权限,避免应用启动时弹窗
setTimeout(async () => {
try {
console.log('开始请求 HealthKit 权限...');
await ensureHealthPermissions();
console.log('HealthKit 权限初始化完成');
console.log('HealthKit 权限请求完成');
} catch (error) {
console.warn('HealthKit 权限初始化失败,可能在模拟器上运行:', error);
console.warn('HealthKit 权限请求失败,可能在模拟器上运行:', error);
}
}, 2000);
console.log('HealthKit 权限管理初始化完成');
} catch (error) {
console.warn('HealthKit 权限管理初始化失败:', error);
}
}

View File

@@ -1,20 +1,14 @@
import React from 'react';
import React, { useState, useCallback, useRef } from 'react';
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
import { router } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native';
import { CircularRing } from './CircularRing';
import { ROUTES } from '@/constants/Routes';
import { fetchActivityRingsForDate, ActivityRingsData } from '@/utils/health';
type FitnessRingsCardProps = {
style?: any;
// 活动卡路里数据
activeCalories?: number;
activeCaloriesGoal?: number;
// 锻炼分钟数据
exerciseMinutes?: number;
exerciseMinutesGoal?: number;
// 站立小时数据
standHours?: number;
standHoursGoal?: number;
selectedDate?: Date;
// 动画重置令牌
resetToken?: unknown;
};
@@ -24,14 +18,48 @@ type FitnessRingsCardProps = {
*/
export function FitnessRingsCard({
style,
activeCalories = 25,
activeCaloriesGoal = 350,
exerciseMinutes = 1,
exerciseMinutesGoal = 5,
standHours = 2,
standHoursGoal = 13,
selectedDate,
resetToken,
}: FitnessRingsCardProps) {
const [activityData, setActivityData] = useState<ActivityRingsData | null>(null);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
// 获取健身圆环数据 - 在页面聚焦、日期变化、从后台切换到前台时触发
useFocusEffect(
useCallback(() => {
const loadActivityData = async () => {
if (!selectedDate) return;
// 防止重复请求
if (loadingRef.current) return;
try {
loadingRef.current = true;
setLoading(true);
const data = await fetchActivityRingsForDate(selectedDate);
setActivityData(data);
} catch (error) {
console.error('FitnessRingsCard: 获取健身圆环数据失败:', error);
setActivityData(null);
} finally {
setLoading(false);
loadingRef.current = false;
}
};
loadActivityData();
}, [selectedDate])
);
// 使用获取到的数据或默认值
const activeCalories = activityData?.activeEnergyBurned ?? 0;
const activeCaloriesGoal = activityData?.activeEnergyBurnedGoal ?? 350;
const exerciseMinutes = activityData?.appleExerciseTime ?? 0;
const exerciseMinutesGoal = activityData?.appleExerciseTimeGoal ?? 30;
const standHours = activityData?.appleStandHours ?? 0;
const standHoursGoal = activityData?.appleStandHoursGoal ?? 12;
// 计算进度百分比
const caloriesProgress = Math.min(1, Math.max(0, activeCalories / activeCaloriesGoal));
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
@@ -95,24 +123,42 @@ export function FitnessRingsCard({
<View style={styles.dataContainer}>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text>
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(exerciseMinutes)}</Text>
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(standHours)}</Text>
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>

View File

@@ -1,18 +1,19 @@
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface SleepCardProps {
selectedDate?: Date;
style?: object;
onPress?: () => void;
}
const SleepCard: React.FC<SleepCardProps> = ({
selectedDate,
style,
onPress
}) => {
const [sleepDuration, setSleepDuration] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
@@ -52,15 +53,11 @@ const SleepCard: React.FC<SleepCardProps> = ({
</View>
);
if (onPress) {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<TouchableOpacity onPress={() => router.push(`/sleep-detail?date=${dayjs(selectedDate).format('YYYY-MM-DD')}`)} activeOpacity={0.7}>
{CardContent}
</TouchableOpacity>
);
}
return CardContent;
};
const styles = StyleSheet.create({

View File

@@ -0,0 +1,236 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
healthPermissionManager,
HealthPermissionStatus,
ensureHealthPermissions,
checkHealthPermissionStatus,
fetchTodayHealthData,
fetchHealthDataForDate,
TodayHealthData
} from '@/utils/health';
export interface UseHealthPermissionsReturn {
// 权限状态
permissionStatus: HealthPermissionStatus;
isLoading: boolean;
// 权限操作
requestPermissions: () => Promise<boolean>;
checkPermissions: (forceCheck?: boolean) => Promise<HealthPermissionStatus>;
// 数据刷新
refreshHealthData: () => Promise<void>;
refreshHealthDataForDate: (date: Date) => Promise<void>;
// 健康数据
healthData: TodayHealthData | null;
// 状态检查
hasPermission: boolean;
needsPermission: boolean;
}
/**
* HealthKit权限状态管理Hook
*
* 功能:
* 1. 监听权限状态变化
* 2. 自动刷新数据当权限状态改变时
* 3. 提供权限请求和数据刷新方法
* 4. 缓存健康数据状态
*/
export function useHealthPermissions(): UseHealthPermissionsReturn {
const [permissionStatus, setPermissionStatus] = useState<HealthPermissionStatus>(
healthPermissionManager.getPermissionStatus()
);
const [isLoading, setIsLoading] = useState(false);
const [healthData, setHealthData] = useState<TodayHealthData | null>(null);
// 使用ref避免闭包问题
const isLoadingRef = useRef(false);
const lastRefreshTime = useRef(0);
const refreshThrottle = 2000; // 2秒内避免重复刷新
// 刷新健康数据
const refreshHealthData = useCallback(async () => {
const now = Date.now();
// 防抖:避免短时间内重复刷新
if (isLoadingRef.current || (now - lastRefreshTime.current) < refreshThrottle) {
console.log('健康数据刷新被节流,跳过本次刷新');
return;
}
if (permissionStatus !== HealthPermissionStatus.Authorized) {
console.log('没有HealthKit权限跳过数据刷新');
return;
}
isLoadingRef.current = true;
setIsLoading(true);
lastRefreshTime.current = now;
try {
console.log('开始刷新今日健康数据...');
const data = await fetchTodayHealthData();
setHealthData(data);
console.log('健康数据刷新成功:', data);
} catch (error) {
console.error('刷新健康数据失败:', error);
} finally {
isLoadingRef.current = false;
setIsLoading(false);
}
}, [permissionStatus]);
// 刷新指定日期的健康数据
const refreshHealthDataForDate = useCallback(async (date: Date) => {
if (permissionStatus !== HealthPermissionStatus.Authorized) {
console.log('没有HealthKit权限跳过数据刷新');
return;
}
setIsLoading(true);
try {
console.log('开始刷新指定日期健康数据...', date);
const data = await fetchHealthDataForDate(date);
// 只有是今天的数据才更新state
const today = new Date();
if (date.toDateString() === today.toDateString()) {
setHealthData(data);
}
console.log('指定日期健康数据刷新成功:', data);
} catch (error) {
console.error('刷新指定日期健康数据失败:', error);
} finally {
setIsLoading(false);
}
}, [permissionStatus]);
// 请求权限
const requestPermissions = useCallback(async (): Promise<boolean> => {
setIsLoading(true);
try {
console.log('开始请求HealthKit权限...');
const granted = await ensureHealthPermissions();
if (granted) {
console.log('权限请求成功,准备刷新数据');
// 权限获取成功后,稍微延迟刷新数据
setTimeout(() => {
refreshHealthData();
}, 500);
}
return granted;
} catch (error) {
console.error('请求HealthKit权限失败:', error);
return false;
} finally {
setIsLoading(false);
}
}, [refreshHealthData]);
// 检查权限状态
const checkPermissions = useCallback(async (forceCheck: boolean = false): Promise<HealthPermissionStatus> => {
try {
const status = await checkHealthPermissionStatus(forceCheck);
return status;
} catch (error) {
console.error('检查权限状态失败:', error);
return HealthPermissionStatus.Unknown;
}
}, []);
// 监听权限状态变化
useEffect(() => {
console.log('设置HealthKit权限状态监听器...');
// 权限状态变化监听
const handlePermissionStatusChanged = (newStatus: HealthPermissionStatus, oldStatus: HealthPermissionStatus) => {
console.log(`权限状态变化: ${oldStatus} -> ${newStatus}`);
setPermissionStatus(newStatus);
// 如果从无权限变为有权限,自动刷新数据
if (oldStatus !== HealthPermissionStatus.Authorized && newStatus === HealthPermissionStatus.Authorized) {
console.log('权限状态变为已授权,准备刷新健康数据...');
setTimeout(() => {
refreshHealthData();
}, 500);
}
};
// 权限获取成功监听
const handlePermissionGranted = () => {
console.log('权限获取成功事件触发,准备刷新数据...');
setTimeout(() => {
refreshHealthData();
}, 500);
};
healthPermissionManager.on('permissionStatusChanged', handlePermissionStatusChanged);
healthPermissionManager.on('permissionGranted', handlePermissionGranted);
// 组件挂载时检查一次权限状态
checkPermissions(true);
// 如果已经有权限,立即刷新数据
if (permissionStatus === HealthPermissionStatus.Authorized) {
refreshHealthData();
}
return () => {
console.log('清理HealthKit权限状态监听器...');
healthPermissionManager.off('permissionStatusChanged', handlePermissionStatusChanged);
healthPermissionManager.off('permissionGranted', handlePermissionGranted);
};
}, [checkPermissions, refreshHealthData, permissionStatus]);
// 计算派生状态
const hasPermission = permissionStatus === HealthPermissionStatus.Authorized;
const needsPermission = permissionStatus === HealthPermissionStatus.NotDetermined ||
permissionStatus === HealthPermissionStatus.Unknown;
return {
permissionStatus,
isLoading,
requestPermissions,
checkPermissions,
refreshHealthData,
refreshHealthDataForDate,
healthData,
hasPermission,
needsPermission
};
}
/**
* 简化版Hook只关注权限状态
*/
export function useHealthPermissionStatus() {
const [permissionStatus, setPermissionStatus] = useState<HealthPermissionStatus>(
healthPermissionManager.getPermissionStatus()
);
useEffect(() => {
const handlePermissionStatusChanged = (newStatus: HealthPermissionStatus) => {
setPermissionStatus(newStatus);
};
healthPermissionManager.on('permissionStatusChanged', handlePermissionStatusChanged);
// 检查一次当前状态
checkHealthPermissionStatus(true);
return () => {
healthPermissionManager.off('permissionStatusChanged', handlePermissionStatusChanged);
};
}, []);
return {
permissionStatus,
hasPermission: permissionStatus === HealthPermissionStatus.Authorized,
needsPermission: permissionStatus === HealthPermissionStatus.NotDetermined ||
permissionStatus === HealthPermissionStatus.Unknown
};
}

View File

@@ -55,4 +55,17 @@ RCT_EXTERN_METHOD(getDailyStepCountSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
// Hourly Data Methods
RCT_EXTERN_METHOD(getHourlyActiveEnergyBurned:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getHourlyExerciseTime:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getHourlyStandHours:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@end

View File

@@ -29,16 +29,16 @@ class HealthKitManager: NSObject, RCTBridgeModule {
static let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
static let stepCount = HKObjectType.quantityType(forIdentifier: .stepCount)!
static let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate)!
static let restingHeartRate = HKObjectType.quantityType(forIdentifier: .restingHeartRate)!
static let heartRateVariability = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!
static let activeEnergyBurned = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!
static let basalEnergyBurned = HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)!
static let appleExerciseTime = HKObjectType.quantityType(forIdentifier: .appleExerciseTime)!
static let appleStandTime = HKObjectType.categoryType(forIdentifier: .appleStandHour)!
static let oxygenSaturation = HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!
static let activitySummary = HKObjectType.activitySummaryType()
static var all: Set<HKObjectType> {
return [sleep, stepCount, heartRate, restingHeartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation]
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary]
}
}
@@ -566,9 +566,14 @@ class HealthKitManager: NSObject, RCTBridgeModule {
}
let calendar = Calendar.current
let anchoredDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate)
var startDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate)
var endDateComponents = calendar.dateComponents([.day, .month, .year], from: endDate)
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: anchoredDateComponents, end: anchoredDateComponents)
// HealthKit requires DateComponents to have a calendar
startDateComponents.calendar = calendar
endDateComponents.calendar = calendar
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: startDateComponents, end: endDateComponents)
let query = HKActivitySummaryQuery(predicate: predicate) { [weak self] (query, summaries, error) in
DispatchQueue.main.async {
@@ -583,7 +588,10 @@ class HealthKitManager: NSObject, RCTBridgeModule {
}
let summaryData = summaries.map { summary in
[
// DateComponents
let summaryDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate)
return [
"activeEnergyBurned": summary.activeEnergyBurned.doubleValue(for: HKUnit.kilocalorie()),
"activeEnergyBurnedGoal": summary.activeEnergyBurnedGoal.doubleValue(for: HKUnit.kilocalorie()),
"appleExerciseTime": summary.appleExerciseTime.doubleValue(for: HKUnit.minute()),
@@ -591,9 +599,9 @@ class HealthKitManager: NSObject, RCTBridgeModule {
"appleStandHours": summary.appleStandHours.doubleValue(for: HKUnit.count()),
"appleStandHoursGoal": summary.appleStandHoursGoal.doubleValue(for: HKUnit.count()),
"dateComponents": [
"day": anchoredDateComponents.day ?? 0,
"month": anchoredDateComponents.month ?? 0,
"year": anchoredDateComponents.year ?? 0
"day": summaryDateComponents.day ?? 0,
"month": summaryDateComponents.month ?? 0,
"year": summaryDateComponents.year ?? 0
]
] as [String : Any]
}
@@ -1061,4 +1069,268 @@ class HealthKitManager: NSObject, RCTBridgeModule {
return formatter.string(from: date)
}
// MARK: - Hourly Data Methods
@objc
func getHourlyActiveEnergyBurned(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let activeEnergyType = ReadTypes.activeEnergyBurned
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let calendar = Calendar.current
let anchorDate = calendar.startOfDay(for: startDate)
let interval = DateComponents(hour: 1)
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let query = HKStatisticsCollectionQuery(
quantityType: activeEnergyType,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: anchorDate,
intervalComponents: interval
)
query.initialResultsHandler = { [weak self] query, results, error in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query hourly active energy: \(error.localizedDescription)", error)
return
}
guard let results = results else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
var hourlyData: [[String: Any]] = []
results.enumerateStatistics(from: startDate, to: endDate) { statistics, stop in
let value = statistics.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie()) ?? 0
let hourData: [String: Any] = [
"startDate": self?.dateToISOString(statistics.startDate) ?? "",
"endDate": self?.dateToISOString(statistics.endDate) ?? "",
"value": value,
"hour": calendar.component(.hour, from: statistics.startDate)
]
hourlyData.append(hourData)
}
let result: [String: Any] = [
"data": hourlyData,
"count": hourlyData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getHourlyExerciseTime(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let exerciseType = ReadTypes.appleExerciseTime
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let calendar = Calendar.current
let anchorDate = calendar.startOfDay(for: startDate)
let interval = DateComponents(hour: 1)
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let query = HKStatisticsCollectionQuery(
quantityType: exerciseType,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: anchorDate,
intervalComponents: interval
)
query.initialResultsHandler = { [weak self] query, results, error in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query hourly exercise time: \(error.localizedDescription)", error)
return
}
guard let results = results else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
var hourlyData: [[String: Any]] = []
results.enumerateStatistics(from: startDate, to: endDate) { statistics, stop in
let value = statistics.sumQuantity()?.doubleValue(for: HKUnit.minute()) ?? 0
let hourData: [String: Any] = [
"startDate": self?.dateToISOString(statistics.startDate) ?? "",
"endDate": self?.dateToISOString(statistics.endDate) ?? "",
"value": value,
"hour": calendar.component(.hour, from: statistics.startDate)
]
hourlyData.append(hourData)
}
let result: [String: Any] = [
"data": hourlyData,
"count": hourlyData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getHourlyStandHours(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let standType = ReadTypes.appleStandTime
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let calendar = Calendar.current
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let query = HKSampleQuery(sampleType: standType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query hourly stand hours: \(error.localizedDescription)", error)
return
}
guard let standSamples = samples as? [HKCategorySample] else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
// 24
var hourlyData: [[String: Any]] = []
//
for hour in 0..<24 {
let hourStart = calendar.date(byAdding: .hour, value: hour, to: calendar.startOfDay(for: startDate))!
let hourEnd = calendar.date(byAdding: .hour, value: hour + 1, to: calendar.startOfDay(for: startDate))!
//
let standSamplesInHour = standSamples.filter { sample in
return sample.startDate >= hourStart && sample.startDate < hourEnd &&
sample.value == HKCategoryValueAppleStandHour.stood.rawValue
}
let hasStood = standSamplesInHour.count > 0 ? 1 : 0
let hourData: [String: Any] = [
"startDate": self?.dateToISOString(hourStart) ?? "",
"endDate": self?.dateToISOString(hourEnd) ?? "",
"value": hasStood,
"hour": hour
]
hourlyData.append(hourData)
}
let result: [String: Any] = [
"data": hourlyData,
"count": hourlyData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
} // end class

109
utils/SimpleEventEmitter.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* React Native兼容的EventEmitter实现
*
* 提供与Node.js EventEmitter相似的API但专门为React Native环境设计
* 避免了对Node.js内置模块的依赖
*/
export class SimpleEventEmitter {
private listeners: { [event: string]: ((...args: any[]) => void)[] } = {};
/**
* 添加事件监听器
* @param event 事件名称
* @param listener 监听器函数
*/
on(event: string, listener: (...args: any[]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(listener);
}
/**
* 移除事件监听器
* @param event 事件名称
* @param listener 要移除的监听器函数
*/
off(event: string, listener: (...args: any[]) => void): void {
if (!this.listeners[event]) return;
const index = this.listeners[event].indexOf(listener);
if (index > -1) {
this.listeners[event].splice(index, 1);
}
}
/**
* 触发事件
* @param event 事件名称
* @param args 传递给监听器的参数
*/
emit(event: string, ...args: any[]): void {
if (!this.listeners[event]) return;
// 复制监听器数组,避免在执行过程中数组被修改
const listeners = [...this.listeners[event]];
listeners.forEach(listener => {
try {
listener(...args);
} catch (error) {
console.error(`Error in event listener for event "${event}":`, error);
}
});
}
/**
* 添加一次性事件监听器
* @param event 事件名称
* @param listener 监听器函数(只会执行一次)
*/
once(event: string, listener: (...args: any[]) => void): void {
const onceWrapper = (...args: any[]) => {
this.off(event, onceWrapper);
listener(...args);
};
this.on(event, onceWrapper);
}
/**
* 移除指定事件的所有监听器
* @param event 事件名称(可选,如果未提供则移除所有事件的监听器)
*/
removeAllListeners(event?: string): void {
if (event) {
delete this.listeners[event];
} else {
this.listeners = {};
}
}
/**
* 获取指定事件的监听器数量
* @param event 事件名称
* @returns 监听器数量
*/
listenerCount(event: string): number {
return this.listeners[event]?.length || 0;
}
/**
* 获取指定事件的所有监听器
* @param event 事件名称
* @returns 监听器数组的副本
*/
listeners(event: string): ((...args: any[]) => void)[] {
return this.listeners[event] ? [...this.listeners[event]] : [];
}
/**
* 获取所有事件名称
* @returns 事件名称数组
*/
eventNames(): string[] {
return Object.keys(this.listeners);
}
}
export default SimpleEventEmitter;

View File

@@ -1,5 +1,6 @@
import dayjs from 'dayjs';
import { NativeModules } from 'react-native';
import { AppState, AppStateStatus, NativeModules } from 'react-native';
import { SimpleEventEmitter } from './SimpleEventEmitter';
type HealthDataOptions = {
startDate: string;
@@ -9,6 +10,140 @@ type HealthDataOptions = {
// React Native bridge to native HealthKitManager
const { HealthKitManager } = NativeModules;
// HealthKit权限状态枚举
export enum HealthPermissionStatus {
Unknown = 'unknown',
Authorized = 'authorized',
Denied = 'denied',
NotDetermined = 'notDetermined'
}
// 权限状态管理类
class HealthPermissionManager extends SimpleEventEmitter {
private permissionStatus: HealthPermissionStatus = HealthPermissionStatus.Unknown;
private isChecking: boolean = false;
private lastCheckTime: number = 0;
private checkInterval: number = 5000; // 5秒检查间隔避免频繁检查
private appStateSubscription: any = null;
constructor() {
super();
this.setupAppStateListener();
}
// 设置应用状态监听
private setupAppStateListener() {
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange.bind(this));
}
// 处理应用状态变化
private handleAppStateChange(nextAppState: AppStateStatus) {
if (nextAppState === 'active') {
// 应用回到前台时检查权限状态
console.log('应用回到前台检查HealthKit权限状态...');
this.checkPermissionStatus(true);
}
}
// 获取当前权限状态
public getPermissionStatus(): HealthPermissionStatus {
return this.permissionStatus;
}
// 设置权限状态
private setPermissionStatus(status: HealthPermissionStatus, shouldEmit: boolean = true) {
const oldStatus = this.permissionStatus;
this.permissionStatus = status;
if (shouldEmit && oldStatus !== status) {
console.log(`HealthKit权限状态变化: ${oldStatus} -> ${status}`);
this.emit('permissionStatusChanged', status, oldStatus);
}
}
// 检查权限状态(通过尝试读取数据来间接判断)
public async checkPermissionStatus(forceCheck: boolean = false): Promise<HealthPermissionStatus> {
const now = Date.now();
// 避免频繁检查
if (!forceCheck && this.isChecking) {
return this.permissionStatus;
}
if (!forceCheck && (now - this.lastCheckTime) < this.checkInterval) {
return this.permissionStatus;
}
this.isChecking = true;
this.lastCheckTime = now;
try {
// 尝试获取简单的步数数据来检测权限
const today = new Date();
const options = {
startDate: dayjs(today).startOf('day').toDate().toISOString(),
endDate: dayjs(today).endOf('day').toDate().toISOString()
};
const result = await HealthKitManager.getStepCount(options);
if (result && result.totalValue !== undefined) {
// 能够获取数据,说明有权限
this.setPermissionStatus(HealthPermissionStatus.Authorized);
} else if (result && result.error) {
// 有错误返回,可能是权限被拒绝
this.setPermissionStatus(HealthPermissionStatus.Denied);
} else {
// 其他情况
this.setPermissionStatus(HealthPermissionStatus.Unknown);
}
} catch (error) {
console.log('HealthKit权限检查失败可能是权限被拒绝:', error);
this.setPermissionStatus(HealthPermissionStatus.Denied);
} finally {
this.isChecking = false;
}
return this.permissionStatus;
}
// 请求权限
public async requestPermission(): Promise<boolean> {
try {
console.log('开始请求HealthKit权限...');
const result = await HealthKitManager.requestAuthorization();
if (result && result.success) {
console.log('HealthKit权限请求成功');
this.setPermissionStatus(HealthPermissionStatus.Authorized);
// 权限获取成功后触发数据刷新事件
this.emit('permissionGranted');
return true;
} else {
console.error('HealthKit权限请求失败');
this.setPermissionStatus(HealthPermissionStatus.Denied);
return false;
}
} catch (error) {
console.error('HealthKit权限请求出现异常:', error);
this.setPermissionStatus(HealthPermissionStatus.Denied);
return false;
}
}
// 清理资源
public destroy() {
if (this.appStateSubscription) {
this.appStateSubscription.remove();
}
this.removeAllListeners();
}
}
// 全局权限管理实例
export const healthPermissionManager = new HealthPermissionManager();
// Interface for activity summary data from HealthKit
export interface HealthActivitySummary {
activeEnergyBurned: number;
@@ -86,23 +221,19 @@ export type TodayHealthData = {
heartRate: number | null;
};
// 更新:使用新的权限管理系统
export async function ensureHealthPermissions(): Promise<boolean> {
try {
console.log('开始请求HealthKit权限...');
const result = await HealthKitManager.requestAuthorization();
return await healthPermissionManager.requestPermission();
}
if (result && result.success) {
console.log('HealthKit权限请求成功');
console.log('权限状态:', result.permissions);
return true;
} else {
console.error('HealthKit权限请求失败');
return false;
}
} catch (error) {
console.error('HealthKit权限请求出现异常:', error);
return false;
// 获取当前权限状态
export function getHealthPermissionStatus(): HealthPermissionStatus {
return healthPermissionManager.getPermissionStatus();
}
// 检查权限状态
export async function checkHealthPermissionStatus(forceCheck: boolean = false): Promise<HealthPermissionStatus> {
return await healthPermissionManager.checkPermissionStatus(forceCheck);
}
// 日期工具函数
@@ -218,36 +349,105 @@ export async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData
}
}
// 获取每小时活动热量数据(简化实现)
async function fetchHourlyActiveCalories(_date: Date): Promise<HourlyActivityData[]> {
// 获取每小时活动热量数据
async function fetchHourlyActiveCalories(date: Date): Promise<HourlyActivityData[]> {
try {
// For now, return default data as hourly data is complex and not critical for basic fitness rings
console.log('每小时活动热量获取暂未实现,返回默认数据');
const options = createDateRange(date);
const result = await HealthKitManager.getHourlyActiveEnergyBurned(options);
if (result && result.data && Array.isArray(result.data)) {
logSuccess('每小时活动热量', result);
// 初始化24小时数据
const hourlyData: HourlyActivityData[] = Array.from({ length: 24 }, (_, i) => ({
hour: i,
calories: 0
}));
// 将API返回的数据映射到对应的小时
result.data.forEach((sample: any) => {
if (sample && sample.hour !== undefined && sample.value !== undefined) {
const hour = sample.hour;
if (hour >= 0 && hour < 24) {
hourlyData[hour].calories = Math.round(sample.value);
}
}
});
return hourlyData;
} else {
logWarning('每小时活动热量', '为空或格式错误');
return Array.from({ length: 24 }, (_, i) => ({ hour: i, calories: 0 }));
}
} catch (error) {
logError('每小时活动热量', error);
return Array.from({ length: 24 }, (_, i) => ({ hour: i, calories: 0 }));
}
}
// 获取每小时锻炼分钟数据(简化实现)
async function fetchHourlyExerciseMinutes(_date: Date): Promise<HourlyExerciseData[]> {
// 获取每小时锻炼分钟数据
async function fetchHourlyExerciseMinutes(date: Date): Promise<HourlyExerciseData[]> {
try {
// For now, return default data as hourly data is complex and not critical for basic fitness rings
console.log('每小时锻炼分钟获取暂未实现,返回默认数据');
const options = createDateRange(date);
const result = await HealthKitManager.getHourlyExerciseTime(options);
if (result && result.data && Array.isArray(result.data)) {
logSuccess('每小时锻炼分钟', result);
// 初始化24小时数据
const hourlyData: HourlyExerciseData[] = Array.from({ length: 24 }, (_, i) => ({
hour: i,
minutes: 0
}));
// 将API返回的数据映射到对应的小时
result.data.forEach((sample: any) => {
if (sample && sample.hour !== undefined && sample.value !== undefined) {
const hour = sample.hour;
if (hour >= 0 && hour < 24) {
hourlyData[hour].minutes = Math.round(sample.value);
}
}
});
return hourlyData;
} else {
logWarning('每小时锻炼分钟', '为空或格式错误');
return Array.from({ length: 24 }, (_, i) => ({ hour: i, minutes: 0 }));
}
} catch (error) {
logError('每小时锻炼分钟', error);
return Array.from({ length: 24 }, (_, i) => ({ hour: i, minutes: 0 }));
}
}
// 获取每小时站立小时数据(简化实现)
async function fetchHourlyStandHours(_date: Date): Promise<number[]> {
// 获取每小时站立小时数据
async function fetchHourlyStandHours(date: Date): Promise<number[]> {
try {
// For now, return default data as hourly data is complex and not critical for basic fitness rings
console.log('每小时站立数据获取暂未实现,返回默认数据');
const options = createDateRange(date);
const result = await HealthKitManager.getHourlyStandHours(options);
if (result && result.data && Array.isArray(result.data)) {
logSuccess('每小时站立数据', result);
// 初始化24小时数据
const hourlyData: number[] = Array.from({ length: 24 }, () => 0);
// 将API返回的数据映射到对应的小时
result.data.forEach((sample: any) => {
if (sample && sample.hour !== undefined && sample.value !== undefined) {
const hour = sample.hour;
if (hour >= 0 && hour < 24) {
hourlyData[hour] = sample.value;
}
}
});
return hourlyData;
} else {
logWarning('每小时站立数据', '为空或格式错误');
return Array.from({ length: 24 }, () => 0);
}
} catch (error) {
logError('每小时站立数据', error);
return Array.from({ length: 24 }, () => 0);
@@ -314,15 +514,15 @@ async function fetchHeartRateVariability(options: HealthDataOptions): Promise<nu
async function fetchActivitySummary(options: HealthDataOptions): Promise<HealthActivitySummary | null> {
try {
// const result = await HealthKitManager.getActivitySummary(options);
const result = await HealthKitManager.getActivitySummary(options);
// if (result && Array.isArray(result) && result.length > 0) {
// logSuccess('ActivitySummary', result[0]);
// return result[0];
// } else {
// logWarning('ActivitySummary', '为空');
// return null;
// }
if (result && Array.isArray(result) && result.length > 0) {
logSuccess('ActivitySummary', result[0]);
return result[0];
} else {
logWarning('ActivitySummary', '为空');
return null;
}
} catch (error) {
logError('ActivitySummary', error);
return null;
@@ -366,8 +566,12 @@ async function fetchHeartRate(options: HealthDataOptions): Promise<number | null
}
// 获取指定时间范围内的最大心率
export async function fetchMaximumHeartRate(options: HealthDataOptions): Promise<number | null> {
export async function fetchMaximumHeartRate(_options: HealthDataOptions): Promise<number | null> {
try {
// 暂未实现返回null
console.log('最大心率获取暂未实现');
return null;
// const result = await HealthKitManager.getHeartRateSamples(options);
// if (result && result.data && Array.isArray(result.data) && result.data.length > 0) {
@@ -536,10 +740,10 @@ export async function updateWeight(_weight: number) {
}
}
export async function testOxygenSaturationData(date: Date = dayjs().toDate()): Promise<void> {
export async function testOxygenSaturationData(_date: Date = dayjs().toDate()): Promise<void> {
console.log('=== 开始测试血氧饱和度数据获取 ===');
const options = createDateRange(date);
// const options = createDateRange(date);
try {
// const result = await HealthKitManager.getOxygenSaturationSamples(options);
@@ -697,3 +901,65 @@ export async function fetchActivityRingsForDate(date: Date): Promise<ActivityRin
}
}
// === 权限管理工具函数 ===
// 初始化健康权限管理(应在应用启动时调用)
export function initializeHealthPermissions() {
console.log('初始化HealthKit权限管理系统...');
// 延迟检查权限状态,避免应用启动时的性能影响
setTimeout(() => {
healthPermissionManager.checkPermissionStatus(true);
}, 1000);
}
// 监听权限状态变化(用于组件外部使用)
export function addHealthPermissionListener(
event: 'permissionStatusChanged' | 'permissionGranted',
listener: (...args: any[]) => void
) {
healthPermissionManager.on(event, listener);
}
// 移除权限状态监听器
export function removeHealthPermissionListener(
event: 'permissionStatusChanged' | 'permissionGranted',
listener: (...args: any[]) => void
) {
healthPermissionManager.off(event, listener);
}
// 清理权限管理资源(应在应用退出时调用)
export function cleanupHealthPermissions() {
console.log('清理HealthKit权限管理资源...');
healthPermissionManager.destroy();
}
// 获取权限状态的可读文本
export function getPermissionStatusText(status: HealthPermissionStatus): string {
switch (status) {
case HealthPermissionStatus.Authorized:
return '已授权';
case HealthPermissionStatus.Denied:
return '已拒绝';
case HealthPermissionStatus.NotDetermined:
return '未确定';
case HealthPermissionStatus.Unknown:
default:
return '未知';
}
}
// 检查是否需要显示权限请求UI
export function shouldShowPermissionRequest(): boolean {
const status = healthPermissionManager.getPermissionStatus();
return status === HealthPermissionStatus.NotDetermined ||
status === HealthPermissionStatus.Unknown;
}
// 检查是否权限被用户拒绝
export function isPermissionDenied(): boolean {
const status = healthPermissionManager.getPermissionStatus();
return status === HealthPermissionStatus.Denied;
}

View File

@@ -1,236 +0,0 @@
/**
* HealthKit Native Module Usage Example
* 展示如何使用HealthKit native module的示例代码
*/
import HealthKitManager, { HealthKitUtils, SleepDataSample } from './healthKit';
export class HealthKitService {
/**
* 初始化HealthKit并请求权限
*/
static async initializeHealthKit(): Promise<boolean> {
try {
// 检查HealthKit是否可用
if (!HealthKitUtils.isAvailable()) {
console.log('HealthKit不可用可能运行在Android设备或模拟器上');
return false;
}
// 请求授权
const result = await HealthKitManager.requestAuthorization();
if (result.success) {
console.log('HealthKit授权成功');
console.log('权限状态:', result.permissions);
// 检查睡眠数据权限
const sleepPermission = result.permissions['HKCategoryTypeIdentifierSleepAnalysis'];
if (sleepPermission === 'authorized') {
console.log('睡眠数据访问权限已获得');
return true;
} else {
console.log('睡眠数据访问权限未获得:', sleepPermission);
return false;
}
} else {
console.log('HealthKit授权失败');
return false;
}
} catch (error) {
console.error('HealthKit初始化失败:', error);
return false;
}
}
/**
* 获取最近的睡眠数据
*/
static async getRecentSleepData(days: number = 7): Promise<SleepDataSample[]> {
try {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - days);
const result = await HealthKitManager.getSleepData({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
limit: 100
});
console.log(`获取到 ${result.count} 条睡眠记录`);
return result.data;
} catch (error) {
console.error('获取睡眠数据失败:', error);
return [];
}
}
/**
* 分析睡眠质量
*/
static async analyzeSleepQuality(days: number = 7): Promise<any> {
try {
const sleepData = await this.getRecentSleepData(days);
if (sleepData.length === 0) {
return {
error: '没有找到睡眠数据',
hasData: false
};
}
// 按日期分组
const groupedData = HealthKitUtils.groupSamplesByDate(sleepData);
const dates = Object.keys(groupedData).sort().reverse(); // 最新的日期在前
const analysis = dates.slice(0, days).map(date => {
const daySamples = groupedData[date];
const totalSleepDuration = HealthKitUtils.getTotalSleepDuration(daySamples, new Date(date));
const qualityMetrics = HealthKitUtils.getSleepQualityMetrics(daySamples);
return {
date,
totalSleepDuration,
totalSleepFormatted: HealthKitUtils.formatDuration(totalSleepDuration),
qualityMetrics,
samplesCount: daySamples.length
};
});
// 计算平均值
const validDays = analysis.filter(day => day.totalSleepDuration > 0);
const averageSleepDuration = validDays.length > 0
? validDays.reduce((sum, day) => sum + day.totalSleepDuration, 0) / validDays.length
: 0;
return {
hasData: true,
days: analysis,
summary: {
averageSleepDuration,
averageSleepFormatted: HealthKitUtils.formatDuration(averageSleepDuration),
daysWithData: validDays.length,
totalDaysAnalyzed: days
}
};
} catch (error) {
console.error('睡眠质量分析失败:', error);
return {
error: error instanceof Error ? error.message : String(error),
hasData: false
};
}
}
/**
* 获取昨晚的睡眠数据
*/
static async getLastNightSleep(): Promise<any> {
try {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
// 设置查询范围昨天下午6点到今天上午12点
const startDate = new Date(yesterday);
startDate.setHours(18, 0, 0, 0);
const endDate = new Date(today);
endDate.setHours(12, 0, 0, 0);
const result = await HealthKitManager.getSleepData({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
limit: 50
});
if (result.data.length === 0) {
return {
hasData: false,
message: '未找到昨晚的睡眠数据'
};
}
const sleepSamples = result.data.filter(sample =>
['asleep', 'core', 'deep', 'rem'].includes(sample.categoryType)
);
if (sleepSamples.length === 0) {
return {
hasData: false,
message: '未找到有效的睡眠阶段数据'
};
}
// 找到睡眠的开始和结束时间
const sleepStart = new Date(Math.min(...sleepSamples.map(s => new Date(s.startDate).getTime())));
const sleepEnd = new Date(Math.max(...sleepSamples.map(s => new Date(s.endDate).getTime())));
const totalDuration = sleepSamples.reduce((sum, s) => sum + s.duration, 0);
const qualityMetrics = HealthKitUtils.getSleepQualityMetrics(sleepSamples);
return {
hasData: true,
sleepStart: sleepStart.toISOString(),
sleepEnd: sleepEnd.toISOString(),
totalDuration,
totalDurationFormatted: HealthKitUtils.formatDuration(totalDuration),
qualityMetrics,
samples: sleepSamples,
bedTime: sleepStart.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
wakeTime: sleepEnd.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
};
} catch (error) {
console.error('获取昨晚睡眠数据失败:', error);
return {
hasData: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
}
// 使用示例
export const useHealthKitExample = async () => {
console.log('=== HealthKit 使用示例 ===');
// 1. 初始化和授权
const initialized = await HealthKitService.initializeHealthKit();
if (!initialized) {
console.log('HealthKit初始化失败无法继续');
return;
}
// 2. 获取昨晚的睡眠数据
console.log('\n--- 昨晚睡眠数据 ---');
const lastNightSleep = await HealthKitService.getLastNightSleep();
if (lastNightSleep.hasData) {
console.log(`睡眠时间: ${lastNightSleep.bedTime} - ${lastNightSleep.wakeTime}`);
console.log(`睡眠时长: ${lastNightSleep.totalDurationFormatted}`);
if (lastNightSleep.qualityMetrics) {
console.log(`深睡眠: ${lastNightSleep.qualityMetrics.deepSleepPercentage.toFixed(1)}%`);
console.log(`REM睡眠: ${lastNightSleep.qualityMetrics.remSleepPercentage.toFixed(1)}%`);
}
} else {
console.log(lastNightSleep.message || '未找到睡眠数据');
}
// 3. 分析最近一周的睡眠质量
console.log('\n--- 最近一周睡眠分析 ---');
const weeklyAnalysis = await HealthKitService.analyzeSleepQuality(7);
if (weeklyAnalysis.hasData) {
console.log(`平均睡眠时长: ${weeklyAnalysis.summary.averageSleepFormatted}`);
console.log(`有数据的天数: ${weeklyAnalysis.summary.daysWithData}/${weeklyAnalysis.summary.totalDaysAnalyzed}`);
console.log('\n每日睡眠详情:');
weeklyAnalysis.days.forEach((day: any) => {
if (day.totalSleepDuration > 0) {
console.log(`${day.date}: ${day.totalSleepFormatted}`);
}
});
} else {
console.log(weeklyAnalysis.error || '睡眠分析失败');
}
};