① 다이나믹 아일랜드 멈춤(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
77 lines
3.3 KiB
Swift
77 lines
3.3 KiB
Swift
//
|
||
// HealthRecorder.swift
|
||
// Keging
|
||
//
|
||
// 완주한 세트를 애플 건강에 '기타' 운동으로 기록 (iOS 측 — 사후 저장).
|
||
// 운동 강도(workout effort score, 1~10)도 설정값대로 함께 저장.
|
||
// 권한 거부·미응답이면 조용히 건너뛴다 — 앱 자체 기록·통계에는 영향 없음.
|
||
//
|
||
|
||
import HealthKit
|
||
|
||
@MainActor
|
||
final class HealthRecorder {
|
||
static let shared = HealthRecorder()
|
||
|
||
private let store = HKHealthStore()
|
||
private var authRequested = false
|
||
|
||
private init() {}
|
||
|
||
private static var shareTypes: Set<HKSampleType> {
|
||
[
|
||
HKObjectType.workoutType(),
|
||
HKQuantityType(.workoutEffortScore),
|
||
HKQuantityType(.activeEnergyBurned),
|
||
]
|
||
}
|
||
|
||
/// 칼로리 추정 — 케겔은 저강도라 MET 1.8(강도 0)~3.5(강도 10), 체중 70kg 가정.
|
||
/// 빌더로 저장한 운동은 에너지 샘플을 직접 넣지 않으면 0kcal로 남는다(실기기 실측)
|
||
static func estimatedKcal(seconds: TimeInterval, effortScore: Int) -> Double {
|
||
let met = 1.8 + Double(min(max(effortScore, 0), 10)) * 0.17
|
||
return met * 3.5 * 70 / 200 * (seconds / 60)
|
||
}
|
||
|
||
/// 운동 시작 시 호출 — 첫 실행에서만 권한 시트가 뜬다 (운동 진행에는 영향 없음)
|
||
func requestAuthorizationIfNeeded() {
|
||
#if DEBUG
|
||
// 화면 QA에서 권한 시트가 캡처를 가리는 것 방지
|
||
if CommandLine.arguments.contains("-noHealth") { return }
|
||
#endif
|
||
guard HKHealthStore.isHealthDataAvailable(), !authRequested else { return }
|
||
authRequested = true
|
||
store.requestAuthorization(toShare: Self.shareTypes, read: nil) { _, _ in }
|
||
}
|
||
|
||
/// 완주한 세트 저장 — 시작 시각부터 세트 길이만큼, 설정한 유형(기타/코어 트레이닝)으로
|
||
func save(record: SessionRecord, effortScore: Int, workoutType: HealthWorkoutType) {
|
||
guard HKHealthStore.isHealthDataAvailable() else { return }
|
||
let start = record.startedAt
|
||
let end = start.addingTimeInterval(Double(record.reps * (record.contractSeconds + record.relaxSeconds)))
|
||
let configuration = HKWorkoutConfiguration()
|
||
configuration.activityType = workoutType.activityType
|
||
let builder = HKWorkoutBuilder(healthStore: store, configuration: configuration, device: .local())
|
||
Task { @MainActor in
|
||
do {
|
||
try await HealthKitCalls.beginCollection(builder, at: start)
|
||
let kcal = Self.estimatedKcal(seconds: end.timeIntervalSince(start), effortScore: effortScore)
|
||
let energySample = HKQuantitySample(
|
||
type: HKQuantityType(.activeEnergyBurned),
|
||
quantity: HKQuantity(unit: .kilocalorie(), doubleValue: kcal),
|
||
start: start,
|
||
end: end
|
||
)
|
||
try await HealthKitCalls.add([energySample], to: builder)
|
||
try await HealthKitCalls.endCollection(builder, at: end)
|
||
let workout = try await HealthKitCalls.finishWorkout(builder)
|
||
if effortScore > 0, let workout {
|
||
try? await HealthKitCalls.relateEffort(store: store, workout: workout, score: effortScore)
|
||
}
|
||
} catch {
|
||
// 권한 없음 등 — 무시
|
||
}
|
||
}
|
||
}
|
||
}
|