feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift #2

Merged
millianlmx merged 18 commits from revamp-timer-video-layout into main 2026-05-23 12:24:34 +02:00
12 changed files with 472 additions and 76 deletions
Showing only changes of commit c152c22ffb - Show all commits

View File

@@ -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() {}
}

View File

@@ -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" {

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -317,5 +317,5 @@ struct ProgramRow: View {
#Preview {
HomeTab(previewVM: HomeViewModel(previewPrograms: [PreviewData.sampleProgram]))
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
.environment(AppState.shared)
}

View File

@@ -48,5 +48,5 @@ struct MainTabView: View {
#Preview {
MainTabView()
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
.environment(AppState.shared)
}

View File

@@ -139,5 +139,5 @@ struct ProfileRow: View {
#Preview {
ProfileTab()
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
.environment(AppState.shared)
}

View File

@@ -103,5 +103,5 @@ struct ProgramsTab: View {
#Preview {
ProgramsTab()
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
.environment(AppState.shared)
}

View File

@@ -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)

View File

@@ -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"
)
}