Redesign workout live activity with circular timer ring, phase icons, and smoother updates
Some checks failed
CI / TypeScript (pull_request) Failing after 19s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 7s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m9s
CI / Deploy Edge Functions (pull_request) Has been skipped
Some checks failed
CI / TypeScript (pull_request) Failing after 19s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 7s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m9s
CI / Deploy Edge Functions (pull_request) Has been skipped
- Add CountdownRing with real-time arc progress on lock screen - Replace generic dots with phase-specific SF Symbols (flame, snowflake, etc.) - Remove horizontal progress bar in favor of round counter text - Increase Dynamic Island expanded font sizes for better visibility - Increase live activity sync frequency from 5s to 1s for smoother arc updates - Add pause/resume button via TogglePauseIntent AppIntent - Remove AlertConfiguration to silence notification sounds on updates
This commit is contained in:
@@ -40,6 +40,7 @@ struct WorkoutActivityAttributes: ActivityAttributes {
|
||||
var exerciseName: String
|
||||
var phase: WorkoutPhase
|
||||
var phaseEndDate: Date
|
||||
var phaseDuration: TimeInterval
|
||||
var roundCurrent: Int
|
||||
var roundTotal: Int
|
||||
var heartRate: Double
|
||||
|
||||
@@ -365,7 +365,7 @@ final class PlayerViewModel: ObservableObject {
|
||||
|
||||
// ─── Dynamic Island / Live Activity ─────────────────────────────
|
||||
|
||||
func syncActivity() {
|
||||
func syncActivity(shouldAlert: Bool = false) {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
guard isRunning else { return }
|
||||
|
||||
@@ -377,6 +377,7 @@ final class PlayerViewModel: ObservableObject {
|
||||
exerciseName: currentExercise?.nameEn ?? "",
|
||||
phase: workoutPhase,
|
||||
phaseEndDate: phaseEnd,
|
||||
phaseDuration: Double(totalPhaseTime),
|
||||
roundCurrent: currentRound,
|
||||
roundTotal: totalRoundsInBlock,
|
||||
heartRate: heartRate,
|
||||
@@ -393,7 +394,7 @@ final class PlayerViewModel: ObservableObject {
|
||||
if let existing = workoutActivity {
|
||||
nonisolated(unsafe) let safeExisting = existing
|
||||
let staleDate = Date().addingTimeInterval(isPaused ? 3600 : 120)
|
||||
let alert = alertForPhase(workoutPhase)
|
||||
let alert = shouldAlert ? alertForPhase(workoutPhase) : nil
|
||||
Task { @MainActor in
|
||||
if let alert {
|
||||
await safeExisting.update(ActivityContent(state: state, staleDate: staleDate), alertConfiguration: alert)
|
||||
@@ -444,6 +445,7 @@ final class PlayerViewModel: ObservableObject {
|
||||
exerciseName: safeActivity.content.state.exerciseName,
|
||||
phase: .complete,
|
||||
phaseEndDate: safeActivity.content.state.phaseEndDate,
|
||||
phaseDuration: safeActivity.content.state.phaseDuration,
|
||||
roundCurrent: safeActivity.content.state.roundCurrent,
|
||||
roundTotal: safeActivity.content.state.roundTotal,
|
||||
heartRate: safeActivity.content.state.heartRate,
|
||||
@@ -457,7 +459,7 @@ final class PlayerViewModel: ObservableObject {
|
||||
|
||||
private func startActivitySyncTimer() {
|
||||
stopActivitySyncTimer()
|
||||
activitySyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||||
activitySyncTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.syncActivity()
|
||||
}
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
import ActivityKit
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
import AppIntents
|
||||
|
||||
// MARK: - Decomposed Views
|
||||
// MARK: - App Intents
|
||||
|
||||
struct PhasePill: View {
|
||||
let phase: WorkoutPhase
|
||||
let isPaused: Bool
|
||||
struct TogglePauseIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Toggle Pause"
|
||||
|
||||
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())
|
||||
}
|
||||
@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 {
|
||||
let phase: WorkoutPhase
|
||||
let isPaused: Bool
|
||||
let size: CGFloat
|
||||
|
||||
private var iconName: String {
|
||||
if isPaused { return "pause.fill" }
|
||||
switch phase {
|
||||
case .work: return "flame.fill"
|
||||
case .rest, .interBlockRest: return "snowflake"
|
||||
case .prep, .warmup: return "timer"
|
||||
case .cooldown: return "heart.circle.fill"
|
||||
case .complete: return "checkmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: size))
|
||||
.foregroundStyle(isPaused ? .white.opacity(0.4) : phase.color)
|
||||
.modifier(PulseEffect(active: phase == .work && !isPaused))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Countdown Text
|
||||
|
||||
struct CountdownText: View {
|
||||
let endDate: Date
|
||||
let isPaused: Bool
|
||||
@@ -59,6 +73,63 @@ struct CountdownText: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Countdown Ring (Lock Screen only)
|
||||
|
||||
struct CountdownRing: View {
|
||||
let endDate: Date
|
||||
let phaseDuration: TimeInterval
|
||||
let isPaused: Bool
|
||||
let frozenSeconds: Int
|
||||
let isUrgent: Bool
|
||||
let phase: WorkoutPhase
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 0.2)) { timeline in
|
||||
let lineWidth: CGFloat = 4
|
||||
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
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.08), lineWidth: lineWidth)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: arcProgress)
|
||||
.stroke(
|
||||
isPaused ? phase.color.opacity(0.3) : (isUrgent ? .orange : phase.color),
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.linear(duration: 0.2), value: arcProgress)
|
||||
|
||||
if isPaused {
|
||||
Text(CountdownText.formatFrozenTime(frozenSeconds))
|
||||
.font(.system(size: size * 0.32, weight: .bold).monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
} else {
|
||||
Text(timerInterval: Date()...endDate, countsDown: true)
|
||||
.font(.system(size: size * 0.32, weight: .bold).monospacedDigit())
|
||||
.foregroundStyle(isUrgent ? .orange : .white)
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
.accessibilityLabel(isPaused
|
||||
? "Timer paused at \(CountdownText.formatFrozenTime(frozenSeconds))"
|
||||
: "Countdown timer")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout Progress Bar
|
||||
|
||||
struct WorkoutProgressBar: View {
|
||||
let progress: CGFloat
|
||||
let color: Color
|
||||
@@ -67,18 +138,20 @@ struct WorkoutProgressBar: View {
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(.white.opacity(0.08))
|
||||
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
||||
.fill(color.opacity(isPaused ? 0.25 : 0.6))
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(color.opacity(isPaused ? 0.25 : 0.7))
|
||||
.frame(width: geo.size.width * progress)
|
||||
}
|
||||
.frame(height: 2)
|
||||
.frame(height: 4)
|
||||
}
|
||||
.frame(height: 2)
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Music Info Row
|
||||
|
||||
struct MusicInfoRow: View {
|
||||
let trackTitle: String
|
||||
let trackArtist: String
|
||||
@@ -100,7 +173,6 @@ struct MusicInfoRow: View {
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white.opacity(isPaused ? 0.3 : 0.5))
|
||||
.accessibilityLabel(trackArtist.isEmpty ? trackTitle : "\(trackTitle) by \(trackArtist)")
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -125,7 +197,6 @@ struct HeartRateBadge: View {
|
||||
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))
|
||||
@@ -134,17 +205,40 @@ struct HeartRateBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct PhaseIndicatorDot: View {
|
||||
let color: Color
|
||||
// MARK: - DI Bottom Info Row
|
||||
|
||||
struct DIBottomInfoRow: View {
|
||||
let heartRate: Double
|
||||
let trackTitle: String
|
||||
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))
|
||||
HStack {
|
||||
if heartRate > 0 {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.red.opacity(0.7))
|
||||
Text("\(Int(heartRate))")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(isPaused ? 0.3 : 0.5))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !trackTitle.isEmpty {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.green)
|
||||
Text(trackTitle)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white.opacity(isPaused ? 0.3 : 0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,69 +258,96 @@ struct WorkoutLiveActivity: Widget {
|
||||
|
||||
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)
|
||||
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)
|
||||
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,
|
||||
phaseDuration: context.state.phaseDuration,
|
||||
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) {
|
||||
PhasePill(phase: phase, isPaused: paused)
|
||||
Text("\(context.state.roundCurrent)/\(context.state.roundTotal)")
|
||||
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)")
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.state.exerciseName)
|
||||
.font(.caption.weight(.semibold))
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(paused ? .white.opacity(0.6) : .white)
|
||||
.lineLimit(1)
|
||||
.accessibilityLabel("Exercise \(context.state.exerciseName)")
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 20)
|
||||
.frame(width: 56, alignment: .trailing)
|
||||
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 26)
|
||||
.frame(width: 64, alignment: .trailing)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(spacing: 6) {
|
||||
VStack(spacing: 8) {
|
||||
WorkoutProgressBar(progress: progress, color: phase.color, isPaused: paused)
|
||||
|
||||
MusicInfoRow(
|
||||
trackTitle: context.state.trackTitle,
|
||||
trackArtist: context.state.trackArtist,
|
||||
isPlaying: context.state.isPlaying,
|
||||
isPaused: paused,
|
||||
DIBottomInfoRow(
|
||||
heartRate: context.state.heartRate,
|
||||
isLuminanceReduced: false
|
||||
trackTitle: context.state.trackTitle,
|
||||
isPaused: paused
|
||||
)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
} compactLeading: {
|
||||
PhaseIndicatorDot(color: phase.color, isPaused: paused, size: 10, isPulsing: isWork)
|
||||
PhaseIcon(phase: phase, isPaused: paused, size: 14)
|
||||
.accessibilityLabel(paused ? "Paused" : "\(phase.capitalized) phase, workout in progress")
|
||||
} compactTrailing: {
|
||||
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 12)
|
||||
} minimal: {
|
||||
PhaseIndicatorDot(color: phase.color, isPaused: paused, size: 6, isPulsing: false)
|
||||
PhaseIcon(phase: phase, isPaused: paused, size: 10)
|
||||
.accessibilityLabel(paused ? "Paused" : "Workout in progress")
|
||||
}
|
||||
.keylineTint(phase.color)
|
||||
.contentMargins(.leading, 8, for: .expanded)
|
||||
.contentMargins(.trailing, 8, for: .expanded)
|
||||
.contentMargins(.bottom, 4, for: .expanded)
|
||||
.contentMargins(.bottom, 6, for: .expanded)
|
||||
.widgetURL(URL(string: "tabatago://workout")!)
|
||||
}
|
||||
.supplementalActivityFamilies([.small, .medium])
|
||||
@@ -247,90 +368,98 @@ struct WorkoutLockScreenView: View {
|
||||
let roundCurrent: Int
|
||||
let roundTotal: Int
|
||||
let endDate: Date
|
||||
let phaseDuration: TimeInterval
|
||||
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 ? phase.color.opacity(0.4) : phase.color)
|
||||
.frame(width: 4, height: 38)
|
||||
.accessibilityHidden(true)
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black, location: 0.4),
|
||||
.init(color: phase.color.opacity(0.1), location: 1.0)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(exerciseName)
|
||||
.font(isFullscreen ? .title2 : .headline)
|
||||
.foregroundStyle(.primary)
|
||||
.minimumScaleFactor(0.7)
|
||||
.accessibilityLabel(exerciseName)
|
||||
if paused {
|
||||
Text("PAUSED")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.accessibilityLabel("Paused")
|
||||
} else {
|
||||
Text(phase.capitalized)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(phase.color)
|
||||
.accessibilityLabel("\(phase.capitalized) phase")
|
||||
}
|
||||
if isStale {
|
||||
Text("Last updated")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary.opacity(0.6))
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
CountdownRing(
|
||||
endDate: endDate,
|
||||
phaseDuration: phaseDuration,
|
||||
isPaused: paused,
|
||||
frozenSeconds: frozenSeconds,
|
||||
isUrgent: isUrgent,
|
||||
phase: phase,
|
||||
size: isFullscreen ? 64 : 56
|
||||
)
|
||||
|
||||
Spacer()
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(exerciseName)
|
||||
.font(isFullscreen ? .title2.bold() : .headline.bold())
|
||||
.foregroundStyle(.primary)
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Group {
|
||||
if paused {
|
||||
Text(CountdownText.formatFrozenTime(frozenSeconds))
|
||||
.font(isFullscreen ? .largeTitle.weight(.bold) : .title2.weight(.bold))
|
||||
.fontDesign(.monospaced)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
} else {
|
||||
Text(timerInterval: Date()...endDate, countsDown: true)
|
||||
.font(isFullscreen ? .largeTitle.weight(.bold) : .title2.weight(.bold))
|
||||
.fontDesign(.monospaced)
|
||||
.foregroundStyle(isUrgent ? .orange : .primary)
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
HStack(spacing: 5) {
|
||||
PhaseIcon(phase: phase, isPaused: paused, size: 10)
|
||||
if paused {
|
||||
Text("PAUSED")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
} else {
|
||||
Text(phase.capitalized)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(phase.color)
|
||||
}
|
||||
Text("·")
|
||||
.foregroundStyle(.secondary.opacity(0.4))
|
||||
Text("Round \(roundCurrent)/\(roundTotal)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if isStale {
|
||||
Text("Last updated")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary.opacity(0.6))
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(paused ? "Timer paused at \(CountdownText.formatFrozenTime(frozenSeconds))" : "Countdown timer")
|
||||
Text("Round \(roundCurrent)/\(roundTotal)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("Round \(roundCurrent) of \(roundTotal)")
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button(intent: TogglePauseIntent()) {
|
||||
Image(systemName: paused ? "play.fill" : "pause.fill")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.frame(width: 32, height: 32)
|
||||
.background(.white.opacity(0.12))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(paused ? "Resume workout" : "Pause workout")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, isFullscreen ? 20 : 12)
|
||||
|
||||
MusicInfoRow(
|
||||
trackTitle: trackTitle,
|
||||
trackArtist: trackArtist,
|
||||
isPlaying: isPlaying,
|
||||
isPaused: paused,
|
||||
heartRate: heartRate,
|
||||
isLuminanceReduced: isLuminanceReduced
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, isFullscreen ? 20 : 12)
|
||||
|
||||
WorkoutProgressBar(progress: progress, color: phase.color, isPaused: paused)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 8)
|
||||
|
||||
MusicInfoRow(
|
||||
trackTitle: trackTitle,
|
||||
trackArtist: trackArtist,
|
||||
isPlaying: isPlaying,
|
||||
isPaused: paused,
|
||||
heartRate: heartRate,
|
||||
isLuminanceReduced: isLuminanceReduced
|
||||
)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
.activityBackgroundTint(.black.opacity(0.9))
|
||||
.activityBackgroundTint(.clear)
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
}
|
||||
}
|
||||
@@ -348,19 +477,16 @@ struct WorkoutSmallView: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
PhaseIndicatorDot(color: phase.color, isPaused: paused, size: 8, isPulsing: false)
|
||||
.accessibilityLabel(paused ? "Paused" : "\(phase.capitalized) phase")
|
||||
PhaseIcon(phase: phase, isPaused: paused, size: 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(exerciseName)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(paused ? Color.primary.opacity(0.6) : Color.primary)
|
||||
.accessibilityLabel(exerciseName)
|
||||
Text(paused ? "PAUSED" : "\(roundCurrent)/\(roundTotal)")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel(paused ? "Paused" : "Round \(roundCurrent) of \(roundTotal)")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -403,6 +529,7 @@ private struct PulseEffect: ViewModifier {
|
||||
exerciseName: "Burpees",
|
||||
phase: .work,
|
||||
phaseEndDate: .now.addingTimeInterval(20),
|
||||
phaseDuration: 20,
|
||||
roundCurrent: 3,
|
||||
roundTotal: 8,
|
||||
heartRate: 142,
|
||||
@@ -420,6 +547,7 @@ private struct PulseEffect: ViewModifier {
|
||||
exerciseName: "Mountain Climbers",
|
||||
phase: .rest,
|
||||
phaseEndDate: .now.addingTimeInterval(8),
|
||||
phaseDuration: 10,
|
||||
roundCurrent: 4,
|
||||
roundTotal: 8,
|
||||
heartRate: 128,
|
||||
@@ -437,6 +565,7 @@ private struct PulseEffect: ViewModifier {
|
||||
exerciseName: "Jump Squats",
|
||||
phase: .work,
|
||||
phaseEndDate: .now.addingTimeInterval(15),
|
||||
phaseDuration: 20,
|
||||
roundCurrent: 5,
|
||||
roundTotal: 8,
|
||||
heartRate: 135,
|
||||
@@ -454,6 +583,7 @@ private struct PulseEffect: ViewModifier {
|
||||
exerciseName: "Workout Complete",
|
||||
phase: .complete,
|
||||
phaseEndDate: .now,
|
||||
phaseDuration: 0,
|
||||
roundCurrent: 8,
|
||||
roundTotal: 8,
|
||||
heartRate: 110,
|
||||
@@ -471,6 +601,7 @@ private struct PulseEffect: ViewModifier {
|
||||
exerciseName: "Burpees",
|
||||
phase: .work,
|
||||
phaseEndDate: .now.addingTimeInterval(18),
|
||||
phaseDuration: 20,
|
||||
roundCurrent: 3,
|
||||
roundTotal: 8,
|
||||
heartRate: 145,
|
||||
@@ -483,6 +614,7 @@ private struct PulseEffect: ViewModifier {
|
||||
exerciseName: "High Knees",
|
||||
phase: .rest,
|
||||
phaseEndDate: .now.addingTimeInterval(6),
|
||||
phaseDuration: 10,
|
||||
roundCurrent: 6,
|
||||
roundTotal: 8,
|
||||
heartRate: 120,
|
||||
@@ -500,6 +632,7 @@ private struct PulseEffect: ViewModifier {
|
||||
exerciseName: "Burpees",
|
||||
phase: .work,
|
||||
phaseEndDate: .now.addingTimeInterval(18),
|
||||
phaseDuration: 20,
|
||||
roundCurrent: 3,
|
||||
roundTotal: 8,
|
||||
heartRate: 0,
|
||||
@@ -517,6 +650,7 @@ private struct PulseEffect: ViewModifier {
|
||||
exerciseName: "Burpees",
|
||||
phase: .work,
|
||||
phaseEndDate: .now.addingTimeInterval(18),
|
||||
phaseDuration: 20,
|
||||
roundCurrent: 3,
|
||||
roundTotal: 8,
|
||||
heartRate: 0,
|
||||
|
||||
Reference in New Issue
Block a user