feat(healthkit): 实现HealthKit与服务端的双向数据同步,包括身高、体重和出生日期的获取与保存

This commit is contained in:
richarjiang
2025-11-19 15:42:50 +08:00
parent dc205ad56e
commit 6039d0a778
8 changed files with 1029 additions and 8 deletions

View File

@@ -59,9 +59,18 @@ class HealthKitManager: RCTEventEmitter {
static var workout: HKWorkoutType {
return HKObjectType.workoutType()
}
static var height: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .height)
}
static var bodyMass: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .bodyMass)
}
static var dateOfBirth: HKCharacteristicType {
return HKObjectType.characteristicType(forIdentifier: .dateOfBirth)!
}
static var all: Set<HKObjectType> {
var types: Set<HKObjectType> = [activitySummary, workout]
var types: Set<HKObjectType> = [activitySummary, workout, dateOfBirth]
if let sleep = sleep { types.insert(sleep) }
if let stepCount = stepCount { types.insert(stepCount) }
if let heartRate = heartRate { types.insert(heartRate) }
@@ -72,6 +81,8 @@ class HealthKitManager: RCTEventEmitter {
if let appleStandTime = appleStandTime { types.insert(appleStandTime) }
if let oxygenSaturation = oxygenSaturation { types.insert(oxygenSaturation) }
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
if let height = height { types.insert(height) }
if let bodyMass = bodyMass { types.insert(bodyMass) }
return types
}
@@ -85,6 +96,9 @@ class HealthKitManager: RCTEventEmitter {
static var bodyMass: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .bodyMass)
}
static var height: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .height)
}
static var dietaryWater: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .dietaryWater)
}
@@ -101,6 +115,7 @@ class HealthKitManager: RCTEventEmitter {
static var all: Set<HKSampleType> {
var types: Set<HKSampleType> = []
if let bodyMass = bodyMass { types.insert(bodyMass) }
if let height = height { types.insert(height) }
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
if let dietaryProtein = dietaryProtein { types.insert(dietaryProtein) }
if let dietaryFatTotal = dietaryFatTotal { types.insert(dietaryFatTotal) }
@@ -2242,6 +2257,297 @@ private func sendHRVUpdateEvent() {
])
}
// MARK: - Personal Health Data Methods (Height, Weight, Birth Date)
///
@objc
func getHeight(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
guard let heightType = ReadTypes.height else {
rejecter("TYPE_NOT_AVAILABLE", "Height type is not available", nil)
return
}
//
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(sampleType: heightType,
predicate: nil,
limit: 1,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query height: \(error.localizedDescription)", error)
return
}
guard let heightSamples = samples as? [HKQuantitySample], let latestHeight = heightSamples.first else {
resolver(["value": NSNull(), "unit": "cm"])
return
}
//
let heightInCm = latestHeight.quantity.doubleValue(for: HKUnit.meterUnit(with: .centi))
let result: [String: Any] = [
"value": heightInCm,
"unit": "cm",
"recordedAt": self?.dateToISOString(latestHeight.endDate) ?? "",
"source": [
"name": latestHeight.sourceRevision.source.name,
"bundleIdentifier": latestHeight.sourceRevision.source.bundleIdentifier
]
]
resolver(result)
}
}
healthStore.execute(query)
}
///
@objc
func getWeight(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
guard let weightType = ReadTypes.bodyMass else {
rejecter("TYPE_NOT_AVAILABLE", "Weight type is not available", nil)
return
}
//
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(sampleType: weightType,
predicate: nil,
limit: 1,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query weight: \(error.localizedDescription)", error)
return
}
guard let weightSamples = samples as? [HKQuantitySample], let latestWeight = weightSamples.first else {
resolver(["value": NSNull(), "unit": "kg"])
return
}
//
let weightInKg = latestWeight.quantity.doubleValue(for: HKUnit.gramUnit(with: .kilo))
let result: [String: Any] = [
"value": weightInKg,
"unit": "kg",
"recordedAt": self?.dateToISOString(latestWeight.endDate) ?? "",
"source": [
"name": latestWeight.sourceRevision.source.name,
"bundleIdentifier": latestWeight.sourceRevision.source.bundleIdentifier
]
]
resolver(result)
}
}
healthStore.execute(query)
}
///
@objc
func getDateOfBirth(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
do {
let dateOfBirthComponents = try healthStore.dateOfBirthComponents()
guard let year = dateOfBirthComponents.year,
let month = dateOfBirthComponents.month,
let day = dateOfBirthComponents.day else {
resolver(["value": NSNull()])
return
}
//
var components = DateComponents()
components.year = year
components.month = month
components.day = day
components.hour = 0
components.minute = 0
components.second = 0
guard let date = Calendar.current.date(from: components) else {
resolver(["value": NSNull()])
return
}
let result: [String: Any] = [
"value": dateToISOString(date),
"year": year,
"month": month,
"day": day
]
resolver(result)
} catch {
rejecter("QUERY_ERROR", "Failed to query date of birth: \(error.localizedDescription)", error)
}
}
/// HealthKit
@objc
func saveHeight(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
guard let heightValue = options["value"] as? Double else {
rejecter("INVALID_PARAMETERS", "Height value is required", nil)
return
}
let unit = options["unit"] as? String ?? "cm"
guard let heightType = WriteTypes.height else {
rejecter("TYPE_NOT_AVAILABLE", "Height type is not available", nil)
return
}
//
let heightQuantity: HKQuantity
if unit == "m" {
heightQuantity = HKQuantity(unit: HKUnit.meter(), doubleValue: heightValue)
} else {
// 使
heightQuantity = HKQuantity(unit: HKUnit.meterUnit(with: .centi), doubleValue: heightValue)
}
let recordedAt: Date
if let recordedAtString = options["recordedAt"] as? String,
let date = parseDate(from: recordedAtString) {
recordedAt = date
} else {
recordedAt = Date()
}
//
let heightSample = HKQuantitySample(
type: heightType,
quantity: heightQuantity,
start: recordedAt,
end: recordedAt,
metadata: nil
)
// HealthKit
healthStore.save(heightSample) { [weak self] (success, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("SAVE_ERROR", "Failed to save height: \(error.localizedDescription)", error)
return
}
if success {
let result: [String: Any] = [
"success": true,
"value": heightValue,
"unit": unit,
"recordedAt": self?.dateToISOString(recordedAt) ?? ""
]
resolver(result)
} else {
rejecter("SAVE_FAILED", "Failed to save height", nil)
}
}
}
}
/// HealthKit
@objc
func saveWeight(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
guard let weightValue = options["value"] as? Double else {
rejecter("INVALID_PARAMETERS", "Weight value is required", nil)
return
}
let unit = options["unit"] as? String ?? "kg"
guard let weightType = WriteTypes.bodyMass else {
rejecter("TYPE_NOT_AVAILABLE", "Weight type is not available", nil)
return
}
// 使
let weightQuantity = HKQuantity(unit: HKUnit.gramUnit(with: .kilo), doubleValue: weightValue)
let recordedAt: Date
if let recordedAtString = options["recordedAt"] as? String,
let date = parseDate(from: recordedAtString) {
recordedAt = date
} else {
recordedAt = Date()
}
//
let weightSample = HKQuantitySample(
type: weightType,
quantity: weightQuantity,
start: recordedAt,
end: recordedAt,
metadata: nil
)
// HealthKit
healthStore.save(weightSample) { [weak self] (success, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("SAVE_ERROR", "Failed to save weight: \(error.localizedDescription)", error)
return
}
if success {
let result: [String: Any] = [
"success": true,
"value": weightValue,
"unit": unit,
"recordedAt": self?.dateToISOString(recordedAt) ?? ""
]
resolver(result)
} else {
rejecter("SAVE_FAILED", "Failed to save weight", nil)
}
}
}
}
// MARK: - RCTEventEmitter Overrides
override func supportedEvents() -> [String]! {