- 잠금화면 실시간 현황(라이브 액티비티)에 종료 수단이 없다는 보고 → 배너 오른쪽에 노란 정지 버튼 추가. 워치·⑥ 위젯과 같은 검증된 경로(NowRunningStopButton → RunActionIntent 토글) 재사용이라 위험 낮음 판단 후 진행 - ContentState에 actionID(optional) 추가 — 앱 업데이트 전 인플라이트 액티비티(키 없음)도 안전 디코딩(WatchSnapshot 선례), 없으면 버튼 없이 기존 표시 - LockScreenActivityView를 NowRunningWidget.swift로 이동 — @main 파일은 앱 타깃에서 제외라 앱 내 위젯 미리보기(-widgetPreview)에서 배너를 렌더하기 위함(동작 불변). 미리보기 최상단에 배너 박스 신설 - 다이나믹 아일랜드 스모크 확인(상태 필드 추가 회귀 없음 — 콤팩트 정상 표시). 실기기 확인 항목: 잠금화면에서 정지 버튼 탭 시 즉시 종료 - 도움말 '다이나믹 아일랜드·잠금화면' 문구에 정지 버튼 안내 추가(ko/en/ja, stale 정리), CLAUDE.md §9 갱신. Debug/Store 빌드 성공, 카탈로그 9종 클린 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
92 lines
3.6 KiB
Swift
92 lines
3.6 KiB
Swift
//
|
|
// LiveActivityManager.swift
|
|
// Haru_Danim
|
|
//
|
|
// 추적 상태를 다이나믹 아일랜드/잠금화면 Live Activity에 반영 (CLAUDE.md §4.1, §4.2)
|
|
//
|
|
|
|
import Foundation
|
|
import ActivityKit
|
|
import SwiftData
|
|
|
|
enum LiveActivityManager {
|
|
/// 백그라운드(워치 명령으로 깨어난 상태 등)에서는 시스템이 Live Activity '시작'을
|
|
/// 허용하지 않으므로, 실패를 기억해 두고 앱이 포그라운드로 돌아올 때 재시도한다
|
|
static var pendingStartRetry = false
|
|
|
|
/// 현재 진행 중인 세션들을 읽어 Live Activity를 시작/갱신/종료한다.
|
|
/// 세션 시작·종료·수정 후마다 호출.
|
|
static func sync(context: ModelContext) {
|
|
let descriptor = FetchDescriptor<TimeSession>(
|
|
predicate: #Predicate { $0.endAt == nil },
|
|
sortBy: [SortDescriptor(\.startAt, order: .forward)]
|
|
)
|
|
let running = (try? context.fetch(descriptor)) ?? []
|
|
|
|
// 장시간 측정 알림 예약도 같은 길목에서 동기화 (Live Activity 허용 여부와 무관)
|
|
SessionAlertManager.sync(running: running)
|
|
|
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
|
|
|
let mode = LiveActivityMode(
|
|
rawValue: AppGroup.defaults.string(forKey: SettingsKeys.liveActivityMode)
|
|
?? UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? ""
|
|
) ?? .latest
|
|
let representative = mode == .earliest ? running.first : running.last
|
|
|
|
guard let representative, let action = representative.action else {
|
|
pendingStartRetry = false
|
|
endAll()
|
|
return
|
|
}
|
|
|
|
let state = TrackingActivityAttributes.ContentState(
|
|
actionName: action.name,
|
|
symbolName: action.symbolName,
|
|
colorHex: action.sortedTags.first?.colorHex ?? "#2F6B4F",
|
|
startedAt: representative.startAt,
|
|
extraCount: max(running.count - 1, 0),
|
|
actionID: action.uuid.uuidString
|
|
)
|
|
let content = ActivityContent(state: state, staleDate: nil)
|
|
|
|
Task {
|
|
let activities = Activity<TrackingActivityAttributes>.activities
|
|
if let activity = activities.first {
|
|
await activity.update(content)
|
|
pendingStartRetry = false
|
|
// 혹시 중복 생성된 것이 있으면 정리
|
|
for extra in activities.dropFirst() {
|
|
await extra.end(nil, dismissalPolicy: .immediate)
|
|
}
|
|
} else {
|
|
do {
|
|
_ = try Activity<TrackingActivityAttributes>.request(
|
|
attributes: TrackingActivityAttributes(),
|
|
content: content
|
|
)
|
|
pendingStartRetry = false
|
|
} catch {
|
|
// 워치 명령 등 백그라운드 컨텍스트에서는 시작이 거부된다(visibility)
|
|
// → 포그라운드 복귀 시 syncIfRetryNeeded가 다시 시도
|
|
pendingStartRetry = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 앱이 포그라운드로 돌아왔을 때, 백그라운드에서 거부됐던 시작을 재시도
|
|
static func syncIfRetryNeeded(context: ModelContext) {
|
|
guard pendingStartRetry else { return }
|
|
sync(context: context)
|
|
}
|
|
|
|
static func endAll() {
|
|
Task {
|
|
for activity in Activity<TrackingActivityAttributes>.activities {
|
|
await activity.end(nil, dismissalPolicy: .immediate)
|
|
}
|
|
}
|
|
}
|
|
}
|