HealthKitService.swift 10 KB
import Foundation
import HealthKit

/// Native Apple Health service for the Flutter host API.
///
/// Responsibilities:
/// - request/read HealthKit permissions
/// - read the health data types used by the original SwiftUI app
/// - keep Watch complication values fresh in the shared App Group
/// - register background observers so HealthKit changes refresh local state
final class HealthKitService {
  static let shared = HealthKitService()

  private let healthStore = HKHealthStore()
  private let syncStore = HealthSyncStateStore()
  private lazy var reader = HealthDataReader(healthStore: healthStore)
  private var observersStarted = false

  private init() {}

  var isHealthDataAvailable: Bool {
    HKHealthStore.isHealthDataAvailable()
  }

  func requestAuthorization(completion: @escaping (Bool, Error?) -> Void) {
    guard isHealthDataAvailable else {
      completion(false, NativeHealthKitError.healthDataUnavailable)
      return
    }

    healthStore.requestAuthorization(
      toShare: NativeHealthTypeCatalog.writeTypes,
      read: NativeHealthTypeCatalog.readTypes
    ) { success, error in
      completion(success, error)
    }
  }

  func authorizationRequestStatus() async -> HKAuthorizationRequestStatus? {
    do {
      return try await healthStore.statusForAuthorizationRequest(
        toShare: NativeHealthTypeCatalog.writeTypes,
        read: NativeHealthTypeCatalog.readTypes
      )
    } catch {
      return nil
    }
  }

  func shouldRequestAuthorization() async -> Bool {
    guard isHealthDataAvailable else { return false }
    return await authorizationRequestStatus() != .unnecessary
  }

  func hasAnyReadableData() async -> Bool {
    guard isHealthDataAvailable else { return false }

    let endDate = Date()
    let startDate = Calendar.current.date(byAdding: .year, value: -2, to: endDate)
      ?? Date(timeInterval: -2 * 365 * 24 * 60 * 60, since: endDate)

    let commonFetches: [(Date, Date) async throws -> [NativeHealthDataPoint]] = [
      reader.fetchHrvData,
      reader.fetchHeartRateData,
      reader.fetchWalkingHeartRateData,
      reader.fetchRestingHeartRateData,
      reader.fetchSleepingHeartRateData,
      reader.fetchOxygenSaturationData,
      reader.fetchActiveEnergyData,
      reader.fetchExerciseData,
      reader.fetchStandData,
      reader.fetchStepCountData,
      reader.fetchSleepingWristTemperatureData,
      reader.fetchRespiratoryRateData,
      reader.fetchIrregularHeartRhythmData,
    ]

    for fetch in commonFetches {
      do {
        if try await !fetch(startDate, endDate).isEmpty {
          return true
        }
      } catch {
        // Keep probing other data types; HealthKit may deny or lack a single type.
      }
    }

    do {
      if try await !reader.fetchSleepData(startDate: startDate, endDate: endDate).isEmpty {
        return true
      }
    } catch {
      // Keep probing activity summaries.
    }

    do {
      return try await reader.fetchActivityTargetData(startDate: startDate, endDate: endDate) != nil
    } catch {
      return false
    }
  }

  func startBackgroundObserversIfNeeded() {
    guard isHealthDataAvailable, !observersStarted else { return }
    observersStarted = true

    for sampleType in NativeHealthTypeCatalog.observedTypes {
      enableBackgroundDelivery(for: sampleType)
      let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { [weak self] _, completion, error in
        guard error == nil else {
          completion()
          return
        }
        Task {
          await self?.handleObservedChange(sampleType)
          completion()
        }
      }
      healthStore.execute(query)
    }
  }

  func performLocalSync() async -> NativeHealthSyncSummary {
    guard isHealthDataAvailable else {
      return NativeHealthSyncSummary(commonCount: 0, sleepCount: 0, startedAt: Date(), endedAt: Date())
    }

    let startDate = earliestStartDate()
    let endDate = Date()

    do {
      let summary = try await reader.collectRecentData(startDate: startDate, endDate: endDate)
      NativeHealthDataType.allCases
        .filter { $0 != .unknown }
        .forEach { syncStore.save(date: endDate, for: $0) }
      await refreshSharedWatchValues()
      startBackgroundObserversIfNeeded()
      return summary
    } catch {
      await refreshSharedWatchValues()
      return NativeHealthSyncSummary(commonCount: 0, sleepCount: 0, startedAt: startDate, endedAt: endDate)
    }
  }

  func refreshSharedWatchValues() async {
    do {
      if let hrv = try await reader.fetchLatestHRV() {
        AppGroupConstants.defaults?.set(hrv, forKey: AppGroupConstants.Key.latestHRV)
      }
    } catch {
      // Keep the previous widget value when a single read fails.
    }

    do {
      let steps = try await reader.fetchTodayStepCount()
      AppGroupConstants.defaults?.set(steps, forKey: AppGroupConstants.Key.latestStepCount)
    } catch {
      // Keep the previous widget value when a single read fails.
    }
  }

  func fetchHrvData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchHrvData(startDate: startDate, endDate: endDate)
  }

  func fetchHeartRateData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchHeartRateData(startDate: startDate, endDate: endDate)
  }

  func fetchWalkingHeartRateData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchWalkingHeartRateData(startDate: startDate, endDate: endDate)
  }

  func fetchRestingHeartRateData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchRestingHeartRateData(startDate: startDate, endDate: endDate)
  }

  func fetchSleepingHeartRateData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchSleepingHeartRateData(startDate: startDate, endDate: endDate)
  }

  func fetchOxygenSaturationData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchOxygenSaturationData(startDate: startDate, endDate: endDate)
  }

  func fetchActiveEnergyData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchActiveEnergyData(startDate: startDate, endDate: endDate)
  }

  func fetchExerciseData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchExerciseData(startDate: startDate, endDate: endDate)
  }

  func fetchStandData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchStandData(startDate: startDate, endDate: endDate)
  }

  func fetchStepCountData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchStepCountData(startDate: startDate, endDate: endDate)
  }

  func fetchSleepData(startDate: Date, endDate: Date) async throws -> [NativeSleepInterval] {
    try await reader.fetchSleepData(startDate: startDate, endDate: endDate)
  }

  func fetchSleepingWristTemperatureData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchSleepingWristTemperatureData(startDate: startDate, endDate: endDate)
  }

  func fetchRespiratoryRateData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchRespiratoryRateData(startDate: startDate, endDate: endDate)
  }

  func fetchIrregularHeartRhythmData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchIrregularHeartRhythmData(startDate: startDate, endDate: endDate)
  }

  func fetchActivityTargetData(startDate: Date, endDate: Date) async throws -> NativeActivityTarget? {
    try await reader.fetchActivityTargetData(startDate: startDate, endDate: endDate)
  }

  private func earliestStartDate() -> Date {
    NativeHealthDataType.allCases
      .filter { $0 != .unknown }
      .map { syncStore.startDate(for: $0) }
      .min() ?? Calendar.current.startOfDay(for: Date())
  }

  private func enableBackgroundDelivery(for sampleType: HKSampleType) {
    let frequency: HKUpdateFrequency = sampleType.identifier == HKQuantityTypeIdentifier.stepCount.rawValue
      ? .hourly
      : .immediate

    healthStore.enableBackgroundDelivery(for: sampleType, frequency: frequency) { success, error in
      if let error {
        print("HealthKit background delivery failed: \(sampleType.identifier), \(error.localizedDescription)")
      } else {
        print("HealthKit background delivery \(success ? "enabled" : "not enabled"): \(sampleType.identifier)")
      }
    }
  }

  private func handleObservedChange(_ sampleType: HKSampleType) async {
    switch sampleType.identifier {
    case HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue,
         HKQuantityTypeIdentifier.stepCount.rawValue:
      await refreshSharedWatchValues()
    default:
      break
    }

    if let type = NativeHealthDataType(sampleTypeIdentifier: sampleType.identifier) {
      syncStore.save(date: Date(), for: type)
    }
  }
}

private extension NativeHealthDataType {
  init?(sampleTypeIdentifier: String) {
    switch sampleTypeIdentifier {
    case HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue:
      self = .hrv
    case HKQuantityTypeIdentifier.heartRate.rawValue:
      self = .heartRate
    case HKQuantityTypeIdentifier.stepCount.rawValue:
      self = .steps
    case HKQuantityTypeIdentifier.oxygenSaturation.rawValue:
      self = .oxygenSaturation
    case HKQuantityTypeIdentifier.activeEnergyBurned.rawValue:
      self = .activeEnergy
    case HKQuantityTypeIdentifier.appleExerciseTime.rawValue:
      self = .exercise
    case HKQuantityTypeIdentifier.appleStandTime.rawValue:
      self = .stand
    case HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue:
      self = .sleepingWristTemperature
    case HKQuantityTypeIdentifier.respiratoryRate.rawValue:
      self = .respiratoryRate
    case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:
      self = .sleep
    case HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue:
      self = .irregularHeartRhythm
    default:
      return nil
    }
  }
}