- 新增 fetchCompleteNutritionCardData 异步 action,统一拉取营养、健康与基础代谢数据 - NutritionRadarCard 改用 Redux 数据源,移除 props 透传,自动根据日期刷新 - BasalMetabolismCard 新增详情弹窗,展示 BMR 计算公式、正常区间及提升策略 - StepsCard 与 StepsCardOptimized 引入 InteractionManager 与动画懒加载,减少 UI 阻塞 - HealthKitManager 新增饮水读写接口,支持将饮水记录同步至 HealthKit - 移除 statistics 页面冗余 mock 与 nutrition/health 重复请求,缓存时间统一为 5 分钟
1485 lines
50 KiB
Swift
1485 lines
50 KiB
Swift
//
|
||
// HealthKitManager.swift
|
||
// digitalpilates
|
||
//
|
||
// Updated module for HealthKit authorization and sleep data access.
|
||
//
|
||
|
||
import Foundation
|
||
import React
|
||
import HealthKit
|
||
|
||
@objc(HealthKitManager)
|
||
class HealthKitManager: NSObject, RCTBridgeModule {
|
||
|
||
private let healthStore = HKHealthStore()
|
||
|
||
static func moduleName() -> String! {
|
||
return "HealthKitManager"
|
||
}
|
||
|
||
static func requiresMainQueueSetup() -> Bool {
|
||
return true
|
||
}
|
||
|
||
// 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 var all: Set<HKObjectType> {
|
||
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater]
|
||
}
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||
|
||
let query = HKSampleQuery(sampleType: basalEnergyType,
|
||
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 basal 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 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) { [weak self] (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)
|
||
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
|
||
|
||
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,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let hrvData = hrvSamples.map { sample in
|
||
[
|
||
"id": sample.uuid.uuidString,
|
||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||
"value": sample.quantity.doubleValue(for: HKUnit.secondUnit(with: .milli)),
|
||
"source": [
|
||
"name": sample.sourceRevision.source.name,
|
||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||
],
|
||
"metadata": sample.metadata ?? [:]
|
||
] as [String : Any]
|
||
}
|
||
|
||
let result: [String: Any] = [
|
||
"data": hrvData,
|
||
"count": hrvData.count,
|
||
"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()
|
||
}
|
||
|
||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||
|
||
let query = HKSampleQuery(sampleType: stepType,
|
||
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 step count: \(error.localizedDescription)", error)
|
||
return
|
||
}
|
||
|
||
guard let stepSamples = samples as? [HKQuantitySample] else {
|
||
resolver([
|
||
"data": [],
|
||
"totalValue": 0,
|
||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||
])
|
||
return
|
||
}
|
||
|
||
let stepData = stepSamples.map { sample in
|
||
[
|
||
"id": sample.uuid.uuidString,
|
||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||
"value": sample.quantity.doubleValue(for: HKUnit.count()),
|
||
"source": [
|
||
"name": sample.sourceRevision.source.name,
|
||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||
],
|
||
"metadata": sample.metadata ?? [:]
|
||
] as [String : Any]
|
||
}
|
||
|
||
let totalValue = stepSamples.reduce(0.0) { total, sample in
|
||
return total + sample.quantity.doubleValue(for: HKUnit.count())
|
||
}
|
||
|
||
let result: [String: Any] = [
|
||
"data": stepData,
|
||
"totalValue": totalValue,
|
||
"count": stepData.count,
|
||
"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: - 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)
|
||
}
|
||
|
||
} // end class
|