mycode/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift
songyc macbook f5f8d3bde4 fix(keging): 실기기 1차 피드백 3건 — DI 백그라운드 멈춤·진동 패턴 동일·칼로리 0
① 다이나믹 아일랜드 멈춤(2중 방어):
- 오디오 키퍼 하드닝 — volume 0.1(0.0은 유지 풀림 사례)·prepareToPlay·인터럽션/씬 전환 revive()
- 엔진을 벽시계 기준으로 재설계 — 전 국면이 sessionStart로부터 결정적(computeTarget),
  타이머는 경계 재장전용, scenePhase .active에서 resyncAfterWake()로 정확한 국면 자가 교정

② 진동 패턴 동일(톡=톡톡): 시스템 바이브는 재생 중 재호출이 무시됨(실측) —
  시간 오프셋 방식 폐기, AudioServicesPlaySystemSoundWithCompletion 완료 체이닝으로 재작성
  (톡 1방 / 톡톡 0.25s 쉬고 1방 / 드르륵 0.15s 3연타 / 지잉 즉시 이어붙인 3방)

③ 운동 칼로리 0: 빌더 운동은 에너지 샘플 직접 추가 필요 —
  iOS는 MET 추정(1.8+강도×0.17, 70kg 가정) activeEnergyBurned 샘플 add,
  워치는 심박·활성 에너지 읽기 권한 추가로 센서 실측 수집, 권한 문구 3언어 갱신

검증: Debug·Release·워치 빌드 그린, 완주 경로 시뮬 재검증(엔진 재설계 후).
DI 갱신·진동 구분·칼로리는 실기기 재확인 필요(재실행 시 '활성 에너지' 권한 시트 뜸)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
2026-08-29 22:42:00 +09:00

98 lines
4.0 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) {
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() }
}
}
}
}