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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user