- 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
70 lines
2.7 KiB
Swift
70 lines
2.7 KiB
Swift
//
|
|
// LiveActivityManager.swift
|
|
// Keging
|
|
//
|
|
// 다이나믹 아일랜드 Live Activity — 현재 횟수·수축/이완 국면 표시.
|
|
// 국면 전환 콜백 + 하트비트(2초)가 모두 sync를 부르지만 상태가 실제로 바뀐 경우에만
|
|
// 갱신을 보낸다. staleDate를 국면 종료+8초로 걸어, 앱이 정지해 갱신이 끊기면
|
|
// 시스템이 stale로 표시(위젯 쪽에서 흐리게 처리)하게 한다.
|
|
//
|
|
|
|
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 sync(phase: KegelPhase, rep: Int, totalReps: Int, phaseStart: Date, phaseEnd: Date) {
|
|
let state = KegingActivityAttributes.ContentState(
|
|
phase: phase, rep: rep, totalReps: totalReps,
|
|
phaseStart: phaseStart, phaseEnd: phaseEnd
|
|
)
|
|
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: Date()),
|
|
content: content
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 하트비트용 — 엔진의 현재 상태를 그대로 반영 (놓친 국면 전환 자가 치유)
|
|
func syncCurrent() {
|
|
guard case let .running(phase, rep, phaseStart, phaseEnd) = KegelEngine.shared.state else { return }
|
|
sync(
|
|
phase: phase, rep: rep, totalReps: KegelEngine.shared.config.reps,
|
|
phaseStart: phaseStart, phaseEnd: phaseEnd
|
|
)
|
|
}
|
|
|
|
func end() {
|
|
lastState = nil
|
|
guard let activity else { return }
|
|
self.activity = nil
|
|
Task { await activity.end(nil, dismissalPolicy: .immediate) }
|
|
}
|
|
}
|