HealthDataReader.swift 11.1 KB
import Foundation
import HealthKit

/// Focused HealthKit query helper. It mirrors the original SwiftUI project data
/// coverage while avoiding dependencies on the old network and user modules.
final class HealthDataReader {
  private let healthStore: HKHealthStore

  init(healthStore: HKHealthStore) {
    self.healthStore = healthStore

  }

  func collectRecentData(startDate: Date, endDate: Date) async throws -> NativeHealthSyncSummary {
    async let hrv = fetchQuantitySamples(
      identifier: .heartRateVariabilitySDNN,
      dataType: .hrv,
      unit: .secondUnit(with: .milli),
      startDate: startDate,
      endDate: endDate
    )
    async let heart = fetchHeartRateFamily(startDate: startDate, endDate: endDate)
    async let oxygen = fetchQuantitySamples(
      identifier: .oxygenSaturation,
      dataType: .oxygenSaturation,
      unit: .percent(),
      startDate: startDate,
      endDate: endDate
    )
    async let activeEnergy = fetchQuantitySamples(
      identifier: .activeEnergyBurned,
      dataType: .activeEnergy,
      unit: .kilocalorie(),
      startDate: startDate,
      endDate: endDate
    )
    async let exercise = fetchQuantitySamples(
      identifier: .appleExerciseTime,
      dataType: .exercise,
      unit: .minute(),
      startDate: startDate,
      endDate: endDate
    )
    async let stand = fetchQuantitySamples(
      identifier: .appleStandTime,
      dataType: .stand,
      unit: .minute(),
      startDate: startDate,
      endDate: endDate
    )
    async let steps = fetchDailyCumulativeSamples(
      identifier: .stepCount,
      dataType: .steps,
      unit: .count(),
      startDate: startDate,
      endDate: endDate
    )
    async let wristTemp = fetchQuantitySamples(
      identifier: .appleSleepingWristTemperature,
      dataType: .sleepingWristTemperature,
      unit: .degreeCelsius(),
      startDate: startDate,
      endDate: endDate
    )
    async let respiratory = fetchQuantitySamples(
      identifier: .respiratoryRate,
      dataType: .respiratoryRate,
      unit: HKUnit.count().unitDivided(by: .minute()),
      startDate: startDate,
      endDate: endDate
    )
    async let rhythm = fetchIrregularHeartRhythmEvents(startDate: startDate, endDate: endDate)
    async let sleep = fetchSleepIntervals(startDate: startDate, endDate: endDate)

    let hrvPoints = try await hrv
    let heartPoints = try await heart
    let oxygenPoints = try await oxygen
    let activeEnergyPoints = try await activeEnergy
    let exercisePoints = try await exercise
    let standPoints = try await stand
    let stepPoints = try await steps
    let wristTempPoints = try await wristTemp
    let respiratoryPoints = try await respiratory
    let rhythmPoints = try await rhythm
    let sleepIntervals = try await sleep

    return NativeHealthSyncSummary(
      commonCount: hrvPoints.count
        + heartPoints.count
        + oxygenPoints.count
        + activeEnergyPoints.count
        + exercisePoints.count
        + standPoints.count
        + stepPoints.count
        + wristTempPoints.count
        + respiratoryPoints.count
        + rhythmPoints.count,
      sleepCount: sleepIntervals.count,
      startedAt: startDate,
      endedAt: endDate
    )
  }

  func fetchLatestHRV() async throws -> Double? {
    guard let type = NativeHealthTypeCatalog.quantity(.heartRateVariabilitySDNN) else {
      throw NativeHealthKitError.invalidType("heartRateVariabilitySDNN")
    }
    let sample = try await fetchLatestQuantitySample(type: type)
    return sample?.quantity.doubleValue(for: .secondUnit(with: .milli))
  }

  func fetchTodayStepCount() async throws -> Int {
    guard let type = NativeHealthTypeCatalog.quantity(.stepCount) else {
      throw NativeHealthKitError.invalidType("stepCount")
    }
    let startOfDay = Calendar.current.startOfDay(for: Date())
    let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date())

    return try await withCheckedThrowingContinuation { continuation in
      let query = HKStatisticsQuery(
        quantityType: type,
        quantitySamplePredicate: predicate,
        options: .cumulativeSum
      ) { _, statistics, error in
        if let error {
          continuation.resume(throwing: error)
          return
        }
        let value = statistics?.sumQuantity()?.doubleValue(for: .count()) ?? 0
        continuation.resume(returning: Int(value))
      }
      healthStore.execute(query)
    }
  }

  private func fetchHeartRateFamily(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    async let heartRate = fetchQuantitySamples(
      identifier: .heartRate,
      dataType: .heartRate,
      unit: HKUnit.count().unitDivided(by: .minute()),
      startDate: startDate,
      endDate: endDate
    )
    async let walking = fetchQuantitySamples(
      identifier: .walkingHeartRateAverage,
      dataType: .walkingHeartRate,
      unit: HKUnit.count().unitDivided(by: .minute()),
      startDate: startDate,
      endDate: endDate
    )
    async let resting = fetchQuantitySamples(
      identifier: .restingHeartRate,
      dataType: .restingHeartRate,
      unit: HKUnit.count().unitDivided(by: .minute()),
      startDate: startDate,
      endDate: endDate
    )
    return try await heartRate + walking + resting
  }

  private func fetchQuantitySamples(
    identifier: HKQuantityTypeIdentifier,
    dataType: NativeHealthDataType,
    unit: HKUnit,
    startDate: Date,
    endDate: Date
  ) async throws -> [NativeHealthDataPoint] {
    guard let type = NativeHealthTypeCatalog.quantity(identifier) else {
      throw NativeHealthKitError.invalidType(identifier.rawValue)
    }
    let predicate = HKQuery.predicateForSamples(
      withStart: startDate,
      end: endDate,
      options: .strictStartDate
    )
    let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)

    return try await withCheckedThrowingContinuation { continuation in
      let query = HKSampleQuery(
        sampleType: type,
        predicate: predicate,
        limit: HKObjectQueryNoLimit,
        sortDescriptors: [sort]
      ) { _, samples, error in
        if let error {
          continuation.resume(throwing: error)
          return
        }
        let points = (samples as? [HKQuantitySample] ?? []).map { sample in
          NativeHealthDataPoint(
            dataType: dataType,
            time: sample.startDate.timeIntervalSince1970,
            value: sample.quantity.doubleValue(for: unit)
          )
        }
        continuation.resume(returning: points)
      }
      healthStore.execute(query)
    }
  }

  private func fetchDailyCumulativeSamples(
    identifier: HKQuantityTypeIdentifier,
    dataType: NativeHealthDataType,
    unit: HKUnit,
    startDate: Date,
    endDate: Date
  ) async throws -> [NativeHealthDataPoint] {
    guard let type = NativeHealthTypeCatalog.quantity(identifier) else {
      throw NativeHealthKitError.invalidType(identifier.rawValue)
    }
    var interval = DateComponents()
    interval.day = 1
    let anchorDate = Calendar.current.startOfDay(for: startDate)
    let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate)

    return try await withCheckedThrowingContinuation { continuation in
      let query = HKStatisticsCollectionQuery(
        quantityType: type,
        quantitySamplePredicate: predicate,
        options: .cumulativeSum,
        anchorDate: anchorDate,
        intervalComponents: interval
      )
      query.initialResultsHandler = { _, collection, error in
        if let error {
          continuation.resume(throwing: error)
          return
        }
        var points: [NativeHealthDataPoint] = []
        collection?.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
          guard let value = statistics.sumQuantity()?.doubleValue(for: unit) else { return }
          points.append(
            NativeHealthDataPoint(
              dataType: dataType,
              time: statistics.startDate.timeIntervalSince1970,
              value: value
            )
          )
        }
        continuation.resume(returning: points)
      }
      healthStore.execute(query)
    }
  }

  private func fetchSleepIntervals(startDate: Date, endDate: Date) async throws -> [NativeSleepInterval] {
    guard let type = NativeHealthTypeCatalog.category(.sleepAnalysis) else {
      throw NativeHealthKitError.invalidType("sleepAnalysis")
    }
    let predicate = HKQuery.predicateForSamples(
      withStart: startDate,
      end: endDate,
      options: .strictStartDate
    )
    let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)

    return try await withCheckedThrowingContinuation { continuation in
      let query = HKSampleQuery(
        sampleType: type,
        predicate: predicate,
        limit: HKObjectQueryNoLimit,
        sortDescriptors: [sort]
      ) { _, samples, error in
        if let error {
          continuation.resume(throwing: error)
          return
        }
        let intervals = (samples as? [HKCategorySample] ?? []).map { sample in
          NativeSleepInterval(
            dataType: sample.value,
            fromTime: sample.startDate.timeIntervalSince1970,
            toTime: sample.endDate.timeIntervalSince1970
          )
        }
        continuation.resume(returning: intervals)
      }
      healthStore.execute(query)
    }
  }

  private func fetchIrregularHeartRhythmEvents(
    startDate: Date,
    endDate: Date
  ) async throws -> [NativeHealthDataPoint] {
    guard let type = NativeHealthTypeCatalog.category(.irregularHeartRhythmEvent) else {
      throw NativeHealthKitError.invalidType("irregularHeartRhythmEvent")
    }
    let predicate = HKQuery.predicateForSamples(
      withStart: startDate,
      end: endDate,
      options: .strictStartDate
    )
    let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)

    return try await withCheckedThrowingContinuation { continuation in
      let query = HKSampleQuery(
        sampleType: type,
        predicate: predicate,
        limit: HKObjectQueryNoLimit,
        sortDescriptors: [sort]
      ) { _, samples, error in
        if let error {
          continuation.resume(throwing: error)
          return
        }
        let points = (samples as? [HKCategorySample] ?? []).map { sample in
          NativeHealthDataPoint(
            dataType: .irregularHeartRhythm,
            time: sample.startDate.timeIntervalSince1970,
            value: Double(sample.value)
          )
        }
        continuation.resume(returning: points)
      }
      healthStore.execute(query)
    }
  }

  private func fetchLatestQuantitySample(type: HKQuantityType) async throws -> HKQuantitySample? {
    let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
    return try await withCheckedThrowingContinuation { continuation in
      let query = HKSampleQuery(
        sampleType: type,
        predicate: nil,
        limit: 1,
        sortDescriptors: [sort]
      ) { _, samples, error in
        if let error {
          continuation.resume(throwing: error)
          return
        }
        continuation.resume(returning: samples?.first as? HKQuantitySample)
      }
      healthStore.execute(query)
    }
  }
}