feat(health): 完善HealthKit权限管理和数据获取系统
- 重构权限管理,新增SimpleEventEmitter实现状态监听 - 实现完整的健身圆环数据获取(活动热量、锻炼时间、站立小时) - 优化组件状态管理,支持实时数据刷新和权限状态响应 - 新增useHealthPermissions Hook,简化权限状态管理 - 完善iOS原生代码,支持按小时统计健身数据 - 优化应用启动时权限初始化流程,避免启动弹窗 BREAKING CHANGE: FitnessRingsCard组件API变更,移除手动传参改为自动获取数据
This commit is contained in:
@@ -55,4 +55,17 @@ RCT_EXTERN_METHOD(getDailyStepCountSamples:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
// Hourly Data Methods
|
||||
RCT_EXTERN_METHOD(getHourlyActiveEnergyBurned:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(getHourlyExerciseTime:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(getHourlyStandHours:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
@end
|
||||
@@ -29,16 +29,16 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
static let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
|
||||
static let stepCount = HKObjectType.quantityType(forIdentifier: .stepCount)!
|
||||
static let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate)!
|
||||
static let restingHeartRate = HKObjectType.quantityType(forIdentifier: .restingHeartRate)!
|
||||
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 var all: Set<HKObjectType> {
|
||||
return [sleep, stepCount, heartRate, restingHeartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation]
|
||||
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,9 +566,14 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let anchoredDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate)
|
||||
var startDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate)
|
||||
var endDateComponents = calendar.dateComponents([.day, .month, .year], from: endDate)
|
||||
|
||||
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: anchoredDateComponents, end: anchoredDateComponents)
|
||||
// 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) { [weak self] (query, summaries, error) in
|
||||
DispatchQueue.main.async {
|
||||
@@ -583,7 +588,10 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
}
|
||||
|
||||
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()),
|
||||
@@ -591,9 +599,9 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
"appleStandHours": summary.appleStandHours.doubleValue(for: HKUnit.count()),
|
||||
"appleStandHoursGoal": summary.appleStandHoursGoal.doubleValue(for: HKUnit.count()),
|
||||
"dateComponents": [
|
||||
"day": anchoredDateComponents.day ?? 0,
|
||||
"month": anchoredDateComponents.month ?? 0,
|
||||
"year": anchoredDateComponents.year ?? 0
|
||||
"day": summaryDateComponents.day ?? 0,
|
||||
"month": summaryDateComponents.month ?? 0,
|
||||
"year": summaryDateComponents.year ?? 0
|
||||
]
|
||||
] as [String : Any]
|
||||
}
|
||||
@@ -1060,5 +1068,269 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
} // end class
|
||||
|
||||
Reference in New Issue
Block a user