feat(healthkit): 实现HealthKit与服务端的双向数据同步,包括身高、体重和出生日期的获取与保存
This commit is contained in:
@@ -117,4 +117,22 @@ RCT_EXTERN_METHOD(startHRVObserver:(RCTPromiseResolveBlock)resolver
|
||||
RCT_EXTERN_METHOD(stopHRVObserver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
// Personal Health Data Methods
|
||||
RCT_EXTERN_METHOD(getHeight:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(getWeight:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(getDateOfBirth:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(saveHeight:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(saveWeight:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
@end
|
||||
|
||||
@@ -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]! {
|
||||
|
||||
Reference in New Issue
Block a user