mycode/myApp/Keging/IOS/HealthRecorder.swift
songyc macbook b751247ba7 feat(keging): 아이폰 시작 세트의 워치 심박 측정 — startWatchApp + 미러링 세션, 배지·사전 경고 폐지 (9차)
- 의도(사용자 TestFlight 피드백): 폰으로 운동해도 워치를 차고 있으면 워치가 심박을 재고,
  진동은 시작한 기기에서만. 종전 '워치 연동됨' 배지는 워치 앱 포그라운드 여부(isReachable)만
  보여 주던 것이라 폐지
- iOS: WatchHeartRateBridge — 시작 시 HKHealthStore.startWatchApp(폰 설정 운동 유형)으로 워치 앱을
  깨우고, 워치가 미러링한 HKWorkoutSession을 workoutSessionMirroringStartHandler로 받아 심박 수신,
  완주/중단 명령 전송(finish/discard). 워치를 못 열거나 30초 안에 미러링이 안 붙으면 폰 단독
  (+설정에 따라 '워치 앱을 열지 못했어요' 알럿, app.watchWarn 재사용) 및 WatchConnectivity로
  워치 세션 폐기 명령. 워치 저장 결과(saved/failed)는 WC로 회신받고 15초 내 미회신이면 폰 사후 저장 폴백.
  워치 깨우기는 폰 건강 권한 응답 뒤에(첫 실행 경합 방지). 건강 읽기 권한(심박·활성 에너지) 추가
  — 워치 세션 센서 수집용(권한 공유)
- 워치: WatchAppDelegate.handle(workoutConfiguration) → MirroredWorkoutSession(라이브 빌더, 무진동·무타이머,
  심박을 미러링 채널로 전송, finish 시 저장+강도 relate 후 WC로 결과 통보, discard/끊김 시 폐기,
  미러링 30초 타임아웃, 앱 재기동 시 잔류 세션 복구·폐기). 화면 '아이폰에서 진행 중 ♥bpm'(중단 버튼).
  WatchHealthAuth로 권한 공용화
- UI: 첫 화면 배지·확인 창 제거, 운동 화면 횟수 아래 워치 상태 줄(연결 중/♥bpm/아이폰만),
  설정 토글 '워치 연결 실패 경고', 도움말 3건 갱신. l10n 카탈로그 3종 missing/stale 0
- DEBUG: -testConfig(35초 세트), -mockWatchHeartRate <bpm>(상태 줄 QA·스크린샷). watchHR 카테고리 notice 로그
- 시뮬 검증(페어링 심): startWatchApp → 워치 기동·권한·세션·가짜 심박·'아이폰에서 진행 중'까지 동작,
  워치 startMirroring 성공 반환 — 그러나 폰 미러링 핸들러는 시뮬에서 불리지 않음(애플 샘플도 실기기 전용)
  → 30초 타임아웃·알럿·WC 폐기·폰 폴백 저장 경로 확인, 워치 자체 세트 정상. 3타깃 빌드 그린·경고 0.
  미러링 수신·완주 저장·회신은 실기기 TestFlight 재테스트 필요
- 문서·마케팅: CLAUDE.md §3.8 신설·§1/§3.7/§4/§6/§7 갱신, 설명 3언어·whats-new·review-notes·PRIVACY.md
  (아이폰 읽기 권한 설명 — GitHub 게시본 갱신 필요), 스크린샷 01·08(배지 제거)·02·03(♥78 상태 줄) 재촬영·합성

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
2026-09-05 10:13:48 +09:00

103 lines
5.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// HealthRecorder.swift
// Keging
//
// '' (iOS ).
// (WatchHeartRateBridge), .
// (workout effort score, 1~10) .
// · · .
//
import HealthKit
@MainActor
final class HealthRecorder {
static let shared = HealthRecorder()
private let store = HKHealthStore()
private var authRequested = false
private var authInFlight = false
/// ( : )
private var pendingAfterAuth: [() -> Void] = []
private init() {}
private static var shareTypes: Set<HKSampleType> {
[
HKObjectType.workoutType(),
HKQuantityType(.workoutEffortScore),
HKQuantityType(.activeEnergyBurned),
]
}
/// · .
/// · ,
/// .
private static var readTypes: Set<HKObjectType> {
[HKQuantityType(.heartRate), 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)
}
/// ( ).
/// `then` ,
/// ( · §3.8)
func requestAuthorizationIfNeeded(then action: @escaping () -> Void) {
#if DEBUG
// QA
if CommandLine.arguments.contains("-noHealth") { action(); return }
#endif
guard HKHealthStore.isHealthDataAvailable() else { action(); return }
if authRequested, !authInFlight { action(); return }
pendingAfterAuth.append(action)
guard !authRequested else { return }
authRequested = true
authInFlight = true
store.requestAuthorization(toShare: Self.shareTypes, read: Self.readTypes) { _, _ in
Task { @MainActor in
self.authInFlight = false
let actions = self.pendingAfterAuth
self.pendingAfterAuth = []
actions.forEach { $0() }
}
}
}
/// , (/ )
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 {
//
}
}
}
}