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
181 lines
6.7 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|