Files
digital-pilates/ios/OutLive/HealthKitManager.swift
richarjiang 7bd0b5fc52 # 方案总结
基于提供的 Git diff,我将生成以下 conventional commit message:

## 变更分析:

1. **核心功能**:
   - 新增睡眠监控服务(`services/sleepMonitor.ts`)
   - 新增睡眠通知服务(`services/sleepNotificationService.ts`)
   - iOS 原生端增加睡眠观察者方法

2. **应用启动优化**:
   - 重构 `app/_layout.tsx` 中的初始化流程,按优先级分阶段加载服务

3. **药品功能改进**:
   - 优化语音识别交互(实时预览、可取消)
   - Widget 增加 URL scheme 支持

4. **路由配置**:
   - 新增药品管理路由常量

## 提交信息类型:
- **主类型**:`feat` (新增睡眠监控功能)
- **作用域**:`health` (健康相关功能)

---

请确认方案后,我将生成最终的 commit message。

---

**最终 Commit Message:**

feat(health): 添加睡眠监控和通知服务,优化应用启动流程

- 新增睡眠监控服务,支持实时监听 HealthKit 睡眠数据更新
- 实现睡眠质量分析算法,计算睡眠评分和各阶段占比
- 新增睡眠通知服务,分析完成后自动推送质量评估和建议
- iOS 原生端实现睡眠数据观察者,支持后台数据传递
- 重构应用启动初始化流程,按优先级分阶段加载服务(关键/次要/后台/空闲)
- 优化药品录入页面语音识别交互,支持实时预览和取消操作
- 药品 Widget 增加 deeplink 支持,点击跳转到应用
- 新增药品管理路由常量配置
2025-11-14 10:52:26 +08:00

1875 lines
62 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 let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
static let stepCount = HKObjectType.quantityType(forIdentifier: .stepCount)!
static let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate)!
static let heartRateVariability = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!
static let activeEnergyBurned = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!
static let basalEnergyBurned = HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)!
static let appleExerciseTime = HKObjectType.quantityType(forIdentifier: .appleExerciseTime)!
static let appleStandTime = HKObjectType.categoryType(forIdentifier: .appleStandHour)!
static let oxygenSaturation = HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!
static let activitySummary = HKObjectType.activitySummaryType()
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
static let workout = HKObjectType.workoutType()
static var all: Set<HKObjectType> {
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater, workout]
}
static var workoutType: HKWorkoutType {
return HKObjectType.workoutType()
}
}
/// For writing (if needed)
private struct WriteTypes {
static let bodyMass = HKObjectType.quantityType(forIdentifier: .bodyMass)!
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
static var all: Set<HKSampleType> {
return [bodyMass, dietaryWater]
}
}
// MARK: - Authorization
@objc
func requestAuthorization(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
healthStore.requestAuthorization(toShare: WriteTypes.all, read: ReadTypes.all) { [weak self] (success, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("AUTHORIZATION_ERROR", "Failed to authorize HealthKit: \(error.localizedDescription)", error)
return
}
if success {
// We don't rely purely on authorizationStatus(for:) for quantity types, see below.
var permissions: [String: Any] = [:]
for type in ReadTypes.all {
if type == ReadTypes.sleep {
// For categoryType, authorizationStatus is meaningful
let status = self?.healthStore.authorizationStatus(for: type) ?? .notDetermined
permissions[type.identifier] = self?.authorizationStatusToString(status)
} else {
// For quantity types, authorizationStatus isn't reliable mark as unknownUntilQueried
permissions[type.identifier] = "unknownUntilQueried"
}
}
// Return success immediately
let result: [String: Any] = [
"success": true,
"permissions": permissions
]
resolver(result)
} else {
rejecter("AUTHORIZATION_DENIED", "User denied HealthKit authorization", nil)
}
}
}
}
// MARK: - Current Authorization Status Check
@objc
func getAuthorizationStatus(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
var permissions: [String: Any] = [:]
for type in ReadTypes.all {
if type == ReadTypes.sleep {
let status = healthStore.authorizationStatus(for: type)
permissions[type.identifier] = authorizationStatusToString(status)
} else {
// For quantity types, we cannot rely on authorizationStatus; attempt a small read test (optional)
// But to keep simple, we mark as unknownUntilQueried
permissions[type.identifier] = "unknownUntilQueried"
}
}
let result: [String: Any] = [
"success": true,
"permissions": permissions
]
resolver(result)
}
// MARK: - Sleep Data
@objc
func getSleepData(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
// Parse options
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(sampleType: sleepType,
predicate: predicate,
limit: limit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query sleep data: \(error.localizedDescription)", error)
return
}
guard let sleepSamples = samples as? [HKCategorySample] else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let sleepData = sleepSamples.map { sample in
[
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": sample.value,
"categoryType": self?.sleepValueToString(sample.value) ?? "unknown",
"duration": sample.endDate.timeIntervalSince(sample.startDate),
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
],
"metadata": sample.metadata ?? [:]
] as [String : Any]
}
let result: [String: Any] = [
"data": sleepData,
"count": sleepData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
// MARK: - Fitness Data Methods
@objc
func getActiveEnergyBurned(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let activeEnergyType = ReadTypes.activeEnergyBurned
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(sampleType: activeEnergyType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query active energy: \(error.localizedDescription)", error)
return
}
guard let energySamples = samples as? [HKQuantitySample] else {
resolver([
"data": [],
"totalValue": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let energyData = energySamples.map { sample in
[
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": sample.quantity.doubleValue(for: HKUnit.kilocalorie()),
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
],
"metadata": sample.metadata ?? [:]
] as [String : Any]
}
let totalValue = energySamples.reduce(0.0) { total, sample in
return total + sample.quantity.doubleValue(for: HKUnit.kilocalorie())
}
let result: [String: Any] = [
"data": energyData,
"totalValue": totalValue,
"count": energyData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getBasalEnergyBurned(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let basalEnergyType = ReadTypes.basalEnergyBurned
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
// 使 HKStatisticsQuery HKSampleQuery
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let query = HKStatisticsQuery(quantityType: basalEnergyType,
quantitySamplePredicate: predicate,
options: .cumulativeSum) { [weak self] (query, statistics, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query basal energy: \(error.localizedDescription)", error)
return
}
guard let statistics = statistics else {
resolver([
"totalValue": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let totalValue = statistics.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie()) ?? 0
let result: [String: Any] = [
"totalValue": totalValue,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getAppleExerciseTime(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let exerciseType = ReadTypes.appleExerciseTime
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(sampleType: exerciseType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query exercise time: \(error.localizedDescription)", error)
return
}
guard let exerciseSamples = samples as? [HKQuantitySample] else {
resolver([
"data": [],
"totalValue": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let exerciseData = exerciseSamples.map { sample in
[
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": sample.quantity.doubleValue(for: HKUnit.minute()),
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
],
"metadata": sample.metadata ?? [:]
] as [String : Any]
}
let totalValue = exerciseSamples.reduce(0.0) { total, sample in
return total + sample.quantity.doubleValue(for: HKUnit.minute())
}
let result: [String: Any] = [
"data": exerciseData,
"totalValue": totalValue,
"count": exerciseData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getAppleStandTime(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let standType = ReadTypes.appleStandTime
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(sampleType: standType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query stand time: \(error.localizedDescription)", error)
return
}
guard let standSamples = samples as? [HKCategorySample] else {
resolver([
"data": [],
"totalHours": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let standData = standSamples.map { sample in
[
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": sample.value,
"categoryType": self?.standValueToString(sample.value) ?? "unknown",
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
],
"metadata": sample.metadata ?? [:]
] as [String : Any]
}
// Count hours where user stood (value = 0 means stood, value = 1 means idle)
let standHours = standSamples.filter { $0.value == HKCategoryValueAppleStandHour.stood.rawValue }.count
let result: [String: Any] = [
"data": standData,
"totalHours": standHours,
"count": standData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getActivitySummary(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let calendar = Calendar.current
var startDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate)
var endDateComponents = calendar.dateComponents([.day, .month, .year], from: endDate)
// HealthKit requires DateComponents to have a calendar
startDateComponents.calendar = calendar
endDateComponents.calendar = calendar
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: startDateComponents, end: endDateComponents)
let query = HKActivitySummaryQuery(predicate: predicate) { (query, summaries, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query activity summary: \(error.localizedDescription)", error)
return
}
guard let summaries = summaries, !summaries.isEmpty else {
resolver([])
return
}
let summaryData = summaries.map { summary in
// DateComponents
let summaryDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate)
return [
"activeEnergyBurned": summary.activeEnergyBurned.doubleValue(for: HKUnit.kilocalorie()),
"activeEnergyBurnedGoal": summary.activeEnergyBurnedGoal.doubleValue(for: HKUnit.kilocalorie()),
"appleExerciseTime": summary.appleExerciseTime.doubleValue(for: HKUnit.minute()),
"appleExerciseTimeGoal": summary.appleExerciseTimeGoal.doubleValue(for: HKUnit.minute()),
"appleStandHours": summary.appleStandHours.doubleValue(for: HKUnit.count()),
"appleStandHoursGoal": summary.appleStandHoursGoal.doubleValue(for: HKUnit.count()),
"dateComponents": [
"day": summaryDateComponents.day ?? 0,
"month": summaryDateComponents.month ?? 0,
"year": summaryDateComponents.year ?? 0
]
] as [String : Any]
}
resolver(summaryData)
}
}
healthStore.execute(query)
}
@objc
func getHeartRateVariabilitySamples(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let hrvType = ReadTypes.heartRateVariability
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
// 50
let limit = options["limit"] as? Int ?? 50
let query = HKSampleQuery(sampleType: hrvType,
predicate: predicate,
limit: limit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query HRV: \(error.localizedDescription)", error)
return
}
guard let hrvSamples = samples as? [HKQuantitySample] else {
resolver([
"data": [],
"count": 0,
"bestQualityValue": nil,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let hrvData = hrvSamples.map { sample in
let hrvValueMs = sample.quantity.doubleValue(for: HKUnit.secondUnit(with: .milli))
let sourceBundle = sample.sourceRevision.source.bundleIdentifier
return [
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": hrvValueMs,
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sourceBundle
],
"metadata": sample.metadata ?? [:],
"isManualMeasurement": self?.isManualHRVMeasurement(sourceBundle: sourceBundle, metadata: sample.metadata) ?? false,
"qualityScore": self?.calculateHRVQualityScore(value: hrvValueMs, sourceBundle: sourceBundle, metadata: sample.metadata) ?? 0
] as [String : Any]
}
// HRV
let bestQualityValue = self?.getBestQualityHRVValue(from: hrvData)
let result: [String: Any] = [
"data": hrvData,
"count": hrvData.count,
"bestQualityValue": bestQualityValue ?? NSNull(),
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getOxygenSaturationSamples(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let oxygenType = ReadTypes.oxygenSaturation
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
let query = HKSampleQuery(sampleType: oxygenType,
predicate: predicate,
limit: limit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query oxygen saturation: \(error.localizedDescription)", error)
return
}
guard let oxygenSamples = samples as? [HKQuantitySample] else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let oxygenData = oxygenSamples.map { sample in
[
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": sample.quantity.doubleValue(for: HKUnit.percent()),
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
],
"metadata": sample.metadata ?? [:]
] as [String : Any]
}
let result: [String: Any] = [
"data": oxygenData,
"count": oxygenData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getHeartRateSamples(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let heartRateType = ReadTypes.heartRate
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
let query = HKSampleQuery(sampleType: heartRateType,
predicate: predicate,
limit: limit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query heart rate: \(error.localizedDescription)", error)
return
}
guard let heartRateSamples = samples as? [HKQuantitySample] else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let heartRateData = heartRateSamples.map { sample in
[
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": sample.quantity.doubleValue(for: HKUnit(from: "count/min")),
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
],
"metadata": sample.metadata ?? [:]
] as [String : Any]
}
let result: [String: Any] = [
"data": heartRateData,
"count": heartRateData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getStepCount(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let stepType = ReadTypes.stepCount
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
// 使 HKStatisticsQuery HKSampleQuery
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let query = HKStatisticsQuery(quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum) { [weak self] (query, statistics, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query step count: \(error.localizedDescription)", error)
return
}
guard let statistics = statistics else {
resolver([
"totalValue": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let totalValue = statistics.sumQuantity()?.doubleValue(for: HKUnit.count()) ?? 0
let result: [String: Any] = [
"totalValue": totalValue,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getDailyStepCountSamples(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let stepType = ReadTypes.stepCount
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
// Create date components for statistics collection interval (hourly)
let calendar = Calendar.current
let anchorDate = calendar.startOfDay(for: startDate)
let interval = DateComponents(hour: 1)
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let query = HKStatisticsCollectionQuery(
quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: anchorDate,
intervalComponents: interval
)
query.initialResultsHandler = { [weak self] query, results, error in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query hourly step count: \(error.localizedDescription)", error)
return
}
guard let results = results else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
var hourlyData: [[String: Any]] = []
results.enumerateStatistics(from: startDate, to: endDate) { statistics, stop in
let value = statistics.sumQuantity()?.doubleValue(for: HKUnit.count()) ?? 0
let hourData: [String: Any] = [
"startDate": self?.dateToISOString(statistics.startDate) ?? "",
"endDate": self?.dateToISOString(statistics.endDate) ?? "",
"value": value,
"hour": calendar.component(.hour, from: statistics.startDate)
]
hourlyData.append(hourData)
}
let result: [String: Any] = [
"data": hourlyData,
"count": hourlyData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
// MARK: - Helper Methods
private func authorizationStatusToString(_ status: HKAuthorizationStatus) -> String {
switch status {
case .notDetermined:
return "notDetermined"
case .sharingDenied:
return "denied"
case .sharingAuthorized:
return "authorized"
@unknown default:
return "unknown"
}
}
private func sleepValueToString(_ value: Int) -> String {
switch value {
case HKCategoryValueSleepAnalysis.inBed.rawValue:
return "inBed"
case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue:
return "asleep"
case HKCategoryValueSleepAnalysis.awake.rawValue:
return "awake"
case HKCategoryValueSleepAnalysis.asleepCore.rawValue:
return "core"
case HKCategoryValueSleepAnalysis.asleepDeep.rawValue:
return "deep"
case HKCategoryValueSleepAnalysis.asleepREM.rawValue:
return "rem"
default:
return "unknown"
}
}
private func standValueToString(_ value: Int) -> String {
switch value {
case HKCategoryValueAppleStandHour.stood.rawValue:
return "stood"
case HKCategoryValueAppleStandHour.idle.rawValue:
return "idle"
default:
return "unknown"
}
}
private func parseDate(from string: String?) -> Date? {
guard let string = string else { return nil }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.date(from: string)
}
private func dateToISOString(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: date)
}
// MARK: - HRV Quality Analysis Methods
/// /HRV
private func isManualHRVMeasurement(sourceBundle: String, metadata: [String: Any]?) -> Bool {
//
if sourceBundle.contains("Breathe") || sourceBundle.contains("breathe") {
return true
}
// HRV
let manualHRVApps = ["HRV4Training", "EliteHRV", "HRVLogger", "Stress & Anxiety Companion"]
if manualHRVApps.contains(where: { sourceBundle.contains($0) }) {
return true
}
//
if let metadata = metadata {
if let wasUserEntered = metadata[HKMetadataKeyWasUserEntered] as? Bool, wasUserEntered {
return true
}
}
return false
}
/// HRV
private func calculateHRVQualityScore(value: Double, sourceBundle: String, metadata: [String: Any]?) -> Int {
var score = 0
// 1. (0-40)
if value >= 10 && value <= 100 {
// SDNN
if value >= 18 && value <= 76 {
score += 40 //
} else if value >= 10 && value <= 18 {
score += 30 //
} else if value >= 76 && value <= 100 {
score += 35 //
}
} else if value > 0 && value < 10 {
score += 10 //
}
// 2. (0-35)
if isManualHRVMeasurement(sourceBundle: sourceBundle, metadata: metadata) {
score += 35 //
} else if sourceBundle.contains("com.apple.health") {
score += 20 //
} else if sourceBundle.contains("Watch") {
score += 25 // Apple Watch
} else {
score += 15 //
}
// 3. (0-25)
if let metadata = metadata {
var metadataScore = 0
//
if let wasUserEntered = metadata[HKMetadataKeyWasUserEntered] as? Bool, wasUserEntered {
metadataScore += 15
}
//
if metadata[HKMetadataKeyDeviceName] != nil {
metadataScore += 5
}
//
if metadata[HKMetadataKeyHeartRateMotionContext] != nil {
metadataScore += 5
}
score += metadataScore
}
return min(score, 100) // 100
}
/// HRV
private func getBestQualityHRVValue(from hrvData: [[String: Any]]) -> Double? {
guard !hrvData.isEmpty else { return nil }
//
let sortedData = hrvData.sorted { item1, item2 in
let quality1 = item1["qualityScore"] as? Int ?? 0
let quality2 = item2["qualityScore"] as? Int ?? 0
let isManual1 = item1["isManualMeasurement"] as? Bool ?? false
let isManual2 = item2["isManualMeasurement"] as? Bool ?? false
// > >
if isManual1 && !isManual2 {
return true
} else if !isManual1 && isManual2 {
return false
} else if quality1 != quality2 {
return quality1 > quality2
} else {
//
let date1 = item1["endDate"] as? String ?? ""
let date2 = item2["endDate"] as? String ?? ""
return date1 > date2
}
}
//
if let bestValue = sortedData.first?["value"] as? Double {
//
if bestValue >= 5 && bestValue <= 150 {
return bestValue
}
}
//
for data in sortedData {
if let value = data["value"] as? Double, value >= 10 && value <= 100 {
return value
}
}
//
return sortedData.first?["value"] as? Double
}
// MARK: - Hourly Data Methods
@objc
func getHourlyActiveEnergyBurned(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let activeEnergyType = ReadTypes.activeEnergyBurned
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let calendar = Calendar.current
let anchorDate = calendar.startOfDay(for: startDate)
let interval = DateComponents(hour: 1)
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let query = HKStatisticsCollectionQuery(
quantityType: activeEnergyType,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: anchorDate,
intervalComponents: interval
)
query.initialResultsHandler = { [weak self] query, results, error in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query hourly active energy: \(error.localizedDescription)", error)
return
}
guard let results = results else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
var hourlyData: [[String: Any]] = []
results.enumerateStatistics(from: startDate, to: endDate) { statistics, stop in
let value = statistics.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie()) ?? 0
let hourData: [String: Any] = [
"startDate": self?.dateToISOString(statistics.startDate) ?? "",
"endDate": self?.dateToISOString(statistics.endDate) ?? "",
"value": value,
"hour": calendar.component(.hour, from: statistics.startDate)
]
hourlyData.append(hourData)
}
let result: [String: Any] = [
"data": hourlyData,
"count": hourlyData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getHourlyExerciseTime(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let exerciseType = ReadTypes.appleExerciseTime
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let calendar = Calendar.current
let anchorDate = calendar.startOfDay(for: startDate)
let interval = DateComponents(hour: 1)
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let query = HKStatisticsCollectionQuery(
quantityType: exerciseType,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: anchorDate,
intervalComponents: interval
)
query.initialResultsHandler = { [weak self] query, results, error in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query hourly exercise time: \(error.localizedDescription)", error)
return
}
guard let results = results else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
var hourlyData: [[String: Any]] = []
results.enumerateStatistics(from: startDate, to: endDate) { statistics, stop in
let value = statistics.sumQuantity()?.doubleValue(for: HKUnit.minute()) ?? 0
let hourData: [String: Any] = [
"startDate": self?.dateToISOString(statistics.startDate) ?? "",
"endDate": self?.dateToISOString(statistics.endDate) ?? "",
"value": value,
"hour": calendar.component(.hour, from: statistics.startDate)
]
hourlyData.append(hourData)
}
let result: [String: Any] = [
"data": hourlyData,
"count": hourlyData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getHourlyStandHours(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let standType = ReadTypes.appleStandTime
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let calendar = Calendar.current
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let query = HKSampleQuery(sampleType: standType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query hourly stand hours: \(error.localizedDescription)", error)
return
}
guard let standSamples = samples as? [HKCategorySample] else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
// 24
var hourlyData: [[String: Any]] = []
//
for hour in 0..<24 {
let hourStart = calendar.date(byAdding: .hour, value: hour, to: calendar.startOfDay(for: startDate))!
let hourEnd = calendar.date(byAdding: .hour, value: hour + 1, to: calendar.startOfDay(for: startDate))!
//
let standSamplesInHour = standSamples.filter { sample in
return sample.startDate >= hourStart && sample.startDate < hourEnd &&
sample.value == HKCategoryValueAppleStandHour.stood.rawValue
}
let hasStood = standSamplesInHour.count > 0 ? 1 : 0
let hourData: [String: Any] = [
"startDate": self?.dateToISOString(hourStart) ?? "",
"endDate": self?.dateToISOString(hourEnd) ?? "",
"value": hasStood,
"hour": hour
]
hourlyData.append(hourData)
}
let result: [String: Any] = [
"data": hourlyData,
"count": hourlyData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
// MARK: - Water Intake Methods
@objc
func saveWaterIntakeToHealthKit(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
// Parse parameters
guard let amount = options["amount"] as? Double else {
rejecter("INVALID_PARAMETERS", "Amount is required", nil)
return
}
let recordedAt: Date
if let recordedAtString = options["recordedAt"] as? String,
let date = parseDate(from: recordedAtString) {
recordedAt = date
} else {
recordedAt = Date()
}
let waterType = WriteTypes.dietaryWater
// Create quantity sample
let quantity = HKQuantity(unit: HKUnit.literUnit(with: .milli), doubleValue: amount)
let sample = HKQuantitySample(
type: waterType,
quantity: quantity,
start: recordedAt,
end: recordedAt,
metadata: nil
)
// Save to HealthKit
healthStore.save(sample) { [weak self] (success, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("SAVE_ERROR", "Failed to save water intake: \(error.localizedDescription)", error)
return
}
if success {
let result: [String: Any] = [
"success": true,
"amount": amount,
"recordedAt": self?.dateToISOString(recordedAt) ?? ""
]
resolver(result)
} else {
rejecter("SAVE_FAILED", "Failed to save water intake", nil)
}
}
}
}
@objc
func getWaterIntakeFromHealthKit(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let waterType = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
// Parse date range
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(sampleType: waterType,
predicate: predicate,
limit: limit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query water intake: \(error.localizedDescription)", error)
return
}
guard let waterSamples = samples as? [HKQuantitySample] else {
resolver([
"data": [],
"totalAmount": 0,
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let waterData = waterSamples.map { sample in
[
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": sample.quantity.doubleValue(for: HKUnit.literUnit(with: .milli)),
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
],
"metadata": sample.metadata ?? [:]
] as [String : Any]
}
let totalAmount = waterSamples.reduce(0.0) { total, sample in
return total + sample.quantity.doubleValue(for: HKUnit.literUnit(with: .milli))
}
let result: [String: Any] = [
"data": waterData,
"totalAmount": totalAmount,
"count": waterData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
// MARK: - Workout Data Methods
@objc
func getRecentWorkouts(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let workoutType = ReadTypes.workoutType
// Parse options
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
// 30
startDate = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let limit = options["limit"] as? Int ?? 10 // 10
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
let query = HKSampleQuery(sampleType: ReadTypes.workoutType,
predicate: predicate,
limit: limit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query workouts: \(error.localizedDescription)", error)
return
}
guard let workoutSamples = samples as? [HKWorkout] else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let workoutData = workoutSamples.map { workout in
var workoutDict: [String: Any] = [
"id": workout.uuid.uuidString,
"startDate": self?.dateToISOString(workout.startDate) ?? "",
"endDate": self?.dateToISOString(workout.endDate) ?? "",
"duration": workout.duration,
"workoutActivityType": workout.workoutActivityType.rawValue,
"workoutActivityTypeString": self?.workoutActivityTypeToString(workout.workoutActivityType) ?? "unknown",
"source": [
"name": workout.sourceRevision.source.name,
"bundleIdentifier": workout.sourceRevision.source.bundleIdentifier
],
"metadata": workout.metadata ?? [:]
]
//
if let totalEnergyBurned = workout.totalEnergyBurned {
workoutDict["totalEnergyBurned"] = totalEnergyBurned.doubleValue(for: HKUnit.kilocalorie())
}
//
if let totalDistance = workout.totalDistance {
workoutDict["totalDistance"] = totalDistance.doubleValue(for: HKUnit.meter())
}
//
if let averageHeartRate = workout.metadata?["HKAverageHeartRate"] as? Double {
workoutDict["averageHeartRate"] = averageHeartRate
}
return workoutDict
}
let result: [String: Any] = [
"data": workoutData,
"count": workoutData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
// MARK: - Workout Helper Methods
// Normalizes the HealthKit enum case so JS receives a predictable camelCase identifier.
private func workoutActivityTypeToString(_ workoutActivityType: HKWorkoutActivityType) -> String {
let description = String(describing: workoutActivityType)
let prefix = "HKWorkoutActivityType"
if description.hasPrefix(prefix) {
let rawName = description.dropFirst(prefix.count)
guard let first = rawName.first else {
return "unknown"
}
let normalized = String(first).lowercased() + rawName.dropFirst()
return normalized
}
return description.lowercased()
}
// MARK: - Observer Methods
private var workoutObserverQuery: HKObserverQuery?
private var sleepObserverQuery: HKObserverQuery?
@objc
func startWorkoutObserver(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
//
if let existingQuery = workoutObserverQuery {
healthStore.stop(existingQuery)
workoutObserverQuery = nil
}
//
let workoutType = ReadTypes.workoutType
workoutObserverQuery = HKObserverQuery(sampleType: workoutType, predicate: nil) { [weak self] (query, completionHandler, error) in
if let error = error {
print("Workout observer error: \(error.localizedDescription)")
completionHandler()
return
}
print("Workout data updated, sending event to React Native")
// React Native
self?.sendWorkoutUpdateEvent()
completionHandler()
}
//
healthStore.enableBackgroundDelivery(for: workoutType, frequency: .immediate) { (success, error) in
if let error = error {
print("Failed to enable background delivery for workouts: \(error.localizedDescription)")
} else {
print("Background delivery for workouts enabled successfully")
}
}
//
healthStore.execute(workoutObserverQuery!)
resolver(["success": true])
}
@objc
func stopWorkoutObserver(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
if let query = workoutObserverQuery {
healthStore.stop(query)
workoutObserverQuery = nil
//
healthStore.disableBackgroundDelivery(for: ReadTypes.workoutType) { (success, error) in
if let error = error {
print("Failed to disable background delivery for workouts: \(error.localizedDescription)")
}
}
resolver(["success": true])
} else {
resolver(["success": true]) // 使
}
}
private func sendWorkoutUpdateEvent() {
// 使 RCTEventEmitter
sendEvent(withName: "workoutUpdate", body: [
"timestamp": Date().timeIntervalSince1970,
"type": "workout_completed"
])
}
// MARK: - Sleep Observer Methods
@objc
func startSleepObserver(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
//
if let existingQuery = sleepObserverQuery {
healthStore.stop(existingQuery)
sleepObserverQuery = nil
}
//
let sleepType = ReadTypes.sleep
sleepObserverQuery = HKObserverQuery(sampleType: sleepType, predicate: nil) { [weak self] (query, completionHandler, error) in
if let error = error {
print("Sleep observer error: \(error.localizedDescription)")
completionHandler()
return
}
print("Sleep data updated, sending event to React Native")
// React Native RN
self?.sendSleepUpdateEvent()
completionHandler()
}
//
healthStore.enableBackgroundDelivery(for: sleepType, frequency: .immediate) { (success, error) in
if let error = error {
print("Failed to enable background delivery for sleep: \(error.localizedDescription)")
} else {
print("Background delivery for sleep enabled successfully")
}
}
//
healthStore.execute(sleepObserverQuery!)
resolver(["success": true])
}
@objc
func stopSleepObserver(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
if let query = sleepObserverQuery {
healthStore.stop(query)
sleepObserverQuery = nil
//
healthStore.disableBackgroundDelivery(for: ReadTypes.sleep) { (success, error) in
if let error = error {
print("Failed to disable background delivery for sleep: \(error.localizedDescription)")
}
}
resolver(["success": true])
} else {
resolver(["success": true]) // 使
}
}
/// React Native
private func sendSleepUpdateEvent() {
sendEvent(withName: "sleepUpdate", body: [
"timestamp": Date().timeIntervalSince1970,
"type": "sleep_data_updated"
])
}
// MARK: - RCTEventEmitter Overrides
override func supportedEvents() -> [String]! {
return ["workoutUpdate", "sleepUpdate"]
}
override static func requiresMainQueueSetup() -> Bool {
return true
}
} // end class