feat: production-grade Live Activity with type-safe phases, decomposed views, previews, and alert transitions

- Replace raw string phase model with WorkoutPhase enum (Codable, Sendable, CaseIterable)
  with built-in .capitalized display name and SwiftUI .color per phase
- Decompose WorkoutLiveActivity into reusable view structs: PhasePill, CountdownText,
  WorkoutProgressBar, MusicInfoRow, HeartRateBadge, PhaseIndicatorDot, WorkoutLockScreenView,
  WorkoutSmallView — following CraftingSwift iOS 26 architecture patterns
- Add AlertConfiguration on work/rest/complete phase transitions so Dynamic Island
  expands and lights up at key moments
- Add 13 #Preview blocks across both widgets covering all presentation types:
  lock screen, expanded, compact, minimal — for instant Xcode Canvas feedback
- Add stale state handling (context.isStale shows 'Last updated' indicator)
- MusicLiveActivity: 5 new #Preview blocks for playing/paused/expanded/compact/minimal
This commit is contained in:
Millian Lamiaux
2026-05-16 15:28:45 +02:00
parent 95f34e6471
commit dc3ff15e81
5 changed files with 476 additions and 255 deletions

View File

@@ -186,3 +186,36 @@ struct LiveActivityMusicBars: View {
private let barMaxHeights: [CGFloat] = [8, 14, 6, 12]
private let barMin: CGFloat = 3
private let barSpeeds: [Double] = [2.2, 1.8, 2.8, 1.5]
// MARK: - Previews
#Preview("Lock Screen — Playing", as: .content, using: MusicActivityAttributes()) {
MusicLiveActivity()
} contentStates: {
MusicActivityAttributes.ContentState(title: "Lose Yourself", artist: "Eminem", isPlaying: true)
}
#Preview("Lock Screen — Paused", as: .content, using: MusicActivityAttributes()) {
MusicLiveActivity()
} contentStates: {
MusicActivityAttributes.ContentState(title: "Stronger", artist: "Kanye West", isPlaying: false)
}
#Preview("Dynamic Island Expanded", as: .dynamicIsland(.expanded), using: MusicActivityAttributes()) {
MusicLiveActivity()
} contentStates: {
MusicActivityAttributes.ContentState(title: "Till I Collapse", artist: "Eminem", isPlaying: true)
MusicActivityAttributes.ContentState(title: "Eye of the Tiger", artist: "Survivor", isPlaying: false)
}
#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: MusicActivityAttributes()) {
MusicLiveActivity()
} contentStates: {
MusicActivityAttributes.ContentState(title: "Lose Yourself", artist: "Eminem", isPlaying: true)
}
#Preview("Dynamic Island Minimal", as: .dynamicIsland(.minimal), using: MusicActivityAttributes()) {
MusicLiveActivity()
} contentStates: {
MusicActivityAttributes.ContentState(title: "Lose Yourself", artist: "Eminem", isPlaying: true)
}

View File

@@ -2,6 +2,154 @@ import ActivityKit
import WidgetKit
import SwiftUI
// MARK: - Decomposed Views
struct PhasePill: View {
let phase: WorkoutPhase
let isPaused: Bool
var body: some View {
if isPaused {
Text("PAUSED")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(.white.opacity(0.15))
.clipShape(Capsule())
} else {
Text(phase.capitalized.uppercased())
.font(.caption.bold())
.foregroundStyle(phase.color)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(phase.color.opacity(0.15))
.clipShape(Capsule())
}
}
}
struct CountdownText: View {
let endDate: Date
let isPaused: Bool
let frozenSeconds: Int
let isUrgent: Bool
let size: CGFloat
var body: some View {
Group {
if isPaused {
Text(Self.formatFrozenTime(frozenSeconds))
.font(.system(size: size, weight: .bold).monospacedDigit())
.foregroundStyle(.white.opacity(0.5))
} else {
Text(timerInterval: Date()...endDate, countsDown: true)
.font(.system(size: size, weight: .bold).monospacedDigit())
.foregroundStyle(isUrgent ? .orange : .white)
.contentTransition(.numericText(countsDown: true))
}
}
.accessibilityLabel(isPaused ? "Timer paused at \(Self.formatFrozenTime(frozenSeconds))" : "Countdown timer")
}
static func formatFrozenTime(_ seconds: Int) -> String {
let m = seconds / 60
let s = seconds % 60
return String(format: "%d:%02d", m, s)
}
}
struct WorkoutProgressBar: View {
let progress: CGFloat
let color: Color
let isPaused: Bool
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 1, style: .continuous)
.fill(.white.opacity(0.08))
RoundedRectangle(cornerRadius: 1, style: .continuous)
.fill(color.opacity(isPaused ? 0.25 : 0.6))
.frame(width: geo.size.width * progress)
}
.frame(height: 2)
}
.frame(height: 2)
}
}
struct MusicInfoRow: View {
let trackTitle: String
let trackArtist: String
let isPlaying: Bool
let isPaused: Bool
let heartRate: Double
let isLuminanceReduced: Bool
var body: some View {
HStack(spacing: 6) {
Image(systemName: "music.note")
.font(.system(size: 10))
.foregroundStyle(.green)
.accessibilityLabel("Music")
Text(trackArtist.isEmpty
? trackTitle
: "\(trackTitle)\(trackArtist)")
.font(.caption2)
.lineLimit(1)
.foregroundStyle(.white.opacity(isPaused ? 0.3 : 0.5))
.accessibilityLabel(trackArtist.isEmpty ? trackTitle : "\(trackTitle) by \(trackArtist)")
Spacer()
if heartRate > 0 {
HeartRateBadge(heartRate: heartRate, isPaused: isPaused)
}
if !isPaused, isPlaying, !isLuminanceReduced {
LiveActivityMusicBars()
}
}
.opacity(isPaused ? 0.5 : 1)
}
}
struct HeartRateBadge: View {
let heartRate: Double
let isPaused: Bool
var body: some View {
HStack(spacing: 2) {
Image(systemName: "heart.fill")
.font(.system(size: 8))
.foregroundStyle(.red.opacity(0.7))
.accessibilityLabel("Heart rate")
Text("\(Int(heartRate))")
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.white.opacity(isPaused ? 0.3 : 0.5))
}
.accessibilityLabel("Heart rate \(Int(heartRate)) beats per minute")
}
}
struct PhaseIndicatorDot: View {
let color: Color
let isPaused: Bool
let size: CGFloat
let isPulsing: Bool
var body: some View {
Circle()
.fill(isPaused ? color.opacity(0.4) : color)
.frame(width: size, height: size)
.modifier(PulseEffect(active: isPulsing))
}
}
// MARK: - Main Widget
struct WorkoutLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WorkoutActivityAttributes.self) { context in
@@ -9,71 +157,36 @@ struct WorkoutLiveActivity: Widget {
@Environment(\.isActivityFullscreen) var isFullscreen
@Environment(\.isLuminanceReduced) var isLuminanceReduced
let phaseColor = Self.colorForPhase(context.state.phase)
let phaseLabel = context.state.phase.capitalized
let paused = context.state.isPaused
let timeRemaining = max(0, paused
? context.state.phaseEndDate.timeIntervalSinceNow
: context.state.phaseEndDate.timeIntervalSinceNow)
let isUrgent = !paused && timeRemaining > 0 && timeRemaining <= 5
switch activityFamily {
case .small:
smallLockScreenView(
context: context,
phaseColor: phaseColor,
phaseLabel: phaseLabel,
paused: paused,
frozenSeconds: Int(max(0, context.state.phaseEndDate.timeIntervalSinceNow))
)
default:
lockScreenView(
context: context,
phaseColor: phaseColor,
phaseLabel: phaseLabel,
isFullscreen: isFullscreen,
isLuminanceReduced: isLuminanceReduced,
isUrgent: isUrgent,
paused: paused,
frozenSeconds: Int(max(0, context.state.phaseEndDate.timeIntervalSinceNow))
)
}
} dynamicIsland: { context in
let phaseColor = Self.colorForPhase(context.state.phase)
let phaseLabel = context.state.phase.capitalized
let phase = context.state.phase
let paused = context.state.isPaused
let frozenSeconds = Int(max(0, context.state.phaseEndDate.timeIntervalSinceNow))
let isUrgent = !paused && frozenSeconds > 0 && frozenSeconds <= 5
let isWork = context.state.phase == "work" && !paused
switch activityFamily {
case .small:
WorkoutSmallView(phase: phase, paused: paused, frozenSeconds: frozenSeconds, exerciseName: context.state.exerciseName, roundCurrent: context.state.roundCurrent, roundTotal: context.state.roundTotal, endDate: context.state.phaseEndDate)
default:
WorkoutLockScreenView(phase: phase, paused: paused, frozenSeconds: frozenSeconds, isFullscreen: isFullscreen, isLuminanceReduced: isLuminanceReduced, isUrgent: isUrgent, isStale: context.isStale, exerciseName: context.state.exerciseName, roundCurrent: context.state.roundCurrent, roundTotal: context.state.roundTotal, endDate: context.state.phaseEndDate, heartRate: context.state.heartRate, trackTitle: context.state.trackTitle, trackArtist: context.state.trackArtist, isPlaying: context.state.isPlaying)
}
} dynamicIsland: { context in
let phase = context.state.phase
let paused = context.state.isPaused
let frozenSeconds = Int(max(0, context.state.phaseEndDate.timeIntervalSinceNow))
let isUrgent = !paused && frozenSeconds > 0 && frozenSeconds <= 5
let isWork = phase == .work && !paused
let progress = CGFloat(context.state.roundCurrent) / CGFloat(max(context.state.roundTotal, 1))
return DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
HStack(spacing: 4) {
if paused {
Text("PAUSED")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(.white.opacity(0.15))
.clipShape(Capsule())
} else {
Text(phaseLabel.uppercased())
.font(.caption.bold())
.foregroundStyle(phaseColor)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(phaseColor.opacity(0.15))
.clipShape(Capsule())
}
PhasePill(phase: phase, isPaused: paused)
Text("\(context.state.roundCurrent)/\(context.state.roundTotal)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.accessibilityLabel(paused
? "Paused, round \(context.state.roundCurrent) of \(context.state.roundTotal)"
: "\(phaseLabel) phase, round \(context.state.roundCurrent) of \(context.state.roundTotal)")
: "\(phase.capitalized) phase, round \(context.state.roundCurrent) of \(context.state.roundTotal)")
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.exerciseName)
@@ -83,95 +196,34 @@ struct WorkoutLiveActivity: Widget {
.accessibilityLabel("Exercise \(context.state.exerciseName)")
}
DynamicIslandExpandedRegion(.trailing) {
Group {
if paused {
Text(Self.formatFrozenTime(frozenSeconds))
.font(.system(size: 20, weight: .bold).monospacedDigit())
.foregroundStyle(.white.opacity(0.5))
} else {
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
.font(.system(size: 20, weight: .bold).monospacedDigit())
.foregroundStyle(isUrgent ? .orange : .white)
.contentTransition(.numericText(countsDown: true))
}
}
.frame(width: 56, alignment: .trailing)
.accessibilityLabel(paused ? "Timer paused at \(Self.formatFrozenTime(frozenSeconds))" : "Countdown timer")
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 20)
.frame(width: 56, alignment: .trailing)
}
DynamicIslandExpandedRegion(.bottom) {
VStack(spacing: 6) {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 1, style: .continuous)
.fill(.white.opacity(0.08))
RoundedRectangle(cornerRadius: 1, style: .continuous)
.fill(phaseColor.opacity(paused ? 0.25 : 0.6))
.frame(width: geo.size.width * progress)
}
.frame(height: 2)
}
.frame(height: 2)
WorkoutProgressBar(progress: progress, color: phase.color, isPaused: paused)
HStack(spacing: 6) {
Image(systemName: "music.note")
.font(.system(size: 10))
.foregroundStyle(.green)
.accessibilityLabel("Music")
Text(context.state.trackArtist.isEmpty
? context.state.trackTitle
: "\(context.state.trackTitle)\(context.state.trackArtist)")
.font(.caption2)
.lineLimit(1)
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
.accessibilityLabel(context.state.trackArtist.isEmpty ? context.state.trackTitle : "\(context.state.trackTitle) by \(context.state.trackArtist)")
Spacer()
if context.state.heartRate > 0 {
HStack(spacing: 2) {
Image(systemName: "heart.fill")
.font(.system(size: 8))
.foregroundStyle(.red.opacity(0.7))
.accessibilityLabel("Heart rate")
Text("\(Int(context.state.heartRate))")
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
}
.accessibilityLabel("Heart rate \(Int(context.state.heartRate)) beats per minute")
}
if !paused, context.state.isPlaying {
LiveActivityMusicBars()
}
}
.opacity(paused ? 0.5 : 1)
MusicInfoRow(
trackTitle: context.state.trackTitle,
trackArtist: context.state.trackArtist,
isPlaying: context.state.isPlaying,
isPaused: paused,
heartRate: context.state.heartRate,
isLuminanceReduced: false
)
}
.padding(.bottom, 4)
}
} compactLeading: {
Circle()
.fill(paused ? phaseColor.opacity(0.4) : phaseColor)
.frame(width: 10, height: 10)
.modifier(PulseEffect(active: isWork))
.accessibilityLabel(paused ? "Paused" : "\(phaseLabel) phase, workout in progress")
PhaseIndicatorDot(color: phase.color, isPaused: paused, size: 10, isPulsing: isWork)
.accessibilityLabel(paused ? "Paused" : "\(phase.capitalized) phase, workout in progress")
} compactTrailing: {
Group {
if paused {
Text(Self.formatFrozenTime(frozenSeconds))
.font(.system(size: 12, weight: .heavy).monospacedDigit())
.foregroundStyle(.white.opacity(0.5))
} else {
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
.font(.system(size: 12, weight: .heavy).monospacedDigit())
.foregroundStyle(isUrgent ? .orange : .white)
.contentTransition(.numericText(countsDown: true))
}
}
.accessibilityLabel(paused ? "Timer paused at \(Self.formatFrozenTime(frozenSeconds))" : "Countdown timer")
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 12)
} minimal: {
Circle()
.fill(paused ? phaseColor.opacity(0.4) : phaseColor)
.frame(width: 6, height: 6)
PhaseIndicatorDot(color: phase.color, isPaused: paused, size: 6, isPulsing: false)
.accessibilityLabel(paused ? "Paused" : "Workout in progress")
}
.keylineTint(phaseColor)
.keylineTint(phase.color)
.contentMargins(.leading, 8, for: .expanded)
.contentMargins(.trailing, 8, for: .expanded)
.contentMargins(.bottom, 4, for: .expanded)
@@ -179,45 +231,58 @@ struct WorkoutLiveActivity: Widget {
}
.supplementalActivityFamilies([.small, .medium])
}
}
// MARK: - Lock Screen / Banner
// MARK: - Lock Screen / Banner
@ViewBuilder
private func lockScreenView(
context: ActivityViewContext<WorkoutActivityAttributes>,
phaseColor: Color,
phaseLabel: String,
isFullscreen: Bool,
isLuminanceReduced: Bool,
isUrgent: Bool,
paused: Bool,
frozenSeconds: Int
) -> some View {
let progress = CGFloat(context.state.roundCurrent) / CGFloat(max(context.state.roundTotal, 1))
struct WorkoutLockScreenView: View {
let phase: WorkoutPhase
let paused: Bool
let frozenSeconds: Int
let isFullscreen: Bool
let isLuminanceReduced: Bool
let isUrgent: Bool
let isStale: Bool
let exerciseName: String
let roundCurrent: Int
let roundTotal: Int
let endDate: Date
let heartRate: Double
let trackTitle: String
let trackArtist: String
let isPlaying: Bool
var progress: CGFloat { CGFloat(roundCurrent) / CGFloat(max(roundTotal, 1)) }
var body: some View {
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 12) {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(paused ? phaseColor.opacity(0.4) : phaseColor)
.fill(paused ? phase.color.opacity(0.4) : phase.color)
.frame(width: 4, height: 38)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(context.state.exerciseName)
Text(exerciseName)
.font(isFullscreen ? .title2 : .headline)
.foregroundStyle(.primary)
.minimumScaleFactor(0.7)
.accessibilityLabel(context.state.exerciseName)
.accessibilityLabel(exerciseName)
if paused {
Text("PAUSED")
.font(.caption.bold())
.foregroundStyle(.white.opacity(0.6))
.accessibilityLabel("Paused")
} else {
Text(phaseLabel)
Text(phase.capitalized)
.font(.caption.weight(.semibold))
.foregroundStyle(phaseColor)
.accessibilityLabel("\(phaseLabel) phase")
.foregroundStyle(phase.color)
.accessibilityLabel("\(phase.capitalized) phase")
}
if isStale {
Text("Last updated")
.font(.caption2)
.foregroundStyle(.secondary.opacity(0.6))
}
}
.layoutPriority(1)
@@ -227,74 +292,40 @@ struct WorkoutLiveActivity: Widget {
VStack(alignment: .trailing, spacing: 2) {
Group {
if paused {
Text(Self.formatFrozenTime(frozenSeconds))
Text(CountdownText.formatFrozenTime(frozenSeconds))
.font(isFullscreen ? .largeTitle.weight(.bold) : .title2.weight(.bold))
.fontDesign(.monospaced)
.foregroundStyle(.white.opacity(0.5))
} else {
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
Text(timerInterval: Date()...endDate, countsDown: true)
.font(isFullscreen ? .largeTitle.weight(.bold) : .title2.weight(.bold))
.fontDesign(.monospaced)
.foregroundStyle(isUrgent ? .orange : .primary)
.contentTransition(.numericText(countsDown: true))
}
}
.accessibilityLabel(paused ? "Timer paused at \(Self.formatFrozenTime(frozenSeconds))" : "Countdown timer")
Text("Round \(context.state.roundCurrent)/\(context.state.roundTotal)")
.accessibilityLabel(paused ? "Timer paused at \(CountdownText.formatFrozenTime(frozenSeconds))" : "Countdown timer")
Text("Round \(roundCurrent)/\(roundTotal)")
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityLabel("Round \(context.state.roundCurrent) of \(context.state.roundTotal)")
.accessibilityLabel("Round \(roundCurrent) of \(roundTotal)")
}
}
.padding(.horizontal, 14)
.padding(.top, isFullscreen ? 20 : 12)
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 1, style: .continuous)
.fill(.white.opacity(0.06))
RoundedRectangle(cornerRadius: 1, style: .continuous)
.fill(phaseColor.opacity(paused ? 0.2 : 0.5))
.frame(width: geo.size.width * progress)
}
.frame(height: 2)
}
.frame(height: 2)
.padding(.horizontal, 14)
.padding(.top, 8)
WorkoutProgressBar(progress: progress, color: phase.color, isPaused: paused)
.padding(.horizontal, 14)
.padding(.top, 8)
HStack(spacing: 8) {
Image(systemName: "music.note")
.font(.system(size: 10))
.foregroundStyle(.green)
.accessibilityLabel("Music")
Text(context.state.trackTitle)
.font(.caption2)
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
.lineLimit(1)
.accessibilityLabel(context.state.trackTitle)
Spacer()
if context.state.heartRate > 0 {
HStack(spacing: 2) {
Image(systemName: "heart.fill")
.font(.system(size: 9))
.foregroundStyle(.red.opacity(0.7))
.accessibilityHidden(true)
Text("\(Int(context.state.heartRate))")
.font(.caption2.monospacedDigit())
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
}
.accessibilityLabel("Heart rate \(Int(context.state.heartRate)) beats per minute")
}
if !paused, context.state.isPlaying, !isLuminanceReduced {
LiveActivityMusicBars()
}
}
.opacity(paused ? 0.5 : 1)
MusicInfoRow(
trackTitle: trackTitle,
trackArtist: trackArtist,
isPlaying: isPlaying,
isPaused: paused,
heartRate: heartRate,
isLuminanceReduced: isLuminanceReduced
)
.padding(.horizontal, 14)
.padding(.top, 6)
.padding(.bottom, 10)
@@ -302,76 +333,48 @@ struct WorkoutLiveActivity: Widget {
.activityBackgroundTint(.black.opacity(0.9))
.activitySystemActionForegroundColor(.white)
}
}
// MARK: - Small (Apple Watch / CarPlay)
// MARK: - Small (Apple Watch / CarPlay)
@ViewBuilder
private func smallLockScreenView(
context: ActivityViewContext<WorkoutActivityAttributes>,
phaseColor: Color,
phaseLabel: String,
paused: Bool,
frozenSeconds: Int
) -> some View {
struct WorkoutSmallView: View {
let phase: WorkoutPhase
let paused: Bool
let frozenSeconds: Int
let exerciseName: String
let roundCurrent: Int
let roundTotal: Int
let endDate: Date
var body: some View {
HStack(spacing: 8) {
Circle()
.fill(paused ? phaseColor.opacity(0.4) : phaseColor)
.frame(width: 8, height: 8)
.accessibilityLabel(paused ? "Paused" : "\(phaseLabel) phase")
PhaseIndicatorDot(color: phase.color, isPaused: paused, size: 8, isPulsing: false)
.accessibilityLabel(paused ? "Paused" : "\(phase.capitalized) phase")
VStack(alignment: .leading, spacing: 1) {
Text(context.state.exerciseName)
Text(exerciseName)
.font(.caption.weight(.semibold))
.lineLimit(1)
.foregroundStyle(paused ? Color.primary.opacity(0.6) : Color.primary)
.accessibilityLabel(context.state.exerciseName)
Text(paused ? "PAUSED" : "\(context.state.roundCurrent)/\(context.state.roundTotal)")
.accessibilityLabel(exerciseName)
Text(paused ? "PAUSED" : "\(roundCurrent)/\(roundTotal)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.accessibilityLabel(paused ? "Paused" : "Round \(context.state.roundCurrent) of \(context.state.roundTotal)")
.accessibilityLabel(paused ? "Paused" : "Round \(roundCurrent) of \(roundTotal)")
}
Spacer()
Group {
if paused {
Text(Self.formatFrozenTime(frozenSeconds))
.font(.caption.monospacedDigit())
.foregroundStyle(.primary.opacity(0.5))
} else {
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
.font(.caption.monospacedDigit())
.foregroundStyle(.primary)
.contentTransition(.numericText(countsDown: true))
}
}
.accessibilityLabel(paused ? "Timer paused at \(Self.formatFrozenTime(frozenSeconds))" : "Countdown timer")
CountdownText(endDate: endDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: false, size: 12)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.activityBackgroundTint(.black.opacity(0.9))
}
// MARK: - Helpers
static func formatFrozenTime(_ seconds: Int) -> String {
let m = seconds / 60
let s = seconds % 60
return String(format: "%d:%02d", m, s)
}
static func colorForPhase(_ phase: String) -> Color {
switch phase {
case "prep": return Color(red: 1.0, green: 0.58, blue: 0.0)
case "work": return Color(red: 1.0, green: 0.42, blue: 0.21)
case "rest", "interBlockRest": return Color(red: 0.35, green: 0.78, blue: 0.98)
case "cooldown": return Color(red: 0.35, green: 0.78, blue: 0.98)
case "complete": return Color(red: 0.19, green: 0.82, blue: 0.35)
default: return .gray
}
}
}
// MARK: - Pulse Effect
private struct PulseEffect: ViewModifier {
let active: Bool
@State private var isPulsing = false
@@ -390,3 +393,136 @@ private struct PulseEffect: ViewModifier {
}
}
}
// MARK: - Previews
#Preview("Lock Screen — Work", as: .content, using: WorkoutActivityAttributes()) {
WorkoutLiveActivity()
} contentStates: {
WorkoutActivityAttributes.ContentState(
exerciseName: "Burpees",
phase: .work,
phaseEndDate: .now.addingTimeInterval(20),
roundCurrent: 3,
roundTotal: 8,
heartRate: 142,
trackTitle: "Lose Yourself",
trackArtist: "Eminem",
isPlaying: true,
isPaused: false
)
}
#Preview("Lock Screen — Rest", as: .content, using: WorkoutActivityAttributes()) {
WorkoutLiveActivity()
} contentStates: {
WorkoutActivityAttributes.ContentState(
exerciseName: "Mountain Climbers",
phase: .rest,
phaseEndDate: .now.addingTimeInterval(8),
roundCurrent: 4,
roundTotal: 8,
heartRate: 128,
trackTitle: "Stronger",
trackArtist: "Kanye West",
isPlaying: true,
isPaused: false
)
}
#Preview("Lock Screen — Paused", as: .content, using: WorkoutActivityAttributes()) {
WorkoutLiveActivity()
} contentStates: {
WorkoutActivityAttributes.ContentState(
exerciseName: "Jump Squats",
phase: .work,
phaseEndDate: .now.addingTimeInterval(15),
roundCurrent: 5,
roundTotal: 8,
heartRate: 135,
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: true
)
}
#Preview("Lock Screen — Complete", as: .content, using: WorkoutActivityAttributes()) {
WorkoutLiveActivity()
} contentStates: {
WorkoutActivityAttributes.ContentState(
exerciseName: "Workout Complete",
phase: .complete,
phaseEndDate: .now,
roundCurrent: 8,
roundTotal: 8,
heartRate: 110,
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: false
)
}
#Preview("Dynamic Island Expanded", as: .dynamicIsland(.expanded), using: WorkoutActivityAttributes()) {
WorkoutLiveActivity()
} contentStates: {
WorkoutActivityAttributes.ContentState(
exerciseName: "Burpees",
phase: .work,
phaseEndDate: .now.addingTimeInterval(18),
roundCurrent: 3,
roundTotal: 8,
heartRate: 145,
trackTitle: "Till I Collapse",
trackArtist: "Eminem",
isPlaying: true,
isPaused: false
)
WorkoutActivityAttributes.ContentState(
exerciseName: "High Knees",
phase: .rest,
phaseEndDate: .now.addingTimeInterval(6),
roundCurrent: 6,
roundTotal: 8,
heartRate: 120,
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: false
)
}
#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: WorkoutActivityAttributes()) {
WorkoutLiveActivity()
} contentStates: {
WorkoutActivityAttributes.ContentState(
exerciseName: "Burpees",
phase: .work,
phaseEndDate: .now.addingTimeInterval(18),
roundCurrent: 3,
roundTotal: 8,
heartRate: 0,
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: false
)
}
#Preview("Dynamic Island Minimal", as: .dynamicIsland(.minimal), using: WorkoutActivityAttributes()) {
WorkoutLiveActivity()
} contentStates: {
WorkoutActivityAttributes.ContentState(
exerciseName: "Burpees",
phase: .work,
phaseEndDate: .now.addingTimeInterval(18),
roundCurrent: 3,
roundTotal: 8,
heartRate: 0,
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: false
)
}