From 95f34e6471033aa7951816bcefd3cc555ad7654f Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Fri, 15 May 2026 23:52:01 +0200 Subject: [PATCH] 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 --- .../Models/WorkoutActivityAttributes.swift | 1 + .../TabataGo/ViewModels/PlayerViewModel.swift | 13 +- .../TabataGoWidget/WorkoutLiveActivity.swift | 310 +++++++++++++----- 3 files changed, 233 insertions(+), 91 deletions(-) diff --git a/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift b/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift index afeb841..ca4c700 100644 --- a/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift +++ b/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift @@ -12,5 +12,6 @@ struct WorkoutActivityAttributes: ActivityAttributes { var trackTitle: String var trackArtist: String var isPlaying: Bool + var isPaused: Bool } } diff --git a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift index ea7ae7a..dec9854 100644 --- a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift +++ b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift @@ -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) } diff --git a/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift b/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift index 2259255..3d92f5b 100644 --- a/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift +++ b/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift @@ -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, 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 + } + } + } +}