Files
Ascently/ios/Ascently/Services/HealthKitService.swift
2025-11-18 12:58:45 -07:00

239 lines
7.6 KiB
Swift

import Combine
import Foundation
import HealthKit
@MainActor
class HealthKitService: ObservableObject {
static let shared = HealthKitService()
private let healthStore = HKHealthStore()
private var currentWorkoutStartDate: Date?
private var currentWorkoutSessionId: UUID?
@Published var isAuthorized = false
@Published var isEnabled = false
private let userDefaults = UserDefaults.standard
private let isEnabledKey = "healthKitEnabled"
private let workoutStartDateKey = "healthKitWorkoutStartDate"
private let workoutSessionIdKey = "healthKitWorkoutSessionId"
private init() {
loadSettings()
restoreActiveWorkout()
}
func loadSettings() {
isEnabled = userDefaults.bool(forKey: isEnabledKey)
if HKHealthStore.isHealthDataAvailable() {
checkAuthorization()
}
}
private func restoreActiveWorkout() {
if let startDate = userDefaults.object(forKey: workoutStartDateKey) as? Date,
let sessionIdString = userDefaults.string(forKey: workoutSessionIdKey),
let sessionId = UUID(uuidString: sessionIdString)
{
currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId
AppLogger.info("HealthKit: Restored active workout from \(startDate)", tag: "HealthKit")
}
}
private func persistActiveWorkout() {
if let startDate = currentWorkoutStartDate, let sessionId = currentWorkoutSessionId {
userDefaults.set(startDate, forKey: workoutStartDateKey)
userDefaults.set(sessionId.uuidString, forKey: workoutSessionIdKey)
} else {
userDefaults.removeObject(forKey: workoutStartDateKey)
userDefaults.removeObject(forKey: workoutSessionIdKey)
}
}
func verifyAndRestoreIntegration() async {
guard isEnabled else { return }
guard HKHealthStore.isHealthDataAvailable() else {
AppLogger.warning("HealthKit: Device does not support HealthKit", tag: "HealthKit")
return
}
checkAuthorization()
if !isAuthorized {
AppLogger.warning(
"HealthKit: Integration was enabled but authorization lost, attempting to restore...",
tag: "HealthKit")
do {
try await requestAuthorization()
AppLogger.info("HealthKit: Authorization restored successfully", tag: "HealthKit")
} catch {
AppLogger.error(
"HealthKit: Failed to restore authorization: \(error.localizedDescription)",
tag: "HealthKit")
}
} else {
AppLogger.info(
"HealthKit: Integration verified - authorization is valid", tag: "HealthKit")
}
if hasActiveWorkout() {
AppLogger.info(
"HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)",
tag: "HealthKit")
}
}
func setEnabled(_ enabled: Bool) {
isEnabled = enabled
userDefaults.set(enabled, forKey: isEnabledKey)
}
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
let workoutType = HKObjectType.workoutType()
let energyBurnedType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!
let typesToShare: Set<HKSampleType> = [
workoutType,
energyBurnedType,
]
let typesToRead: Set<HKObjectType> = [
workoutType
]
try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)
self.isAuthorized = true
}
private func checkAuthorization() {
let workoutType = HKObjectType.workoutType()
let status = healthStore.authorizationStatus(for: workoutType)
isAuthorized = (status == .sharingAuthorized)
}
func startWorkout(startDate: Date, sessionId: UUID) async throws {
guard isEnabled && isAuthorized else {
return
}
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId
persistActiveWorkout()
AppLogger.info("HealthKit: Started workout for session \(sessionId)", tag: "HealthKit")
}
func endWorkout(endDate: Date) async throws {
guard isEnabled && isAuthorized else {
return
}
guard let startDate = currentWorkoutStartDate else {
return
}
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
let duration = endDate.timeIntervalSince(startDate)
let calories = estimateCalories(durationInMinutes: duration / 60.0)
let energyBurned = HKQuantity(unit: .kilocalorie(), doubleValue: calories)
let workoutConfiguration = HKWorkoutConfiguration()
workoutConfiguration.activityType = .climbing
workoutConfiguration.locationType = .indoor
let builder = HKWorkoutBuilder(
healthStore: healthStore,
configuration: workoutConfiguration,
device: .local()
)
do {
try await builder.beginCollection(at: startDate)
let energyBurnedType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!
let energySample = HKQuantitySample(
type: energyBurnedType,
quantity: energyBurned,
start: startDate,
end: endDate
)
try await builder.addSamples([energySample])
try await builder.addMetadata([HKMetadataKeyIndoorWorkout: true])
try await builder.endCollection(at: endDate)
let workout = try await builder.finishWorkout()
AppLogger.info(
"HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")",
tag: "HealthKit")
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
} catch {
AppLogger.error(
"HealthKit: Failed to save workout: \(error.localizedDescription)", tag: "HealthKit"
)
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
throw HealthKitError.workoutSaveFailed
}
}
func cancelWorkout() {
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
AppLogger.info("HealthKit: Workout cancelled", tag: "HealthKit")
}
func hasActiveWorkout() -> Bool {
return currentWorkoutStartDate != nil
}
private func estimateCalories(durationInMinutes: Double) -> Double {
let caloriesPerMinute = 8.0
return durationInMinutes * caloriesPerMinute
}
}
enum HealthKitError: LocalizedError {
case notAvailable
case notAuthorized
case workoutStartFailed
case workoutSaveFailed
var errorDescription: String? {
switch self {
case .notAvailable:
return "HealthKit is not available on this device"
case .notAuthorized:
return "HealthKit authorization not granted"
case .workoutStartFailed:
return "Failed to start HealthKit workout"
case .workoutSaveFailed:
return "Failed to save workout to HealthKit"
}
}
}