// // WatchWorkoutSession.swift // KegingWatch Watch App // // 워치 라이브 워크아웃 세션 — 애플 건강에 '기타' 운동으로 기록(심박 포함)하면서 // 손목을 내려도 앱이 계속 돌게 한다. 시작 실패(권한 거부 등) 시 확장 런타임 세션 폴백. // 중단(완주 실패)하면 워크아웃도 폐기 — 세트 규칙과 동일. // import HealthKit @MainActor final class WatchWorkoutSession: NSObject { static let shared = WatchWorkoutSession() private let store = HKHealthStore() private var session: HKWorkoutSession? private var builder: HKLiveWorkoutBuilder? private override init() { super.init() } private static var shareTypes: Set { var types: Set = [ HKObjectType.workoutType(), HKQuantityType(.activeEnergyBurned), HKQuantityType(.heartRate), ] if #available(watchOS 11.0, *) { types.insert(HKQuantityType(.workoutEffortScore)) } return types } /// 라이브 빌더가 센서에서 심박·활성 에너지를 수집하려면 읽기 권한 필요 private static var readTypes: Set { [HKQuantityType(.heartRate), HKQuantityType(.activeEnergyBurned)] } /// 운동 시작 직후 호출 — 권한 요청·세션 시작은 비동기라 타이머 진행에 영향 없음 func begin(startDate: Date) { #if DEBUG // 화면 QA에서 권한 시트가 캡처를 가리는 것 방지 if CommandLine.arguments.contains("-noHealth") { RuntimeSessionManager.shared.start() return } #endif Task { @MainActor in guard HKHealthStore.isHealthDataAvailable() else { RuntimeSessionManager.shared.start() return } do { try await requestAuthorization() // 권한 응답이 늦어 운동이 이미 끝났으면 세션을 열지 않는다 guard KegelEngine.shared.isRunning, session == nil else { return } let configuration = HKWorkoutConfiguration() configuration.activityType = SettingsStore.shared.config.healthWorkoutType.activityType configuration.locationType = .unknown let newSession = try HKWorkoutSession(healthStore: store, configuration: configuration) let newBuilder = newSession.associatedWorkoutBuilder() newBuilder.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: configuration) newSession.startActivity(with: startDate) try await HealthKitCalls.beginCollection(newBuilder, at: startDate) session = newSession builder = newBuilder } catch { RuntimeSessionManager.shared.start() } } } /// 세션 종료 — 완주면 저장(+운동 강도), 중단이면 폐기 func end(completed: Bool, effortScore: Int) { RuntimeSessionManager.shared.stop() guard let session, let builder else { return } self.session = nil self.builder = nil session.end() Task { @MainActor [store] in do { if completed { try await HealthKitCalls.endCollection(builder, at: Date()) let workout = try await HealthKitCalls.finishWorkout(builder) if #available(watchOS 11.0, *), effortScore > 0, let workout { try? await HealthKitCalls.relateEffort(store: store, workout: workout, score: effortScore) } } else { builder.discardWorkout() } } catch { // 저장 실패 — 앱 자체 기록에는 영향 없음 } } } private func requestAuthorization() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in store.requestAuthorization(toShare: Self.shareTypes, read: Self.readTypes) { _, error in if let error { continuation.resume(throwing: error) } else { continuation.resume() } } } } }