A new iOS widget extension shows the active exercise, its phase, and the work/rest countdown on the lock screen and in the Dynamic Island, driven by the run flow's live frames so locking the phone mid-set keeps the timer. The activity is seeded on open, refreshed on every page settle, dismissed when the flow is left, and cleared on next launch if stranded. Unifies the build-info stamping across all targets via a YAML anchor. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
175 lines
6.6 KiB
Swift
175 lines
6.6 KiB
Swift
//
|
|
// 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<State>
|
|
|
|
/// 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<WorkoutActivityAttributes>.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<WorkoutActivityAttributes>.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<WorkoutActivityAttributes>.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
|
|
)
|
|
}
|
|
}
|