feat(live-activity): add ActivityKit attributes and LiveActivityManager for Dynamic Island
This commit is contained in:
parent
46d9866253
commit
4b1cbcf81b
145
ios/ChronoMind/LiveActivity/TimerActivityAttributes.swift
Normal file
145
ios/ChronoMind/LiveActivity/TimerActivityAttributes.swift
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
// ── 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<TimerActivityAttributes>?
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user