- 실기기 판독: 진동은 끝까지 정시 = 앱 생존, 그러나 국면 라벨·횟수는 항상 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
101 lines
3.5 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|