// // HealthKitManager.swift // digitalpilates // // Updated module for HealthKit authorization and sleep data access. // import Foundation import React import HealthKit @objc(HealthKitManager) class HealthKitManager: NSObject, RCTBridgeModule { private let healthStore = HKHealthStore() static func moduleName() -> String! { return "HealthKitManager" } static func requiresMainQueueSetup() -> Bool { return true } // 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 { 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 { 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() } } // end class