239 lines
7.6 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|