- CHHapticEngine을 공유 AVAudioSession에 부착(기본 생성 시 독자 오디오 정책이 백그라운드 무음 루프를 끊던 것이 DI 멈춤 원인) + 오디오 키퍼 하드닝(루트 변경· 미디어 서버 리셋·ensureAlive) + 운동 중 2초 하트비트(오디오·엔진·LA 자가 치유) - Live Activity: 실제 phaseStart 사용·동일 상태 no-op·staleDate 8초·isStale 흐림 - 워치 세팅 동기화 3중화: 변경 push + reachability 재푸시 + 워치 앱 열릴 때 sendMessage pull - 완주 진동 신설: 폰 2.4초 연속(백그라운드 6방 체인, 오디오 정지 4초 지연), 워치 retry×2+success - 통계 하루 탭 맨 위 '오늘의 타임라인'(24시간 축 점 그래프, 시각 라벨·지금 룰) - 폰 칼로리 공식을 애플워치 '기타' 운동 규칙(빠르게 걷기 상당)에 정합: MET 4.0+강도×0.15 - 도움말 4건 갱신·en/ja 번역·CFBundleName 보충, CLAUDE.md 현행화 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
79 lines
3.6 KiB
Swift
79 lines
3.6 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),
|
||
]
|
||
}
|
||
|
||
/// 칼로리 추정 — 애플워치의 '기타' 운동 규칙과 정합시킨다: 워치는 "심박 기반 vs 빠르게 걷기
|
||
/// 상당 중 큰 쪽"으로 적립하므로(케겔은 심박이 낮아 사실상 빠르게 걷기 ≈ MET 4.3),
|
||
/// 폰도 MET 4.0 + 강도×0.15(기본 강도 2 → 4.3), 체중 70kg 가정으로 계산 — 워치 실측과의
|
||
/// 잔차는 체중·심박 개인차. 빌더 저장 운동은 에너지 샘플을 직접 넣지 않으면 0kcal(실기기 실측)
|
||
static func estimatedKcal(seconds: TimeInterval, effortScore: Int) -> Double {
|
||
let met = 4.0 + Double(min(max(effortScore, 0), 10)) * 0.15
|
||
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 {
|
||
// 권한 없음 등 — 무시
|
||
}
|
||
}
|
||
}
|
||
}
|