mycode/myApp/HaruDanim/IOS/Core/LiveActivityManager.swift
songyc macbook 59d6105d21 feat(tracking): long-running session alerts, timer cap fix, memo save
- 장시간 측정 알림: 측정이 설정한 시간(1/2/3/6/12시간, 기본 끔)을 넘기면
  로컬 알림으로 종료를 잊었음을 알려준다. LiveActivityManager.sync 길목에서
  진행 세션과 예약을 동기화하므로 앱·위젯·시리·워치 어느 경로로
  시작/종료/수정해도 예약이 따라가고, 세션이 끝나거나 설정이 바뀌면
  기존 예약이 제거된다 (식별자에 행동·시작시각·설정시간 포함).
  설정 탭 '측정' 섹션에서 켜는 순간 권한 요청.
- Live Activity 타이머 상한 30일 → 1년 (상한 도달로 타이머가 멈춰 보이던
  엣지 제거)
- 메모 시트(측정 종료/횟수 기록) 저장 시 명시적 context.save — autosave
  의존 제거
- 검증용 -longSessionAlertTestSeconds(시간 대신 N초 발화)·-alertDump
  (예약 상태를 Documents/session-alerts.txt로) 추가. 시뮬레이터에서
  예약 생성(진행 세션 2건)과 설정 해제 시 제거를 덤프로 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 00:04:08 +09:00

91 lines
3.5 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)
)
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)
}
}
}
}