// // WorkoutLiveActivityController.swift // Workouts // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import ActivityKit import Foundation import os /// Owns the lifecycle of the active-workout Live Activity (lock screen + Dynamic Island). /// /// It is driven by the **same** run-flow frames the phone↔watch mirror uses (`LiveProgress`): /// `ExerciseProgressView` hands us one on every phase / set settle, we translate it to a /// `WorkoutActivityAttributes.ContentState`, and start or update the activity. Because the /// content carries wall-clock anchors, the widget's countdowns keep ticking while the phone is /// locked without any push update from here. /// /// Lifecycle: /// • **Start** — the first non-Ready frame of a run spins the activity up. /// • **Update** — every subsequent frame refreshes it (idempotent). /// • **End** — leaving the run flow tears it down; a completed run lingers briefly on a /// terminal "Complete" state before auto-dismissing. /// /// Degrades silently when Live Activities are disabled (`areActivitiesEnabled`). /// /// Concurrency: the public entry points are **synchronous**, so `currentLogID` / `lastState` /// (both `Sendable`) mutate in call order on the main actor. The async ActivityKit I/O is fired /// off to detached tasks that resolve the live activity from the `nonisolated` /// `Activity.activities` static — a *disconnected* value that crosses into the task cleanly, /// unlike a non-`Sendable` `Activity` stashed in a main-actor-isolated property. The run flow is /// per-exercise, so there's only ever one of our activities live at a time. @MainActor final class WorkoutLiveActivityController { private static let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "live-activity") private typealias State = WorkoutActivityAttributes.ContentState private typealias Content = ActivityContent /// The run the live activity belongs to, so a frame for a *different* run replaces it, and a /// `nil` means nothing is showing. private var currentLogID: String? /// The most recent content state, so `end()` can render a terminal "Complete" card without /// reaching back into the (non-`Sendable`) activity handle. private var lastState: State? private var isEnabled: Bool { ActivityAuthorizationInfo().areActivitiesEnabled } // MARK: - Update /// Reflect the given run frame on the Live Activity — starting one if needed. Fired on every /// settle (human swipe, auto-advance, or a followed watch frame), so it must be idempotent. func update(_ frame: LiveProgress) { guard isEnabled else { return } // A frame for a run other than the one we're showing — drop the stale activity first. if let current = currentLogID, current != frame.logID { reset() } // Swiping back to the Ready page resets the exercise — nothing to show anymore. guard frame.phase != .ready else { if currentLogID != nil { reset() } return } let state = Self.contentState(from: frame) lastState = state let content = Content(state: state, staleDate: nil) if currentLogID == nil { // Don't spin one up just for the Ready lead-in; this is the first real frame. currentLogID = frame.logID start(content) } else { pushUpdate(content) } } private func start(_ content: Content) { do { _ = try Activity.request( attributes: WorkoutActivityAttributes(), content: content, pushType: nil ) } catch { Self.log.error("Live Activity start failed: \(error, privacy: .public)") currentLogID = nil lastState = nil } } // MARK: - End /// Leave the run flow. If the run reached its terminal Done state, linger briefly on a /// "Complete" card before dismissing; otherwise dismiss at once. func end() { guard let state = lastState else { return } currentLogID = nil lastState = nil if state.phase == .done { var terminal = state terminal.phaseEnd = nil // stop the auto-Done countdown; show the finished state endAll(final: Content(state: terminal, staleDate: nil), dismissal: .after(Date().addingTimeInterval(4))) } else { endAll(final: nil, dismissal: .immediate) } } /// Immediately tear down whatever is showing (a run switch or a Ready reset). private func reset() { currentLogID = nil lastState = nil endAll(final: nil, dismissal: .immediate) } /// Dismiss any activity still on screen from a previous launch (e.g. the app was killed /// mid-run). Called defensively at bootstrap. func endStaleActivities() async { currentLogID = nil lastState = nil for stale in Activity.activities { await stale.end(nil, dismissalPolicy: .immediate) } } // MARK: - Fire-and-forget ActivityKit I/O /// Push a new content state to the live activity. `content` is `Sendable`; the activity is /// resolved inside the task from the nonisolated `activities` list, so nothing non-`Sendable` /// crosses the main-actor boundary. private nonisolated func pushUpdate(_ content: Content) { Task { for activity in Activity.activities { await activity.update(content) } } } /// End every live activity of our type, optionally showing a terminal `final` state first. private nonisolated func endAll(final content: Content?, dismissal: ActivityUIDismissalPolicy) { Task { for activity in Activity.activities { await activity.end(content, dismissalPolicy: dismissal) } } } // MARK: - Helpers /// Translate a run-flow frame into a widget content state (mapping `.finish` → `.done`). private static func contentState(from frame: LiveProgress) -> State { let phase: State.Phase switch frame.phase { case .ready: phase = .ready case .work: phase = .work case .rest: phase = .rest case .finish: phase = .done } return State( exerciseName: frame.exerciseName, phase: phase, setIndex: frame.setIndex, setCount: frame.setCount, detail: frame.detail, phaseStart: frame.phaseStart, phaseEnd: frame.phaseEnd ) } }