// // 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 } } }