feat(health): 完善HealthKit权限管理和数据获取系统

- 重构权限管理,新增SimpleEventEmitter实现状态监听
- 实现完整的健身圆环数据获取(活动热量、锻炼时间、站立小时)
- 优化组件状态管理,支持实时数据刷新和权限状态响应
- 新增useHealthPermissions Hook,简化权限状态管理
- 完善iOS原生代码,支持按小时统计健身数据
- 优化应用启动时权限初始化流程,避免启动弹窗

BREAKING CHANGE: FitnessRingsCard组件API变更,移除手动传参改为自动获取数据
This commit is contained in:
richarjiang
2025-09-19 14:16:11 +08:00
parent 184fb672b7
commit ccfccca7bc
11 changed files with 1044 additions and 360 deletions

View File

@@ -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

View File

@@ -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