mycode/myApp/Keging/IOS/WorkoutCoordinator.swift
songyc macbook fd9fd134d2 feat(keging): Live Activity 무갱신·완전 자가 렌더링 확정 — 백그라운드 예산제 동결 원천 회피 (실기기 5차)
- 실기기 판독: 진동은 끝까지 정시 = 앱 생존, 그러나 국면 라벨·횟수는 항상 4번째
  전송(수축 2회차)에서 동결 + 진행바만 흐름 → 백그라운드 Activity.update를
  시스템이 예산제로 폐기하는 것으로 확정. staleDate 재렌더도 미발화(시뮬 실측)
- 설계 전환: ContentState = 세트 일정뿐, 시작 시 1회 등록(ensureStarted) 후
  update 호출 0회. 화면은 시스템 렌더(국면 색 타임라인 바+플레이헤드+남은 시간)와
  영원히 참인 정적 정보(수축·이완 범례, 세트 구성)만 — 동결할 데이터 자체가 없음
- 컴팩트 = 미니 타임라인+남은 시간, 미니멀 = 국면 두 색 점. 국면 라벨·횟수
  텍스트는 LA에서 제거(국면=진동+플레이헤드 색, 횟수=앱)
- 통계 타임라인 점별 시각 라벨 제거(겹침), 도움말·번역 갱신, CLAUDE.md 현행화
- 시뮬: 등록 후 갱신 없이 35초간 4:57→4:27 정확 진행·완주 시 DI 해제 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
2026-09-02 17:00:34 +09:00

101 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, _, _, _ 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)
}
// Live Activity 1 ( , §3.4)
}
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)
LiveActivityManager.shared.ensureStarted()
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.ensureStarted() // ( no-op)
}
}
}
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()
}
}
}