feat: Dynamic Island pause state, Apple-aligned spacing, and UI polish

- Add isPaused to WorkoutActivityAttributes.ContentState
- Show PAUSED badge, freeze timer to static text, dim content when paused
- Prevent stale spinner on pause by extending staleDate to 1 hour
- Add 6s timer warning color, progress bar, compact heavy timer
- Pulsing compact indicator during WORK phase
- Lock Screen margins aligned to Apple's 14pt HIG spec
This commit is contained in:
Millian Lamiaux
2026-05-15 23:52:01 +02:00
parent 057fbb3c9a
commit 95f34e6471
3 changed files with 233 additions and 91 deletions

View File

@@ -12,5 +12,6 @@ struct WorkoutActivityAttributes: ActivityAttributes {
var trackTitle: String
var trackArtist: String
var isPlaying: Bool
var isPaused: Bool
}
}

View File

@@ -152,6 +152,7 @@ final class PlayerViewModel: ObservableObject {
timer?.invalidate()
stopActivitySyncTimer()
liveSession.pause()
syncActivity()
softHaptics.impactOccurred()
}
@@ -380,7 +381,8 @@ final class PlayerViewModel: ObservableObject {
heartRate: heartRate,
trackTitle: currentTrackTitle,
trackArtist: currentTrackArtist,
isPlaying: isPlayingMusic
isPlaying: isPlayingMusic,
isPaused: isPaused
)
// Discard stale reference user may have dismissed the Dynamic Island
@@ -390,8 +392,9 @@ final class PlayerViewModel: ObservableObject {
if let existing = workoutActivity {
nonisolated(unsafe) let safeExisting = existing
let staleDate = Date().addingTimeInterval(isPaused ? 3600 : 120)
Task { @MainActor in
await safeExisting.update(ActivityContent(state: state, staleDate: Date().addingTimeInterval(120)))
await safeExisting.update(ActivityContent(state: state, staleDate: staleDate))
}
} else {
createOrUpdateActivity(with: state)
@@ -401,9 +404,10 @@ final class PlayerViewModel: ObservableObject {
private func createOrUpdateActivity(with state: WorkoutActivityAttributes.ContentState) {
let attrs = WorkoutActivityAttributes()
do {
let staleDate = Date().addingTimeInterval(isPaused ? 3600 : 120)
workoutActivity = try Activity.request(
attributes: attrs,
content: ActivityContent(state: state, staleDate: Date().addingTimeInterval(120))
content: ActivityContent(state: state, staleDate: staleDate)
)
observeActivityState()
} catch {
@@ -427,7 +431,8 @@ final class PlayerViewModel: ObservableObject {
heartRate: safeActivity.content.state.heartRate,
trackTitle: safeActivity.content.state.trackTitle,
trackArtist: safeActivity.content.state.trackArtist,
isPlaying: false
isPlaying: false,
isPaused: false
)
await safeActivity.end(ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .immediate)
}

View File

@@ -11,99 +11,165 @@ struct WorkoutLiveActivity: Widget {
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)
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
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 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
let progress = CGFloat(context.state.roundCurrent) / CGFloat(max(context.state.roundTotal, 1))
return DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
HStack(spacing: 4) {
Text(phaseLabel.uppercased())
.font(.caption.bold())
.foregroundStyle(phaseColor)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(phaseColor.opacity(0.15))
.clipShape(Capsule())
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())
}
Text("\(context.state.roundCurrent)/\(context.state.roundTotal)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.accessibilityLabel("\(phaseLabel) phase, round \(context.state.roundCurrent) of \(context.state.roundTotal)")
.accessibilityLabel(paused
? "Paused, round \(context.state.roundCurrent) of \(context.state.roundTotal)"
: "\(phaseLabel) phase, round \(context.state.roundCurrent) of \(context.state.roundTotal)")
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.exerciseName)
.font(.caption.weight(.semibold))
.foregroundStyle(paused ? .white.opacity(0.6) : .white)
.lineLimit(1)
.accessibilityLabel("Exercise \(context.state.exerciseName)")
}
DynamicIslandExpandedRegion(.trailing) {
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
.font(.system(size: 20, weight: .bold).monospacedDigit())
.contentTransition(.numericText(countsDown: true))
.frame(width: 56, alignment: .trailing)
.accessibilityLabel("Countdown timer")
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")
}
DynamicIslandExpandedRegion(.bottom) {
HStack(spacing: 6) {
Image(systemName: "music.note")
.font(.caption)
.foregroundStyle(.green)
.accessibilityLabel("Music")
Text(context.state.trackArtist.isEmpty
? context.state.trackTitle
: "\(context.state.trackTitle)\(context.state.trackArtist)")
.font(.caption)
.lineLimit(1)
.foregroundStyle(.secondary)
.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: 9))
.foregroundStyle(.red)
.accessibilityLabel("Heart rate")
Text("\(Int(context.state.heartRate))")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
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)
}
.accessibilityLabel("Heart rate \(Int(context.state.heartRate)) beats per minute")
.frame(height: 2)
}
if context.state.isPlaying {
LiveActivityMusicBars()
.frame(height: 2)
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)
}
.padding(.bottom, 4)
}
} compactLeading: {
Circle()
.fill(phaseColor)
.fill(paused ? phaseColor.opacity(0.4) : phaseColor)
.frame(width: 10, height: 10)
.accessibilityLabel("\(phaseLabel) phase, workout in progress")
.modifier(PulseEffect(active: isWork))
.accessibilityLabel(paused ? "Paused" : "\(phaseLabel) phase, workout in progress")
} compactTrailing: {
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
.font(.system(size: 12, weight: .medium).monospacedDigit())
.contentTransition(.numericText(countsDown: true))
.accessibilityLabel("Countdown timer")
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")
} minimal: {
Circle()
.fill(phaseColor)
.fill(paused ? phaseColor.opacity(0.4) : phaseColor)
.frame(width: 6, height: 6)
.accessibilityLabel("Workout in progress")
.accessibilityLabel(paused ? "Paused" : "Workout in progress")
}
.keylineTint(phaseColor)
.contentMargins(.leading, 8, for: .expanded)
@@ -122,12 +188,17 @@ struct WorkoutLiveActivity: Widget {
phaseColor: Color,
phaseLabel: String,
isFullscreen: Bool,
isLuminanceReduced: Bool
isLuminanceReduced: Bool,
isUrgent: Bool,
paused: Bool,
frozenSeconds: Int
) -> some View {
let progress = CGFloat(context.state.roundCurrent) / CGFloat(max(context.state.roundTotal, 1))
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 12) {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(phaseColor)
.fill(paused ? phaseColor.opacity(0.4) : phaseColor)
.frame(width: 4, height: 38)
.accessibilityHidden(true)
@@ -137,68 +208,96 @@ struct WorkoutLiveActivity: Widget {
.foregroundStyle(.primary)
.minimumScaleFactor(0.7)
.accessibilityLabel(context.state.exerciseName)
Text(phaseLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(phaseColor)
.accessibilityLabel("\(phaseLabel) phase")
if paused {
Text("PAUSED")
.font(.caption.bold())
.foregroundStyle(.white.opacity(0.6))
.accessibilityLabel("Paused")
} else {
Text(phaseLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(phaseColor)
.accessibilityLabel("\(phaseLabel) phase")
}
}
.layoutPriority(1)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
.font(isFullscreen ? .largeTitle.weight(.bold) : .title2.weight(.bold))
.fontDesign(.monospaced)
.foregroundStyle(.primary)
.contentTransition(.numericText(countsDown: true))
.accessibilityLabel("Countdown timer")
Group {
if paused {
Text(Self.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)
.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)")
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityLabel("Round \(context.state.roundCurrent) of \(context.state.roundTotal)")
}
}
.padding(.horizontal, 16)
.padding(.top, isFullscreen ? 24 : 12)
.padding(.horizontal, 14)
.padding(.top, isFullscreen ? 20 : 12)
Divider()
.padding(.horizontal, 16)
.padding(.vertical, 6)
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)
HStack(spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "music.note")
.font(.caption.weight(.semibold))
.font(.system(size: 10))
.foregroundStyle(.green)
.accessibilityLabel("Music")
Text(context.state.trackTitle)
.font(.caption)
.foregroundStyle(.secondary)
.font(.caption2)
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
.lineLimit(1)
.accessibilityLabel(context.state.trackTitle)
Spacer()
if context.state.heartRate > 0 {
HStack(spacing: 3) {
HStack(spacing: 2) {
Image(systemName: "heart.fill")
.font(.system(size: 10))
.foregroundStyle(.red)
.font(.system(size: 9))
.foregroundStyle(.red.opacity(0.7))
.accessibilityHidden(true)
Text("\(Int(context.state.heartRate))")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.font(.caption2.monospacedDigit())
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
}
.accessibilityLabel("Heart rate \(Int(context.state.heartRate)) beats per minute")
}
if context.state.isPlaying, !isLuminanceReduced {
if !paused, context.state.isPlaying, !isLuminanceReduced {
LiveActivityMusicBars()
}
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
.opacity(paused ? 0.5 : 1)
.padding(.horizontal, 14)
.padding(.top, 6)
.padding(.bottom, 10)
}
.activityBackgroundTint(.black.opacity(0.9))
.activitySystemActionForegroundColor(.white)
@@ -210,38 +309,56 @@ struct WorkoutLiveActivity: Widget {
private func smallLockScreenView(
context: ActivityViewContext<WorkoutActivityAttributes>,
phaseColor: Color,
phaseLabel: String
phaseLabel: String,
paused: Bool,
frozenSeconds: Int
) -> some View {
HStack(spacing: 8) {
Circle()
.fill(phaseColor)
.fill(paused ? phaseColor.opacity(0.4) : phaseColor)
.frame(width: 8, height: 8)
.accessibilityLabel("\(phaseLabel) phase")
.accessibilityLabel(paused ? "Paused" : "\(phaseLabel) phase")
VStack(alignment: .leading, spacing: 1) {
Text(context.state.exerciseName)
.font(.caption.weight(.semibold))
.lineLimit(1)
.foregroundStyle(paused ? Color.primary.opacity(0.6) : Color.primary)
.accessibilityLabel(context.state.exerciseName)
Text("\(context.state.roundCurrent)/\(context.state.roundTotal)")
Text(paused ? "PAUSED" : "\(context.state.roundCurrent)/\(context.state.roundTotal)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.accessibilityLabel(paused ? "Paused" : "Round \(context.state.roundCurrent) of \(context.state.roundTotal)")
}
Spacer()
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
.font(.caption.monospacedDigit())
.foregroundStyle(.primary)
.contentTransition(.numericText(countsDown: true))
.accessibilityLabel("Countdown timer")
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")
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.activityBackgroundTint(.black.opacity(0.9))
}
// MARK: - Phase Color
// 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 {
@@ -254,3 +371,22 @@ struct WorkoutLiveActivity: Widget {
}
}
}
private struct PulseEffect: ViewModifier {
let active: Bool
@State private var isPulsing = false
func body(content: Content) -> some View {
content
.opacity(active ? (isPulsing ? 0.4 : 1.0) : 1.0)
.animation(active ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : .default, value: isPulsing)
.onAppear { if active { isPulsing = true } }
.onChange(of: active) { _, newValue in
if newValue {
isPulsing = true
} else {
isPulsing = false
}
}
}
}