// // LiveActivityManager.swift // Keging // // 다이나믹 아일랜드 Live Activity — 세트 전체 일정을 상태에 실어 위젯이 스스로 그리게 한다 // (타임라인 바·플레이헤드·남은 시간은 시스템 렌더라 앱이 정지해도 움직임 — §3.4). // 국면 전환 콜백 + 하트비트(2초)가 모두 syncCurrent를 부르지만 상태가 실제로 바뀐 // 경우에만 갱신을 보낸다. staleDate(국면 종료+8초)가 지나면 위젯이 국면 정보를 흐린다. // import ActivityKit import Foundation @MainActor final class LiveActivityManager { static let shared = LiveActivityManager() private var activity: Activity? private var lastState: KegingActivityAttributes.ContentState? private init() {} /// 앱 시작 시 호출 — 강제 종료 등으로 남은 이전 Live Activity를 정리한다 /// (안 하면 운동 중 강제 종료 시 다이나믹 아일랜드가 몇 시간 동안 유령으로 남음) func cleanupStaleActivities() { for stale in Activity.activities { Task { await stale.end(nil, dismissalPolicy: .immediate) } } activity = nil lastState = nil } /// 엔진의 현재 상태를 그대로 반영 — 없으면 시작, 있으면 갱신. /// 같은 상태면 아무것도 하지 않아 하트비트에서 매번 불러도 안전 func syncCurrent() { let engine = KegelEngine.shared guard case let .running(phase, rep, phaseStart, phaseEnd) = engine.state else { return } let config = engine.config let state = KegingActivityAttributes.ContentState( phase: phase, rep: rep, totalReps: config.reps, phaseStart: phaseStart, phaseEnd: phaseEnd, sessionStart: engine.sessionStart, prepSeconds: config.prepSeconds, contractSeconds: config.contractSeconds, relaxSeconds: config.relaxSeconds ) let content = ActivityContent(state: state, staleDate: phaseEnd.addingTimeInterval(8)) if let activity { guard state != lastState else { return } lastState = state Task { await activity.update(content) } } else { guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } lastState = state activity = try? Activity.request( attributes: KegingActivityAttributes(startedAt: engine.sessionStart), content: content ) } } func end() { lastState = nil guard let activity else { return } self.activity = nil Task { await activity.end(nil, dismissalPolicy: .immediate) } } }