Files
digital-pilates/ios/OutLive/HealthKitManager.swift
richarjiang f43cfe7ac6 fix(ios): 修复HealthKit类型安全性并优化HRV通知频率
将HealthKit数据类型从强制解包改为可选类型,避免潜在的运行时崩溃。所有数据类型访问现在都通过guard语句进行安全检查,当类型不可用时返回明确的错误信息。同时修复了活动摘要日期计算错误,确保每个摘要使用正确的日期。

HRV压力通知的最小间隔从4小时缩短至2小时,并移除了每日一次的限制,允许更及时的压力状态提醒。

BREAKING CHANGE: HealthKit数据类型API现在可能返回"TYPE_NOT_AVAILABLE"错误,调用方需要处理此新错误类型
2025-11-19 09:23:42 +08:00

2056 lines
68 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 var sleep: HKCategoryType? {
return HKObjectType.categoryType(forIdentifier: .sleepAnalysis)
}
static var stepCount: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .stepCount)
}
static var heartRate: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .heartRate)
}
static var heartRateVariability: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
}
static var activeEnergyBurned: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
}
static var basalEnergyBurned: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
}
static var appleExerciseTime: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .appleExerciseTime)
}
static var appleStandTime: HKCategoryType? {
return HKObjectType.categoryType(forIdentifier: .appleStandHour)
}
static var oxygenSaturation: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .oxygenSaturation)
}
static var activitySummary: HKActivitySummaryType {
return HKObjectType.activitySummaryType()
}
static var dietaryWater: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .dietaryWater)
}
static var workout: HKWorkoutType {
return HKObjectType.workoutType()
}
static var all: Set<HKObjectType> {
var types: Set<HKObjectType> = [activitySummary, workout]
if let sleep = sleep { types.insert(sleep) }
if let stepCount = stepCount { types.insert(stepCount) }
if let heartRate = heartRate { types.insert(heartRate) }
if let heartRateVariability = heartRateVariability { types.insert(heartRateVariability) }
if let activeEnergyBurned = activeEnergyBurned { types.insert(activeEnergyBurned) }
if let basalEnergyBurned = basalEnergyBurned { types.insert(basalEnergyBurned) }
if let appleExerciseTime = appleExerciseTime { types.insert(appleExerciseTime) }
if let appleStandTime = appleStandTime { types.insert(appleStandTime) }
if let oxygenSaturation = oxygenSaturation { types.insert(oxygenSaturation) }
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
return types
}
static var workoutType: HKWorkoutType {
return HKObjectType.workoutType()
}
}
/// For writing (if needed)
private struct WriteTypes {
static var bodyMass: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .bodyMass)
}
static var dietaryWater: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .dietaryWater)
}
static var all: Set<HKSampleType> {
var types: Set<HKSampleType> = []
if let bodyMass = bodyMass { types.insert(bodyMass) }
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
return types
}
}
// 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
}
guard let sleepType = ReadTypes.sleep else {
rejecter("TYPE_NOT_AVAILABLE", "Sleep type is not available", nil)
return
}
// 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()) ?? 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
}
guard let activeEnergyType = ReadTypes.activeEnergyBurned else {
rejecter("TYPE_NOT_AVAILABLE", "Active energy type is not available", 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 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
}
guard let basalEnergyType = ReadTypes.basalEnergyBurned else {
rejecter("TYPE_NOT_AVAILABLE", "Basal energy type is not available", 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()
}
// 使 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
}
guard let exerciseType = ReadTypes.appleExerciseTime else {
rejecter("TYPE_NOT_AVAILABLE", "Exercise time type is not available", 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 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
}
guard let standType = ReadTypes.appleStandTime else {
rejecter("TYPE_NOT_AVAILABLE", "Stand time type is not available", 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 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.enumerated().map { (index, summary) in
// summary
let summaryDate = calendar.date(byAdding: .day, value: index, to: startDate) ?? startDate
let summaryDateComponents = calendar.dateComponents([.day, .month, .year], from: summaryDate)
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
}
guard let hrvType = ReadTypes.heartRateVariability else {
rejecter("TYPE_NOT_AVAILABLE", "HRV type is not available", 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 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
}
guard let oxygenType = ReadTypes.oxygenSaturation else {
rejecter("TYPE_NOT_AVAILABLE", "Oxygen saturation type is not available", 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 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
}
guard let heartRateType = ReadTypes.heartRate else {
rejecter("TYPE_NOT_AVAILABLE", "Heart rate type is not available", 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 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
}
guard let stepType = ReadTypes.stepCount else {
rejecter("TYPE_NOT_AVAILABLE", "Step count type is not available", 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()
}
// 使 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
}
guard let stepType = ReadTypes.stepCount else {
rejecter("TYPE_NOT_AVAILABLE", "Step count type is not available", 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()
}
// 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
}
guard let activeEnergyType = ReadTypes.activeEnergyBurned else {
rejecter("TYPE_NOT_AVAILABLE", "Active energy type is not available", 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
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
}
guard let exerciseType = ReadTypes.appleExerciseTime else {
rejecter("TYPE_NOT_AVAILABLE", "Exercise time type is not available", 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
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
}
guard let standType = ReadTypes.appleStandTime else {
rejecter("TYPE_NOT_AVAILABLE", "Stand time type is not available", 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
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 {
guard let dayStart = calendar.startOfDay(for: startDate) as Date?,
let hourStart = calendar.date(byAdding: .hour, value: hour, to: dayStart),
let hourEnd = calendar.date(byAdding: .hour, value: hour + 1, to: dayStart) else {
continue
}
//
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()
}
guard let waterType = WriteTypes.dietaryWater else {
rejecter("TYPE_NOT_AVAILABLE", "Water intake type is not available", nil)
return
}
// 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
}
guard let waterType = ReadTypes.dietaryWater else {
rejecter("TYPE_NOT_AVAILABLE", "Water intake type is not available", nil)
return
}
// 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()) ?? 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
}
//
guard let sleepType = ReadTypes.sleep else {
rejecter("TYPE_NOT_AVAILABLE", "Sleep type is not available", nil)
return
}
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")
}
}
//
guard let query = sleepObserverQuery else {
rejecter("QUERY_ERROR", "Failed to create sleep observer", nil)
return
}
healthStore.execute(query)
resolver(["success": true])
}
@objc
func stopSleepObserver(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
if let query = sleepObserverQuery {
healthStore.stop(query)
sleepObserverQuery = nil
//
if let sleepType = ReadTypes.sleep {
healthStore.disableBackgroundDelivery(for: sleepType) { (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
}
guard let hrvType = ReadTypes.heartRateVariability else {
rejecter("TYPE_NOT_AVAILABLE", "HRV type is not available", nil)
return
}
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()
}
if let hrvType = ReadTypes.heartRateVariability {
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")
}
}
}
guard let query = hrvObserverQuery else {
rejecter("QUERY_ERROR", "Failed to create HRV observer", nil)
return
}
healthStore.execute(query)
resolver(["success": true])
}
@objc
func stopHRVObserver(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
if let query = hrvObserverQuery {
healthStore.stop(query)
hrvObserverQuery = nil
if let hrvType = ReadTypes.heartRateVariability {
healthStore.disableBackgroundDelivery(for: hrvType) { 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