// ── Live Activity Attributes ────────────────────────────────── // ActivityKit data model for Dynamic Island + Lock Screen Live Activities // Shared between main app and widget extension import Foundation import ActivityKit // MARK: - Timer Live Activity struct TimerActivityAttributes: ActivityAttributes { /// Static data that doesn't change during the activity struct ContentState: Codable, Hashable { let remainingSeconds: Int let targetTime: Date let state: String // CMTimerState raw value let urgencyHex: String // color hex for urgency let pomodoroRound: Int? // current round (nil if not pomodoro) let pomodoroTotal: Int? // total rounds let isBreak: Bool // pomodoro break phase } // Fixed for the lifetime of the activity let timerId: String let timerLabel: String let timerType: String // CMTimerType raw value let urgency: String // UrgencyLevel raw value let createdAt: Date } // MARK: - Activity Manager @MainActor final class LiveActivityManager { static let shared = LiveActivityManager() private var currentActivity: Activity? private init() {} // MARK: - Start /// Start a Live Activity for an active timer func startActivity(for timer: CMTimer) { guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } // End any existing activity first endActivity() let urgencyConfig = getUrgencyConfig(timer.urgency) let remaining = Int(max(0, timer.targetTime.timeIntervalSinceNow)) let attributes = TimerActivityAttributes( timerId: timer.id, timerLabel: timer.label, timerType: timer.type.rawValue, urgency: timer.urgency.rawValue, createdAt: timer.createdAt ) let state = TimerActivityAttributes.ContentState( remainingSeconds: remaining, targetTime: timer.targetTime, state: timer.state.rawValue, urgencyHex: urgencyConfig.colorHex, pomodoroRound: timer.pomodoroState?.currentRound, pomodoroTotal: timer.pomodoroConfig?.rounds, isBreak: timer.pomodoroState?.isBreak ?? false ) let content = ActivityContent(state: state, staleDate: timer.targetTime.addingTimeInterval(60)) do { currentActivity = try Activity.request( attributes: attributes, content: content, pushType: nil ) } catch { // Live Activity not available — silent fallback } } // MARK: - Update /// Update the Live Activity with new timer state func updateActivity(for timer: CMTimer) { guard let activity = currentActivity, activity.activityState == .active else { // If no active activity but timer is active, start one if [.active, .warning].contains(timer.state) { startActivity(for: timer) } return } let urgencyConfig = getUrgencyConfig(timer.urgency) let remaining = Int(max(0, timer.targetTime.timeIntervalSinceNow)) let state = TimerActivityAttributes.ContentState( remainingSeconds: remaining, targetTime: timer.targetTime, state: timer.state.rawValue, urgencyHex: urgencyConfig.colorHex, pomodoroRound: timer.pomodoroState?.currentRound, pomodoroTotal: timer.pomodoroConfig?.rounds, isBreak: timer.pomodoroState?.isBreak ?? false ) let content = ActivityContent(state: state, staleDate: timer.targetTime.addingTimeInterval(60)) Task { await activity.update(content) } } // MARK: - End /// End the current Live Activity func endActivity() { guard let activity = currentActivity else { return } let finalState = TimerActivityAttributes.ContentState( remainingSeconds: 0, targetTime: Date(), state: CMTimerState.dismissed.rawValue, urgencyHex: "#5B8DEE", pomodoroRound: nil, pomodoroTotal: nil, isBreak: false ) let content = ActivityContent(state: finalState, staleDate: nil) Task { await activity.end(content, dismissalPolicy: .immediate) } currentActivity = nil } /// End activity for a specific timer ID func endActivity(for timerId: String) { guard let activity = currentActivity, activity.attributes.timerId == timerId else { return } endActivity() } }