- ActivityKit엔 미래 상태 예약 API가 없음을 확인(푸시 서버 없이는 국면 갱신이 앱 런타임 의존) → 상태에 세트 전체 일정을 실어 시스템 렌더 요소로 심장을 재구성: 국면 색 타임라인 바(하드 스톱 그라데이션) + 실시간 플레이헤드 ProgressView(timerInterval:) + 전체 남은 시간. 플레이헤드가 걸친 색 = 지금 국면 - 국면 라벨·횟수는 앱 갱신 유지(syncCurrent 단일 진입점), stale 시 흐림 - 오디오 키퍼: 완전 무음 대신 ±2 LSB 디더 노이즈(무음 감지 정지 회피) + 백그라운드 전환 ~30초 브리지 태스크 - 통계 타임라인 점별 시각 라벨 제거(겹침 — 시각은 오늘 기록 목록에서) - 도움말·번역 갱신, CLAUDE.md 현행화. 시뮬: DI 백그라운드 80초+(10/20) 진행 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
69 lines
2.7 KiB
Swift
69 lines
2.7 KiB
Swift
//
|
|
// 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<KegingActivityAttributes>?
|
|
private var lastState: KegingActivityAttributes.ContentState?
|
|
|
|
private init() {}
|
|
|
|
/// 앱 시작 시 호출 — 강제 종료 등으로 남은 이전 Live Activity를 정리한다
|
|
/// (안 하면 운동 중 강제 종료 시 다이나믹 아일랜드가 몇 시간 동안 유령으로 남음)
|
|
func cleanupStaleActivities() {
|
|
for stale in Activity<KegingActivityAttributes>.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) }
|
|
}
|
|
}
|