mycode/myApp/Keging/IOS/WorkoutCoordinator.swift
songyc macbook 8f634c83f4 fix(keging): 실기기 3차 피드백 6건 — DI 멈춤 근본 원인(햅틱 엔진 세션 충돌) 제거·워치 세팅 pull 동기화·완료 진동·하루 타임라인·칼로리 정합
- 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
2026-09-02 14:30:08 +09:00

103 lines
3.5 KiB
Swift

//
// WorkoutCoordinator.swift
// Keging
//
// ·Live Activity· · (iOS).
// 2 · ·Live Activity
// ( 2 DI 3 ).
//
import UIKit
@MainActor
final class WorkoutCoordinator {
static let shared = WorkoutCoordinator()
private var wired = false
private var heartbeat: Timer?
/// ( ~2.7)
private var pendingAudioStop: Task<Void, Never>?
private init() {}
func wire() {
guard !wired else { return }
wired = true
LiveActivityManager.shared.cleanupStaleActivities()
let engine = KegelEngine.shared
engine.onPhaseChange = { [weak engine] phase, rep, phaseStart, phaseEnd in
guard let engine else { return }
let config = engine.config
switch phase {
case .prepare: break //
case .contract: Haptics.play(config.contractPattern)
case .relax: Haptics.play(config.relaxPattern)
}
LiveActivityManager.shared.sync(
phase: phase, rep: rep, totalReps: config.reps,
phaseStart: phaseStart, phaseEnd: phaseEnd
)
}
engine.onSessionEnd = { [weak self] completed, record in
self?.stopHeartbeat()
LiveActivityManager.shared.end()
UIApplication.shared.isIdleTimerDisabled = false
if completed {
Haptics.playFinish()
self?.stopAudioSoon()
} else {
BackgroundAudioKeeper.shared.stop()
}
if completed, let record {
let config = SettingsStore.shared.config
HealthRecorder.shared.save(
record: record,
effortScore: config.effortScore,
workoutType: config.healthWorkoutType
)
}
}
}
func startWorkout() {
guard !KegelEngine.shared.isRunning else { return }
pendingAudioStop?.cancel()
pendingAudioStop = nil
HealthRecorder.shared.requestAuthorizationIfNeeded()
BackgroundAudioKeeper.shared.start()
UIApplication.shared.isIdleTimerDisabled = true
KegelEngine.shared.start(config: SettingsStore.shared.config)
startHeartbeat()
}
// MARK:
private func startHeartbeat() {
heartbeat?.invalidate()
heartbeat = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
Task { @MainActor in
guard KegelEngine.shared.isRunning else { return }
BackgroundAudioKeeper.shared.ensureAlive()
KegelEngine.shared.resyncAfterWake()
LiveActivityManager.shared.syncCurrent()
}
}
}
private func stopHeartbeat() {
heartbeat?.invalidate()
heartbeat = nil
}
private func stopAudioSoon() {
pendingAudioStop?.cancel()
pendingAudioStop = Task { @MainActor in
try? await Task.sleep(for: .seconds(4))
guard !Task.isCancelled, !KegelEngine.shared.isRunning else { return }
BackgroundAudioKeeper.shared.stop()
}
}
}