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
This commit is contained in:
2026-07-08 08:02:34 -04:00
parent e04eb83e70
commit df3eac9d5f
11 changed files with 569 additions and 18 deletions
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Workouts</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,82 @@
//
// WorkoutActivityAttributes.swift
// Workouts (shared: iOS app + Workouts Widget)
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import ActivityKit
import Foundation
/// The ActivityKit attributes backing the active-workout Live Activity (lock screen +
/// Dynamic Island). This file is compiled into **both** the iOS app (which starts / updates /
/// ends the activity via `WorkoutLiveActivityController`) and the `Workouts Widget` extension
/// (which renders it). It intentionally depends on nothing but ActivityKit + Foundation, so it
/// can live in the widget target without pulling in the app's model/sync layer.
///
/// The whole design is **push-free**: every content state carries wall-clock anchors
/// (`phaseStart` / `phaseEnd`), so the widget renders live countdowns with
/// `Text(timerInterval:)` / `ProgressView(timerInterval:)` and never needs a server or a
/// background update to keep ticking the same wall-clock-anchored trick the in-app run flow
/// and the watch mirror already use.
struct WorkoutActivityAttributes: ActivityAttributes {
/// The dynamic part of the activity mirrors one page of the Ready Work Rest Finish
/// run flow. Refreshed by the app on every phase / set transition while it's foregrounded.
struct ContentState: Codable, Hashable, Sendable {
/// Which page of the run flow the driving device is on.
enum Phase: String, Codable, Hashable, Sendable {
case ready, work, rest, done
}
var exerciseName: String
var phase: Phase
/// 0-based set this frame pertains to, and the planned total.
var setIndex: Int
var setCount: Int
/// Footer line under the timer, e.g. "8 reps" or "30 sec".
var detail: String
/// Wall-clock anchor for the phase's timer. A count-up work set counts up from here; a
/// count-down phase (rest / timed work / auto-Done) runs from here to `phaseEnd`.
var phaseStart: Date
/// End anchor for a count-down phase; `nil` for a rep-based work set (which counts up)
/// or the Ready lead-in (which shows no timer).
var phaseEnd: Date?
// MARK: Derived (shared by every widget presentation)
/// 1-based set number for display.
var setNumber: Int { setIndex + 1 }
/// A running count-down is present (rest, timed work, or the auto-Done deadline).
var isCountingDown: Bool { phaseEnd != nil }
/// A rep-based work set that counts up (no end anchor).
var isCountingUp: Bool { phase == .work && phaseEnd == nil }
/// Short phase word for the compact/expanded labels.
var phaseTitle: String {
switch phase {
case .ready: return "Ready"
case .work: return "Work"
case .rest: return "Rest"
case .done: return "Done"
}
}
/// One-line status under the exercise name, e.g. "Rest · Set 2 of 4" or
/// "Set 2 of 4 · 8 reps".
var statusLine: String {
let sets = "Set \(setNumber) of \(setCount)"
switch phase {
case .ready: return "Ready · \(sets)"
case .work: return detail.isEmpty ? sets : "\(sets) · \(detail)"
case .rest: return "Rest · \(sets)"
case .done: return "Complete"
}
}
}
}
+180
View File
@@ -0,0 +1,180 @@
//
// 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
}
}
}
@@ -0,0 +1,18 @@
//
// WorkoutsWidgetBundle.swift
// Workouts Widget
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import WidgetKit
/// The iOS widget extension's entry point. It currently hosts only the active-workout Live
/// Activity; ordinary home/lock-screen widgets can be added to the bundle later.
@main
struct WorkoutsWidgetBundle: WidgetBundle {
var body: some Widget {
WorkoutLiveActivity()
}
}