- 新增 HRV 监听服务,实时监控心率变异性数据 - 实现 HRV 到压力指数的转换算法和压力等级评估 - 添加智能通知服务,在压力偏高时推送健康建议 - 优化日志系统,修复日志丢失问题并增强刷新机制 - 改进个人页面下拉刷新,支持并行数据加载 - 优化勋章数据缓存策略,减少不必要的网络请求 - 重构应用初始化流程,优化权限服务和健康监听服务的启动顺序 - 移除冗余日志输出,提升应用性能
1947 lines
64 KiB
Swift
1947 lines
64 KiB
Swift
//
|
||
// HealthKitManager.swift
|
||
// digitalpilates
|
||
//
|
||
// Updated module for HealthKit authorization and sleep data access.
|
||
//
|
||
|
||
import Foundation
|
||
import React
|
||
import HealthKit
|
||
|
||
@objc(HealthKitManager)
|
||
class HealthKitManager: RCTEventEmitter {
|
||
|
||
private let healthStore = HKHealthStore()
|
||
|
||
override static func moduleName() -> String! {
|
||
return "HealthKitManager"
|
||
}
|
||
|
||
|
||
// MARK: - Types We Care About
|
||
|
||
/// For reading
|
||
private struct ReadTypes {
|
||
static let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
|
||
static let stepCount = HKObjectType.quantityType(forIdentifier: .stepCount)!
|
||
static let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate)!
|
||
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 let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
|
||
static let workout = HKObjectType.workoutType()
|
||
|
||
static var all: Set<HKObjectType> {
|
||
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater, workout]
|
||
}
|
||
|
||
static var workoutType: HKWorkoutType {
|
||
return HKObjectType.workoutType()
|
||
}
|
||
}
|
||
|
||
/// For writing (if needed)
|
||
private struct WriteTypes {
|
||
static let bodyMass = HKObjectType.quantityType(forIdentifier: .bodyMass)!
|
||
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
|
||
|
||
static var all: Set<HKSampleType> {
|
||
return [bodyMass, dietaryWater]
|
||
}
|
||
}
|
||
|
||
// MARK: - Authorization
|
||
|
||
@objc
|
||
func requestAuthorization(
|
||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||
rejecter: @escaping RCTPromiseRejectBlock
|
||
) {
|
||
guard HKHealthStore.isHealthDataAvailable() else {
|
||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||
return
|
||
}
|
||
|
||
healthStore.requestAuthorization(toShare: WriteTypes.all, read: ReadTypes.all) { [weak self] (success, error) in
|
||
DispatchQueue.main.async {
|
||
if let error = error {
|
||
rejecter("AUTHORIZATION_ERROR", "Failed to authorize HealthKit: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
if success {
|
||
// We don't rely purely on authorizationStatus(for:) for quantity types, see below.
|
||
var permissions: [String: Any] = [:]
|
||
|
||
for type in ReadTypes.all {
|
||
if type == ReadTypes.sleep {
|
||
// For categoryType, authorizationStatus is meaningful
|
||
let status = self?.healthStore.authorizationStatus(for: type) ?? .notDetermined
|
||
permissions[type.identifier] = self?.authorizationStatusToString(status)
|
||
} else {
|
||
// For quantity types, authorizationStatus isn't reliable – mark as “unknownUntilQueried”
|
||
permissions[type.identifier] = "unknownUntilQueried"
|
||
}
|
||
}
|
||
|
||
// Return success immediately
|
||
let result: [String: Any] = [
|
||
"success": true,
|
||
"permissions": permissions
|
||
]
|
||
resolver(result)
|
||
} else {
|
||
rejecter("AUTHORIZATION_DENIED", "User denied HealthKit authorization", nil)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Current Authorization Status Check
|
||
|
||
@objc
|
||
func getAuthorizationStatus(
|
||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||
rejecter: @escaping RCTPromiseRejectBlock
|
||
) {
|
||
guard HKHealthStore.isHealthDataAvailable() else {
|
||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||
return
|
||
}
|
||
|
||
var permissions: [String: Any] = [:]
|
||
|
||
for type in ReadTypes.all {
|
||
if type == ReadTypes.sleep {
|
||
let status = healthStore.authorizationStatus(for: type)
|
||
permissions[type.identifier] = authorizationStatusToString(status)
|
||
} else {
|
||
// For quantity types, we cannot rely on authorizationStatus; attempt a small read test (optional)
|
||
// But to keep simple, we mark as “unknownUntilQueried”
|
||
permissions[type.identifier] = "unknownUntilQueried"
|
||
}
|
||
}
|
||
|
||
let result: [String: Any] = [
|
||
"success": true,
|
||
"permissions": permissions
|
||
]
|
||
resolver(result)
|
||
}
|
||
|
||
// MARK: - Sleep Data
|
||
|
||
@objc
|
||
func getSleepData(
|
||
_ 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 sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
|
||
|
||
|
||
// Parse options
|
||
let startDate: Date
|
||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||
startDate = d
|
||
} else {
|
||
startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
|
||
}
|
||
|
||
let endDate: Date
|
||
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
|
||
endDate = d
|
||
} else {
|
||
endDate = Date()
|
||
}
|
||
|
||
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
|
||
|
||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||
|
||
let query = HKSampleQuery(sampleType: sleepType,
|
||
predicate: predicate,
|
||
limit: limit,
|
||
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
|
||
DispatchQueue.main.async {
|
||
if let error = error {
|
||
rejecter("QUERY_ERROR", "Failed to query sleep data: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let sleepSamples = samples as? [HKCategorySample] else {
|
||
resolver([
|
||
"data": [],
|
||
"count": 0,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let sleepData = sleepSamples.map { sample in
|
||
[
|
||
"id": sample.uuid.uuidString,
|
||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||
"value": sample.value,
|
||
"categoryType": self?.sleepValueToString(sample.value) ?? "unknown",
|
||
"duration": sample.endDate.timeIntervalSince(sample.startDate),
|
||
"source": [
|
||
"name": sample.sourceRevision.source.name,
|
||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||
],
|
||
"metadata": sample.metadata ?? [:]
|
||
] as [String : Any]
|
||
}
|
||
|
||
let result: [String: Any] = [
|
||
"data": sleepData,
|
||
"count": sleepData.count,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
]
|
||
resolver(result)
|
||
}
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
// MARK: - Fitness Data Methods
|
||
|
||
@objc
|
||
func getActiveEnergyBurned(
|
||
_ 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 predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||
|
||
let query = HKSampleQuery(sampleType: activeEnergyType,
|
||
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 active energy: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let energySamples = samples as? [HKQuantitySample] else {
|
||
resolver([
|
||
"data": [],
|
||
"totalValue": 0,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let energyData = energySamples.map { sample in
|
||
[
|
||
"id": sample.uuid.uuidString,
|
||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||
"value": sample.quantity.doubleValue(for: HKUnit.kilocalorie()),
|
||
"source": [
|
||
"name": sample.sourceRevision.source.name,
|
||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||
],
|
||
"metadata": sample.metadata ?? [:]
|
||
] as [String : Any]
|
||
}
|
||
|
||
let totalValue = energySamples.reduce(0.0) { total, sample in
|
||
return total + sample.quantity.doubleValue(for: HKUnit.kilocalorie())
|
||
}
|
||
|
||
let result: [String: Any] = [
|
||
"data": energyData,
|
||
"totalValue": totalValue,
|
||
"count": energyData.count,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
]
|
||
resolver(result)
|
||
}
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
@objc
|
||
func getBasalEnergyBurned(
|
||
_ 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 basalEnergyType = ReadTypes.basalEnergyBurned
|
||
|
||
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()
|
||
}
|
||
|
||
// 使用 HKStatisticsQuery 代替 HKSampleQuery 来直接获取总和,避免处理大量样本
|
||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
|
||
let query = HKStatisticsQuery(quantityType: basalEnergyType,
|
||
quantitySamplePredicate: predicate,
|
||
options: .cumulativeSum) { [weak self] (query, statistics, error) in
|
||
DispatchQueue.main.async {
|
||
if let error = error {
|
||
rejecter("QUERY_ERROR", "Failed to query basal energy: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let statistics = statistics else {
|
||
resolver([
|
||
"totalValue": 0,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let totalValue = statistics.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie()) ?? 0
|
||
|
||
let result: [String: Any] = [
|
||
"totalValue": totalValue,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
]
|
||
resolver(result)
|
||
}
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
@objc
|
||
func getAppleExerciseTime(
|
||
_ 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 predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||
|
||
let query = HKSampleQuery(sampleType: exerciseType,
|
||
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 exercise time: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let exerciseSamples = samples as? [HKQuantitySample] else {
|
||
resolver([
|
||
"data": [],
|
||
"totalValue": 0,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let exerciseData = exerciseSamples.map { sample in
|
||
[
|
||
"id": sample.uuid.uuidString,
|
||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||
"value": sample.quantity.doubleValue(for: HKUnit.minute()),
|
||
"source": [
|
||
"name": sample.sourceRevision.source.name,
|
||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||
],
|
||
"metadata": sample.metadata ?? [:]
|
||
] as [String : Any]
|
||
}
|
||
|
||
let totalValue = exerciseSamples.reduce(0.0) { total, sample in
|
||
return total + sample.quantity.doubleValue(for: HKUnit.minute())
|
||
}
|
||
|
||
let result: [String: Any] = [
|
||
"data": exerciseData,
|
||
"totalValue": totalValue,
|
||
"count": exerciseData.count,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
]
|
||
resolver(result)
|
||
}
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
@objc
|
||
func getAppleStandTime(
|
||
_ 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 predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||
|
||
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 stand time: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let standSamples = samples as? [HKCategorySample] else {
|
||
resolver([
|
||
"data": [],
|
||
"totalHours": 0,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let standData = standSamples.map { sample in
|
||
[
|
||
"id": sample.uuid.uuidString,
|
||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||
"value": sample.value,
|
||
"categoryType": self?.standValueToString(sample.value) ?? "unknown",
|
||
"source": [
|
||
"name": sample.sourceRevision.source.name,
|
||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||
],
|
||
"metadata": sample.metadata ?? [:]
|
||
] as [String : Any]
|
||
}
|
||
|
||
// Count hours where user stood (value = 0 means stood, value = 1 means idle)
|
||
let standHours = standSamples.filter { $0.value == HKCategoryValueAppleStandHour.stood.rawValue }.count
|
||
|
||
let result: [String: Any] = [
|
||
"data": standData,
|
||
"totalHours": standHours,
|
||
"count": standData.count,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
]
|
||
resolver(result)
|
||
}
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
@objc
|
||
func getActivitySummary(
|
||
_ 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 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
|
||
var startDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate)
|
||
var endDateComponents = calendar.dateComponents([.day, .month, .year], from: endDate)
|
||
|
||
// 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) { (query, summaries, error) in
|
||
DispatchQueue.main.async {
|
||
if let error = error {
|
||
rejecter("QUERY_ERROR", "Failed to query activity summary: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let summaries = summaries, !summaries.isEmpty else {
|
||
resolver([])
|
||
return
|
||
}
|
||
|
||
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()),
|
||
"appleExerciseTimeGoal": summary.appleExerciseTimeGoal.doubleValue(for: HKUnit.minute()),
|
||
"appleStandHours": summary.appleStandHours.doubleValue(for: HKUnit.count()),
|
||
"appleStandHoursGoal": summary.appleStandHoursGoal.doubleValue(for: HKUnit.count()),
|
||
"dateComponents": [
|
||
"day": summaryDateComponents.day ?? 0,
|
||
"month": summaryDateComponents.month ?? 0,
|
||
"year": summaryDateComponents.year ?? 0
|
||
]
|
||
] as [String : Any]
|
||
}
|
||
|
||
resolver(summaryData)
|
||
}
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
@objc
|
||
func getHeartRateVariabilitySamples(
|
||
_ 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 hrvType = ReadTypes.heartRateVariability
|
||
|
||
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 predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||
|
||
// 获取更多样本用于质量分析,默认50个样本
|
||
let limit = options["limit"] as? Int ?? 50
|
||
|
||
let query = HKSampleQuery(sampleType: hrvType,
|
||
predicate: predicate,
|
||
limit: limit,
|
||
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
|
||
DispatchQueue.main.async {
|
||
if let error = error {
|
||
rejecter("QUERY_ERROR", "Failed to query HRV: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let hrvSamples = samples as? [HKQuantitySample] else {
|
||
resolver([
|
||
"data": [],
|
||
"count": 0,
|
||
"bestQualityValue": nil,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let hrvData = hrvSamples.map { sample in
|
||
let hrvValueMs = sample.quantity.doubleValue(for: HKUnit.secondUnit(with: .milli))
|
||
let sourceBundle = sample.sourceRevision.source.bundleIdentifier
|
||
|
||
return [
|
||
"id": sample.uuid.uuidString,
|
||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||
"value": hrvValueMs,
|
||
"source": [
|
||
"name": sample.sourceRevision.source.name,
|
||
"bundleIdentifier": sourceBundle
|
||
],
|
||
"metadata": sample.metadata ?? [:],
|
||
"isManualMeasurement": self?.isManualHRVMeasurement(sourceBundle: sourceBundle, metadata: sample.metadata) ?? false,
|
||
"qualityScore": self?.calculateHRVQualityScore(value: hrvValueMs, sourceBundle: sourceBundle, metadata: sample.metadata) ?? 0
|
||
] as [String : Any]
|
||
}
|
||
|
||
// 计算最佳质量的HRV值
|
||
let bestQualityValue = self?.getBestQualityHRVValue(from: hrvData)
|
||
|
||
let result: [String: Any] = [
|
||
"data": hrvData,
|
||
"count": hrvData.count,
|
||
"bestQualityValue": bestQualityValue ?? NSNull(),
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
]
|
||
resolver(result)
|
||
}
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
@objc
|
||
func getOxygenSaturationSamples(
|
||
_ 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 oxygenType = ReadTypes.oxygenSaturation
|
||
|
||
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 predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
|
||
|
||
let query = HKSampleQuery(sampleType: oxygenType,
|
||
predicate: predicate,
|
||
limit: limit,
|
||
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
|
||
DispatchQueue.main.async {
|
||
if let error = error {
|
||
rejecter("QUERY_ERROR", "Failed to query oxygen saturation: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let oxygenSamples = samples as? [HKQuantitySample] else {
|
||
resolver([
|
||
"data": [],
|
||
"count": 0,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let oxygenData = oxygenSamples.map { sample in
|
||
[
|
||
"id": sample.uuid.uuidString,
|
||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||
"value": sample.quantity.doubleValue(for: HKUnit.percent()),
|
||
"source": [
|
||
"name": sample.sourceRevision.source.name,
|
||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||
],
|
||
"metadata": sample.metadata ?? [:]
|
||
] as [String : Any]
|
||
}
|
||
|
||
let result: [String: Any] = [
|
||
"data": oxygenData,
|
||
"count": oxygenData.count,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
]
|
||
resolver(result)
|
||
}
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
@objc
|
||
func getHeartRateSamples(
|
||
_ 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 heartRateType = ReadTypes.heartRate
|
||
|
||
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 predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
|
||
|
||
let query = HKSampleQuery(sampleType: heartRateType,
|
||
predicate: predicate,
|
||
limit: limit,
|
||
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
|
||
DispatchQueue.main.async {
|
||
if let error = error {
|
||
rejecter("QUERY_ERROR", "Failed to query heart rate: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let heartRateSamples = samples as? [HKQuantitySample] else {
|
||
resolver([
|
||
"data": [],
|
||
"count": 0,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let heartRateData = heartRateSamples.map { sample in
|
||
[
|
||
"id": sample.uuid.uuidString,
|
||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||
"value": sample.quantity.doubleValue(for: HKUnit(from: "count/min")),
|
||
"source": [
|
||
"name": sample.sourceRevision.source.name,
|
||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||
],
|
||
"metadata": sample.metadata ?? [:]
|
||
] as [String : Any]
|
||
}
|
||
|
||
let result: [String: Any] = [
|
||
"data": heartRateData,
|
||
"count": heartRateData.count,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
]
|
||
resolver(result)
|
||
}
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
@objc
|
||
func getStepCount(
|
||
_ 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 stepType = ReadTypes.stepCount
|
||
|
||
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()
|
||
}
|
||
|
||
// 使用 HKStatisticsQuery 代替 HKSampleQuery 来直接获取步数总和,提高性能
|
||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
|
||
let query = HKStatisticsQuery(quantityType: stepType,
|
||
quantitySamplePredicate: predicate,
|
||
options: .cumulativeSum) { [weak self] (query, statistics, error) in
|
||
DispatchQueue.main.async {
|
||
if let error = error {
|
||
rejecter("QUERY_ERROR", "Failed to query step count: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let statistics = statistics else {
|
||
resolver([
|
||
"totalValue": 0,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let totalValue = statistics.sumQuantity()?.doubleValue(for: HKUnit.count()) ?? 0
|
||
|
||
let result: [String: Any] = [
|
||
"totalValue": totalValue,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
]
|
||
resolver(result)
|
||
}
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
@objc
|
||
func getDailyStepCountSamples(
|
||
_ 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 stepType = ReadTypes.stepCount
|
||
|
||
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()
|
||
}
|
||
|
||
// Create date components for statistics collection interval (hourly)
|
||
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: stepType,
|
||
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 step count: \(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.count()) ?? 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)
|
||
}
|
||
|
||
// MARK: - Helper Methods
|
||
|
||
private func authorizationStatusToString(_ status: HKAuthorizationStatus) -> String {
|
||
switch status {
|
||
case .notDetermined:
|
||
return "notDetermined"
|
||
case .sharingDenied:
|
||
return "denied"
|
||
case .sharingAuthorized:
|
||
return "authorized"
|
||
@unknown default:
|
||
return "unknown"
|
||
}
|
||
}
|
||
|
||
private func sleepValueToString(_ value: Int) -> String {
|
||
switch value {
|
||
case HKCategoryValueSleepAnalysis.inBed.rawValue:
|
||
return "inBed"
|
||
case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue:
|
||
return "asleep"
|
||
case HKCategoryValueSleepAnalysis.awake.rawValue:
|
||
return "awake"
|
||
case HKCategoryValueSleepAnalysis.asleepCore.rawValue:
|
||
return "core"
|
||
case HKCategoryValueSleepAnalysis.asleepDeep.rawValue:
|
||
return "deep"
|
||
case HKCategoryValueSleepAnalysis.asleepREM.rawValue:
|
||
return "rem"
|
||
default:
|
||
return "unknown"
|
||
}
|
||
}
|
||
|
||
private func standValueToString(_ value: Int) -> String {
|
||
switch value {
|
||
case HKCategoryValueAppleStandHour.stood.rawValue:
|
||
return "stood"
|
||
case HKCategoryValueAppleStandHour.idle.rawValue:
|
||
return "idle"
|
||
default:
|
||
return "unknown"
|
||
}
|
||
}
|
||
|
||
private func parseDate(from string: String?) -> Date? {
|
||
guard let string = string else { return nil }
|
||
let formatter = ISO8601DateFormatter()
|
||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||
return formatter.date(from: string)
|
||
}
|
||
|
||
private func dateToISOString(_ date: Date) -> String {
|
||
let formatter = ISO8601DateFormatter()
|
||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
// MARK: - HRV Quality Analysis Methods
|
||
|
||
/// 判断是否为手动/高质量HRV测量
|
||
private func isManualHRVMeasurement(sourceBundle: String, metadata: [String: Any]?) -> Bool {
|
||
// 来自呼吸应用的测量通常是手动触发的高质量测量
|
||
if sourceBundle.contains("Breathe") || sourceBundle.contains("breathe") {
|
||
return true
|
||
}
|
||
|
||
// 来自第三方HRV应用的测量通常是手动的
|
||
let manualHRVApps = ["HRV4Training", "EliteHRV", "HRVLogger", "Stress & Anxiety Companion"]
|
||
if manualHRVApps.contains(where: { sourceBundle.contains($0) }) {
|
||
return true
|
||
}
|
||
|
||
// 检查元数据中的手动测量标识
|
||
if let metadata = metadata {
|
||
if let wasUserEntered = metadata[HKMetadataKeyWasUserEntered] as? Bool, wasUserEntered {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/// 计算HRV测量的质量评分
|
||
private func calculateHRVQualityScore(value: Double, sourceBundle: String, metadata: [String: Any]?) -> Int {
|
||
var score = 0
|
||
|
||
// 1. 数值有效性检查 (0-40分)
|
||
if value >= 10 && value <= 100 {
|
||
// 正常SDNN范围,给予基础分数
|
||
if value >= 18 && value <= 76 {
|
||
score += 40 // 完全正常范围
|
||
} else if value >= 10 && value <= 18 {
|
||
score += 30 // 偏低但可能有效
|
||
} else if value >= 76 && value <= 100 {
|
||
score += 35 // 偏高但可能有效
|
||
}
|
||
} else if value > 0 && value < 10 {
|
||
score += 10 // 数值过低,质量存疑
|
||
}
|
||
|
||
// 2. 数据源质量 (0-35分)
|
||
if isManualHRVMeasurement(sourceBundle: sourceBundle, metadata: metadata) {
|
||
score += 35 // 手动测量,质量最高
|
||
} else if sourceBundle.contains("com.apple.health") {
|
||
score += 20 // 系统自动测量,中等质量
|
||
} else if sourceBundle.contains("Watch") {
|
||
score += 25 // Apple Watch测量,较好质量
|
||
} else {
|
||
score += 15 // 其他来源,质量一般
|
||
}
|
||
|
||
// 3. 元数据质量指标 (0-25分)
|
||
if let metadata = metadata {
|
||
var metadataScore = 0
|
||
|
||
// 用户手动输入
|
||
if let wasUserEntered = metadata[HKMetadataKeyWasUserEntered] as? Bool, wasUserEntered {
|
||
metadataScore += 15
|
||
}
|
||
|
||
// 设备信息完整性
|
||
if metadata[HKMetadataKeyDeviceName] != nil {
|
||
metadataScore += 5
|
||
}
|
||
|
||
// 其他质量指标
|
||
if metadata[HKMetadataKeyHeartRateMotionContext] != nil {
|
||
metadataScore += 5
|
||
}
|
||
|
||
score += metadataScore
|
||
}
|
||
|
||
return min(score, 100) // 限制最大分数为100
|
||
}
|
||
|
||
/// 从HRV数据中获取最佳质量的测量值
|
||
private func getBestQualityHRVValue(from hrvData: [[String: Any]]) -> Double? {
|
||
guard !hrvData.isEmpty else { return nil }
|
||
|
||
// 按质量分数和时间排序,优先选择高质量的最新测量
|
||
let sortedData = hrvData.sorted { item1, item2 in
|
||
let quality1 = item1["qualityScore"] as? Int ?? 0
|
||
let quality2 = item2["qualityScore"] as? Int ?? 0
|
||
let isManual1 = item1["isManualMeasurement"] as? Bool ?? false
|
||
let isManual2 = item2["isManualMeasurement"] as? Bool ?? false
|
||
|
||
// 优先级:手动测量 > 质量分数 > 时间新旧
|
||
if isManual1 && !isManual2 {
|
||
return true
|
||
} else if !isManual1 && isManual2 {
|
||
return false
|
||
} else if quality1 != quality2 {
|
||
return quality1 > quality2
|
||
} else {
|
||
// 同等质量下,选择更新的数据
|
||
let date1 = item1["endDate"] as? String ?? ""
|
||
let date2 = item2["endDate"] as? String ?? ""
|
||
return date1 > date2
|
||
}
|
||
}
|
||
|
||
// 返回质量最高的测量值
|
||
if let bestValue = sortedData.first?["value"] as? Double {
|
||
// 对最终值进行合理性验证
|
||
if bestValue >= 5 && bestValue <= 150 {
|
||
return bestValue
|
||
}
|
||
}
|
||
|
||
// 如果最佳值不合理,尝试返回第一个合理的值
|
||
for data in sortedData {
|
||
if let value = data["value"] as? Double, value >= 10 && value <= 100 {
|
||
return value
|
||
}
|
||
}
|
||
|
||
// 如果都没有合理值,返回第一个值(可能需要用户注意数据质量)
|
||
return sortedData.first?["value"] as? Double
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// MARK: - Water Intake Methods
|
||
|
||
@objc
|
||
func saveWaterIntakeToHealthKit(
|
||
_ 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
|
||
}
|
||
|
||
// Parse parameters
|
||
guard let amount = options["amount"] as? Double else {
|
||
rejecter("INVALID_PARAMETERS", "Amount is required", nil)
|
||
return
|
||
}
|
||
|
||
let recordedAt: Date
|
||
if let recordedAtString = options["recordedAt"] as? String,
|
||
let date = parseDate(from: recordedAtString) {
|
||
recordedAt = date
|
||
} else {
|
||
recordedAt = Date()
|
||
}
|
||
|
||
let waterType = WriteTypes.dietaryWater
|
||
|
||
// Create quantity sample
|
||
let quantity = HKQuantity(unit: HKUnit.literUnit(with: .milli), doubleValue: amount)
|
||
let sample = HKQuantitySample(
|
||
type: waterType,
|
||
quantity: quantity,
|
||
start: recordedAt,
|
||
end: recordedAt,
|
||
metadata: nil
|
||
)
|
||
|
||
// Save to HealthKit
|
||
healthStore.save(sample) { [weak self] (success, error) in
|
||
DispatchQueue.main.async {
|
||
if let error = error {
|
||
rejecter("SAVE_ERROR", "Failed to save water intake: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
if success {
|
||
let result: [String: Any] = [
|
||
"success": true,
|
||
"amount": amount,
|
||
"recordedAt": self?.dateToISOString(recordedAt) ?? ""
|
||
]
|
||
resolver(result)
|
||
} else {
|
||
rejecter("SAVE_FAILED", "Failed to save water intake", nil)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@objc
|
||
func getWaterIntakeFromHealthKit(
|
||
_ 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 waterType = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
|
||
|
||
// Parse date range
|
||
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 limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
|
||
|
||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||
|
||
let query = HKSampleQuery(sampleType: waterType,
|
||
predicate: predicate,
|
||
limit: limit,
|
||
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
|
||
DispatchQueue.main.async {
|
||
if let error = error {
|
||
rejecter("QUERY_ERROR", "Failed to query water intake: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let waterSamples = samples as? [HKQuantitySample] else {
|
||
resolver([
|
||
"data": [],
|
||
"totalAmount": 0,
|
||
"count": 0,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let waterData = waterSamples.map { sample in
|
||
[
|
||
"id": sample.uuid.uuidString,
|
||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||
"value": sample.quantity.doubleValue(for: HKUnit.literUnit(with: .milli)),
|
||
"source": [
|
||
"name": sample.sourceRevision.source.name,
|
||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||
],
|
||
"metadata": sample.metadata ?? [:]
|
||
] as [String : Any]
|
||
}
|
||
|
||
let totalAmount = waterSamples.reduce(0.0) { total, sample in
|
||
return total + sample.quantity.doubleValue(for: HKUnit.literUnit(with: .milli))
|
||
}
|
||
|
||
let result: [String: Any] = [
|
||
"data": waterData,
|
||
"totalAmount": totalAmount,
|
||
"count": waterData.count,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
]
|
||
resolver(result)
|
||
}
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
// MARK: - Workout Data Methods
|
||
|
||
@objc
|
||
func getRecentWorkouts(
|
||
_ 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 workoutType = ReadTypes.workoutType
|
||
|
||
// Parse options
|
||
let startDate: Date
|
||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||
startDate = d
|
||
} else {
|
||
// 默认获取最近30天的锻炼记录
|
||
startDate = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
|
||
}
|
||
|
||
let endDate: Date
|
||
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
|
||
endDate = d
|
||
} else {
|
||
endDate = Date()
|
||
}
|
||
|
||
let limit = options["limit"] as? Int ?? 10 // 默认返回最近10条记录
|
||
|
||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
|
||
|
||
let query = HKSampleQuery(sampleType: ReadTypes.workoutType,
|
||
predicate: predicate,
|
||
limit: limit,
|
||
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
|
||
DispatchQueue.main.async {
|
||
if let error = error {
|
||
rejecter("QUERY_ERROR", "Failed to query workouts: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let workoutSamples = samples as? [HKWorkout] else {
|
||
resolver([
|
||
"data": [],
|
||
"count": 0,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let workoutData = workoutSamples.map { workout in
|
||
var workoutDict: [String: Any] = [
|
||
"id": workout.uuid.uuidString,
|
||
"startDate": self?.dateToISOString(workout.startDate) ?? "",
|
||
"endDate": self?.dateToISOString(workout.endDate) ?? "",
|
||
"duration": workout.duration,
|
||
"workoutActivityType": workout.workoutActivityType.rawValue,
|
||
"workoutActivityTypeString": self?.workoutActivityTypeToString(workout.workoutActivityType) ?? "unknown",
|
||
"source": [
|
||
"name": workout.sourceRevision.source.name,
|
||
"bundleIdentifier": workout.sourceRevision.source.bundleIdentifier
|
||
],
|
||
"metadata": workout.metadata ?? [:]
|
||
]
|
||
|
||
// 添加能量消耗信息(如果有)
|
||
if let totalEnergyBurned = workout.totalEnergyBurned {
|
||
workoutDict["totalEnergyBurned"] = totalEnergyBurned.doubleValue(for: HKUnit.kilocalorie())
|
||
}
|
||
|
||
// 添加距离信息(如果有)
|
||
if let totalDistance = workout.totalDistance {
|
||
workoutDict["totalDistance"] = totalDistance.doubleValue(for: HKUnit.meter())
|
||
}
|
||
|
||
// 添加平均心率信息(如果有)
|
||
if let averageHeartRate = workout.metadata?["HKAverageHeartRate"] as? Double {
|
||
workoutDict["averageHeartRate"] = averageHeartRate
|
||
}
|
||
|
||
return workoutDict
|
||
}
|
||
|
||
let result: [String: Any] = [
|
||
"data": workoutData,
|
||
"count": workoutData.count,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
]
|
||
resolver(result)
|
||
}
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
// MARK: - Workout Helper Methods
|
||
|
||
// Normalizes the HealthKit enum case so JS receives a predictable camelCase identifier.
|
||
private func workoutActivityTypeToString(_ workoutActivityType: HKWorkoutActivityType) -> String {
|
||
let description = String(describing: workoutActivityType)
|
||
let prefix = "HKWorkoutActivityType"
|
||
|
||
if description.hasPrefix(prefix) {
|
||
let rawName = description.dropFirst(prefix.count)
|
||
guard let first = rawName.first else {
|
||
return "unknown"
|
||
}
|
||
|
||
let normalized = String(first).lowercased() + rawName.dropFirst()
|
||
return normalized
|
||
}
|
||
|
||
return description.lowercased()
|
||
}
|
||
|
||
// MARK: - Observer Methods
|
||
|
||
private var workoutObserverQuery: HKObserverQuery?
|
||
private var sleepObserverQuery: HKObserverQuery?
|
||
private var hrvObserverQuery: HKObserverQuery?
|
||
|
||
@objc
|
||
func startWorkoutObserver(
|
||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||
rejecter: @escaping RCTPromiseRejectBlock
|
||
) {
|
||
guard HKHealthStore.isHealthDataAvailable() else {
|
||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||
return
|
||
}
|
||
|
||
// 如果已经有观察者在运行,先停止它
|
||
if let existingQuery = workoutObserverQuery {
|
||
healthStore.stop(existingQuery)
|
||
workoutObserverQuery = nil
|
||
}
|
||
|
||
// 创建锻炼数据观察者
|
||
let workoutType = ReadTypes.workoutType
|
||
|
||
workoutObserverQuery = HKObserverQuery(sampleType: workoutType, predicate: nil) { [weak self] (query, completionHandler, error) in
|
||
if let error = error {
|
||
print("Workout observer error: \(error.localizedDescription)")
|
||
completionHandler()
|
||
return
|
||
}
|
||
|
||
print("Workout data updated, sending event to React Native")
|
||
// 发送事件到 React Native
|
||
self?.sendWorkoutUpdateEvent()
|
||
completionHandler()
|
||
}
|
||
|
||
// 启用后台传递
|
||
healthStore.enableBackgroundDelivery(for: workoutType, frequency: .immediate) { (success, error) in
|
||
if let error = error {
|
||
print("Failed to enable background delivery for workouts: \(error.localizedDescription)")
|
||
} else {
|
||
print("Background delivery for workouts enabled successfully")
|
||
}
|
||
}
|
||
|
||
// 执行查询
|
||
healthStore.execute(workoutObserverQuery!)
|
||
|
||
resolver(["success": true])
|
||
}
|
||
|
||
@objc
|
||
func stopWorkoutObserver(
|
||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||
rejecter: @escaping RCTPromiseRejectBlock
|
||
) {
|
||
if let query = workoutObserverQuery {
|
||
healthStore.stop(query)
|
||
workoutObserverQuery = nil
|
||
|
||
// 禁用后台传递
|
||
healthStore.disableBackgroundDelivery(for: ReadTypes.workoutType) { (success, error) in
|
||
if let error = error {
|
||
print("Failed to disable background delivery for workouts: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
resolver(["success": true])
|
||
} else {
|
||
resolver(["success": true]) // 即使没有查询在运行也返回成功
|
||
}
|
||
}
|
||
|
||
private func sendWorkoutUpdateEvent() {
|
||
// 使用 RCTEventEmitter 发送事件
|
||
sendEvent(withName: "workoutUpdate", body: [
|
||
"timestamp": Date().timeIntervalSince1970,
|
||
"type": "workout_completed"
|
||
])
|
||
}
|
||
|
||
// MARK: - Sleep Observer Methods
|
||
|
||
@objc
|
||
func startSleepObserver(
|
||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||
rejecter: @escaping RCTPromiseRejectBlock
|
||
) {
|
||
guard HKHealthStore.isHealthDataAvailable() else {
|
||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||
return
|
||
}
|
||
|
||
// 如果已经有观察者在运行,先停止它
|
||
if let existingQuery = sleepObserverQuery {
|
||
healthStore.stop(existingQuery)
|
||
sleepObserverQuery = nil
|
||
}
|
||
|
||
// 创建睡眠数据观察者
|
||
let sleepType = ReadTypes.sleep
|
||
|
||
sleepObserverQuery = HKObserverQuery(sampleType: sleepType, predicate: nil) { [weak self] (query, completionHandler, error) in
|
||
if let error = error {
|
||
print("Sleep observer error: \(error.localizedDescription)")
|
||
completionHandler()
|
||
return
|
||
}
|
||
|
||
print("Sleep data updated, sending event to React Native")
|
||
// 发送简单的更新事件到 React Native,让 RN 层处理数据分析
|
||
self?.sendSleepUpdateEvent()
|
||
completionHandler()
|
||
}
|
||
|
||
// 启用后台传递
|
||
healthStore.enableBackgroundDelivery(for: sleepType, frequency: .immediate) { (success, error) in
|
||
if let error = error {
|
||
print("Failed to enable background delivery for sleep: \(error.localizedDescription)")
|
||
} else {
|
||
print("Background delivery for sleep enabled successfully")
|
||
}
|
||
}
|
||
|
||
// 执行查询
|
||
healthStore.execute(sleepObserverQuery!)
|
||
|
||
resolver(["success": true])
|
||
}
|
||
|
||
@objc
|
||
func stopSleepObserver(
|
||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||
rejecter: @escaping RCTPromiseRejectBlock
|
||
) {
|
||
if let query = sleepObserverQuery {
|
||
healthStore.stop(query)
|
||
sleepObserverQuery = nil
|
||
|
||
// 禁用后台传递
|
||
healthStore.disableBackgroundDelivery(for: ReadTypes.sleep) { (success, error) in
|
||
if let error = error {
|
||
print("Failed to disable background delivery for sleep: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
resolver(["success": true])
|
||
} else {
|
||
resolver(["success": true]) // 即使没有查询在运行也返回成功
|
||
}
|
||
}
|
||
|
||
/// 发送睡眠更新事件到 React Native(简单通知,不包含分析数据)
|
||
private func sendSleepUpdateEvent() {
|
||
sendEvent(withName: "sleepUpdate", body: [
|
||
"timestamp": Date().timeIntervalSince1970,
|
||
"type": "sleep_data_updated"
|
||
])
|
||
}
|
||
|
||
// MARK: - HRV Observer Methods
|
||
|
||
@objc
|
||
func startHRVObserver(
|
||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||
rejecter: @escaping RCTPromiseRejectBlock
|
||
) {
|
||
guard HKHealthStore.isHealthDataAvailable() else {
|
||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||
return
|
||
}
|
||
|
||
if let existingQuery = hrvObserverQuery {
|
||
healthStore.stop(existingQuery)
|
||
hrvObserverQuery = nil
|
||
}
|
||
|
||
let hrvType = ReadTypes.heartRateVariability
|
||
hrvObserverQuery = HKObserverQuery(sampleType: hrvType, predicate: nil) { [weak self] (_, completionHandler, error) in
|
||
if let error = error {
|
||
print("HRV observer error: \(error.localizedDescription)")
|
||
completionHandler()
|
||
return
|
||
}
|
||
|
||
print("HRV data updated, sending event to React Native")
|
||
self?.sendHRVUpdateEvent()
|
||
completionHandler()
|
||
}
|
||
|
||
healthStore.enableBackgroundDelivery(for: hrvType, frequency: .immediate) { success, error in
|
||
if let error = error {
|
||
print("Failed to enable background delivery for HRV: \(error.localizedDescription)")
|
||
} else if success {
|
||
print("Background delivery for HRV enabled successfully")
|
||
}
|
||
}
|
||
|
||
if let query = hrvObserverQuery {
|
||
healthStore.execute(query)
|
||
}
|
||
|
||
resolver(["success": true])
|
||
}
|
||
|
||
@objc
|
||
func stopHRVObserver(
|
||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||
rejecter: @escaping RCTPromiseRejectBlock
|
||
) {
|
||
if let query = hrvObserverQuery {
|
||
healthStore.stop(query)
|
||
hrvObserverQuery = nil
|
||
|
||
healthStore.disableBackgroundDelivery(for: ReadTypes.heartRateVariability) { success, error in
|
||
if let error = error {
|
||
print("Failed to disable background delivery for HRV: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
|
||
resolver(["success": true])
|
||
}
|
||
|
||
private func sendHRVUpdateEvent() {
|
||
sendEvent(withName: "hrvUpdate", body: [
|
||
"timestamp": Date().timeIntervalSince1970,
|
||
"type": "hrv_data_updated"
|
||
])
|
||
}
|
||
|
||
// MARK: - RCTEventEmitter Overrides
|
||
|
||
override func supportedEvents() -> [String]! {
|
||
return ["workoutUpdate", "sleepUpdate", "hrvUpdate"]
|
||
}
|
||
|
||
override static func requiresMainQueueSetup() -> Bool {
|
||
return true
|
||
}
|
||
|
||
} // end class
|