Files
Ascently/ios/OpenClimb/Services/HealthKitService.swift
2025-10-10 16:32:10 -06:00

237 lines
7.3 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()
}
}
/// Restore active workout state
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
print("HealthKit: Restored active workout from \(startDate)")
}
}
/// Persist active workout state
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)
}
}
/// Verify and restore health integration
func verifyAndRestoreIntegration() async {
guard isEnabled else { return }
guard HKHealthStore.isHealthDataAvailable() else {
print("HealthKit: Device does not support HealthKit")
return
}
checkAuthorization()
if !isAuthorized {
print(
"HealthKit: Integration was enabled but authorization lost, attempting to restore..."
)
do {
try await requestAuthorization()
print("HealthKit: Authorization restored successfully")
} catch {
print("HealthKit: Failed to restore authorization: \(error.localizedDescription)")
}
} else {
print("HealthKit: Integration verified - authorization is valid")
}
if hasActiveWorkout() {
print(
"HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)"
)
}
}
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()
print("HealthKit: Started workout for session \(sessionId)")
}
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()
print(
"HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")"
)
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
} catch {
print("HealthKit: Failed to save workout: \(error.localizedDescription)")
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
throw HealthKitError.workoutSaveFailed
}
}
func cancelWorkout() {
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
print("HealthKit: Workout cancelled")
}
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"
}
}
}