Files
workouts/Workouts Widget/WorkoutLiveActivity.swift
T
rzen df3eac9d5f Add a Live Activity for the running workout
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
2026-07-08 08:02:34 -04:00

181 lines
6.7 KiB
Swift

//
// WorkoutLiveActivity.swift
// Workouts Widget
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import ActivityKit
import SwiftUI
import WidgetKit
/// The active-workout Live Activity: a lock-screen / banner presentation plus the Dynamic
/// Island (compact, minimal, expanded). Every timer renders straight off the content state's
/// wall-clock anchors via `Text(timerInterval:)` / `ProgressView(timerInterval:)`, so the
/// countdowns stay live while the phone is locked with **no push updates**.
///
/// Backgrounds are left to the system (lock-screen material / Dynamic Island black), so light
/// and dark both look right without hardcoding a fill. Phase tints echo the in-app run flow —
/// brand purple for work, teal for rest.
struct WorkoutLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WorkoutActivityAttributes.self) { context in
LockScreenView(state: context.state)
.activitySystemActionForegroundColor(.primary)
} dynamicIsland: { context in
let state = context.state
return DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label(state.phaseTitle, systemImage: Glyph.exercise)
.font(.caption.weight(.semibold))
.foregroundStyle(state.phase.tint)
.padding(.leading, 4)
}
DynamicIslandExpandedRegion(.trailing) {
TimerLabel(state: state)
.font(.system(.title3, design: .rounded, weight: .semibold))
.foregroundStyle(state.phase.tint)
.frame(maxWidth: 96, alignment: .trailing)
.padding(.trailing, 4)
}
DynamicIslandExpandedRegion(.bottom) {
VStack(alignment: .leading, spacing: 4) {
Text(state.exerciseName)
.font(.headline)
.lineLimit(1)
Text(state.statusLine)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
if let end = state.phaseEnd {
ProgressView(timerInterval: state.phaseStart...end, countsDown: true) {
EmptyView()
} currentValueLabel: {
EmptyView()
}
.tint(state.phase.tint)
}
}
.padding(.top, 2)
}
} compactLeading: {
Image(systemName: Glyph.exercise)
.foregroundStyle(state.phase.tint)
} compactTrailing: {
if let end = state.phaseEnd {
CountdownText(start: state.phaseStart, end: end)
.foregroundStyle(state.phase.tint)
.frame(maxWidth: 52)
} else {
Text("\(state.setNumber)/\(state.setCount)")
.monospacedDigit()
}
} minimal: {
if let end = state.phaseEnd {
CountdownText(start: state.phaseStart, end: end)
.foregroundStyle(state.phase.tint)
.frame(maxWidth: 52)
} else {
Image(systemName: Glyph.exercise)
.foregroundStyle(state.phase.tint)
}
}
.keylineTint(state.phase.tint)
}
}
}
// MARK: - Lock screen / banner
private struct LockScreenView: View {
let state: WorkoutActivityAttributes.ContentState
var body: some View {
HStack(alignment: .center, spacing: 14) {
Image(systemName: Glyph.exercise)
.font(.title2)
.foregroundStyle(state.phase.tint)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text(state.exerciseName)
.font(.headline)
.lineLimit(1)
Text(state.statusLine)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
if let end = state.phaseEnd {
ProgressView(timerInterval: state.phaseStart...end, countsDown: true) {
EmptyView()
} currentValueLabel: {
EmptyView()
}
.tint(state.phase.tint)
.padding(.top, 1)
}
}
Spacer(minLength: 8)
TimerLabel(state: state)
.font(.system(.title, design: .rounded, weight: .semibold))
.foregroundStyle(state.phase.tint)
}
.padding()
}
}
// MARK: - Timer helpers
/// Picks the right live timer for the phase: a count-down for rest / timed work / auto-Done, a
/// stopwatch count-up for a rep-based work set, a checkmark for the terminal Done state, and
/// nothing for the Ready lead-in.
private struct TimerLabel: View {
let state: WorkoutActivityAttributes.ContentState
var body: some View {
if let end = state.phaseEnd {
CountdownText(start: state.phaseStart, end: end)
} else if state.phase == .work {
Text(state.phaseStart, style: .timer)
.monospacedDigit()
} else if state.phase == .done {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
}
/// A self-updating count-down rendered off wall-clock anchors — no push needed.
private struct CountdownText: View {
let start: Date
let end: Date
var body: some View {
Text(timerInterval: start...end, countsDown: true)
.monospacedDigit()
.multilineTextAlignment(.trailing)
}
}
// MARK: - Styling
private enum Glyph {
static let exercise = "figure.strengthtraining.traditional"
}
private extension WorkoutActivityAttributes.ContentState.Phase {
/// Phase tint echoing the in-app run flow (brand purple for work, teal for rest). Kept
/// local to the widget so the extension stays self-contained; values mirror
/// `Color.workTint` / `Color.restTint` in `Shared/Utils/Color+Extensions.swift`.
var tint: Color {
switch self {
case .ready: return Color(red: 0.51, green: 0.22, blue: 0.84)
case .work: return Color(red: 0.51, green: 0.22, blue: 0.84)
case .rest: return Color(red: 0.44, green: 0.85, blue: 0.84)
case .done: return .green
}
}
}