From c152c22ffb3a577867ce7dbbebdc8a83bf3e0c39 Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Thu, 21 May 2026 10:21:22 +0200 Subject: [PATCH] feat: redesign Dynamic Island with phase-driven UI and animations --- tabatago-swift/TabataGo/App/AppState.swift | 7 +- tabatago-swift/TabataGo/App/TabataGoApp.swift | 7 +- .../Models/WorkoutActivityAttributes.swift | 91 +++++ .../ViewModels/MusicPlayerViewModel.swift | 2 + .../TabataGo/ViewModels/PlayerViewModel.swift | 22 +- .../TabataGo/Views/Player/PlayerView.swift | 4 + .../TabataGo/Views/Tabs/HomeTab.swift | 2 +- .../TabataGo/Views/Tabs/MainTabView.swift | 2 +- .../TabataGo/Views/Tabs/ProfileTab.swift | 2 +- .../TabataGo/Views/Tabs/ProgramsTab.swift | 2 +- .../TabataGoWidget/MusicLiveActivity.swift | 47 ++- .../TabataGoWidget/WorkoutLiveActivity.swift | 360 +++++++++++++++--- 12 files changed, 472 insertions(+), 76 deletions(-) diff --git a/tabatago-swift/TabataGo/App/AppState.swift b/tabatago-swift/TabataGo/App/AppState.swift index a18b154..00bf40e 100644 --- a/tabatago-swift/TabataGo/App/AppState.swift +++ b/tabatago-swift/TabataGo/App/AppState.swift @@ -2,12 +2,13 @@ import Foundation import Observation /// Global app bootstrap state — initialises all services once at launch. +@MainActor @Observable final class AppState { - var isBootstrapped = false + static let shared = AppState() - @MainActor + var isBootstrapped = false func bootstrap() async { guard !isBootstrapped else { return } guard !AppEnvironment.isPreview else { isBootstrapped = true; return } @@ -15,4 +16,6 @@ final class AppState { AnalyticsService.shared.initialize() isBootstrapped = true } + + private init() {} } diff --git a/tabatago-swift/TabataGo/App/TabataGoApp.swift b/tabatago-swift/TabataGo/App/TabataGoApp.swift index 4339fd7..56e65bf 100644 --- a/tabatago-swift/TabataGo/App/TabataGoApp.swift +++ b/tabatago-swift/TabataGo/App/TabataGoApp.swift @@ -3,20 +3,19 @@ import SwiftData extension Notification.Name { static let skipTrackFromActivity = Notification.Name("skipTrackFromActivity") + // togglePauseFromActivity is declared in WorkoutActivityAttributes.swift (shared with widget) } @main struct TabataGoApp: App { - @State private var appState = AppState() - var body: some Scene { WindowGroup { RootView() - .environment(appState) + .environment(AppState.shared) .modelContainer(TabataGoSchema.container) .task { - await appState.bootstrap() + await AppState.shared.bootstrap() } .onOpenURL { url in if url.scheme == "tabatago", url.host == "skipTrack" { diff --git a/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift b/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift index dddd5f8..586c712 100644 --- a/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift +++ b/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift @@ -1,10 +1,19 @@ import ActivityKit import Foundation +import AppIntents #if canImport(SwiftUI) import SwiftUI #endif +// MARK: — Shared notification names (used by LiveActivityIntent + PlayerView) + +extension Notification.Name { + static let togglePauseFromActivity = Notification.Name("togglePauseFromActivity") +} + +// MARK: — Phase enum + enum WorkoutPhase: String, Codable, Hashable, Sendable, CaseIterable { case prep case warmup @@ -32,9 +41,49 @@ enum WorkoutPhase: String, Codable, Hashable, Sendable, CaseIterable { case .complete: return Color(red: 0.19, green: 0.82, blue: 0.35) } } + + var dimColor: Color { + color.opacity(0.3) + } + + var glowColor: Color { + color.opacity(0.5) + } + + var gradientStops: [Gradient.Stop] { + switch self { + case .work: + return [ + .init(color: Color(red: 1.0, green: 0.42, blue: 0.21).opacity(0.15), location: 0), + .init(color: .clear, location: 1), + ] + case .rest, .interBlockRest: + return [ + .init(color: Color(red: 0.35, green: 0.78, blue: 0.98).opacity(0.12), location: 0), + .init(color: .clear, location: 1), + ] + case .prep, .warmup: + return [ + .init(color: Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.12), location: 0), + .init(color: .clear, location: 1), + ] + case .cooldown: + return [ + .init(color: Color(red: 0.35, green: 0.78, blue: 0.98).opacity(0.08), location: 0), + .init(color: .clear, location: 1), + ] + case .complete: + return [ + .init(color: Color(red: 0.19, green: 0.82, blue: 0.35).opacity(0.15), location: 0), + .init(color: .clear, location: 1), + ] + } + } #endif } +// MARK: — Activity Attributes + struct WorkoutActivityAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable, Sendable { var exerciseName: String @@ -48,5 +97,47 @@ struct WorkoutActivityAttributes: ActivityAttributes { var trackArtist: String var isPlaying: Bool var isPaused: Bool + var blockIndex: Int = 0 + var blockCount: Int = 0 + var exerciseShortName: String = "" + var phaseElapsedSeconds: TimeInterval = 0 + } +} + +// MARK: — Concurrency-safe throttle + +private actor TogglePauseThrottle { + private var lastFireTime: Date = .distantPast + + func shouldFire() -> Bool { + let now = Date() + guard now.timeIntervalSince(lastFireTime) > 3.0 else { return false } + lastFireTime = now + return true + } +} + +// MARK: — Live Activity Intents (run in the main app process) + +struct TogglePauseIntent: LiveActivityIntent { + + static let title: LocalizedStringResource = "Toggle Pause" + private static let throttle = TogglePauseThrottle() + + @MainActor + func perform() async throws -> some IntentResult { + guard await Self.throttle.shouldFire() else { + print("[LiveActivityIntent] TogglePauseIntent throttled") + return .result() + } + + print("[LiveActivityIntent] TogglePauseIntent.perform() fired") + + NotificationCenter.default.post(name: .togglePauseFromActivity, object: nil) + + // Small sleep keeps widget button in a "processing" visual state + try await Task.sleep(for: .milliseconds(400)) + + return .result() } } diff --git a/tabatago-swift/TabataGo/ViewModels/MusicPlayerViewModel.swift b/tabatago-swift/TabataGo/ViewModels/MusicPlayerViewModel.swift index 9ed1e59..58cb7f7 100644 --- a/tabatago-swift/TabataGo/ViewModels/MusicPlayerViewModel.swift +++ b/tabatago-swift/TabataGo/ViewModels/MusicPlayerViewModel.swift @@ -57,6 +57,8 @@ final class MusicPlayerViewModel: ObservableObject { func play() { guard audio.isMusicEnabled, player != nil else { return } + // Reactivate the audio session in case the system deactivated it while backgrounded + try? AVAudioSession.sharedInstance().setActive(true) player?.volume = audio.musicVolume player?.play() } diff --git a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift index dbdcb38..38de8fc 100644 --- a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift +++ b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift @@ -58,6 +58,8 @@ final class PlayerViewModel: ObservableObject { private var warmupIndex: Int = 0 // Cooldown phase index private var cooldownIndex: Int = 0 + // Throttle rapid toggle from widget + private var lastToggleTimestamp: Date = .distantPast private var currentBlock: TabataBlock? { guard currentBlockIndex < program.blocks.count else { return nil } @@ -91,6 +93,14 @@ final class PlayerViewModel: ObservableObject { // ─── Controls ───────────────────────────────────────────────── func togglePlayPause() { + let now = Date() + guard now.timeIntervalSince(lastToggleTimestamp) > 0.6 else { + print("[PlayerVM] TogglePlayPause throttled (last tap was < 0.6s ago)") + return + } + lastToggleTimestamp = now + print("[PlayerVM] TogglePlayPause — isPaused=\(isPaused), isRunning=\(isRunning)") + if !isRunning { startWorkout() } else if isPaused { @@ -384,7 +394,11 @@ final class PlayerViewModel: ObservableObject { trackTitle: currentTrackTitle, trackArtist: currentTrackArtist, isPlaying: isPlayingMusic, - isPaused: isPaused + isPaused: isPaused, + blockIndex: currentBlockIndex + 1, + blockCount: program.blocks.count, + exerciseShortName: String(currentExercise?.nameEn.prefix(8) ?? ""), + phaseElapsedSeconds: max(0, Double(totalPhaseTime) - Double(timeRemaining)) ) if let existing = workoutActivity, existing.activityState != .active { @@ -452,7 +466,11 @@ final class PlayerViewModel: ObservableObject { trackTitle: safeActivity.content.state.trackTitle, trackArtist: safeActivity.content.state.trackArtist, isPlaying: false, - isPaused: false + isPaused: false, + blockIndex: safeActivity.content.state.blockIndex, + blockCount: safeActivity.content.state.blockCount, + exerciseShortName: safeActivity.content.state.exerciseShortName, + phaseElapsedSeconds: safeActivity.content.state.phaseElapsedSeconds ) await safeActivity.end(ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .immediate) } diff --git a/tabatago-swift/TabataGo/Views/Player/PlayerView.swift b/tabatago-swift/TabataGo/Views/Player/PlayerView.swift index c70cfc2..c43cb71 100644 --- a/tabatago-swift/TabataGo/Views/Player/PlayerView.swift +++ b/tabatago-swift/TabataGo/Views/Player/PlayerView.swift @@ -166,6 +166,10 @@ struct PlayerView: View { .onReceive(NotificationCenter.default.publisher(for: .skipTrackFromActivity)) { _ in musicVM.skipTrack() } + .onReceive(NotificationCenter.default.publisher(for: .togglePauseFromActivity)) { _ in + print("[PlayerView] Received togglePauseFromActivity notification (fallback)") + vm.togglePlayPause() + } .navigationDestination(isPresented: $vm.isComplete) { CompletionView(session: vm.completedSession, program: program, onDone: { dismiss() }) .navigationBarBackButtonHidden() diff --git a/tabatago-swift/TabataGo/Views/Tabs/HomeTab.swift b/tabatago-swift/TabataGo/Views/Tabs/HomeTab.swift index b9cd187..d6aa5a3 100644 --- a/tabatago-swift/TabataGo/Views/Tabs/HomeTab.swift +++ b/tabatago-swift/TabataGo/Views/Tabs/HomeTab.swift @@ -317,5 +317,5 @@ struct ProgramRow: View { #Preview { HomeTab(previewVM: HomeViewModel(previewPrograms: [PreviewData.sampleProgram])) .modelContainer(TabataGoSchema.previewContainer) - .environment(AppState()) + .environment(AppState.shared) } diff --git a/tabatago-swift/TabataGo/Views/Tabs/MainTabView.swift b/tabatago-swift/TabataGo/Views/Tabs/MainTabView.swift index c7c0dbf..4418ff5 100644 --- a/tabatago-swift/TabataGo/Views/Tabs/MainTabView.swift +++ b/tabatago-swift/TabataGo/Views/Tabs/MainTabView.swift @@ -48,5 +48,5 @@ struct MainTabView: View { #Preview { MainTabView() .modelContainer(TabataGoSchema.previewContainer) - .environment(AppState()) + .environment(AppState.shared) } diff --git a/tabatago-swift/TabataGo/Views/Tabs/ProfileTab.swift b/tabatago-swift/TabataGo/Views/Tabs/ProfileTab.swift index 37ea27e..8461dbe 100644 --- a/tabatago-swift/TabataGo/Views/Tabs/ProfileTab.swift +++ b/tabatago-swift/TabataGo/Views/Tabs/ProfileTab.swift @@ -139,5 +139,5 @@ struct ProfileRow: View { #Preview { ProfileTab() .modelContainer(TabataGoSchema.previewContainer) - .environment(AppState()) + .environment(AppState.shared) } diff --git a/tabatago-swift/TabataGo/Views/Tabs/ProgramsTab.swift b/tabatago-swift/TabataGo/Views/Tabs/ProgramsTab.swift index 2462d29..ae7dc28 100644 --- a/tabatago-swift/TabataGo/Views/Tabs/ProgramsTab.swift +++ b/tabatago-swift/TabataGo/Views/Tabs/ProgramsTab.swift @@ -103,5 +103,5 @@ struct ProgramsTab: View { #Preview { ProgramsTab() .modelContainer(TabataGoSchema.previewContainer) - .environment(AppState()) + .environment(AppState.shared) } diff --git a/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift b/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift index feba60f..cab041d 100644 --- a/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift +++ b/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift @@ -41,13 +41,38 @@ struct MusicLiveActivity: Widget { LiveActivityMusicBars() } } + .background( + RadialGradient( + stops: [ + .init(color: Color.green.opacity(context.state.isPlaying ? 0.1 : 0.03), location: 0), + .init(color: .clear, location: 1) + ], + center: .center, + startRadius: 0, + endRadius: 30 + ) + ) + .opacity(context.state.isPlaying ? 1.0 : 0.5) + .animation(.easeInOut(duration: 0.3), value: context.state.isPlaying) } DynamicIslandExpandedRegion(.center) { - Text(context.state.artist) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .accessibilityLabel(context.state.artist) + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(LinearGradient( + colors: [.green.opacity(0.4), .mint.opacity(0.3)], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 20, height: 20) + + Text(context.state.artist) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .accessibilityLabel(context.state.artist) + } + .opacity(context.state.isPlaying ? 1.0 : 0.5) + .animation(.easeInOut(duration: 0.3), value: context.state.isPlaying) } DynamicIslandExpandedRegion(.trailing) { Button(intent: SkipTrackIntent()) { @@ -73,10 +98,14 @@ struct MusicLiveActivity: Widget { .accessibilityLabel("Music playing") } compactTrailing: { HStack(spacing: 3) { - Image(systemName: "music.note") - .font(.system(size: 10)) - .foregroundStyle(.green) - .accessibilityHidden(true) + if context.state.isPlaying { + LiveActivityMusicBars() + } else { + Image(systemName: "music.note") + .font(.system(size: 10)) + .foregroundStyle(.green.opacity(0.5)) + .accessibilityHidden(true) + } Text(context.state.title) .font(.system(size: 10, weight: .medium)) .lineLimit(1) diff --git a/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift b/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift index 3177aa9..59441fe 100644 --- a/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift +++ b/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift @@ -3,18 +3,6 @@ import WidgetKit import SwiftUI import AppIntents -// MARK: - App Intents - -struct TogglePauseIntent: AppIntent { - static let title: LocalizedStringResource = "Toggle Pause" - - @MainActor - func perform() async throws -> some IntentResult & OpensIntent { - let url = URL(string: "tabatago://togglePause")! - return .result(opensIntent: OpenURLIntent(url)) - } -} - // MARK: - Phase Icon struct PhaseIcon: View { @@ -242,6 +230,109 @@ struct DIBottomInfoRow: View { } } +// MARK: - DI Countdown Ring (48pt for Dynamic Island Expanded) + +struct DICountdownRing: View { + let endDate: Date + let phaseDuration: TimeInterval + let isPaused: Bool + let frozenSeconds: Int + let phase: WorkoutPhase + let isUrgent: Bool + var isLuminanceReduced: Bool = false + + private let diameter: CGFloat = 48 + + /// Thinner stroke during rest phases for a lighter visual feel. + private var lineWidth: CGFloat { + let isRestOrBreak = phase == .rest || phase == .interBlockRest + return (isRestOrBreak && !isPaused) ? 4 : 5 + } + + var body: some View { + let isRestOrBreak = phase == .rest || phase == .interBlockRest + + TimelineView(.periodic(from: .now, by: 0.5)) { timeline in + let remaining = max(0, endDate.timeIntervalSince(timeline.date)) + let activeProgress = phaseDuration > 0 ? CGFloat(remaining / phaseDuration) : 1.0 + let frozenProgress = phaseDuration > 0 ? CGFloat(frozenSeconds) / CGFloat(phaseDuration) : 1.0 + let arcProgress = isPaused ? frozenProgress : activeProgress + + let strokeColor: Color = { + if isPaused { return phase.glowColor } + return isUrgent ? .orange : phase.color + }() + + ZStack { + Circle() + .stroke(.white.opacity(0.08), lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: arcProgress) + .stroke( + strokeColor, + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(isLuminanceReduced ? nil : .linear(duration: 0.3), value: arcProgress) + + CountdownText( + endDate: endDate, + isPaused: isPaused, + frozenSeconds: frozenSeconds, + isUrgent: isUrgent, + size: 14 + ) + } + // Work-phase scale pulse + .scaleEffect(phase == .work && !isPaused ? 1.05 : 1.0) + .animation(isLuminanceReduced ? nil : .spring(response: 0.3, dampingFraction: 0.6), value: phase == .work) + // Rest-phase visual cooling + .opacity(isRestOrBreak && !isPaused ? 0.85 : 1.0) + .animation(isLuminanceReduced ? nil : .easeInOut(duration: 1.0), value: isRestOrBreak) + .frame(width: diameter, height: diameter) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(isPaused + ? "Timer paused at \(CountdownText.formatFrozenTime(frozenSeconds))" + : "Countdown timer") + } +} + +// MARK: - Compact Countdown Ring (16pt for Dynamic Island Compact) + +struct CompactCountdownRing: View { + let endDate: Date + let phaseDuration: TimeInterval + let isPaused: Bool + let frozenSeconds: Int + let phase: WorkoutPhase + let size: CGFloat = 16 + let lineWidth: CGFloat = 2.5 + + var body: some View { + TimelineView(.periodic(from: .now, by: 1.0)) { timeline in + let remaining = max(0, endDate.timeIntervalSince(timeline.date)) + let progress = phaseDuration > 0 ? CGFloat(remaining / phaseDuration) : 1.0 + let frozen = phaseDuration > 0 ? CGFloat(frozenSeconds) / CGFloat(phaseDuration) : 1.0 + let arc = isPaused ? frozen : progress + + ZStack { + Circle() + .stroke(.white.opacity(0.12), lineWidth: lineWidth) + Circle() + .trim(from: 0, to: arc) + .stroke( + isPaused ? phase.dimColor : phase.color, + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + } + .frame(width: size, height: size) + } + } +} + // MARK: - Main Widget struct WorkoutLiveActivity: Widget { @@ -292,57 +383,171 @@ struct WorkoutLiveActivity: Widget { let paused = context.state.isPaused let frozenSeconds = Int(max(0, context.state.phaseEndDate.timeIntervalSinceNow)) let isUrgent = !paused && frozenSeconds > 0 && frozenSeconds <= 5 - let progress = CGFloat(context.state.roundCurrent) / CGFloat(max(context.state.roundTotal, 1)) return DynamicIsland { DynamicIslandExpandedRegion(.leading) { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - PhaseIcon(phase: phase, isPaused: paused, size: 16) - Text(paused ? "PAUSED" : phase.capitalized.uppercased()) - .font(.subheadline.bold()) - .foregroundStyle(paused ? .white.opacity(0.5) : phase.color) - } - Text("Rd \(context.state.roundCurrent)/\(context.state.roundTotal)") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - .padding(.vertical, 2) - .accessibilityLabel(paused - ? "Paused, round \(context.state.roundCurrent) of \(context.state.roundTotal)" - : "\(phase.capitalized) phase, round \(context.state.roundCurrent) of \(context.state.roundTotal)") + EmptyView() } DynamicIslandExpandedRegion(.center) { - Text(context.state.exerciseName) - .font(.body.weight(.semibold)) - .foregroundStyle(paused ? .white.opacity(0.6) : .white) - .lineLimit(1) - .accessibilityLabel("Exercise \(context.state.exerciseName)") + @Environment(\.isLuminanceReduced) var isLuminanceReduced + + let elapsedFraction: CGFloat = { + let duration = context.state.phaseDuration + guard duration > 0 else { return 0 } + if context.state.isPaused { + return CGFloat(context.state.phaseElapsedSeconds) / CGFloat(duration) + } + return min(max(0, 1 - CGFloat(max(0, context.state.phaseEndDate.timeIntervalSinceNow)) / CGFloat(duration)), 1) + }() + + VStack(spacing: 4) { + Text(context.state.exerciseName) + .font(.headline.weight(.bold)) + .foregroundStyle(.white) + .lineLimit(1) + .accessibilityLabel("Exercise \(context.state.exerciseName)") + + let phaseSymbol: String = { + switch phase { + case .rest, .interBlockRest, .cooldown: return "snowflake" + case .complete: return "checkmark.circle.fill" + default: return "flame.fill" + } + }() + + HStack(spacing: 4) { + Image(systemName: phaseSymbol) + .font(.system(size: 10)) + .foregroundStyle(phase.color) + Text(paused ? "PAUSED" : phase.capitalized.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(paused ? .white.opacity(0.4) : phase.color) + .animation(isLuminanceReduced ? nil : .easeInOut(duration: 0.8), value: phase) + Text("· Rd \(context.state.roundCurrent)/\(context.state.roundTotal)") + .font(.caption) + .foregroundStyle(.secondary) + } + .accessibilityLabel(paused + ? "Paused, round \(context.state.roundCurrent) of \(context.state.roundTotal)" + : "\(phase.capitalized), round \(context.state.roundCurrent) of \(context.state.roundTotal)") + + HStack(alignment: .center, spacing: 8) { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(.white.opacity(0.1)) + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(phase.color) + .frame(width: geo.size.width * elapsedFraction) + } + } + .frame(width: 120, height: 4) + CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 14) + .frame(width: 48, alignment: .trailing) + } + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + .accessibilityLabel(paused + ? "Paused, \(context.state.exerciseName), round \(context.state.roundCurrent) of \(context.state.roundTotal)" + : "\(phase.capitalized) phase, \(context.state.exerciseName), round \(context.state.roundCurrent) of \(context.state.roundTotal), \(CountdownText.formatFrozenTime(frozenSeconds)) remaining") } DynamicIslandExpandedRegion(.trailing) { - CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 26) - .frame(width: 64, alignment: .trailing) + Button(intent: TogglePauseIntent()) { + Image(systemName: paused ? "play.fill" : "pause.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + .frame(width: 24, height: 24) + .background(.white.opacity(0.12)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(paused ? "Resume workout" : "Pause workout") + .padding(8) } DynamicIslandExpandedRegion(.bottom) { - VStack(spacing: 8) { - WorkoutProgressBar(progress: progress, color: phase.color, isPaused: paused) + VStack(spacing: 6) { + // Stats row + ZStack { + HStack { + if context.state.heartRate > 0 { + HStack(spacing: 3) { + Image(systemName: "heart.fill") + .font(.system(size: 9)) + .foregroundStyle(.red.opacity(0.7)) + Text("\(Int(context.state.heartRate))") + .font(.system(size: 11).monospacedDigit()) + .foregroundStyle(.white.opacity(paused ? 0.3 : 0.5)) + } + .accessibilityLabel("Heart rate \(Int(context.state.heartRate)) beats per minute") + } + Spacer() + } + if !context.state.trackTitle.isEmpty { + HStack(spacing: 3) { + Image(systemName: "music.note") + .font(.system(size: 9)) + .foregroundStyle(.green) + Text(context.state.trackTitle) + .font(.system(size: 10)) + .lineLimit(1) + .foregroundStyle(.white.opacity(paused ? 0.3 : 0.5)) + } + .accessibilityLabel("Now playing \(context.state.trackTitle)") + } + } - DIBottomInfoRow( - heartRate: context.state.heartRate, - trackTitle: context.state.trackTitle, - isPaused: paused - ) + // Block indicator dots + if context.state.blockCount > 1 { + HStack(spacing: 3) { + ForEach(1...context.state.blockCount, id: \.self) { idx in + Circle() + .fill(idx <= context.state.blockIndex ? phase.color : .white.opacity(0.1)) + .frame(width: 4, height: 4) + } + } + .accessibilityLabel("Block \(context.state.blockIndex) of \(context.state.blockCount)") + } } + .padding(.top, 6) .padding(.bottom, 8) } } compactLeading: { - PhaseIcon(phase: phase, isPaused: paused, size: 14) - .accessibilityLabel(paused ? "Paused" : "\(phase.capitalized) phase, workout in progress") + CompactCountdownRing( + endDate: context.state.phaseEndDate, + phaseDuration: context.state.phaseDuration, + isPaused: paused, + frozenSeconds: frozenSeconds, + phase: phase + ) + .dynamicIsland(verticalPlacement: .belowIfTooWide) + .accessibilityLabel(paused + ? "Workout paused" + : "\(phase.capitalized) phase, workout in progress") } compactTrailing: { - CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 12) + if !context.state.exerciseShortName.isEmpty { + Text(context.state.exerciseShortName) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(paused ? .white.opacity(0.3) : .white.opacity(0.7)) + .lineLimit(1) + .accessibilityLabel("Exercise \(context.state.exerciseShortName)") + } else { + CountdownText( + endDate: context.state.phaseEndDate, + isPaused: paused, + frozenSeconds: frozenSeconds, + isUrgent: isUrgent, + size: 11 + ) + } } minimal: { - PhaseIcon(phase: phase, isPaused: paused, size: 10) - .accessibilityLabel(paused ? "Paused" : "Workout in progress") + Circle() + .fill(phase.color) + .frame(width: 8, height: 8) + .modifier(PulseEffect(active: phase == .work && !paused)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(paused ? "Paused workout" : "\(phase.capitalized) phase — workout in progress") } .keylineTint(phase.color) .contentMargins(.leading, 8, for: .expanded) @@ -536,7 +741,10 @@ private struct PulseEffect: ViewModifier { trackTitle: "Lose Yourself", trackArtist: "Eminem", isPlaying: true, - isPaused: false + isPaused: false, + blockIndex: 1, + blockCount: 4, + exerciseShortName: "BURPEES" ) } @@ -554,7 +762,10 @@ private struct PulseEffect: ViewModifier { trackTitle: "Stronger", trackArtist: "Kanye West", isPlaying: true, - isPaused: false + isPaused: false, + blockIndex: 1, + blockCount: 4, + exerciseShortName: "MTN CLMB" ) } @@ -572,7 +783,10 @@ private struct PulseEffect: ViewModifier { trackTitle: "", trackArtist: "", isPlaying: false, - isPaused: true + isPaused: true, + blockIndex: 2, + blockCount: 4, + exerciseShortName: "JMP SQTS" ) } @@ -590,7 +804,10 @@ private struct PulseEffect: ViewModifier { trackTitle: "", trackArtist: "", isPlaying: false, - isPaused: false + isPaused: false, + blockIndex: 4, + blockCount: 4, + exerciseShortName: "" ) } @@ -608,7 +825,10 @@ private struct PulseEffect: ViewModifier { trackTitle: "Till I Collapse", trackArtist: "Eminem", isPlaying: true, - isPaused: false + isPaused: false, + blockIndex: 1, + blockCount: 4, + exerciseShortName: "BURPEES" ) WorkoutActivityAttributes.ContentState( exerciseName: "High Knees", @@ -621,7 +841,31 @@ private struct PulseEffect: ViewModifier { trackTitle: "", trackArtist: "", isPlaying: false, - isPaused: false + isPaused: false, + blockIndex: 2, + blockCount: 4, + exerciseShortName: "HI KNEE" + ) +} + +#Preview("Dynamic Island Expanded — Rest", as: .dynamicIsland(.expanded), using: WorkoutActivityAttributes()) { + WorkoutLiveActivity() +} contentStates: { + WorkoutActivityAttributes.ContentState( + exerciseName: "Mountain Climbers", + phase: .rest, + phaseEndDate: .now.addingTimeInterval(7), + phaseDuration: 10, + roundCurrent: 1, + roundTotal: 8, + heartRate: 120, + trackTitle: "Stronger", + trackArtist: "Kanye West", + isPlaying: true, + isPaused: false, + blockIndex: 1, + blockCount: 4, + exerciseShortName: "MTN CLMB" ) } @@ -639,7 +883,10 @@ private struct PulseEffect: ViewModifier { trackTitle: "", trackArtist: "", isPlaying: false, - isPaused: false + isPaused: false, + blockIndex: 1, + blockCount: 4, + exerciseShortName: "BURPEES" ) } @@ -657,6 +904,9 @@ private struct PulseEffect: ViewModifier { trackTitle: "", trackArtist: "", isPlaying: false, - isPaused: false + isPaused: false, + blockIndex: 1, + blockCount: 4, + exerciseShortName: "BURPEES" ) }