- 'cannot find in scope' 판정: 새 DerivedData 클린룸 빌드 3종(Debug/Release/워치) 전부 그린 → SourceKit 편집기 오탐 확정 (실제 컴파일 에러 0) - 강제 종료 유령 Live Activity 정리: 운동 중 kill 시 DI가 몇 시간 잔존(시뮬 재현) → 앱 시작 시 cleanupStaleActivities()로 즉시 소멸(실측 확인) - 진동 체인 세대 토큰: 짧은 수축·이완에서 이전 패턴 체인이 다음 국면 첫 진동을 삼키는 엣지 방지, Haptics를 Task 기반으로 재작성해 Swift 6 경고 3건 전부 제거 - 워치에도 -noHealth QA 인자 추가 - 시뮬 실측: DI 백그라운드 갱신(수축 그린→이완 앰버)·포그라운드 복귀 벽시계 재동기화 (5/20 정확 착지)·유령 DI 정리·주간 통계 검산(11세트/220회)·완주 경로·워치 실행· 18.5 빌드 그린·전 카탈로그 missing/stale/ASCII~ 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
105 lines
4.2 KiB
Swift
105 lines
4.2 KiB
Swift
//
|
|
// 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<HKSampleType> {
|
|
var types: Set<HKSampleType> = [
|
|
HKObjectType.workoutType(),
|
|
HKQuantityType(.activeEnergyBurned),
|
|
HKQuantityType(.heartRate),
|
|
]
|
|
if #available(watchOS 11.0, *) {
|
|
types.insert(HKQuantityType(.workoutEffortScore))
|
|
}
|
|
return types
|
|
}
|
|
|
|
/// 라이브 빌더가 센서에서 심박·활성 에너지를 수집하려면 읽기 권한 필요
|
|
private static var readTypes: Set<HKObjectType> {
|
|
[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<Void, Error>) in
|
|
store.requestAuthorization(toShare: Self.shareTypes, read: Self.readTypes) { _, error in
|
|
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
|
|
}
|
|
}
|
|
}
|
|
}
|