Match the Apple Watch progress run to the iPhone redesign

Times work sets count down for timed exercises (rep sets still count up), rest tint is now gray, and the dot row shows one marker per work set (dash on the active set, gap widening at the current rest). Start/Done/One More are capsule buttons with a heavier rounded label and Start is text-only; the Finished! label is gone; swiping back to Ready resets the run. Shares a timer-layout skeleton so work/rest timers match; keeps WatchKit haptics, the cancel-X toolbar, the screenshot hook, and watch-sized fonts.
This commit is contained in:
2026-06-20 15:36:10 -04:00
parent 1a0c484177
commit 2ae8dc6dc9
@@ -8,22 +8,20 @@
import SwiftUI import SwiftUI
import WatchKit import WatchKit
/// Runs a single exercise as a horizontally-paged flow: /// Runs a single exercise as a horizontally-paged flow, mirroring the iPhone's:
/// ///
/// [Ready] Work Rest Work Work Finish /// [Ready] Work Rest Work Work Finish
/// ///
/// A **Ready?** page leads in *only* when the exercise hasn't been started yet (a /// A **Ready?** page leads in *only* when the exercise hasn't been started yet (a resumed
/// resumed exercise jumps straight to its first unfinished set). The **work** phase /// exercise jumps straight to its first unfinished set). Rep-based **work** phases count
/// counts *up* (a stopwatch for the current set, tinted with the brand purple); the /// *up* (brand purple) and the user swipes on when done; **timed** work phases and
/// user swipes left when they're done. The **rest** phase counts *down* from the /// **rests** count *down* (rest in gray), ping once per second in the final three
/// configurable rest time (light teal), beeps once per second in the final three /// seconds, then auto-advance. Sliding past the final set reaches a **Finish** page with
/// seconds, then auto-advances to the next work phase. Every work phase including /// **One More** and an auto-firing **Done**. Swiping all the way back to Ready resets the
/// the last looks the same; sliding past the final set reaches a **Finish** page /// run from scratch.
/// offering **One More** (append a bonus set and keep going) and **Done** (which also
/// auto-fires after a configurable countdown, completing the exercise).
/// ///
/// A row of phase dots tracks progress: purple for work, teal for rest, with the /// A dot row tracks progress with one marker per work set the active set drawn as a
/// current phase drawn as a wider dash. /// wider dash, with the gap widening at the rest you're currently in.
struct ExerciseProgressView: View { struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -35,16 +33,20 @@ struct ExerciseProgressView: View {
let logID: String let logID: String
let onChange: () -> Void let onChange: () -> Void
/// Rest length between sets, shared with the phone via the same defaults key.
@AppStorage("restSeconds") private var restSeconds: Int = 45
/// Planned set count for this run. `One More` bumps it (and the log's `sets`). /// Planned set count for this run. `One More` bumps it (and the log's `sets`).
@State private var setCount: Int @State private var setCount: Int
@State private var currentPage: Int @State private var currentPage: Int
@State private var showingCancelConfirm = false @State private var showingCancelConfirm = false
@State private var didRestorePage = false @State private var didRestorePage = false
/// True when this run opens on the lead-in **Ready?** page (the exercise hadn't /// True when this run opens on the lead-in **Ready?** page (the exercise hadn't been
/// been started). A resumed exercise or the screenshot host skips it. Constant /// started). A resumed exercise or the screenshot host skips it. Held in `@State`
/// for the lifetime of the view, so all the page-index math below is stable. /// so it stays fixed for the life of the screen (same reasoning as the iPhone view),
private let showsReady: Bool /// keeping the page-index math below stable.
@State private var showsReady: Bool
/// Forces the starting page (used only by the DEBUG screenshot host). When set it /// Forces the starting page (used only by the DEBUG screenshot host). When set it
/// also suppresses the Ready page so the index is a plain work/rest cycle offset. /// also suppresses the Ready page so the index is a plain work/rest cycle offset.
@@ -62,7 +64,7 @@ struct ExerciseProgressView: View {
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
let ready = debugInitialPage == nil && notStarted let ready = debugInitialPage == nil && notStarted
self.showsReady = ready _showsReady = State(initialValue: ready)
let base = ready ? 1 : 0 let base = ready ? 1 : 0
// Resume on the first unfinished set's work page (clamped to the last set). // Resume on the first unfinished set's work page (clamped to the last set).
@@ -84,9 +86,6 @@ struct ExerciseProgressView: View {
/// Ready (`base`) + cycle (`2N 1`) + Finish (`1`). /// Ready (`base`) + cycle (`2N 1`) + Finish (`1`).
private var totalPages: Int { base + cycleCount + 1 } private var totalPages: Int { base + cycleCount + 1 }
/// The single Finish page sits just past the last work page.
private var finishIndex: Int { base + cycleCount }
/// The first unfinished set's work page (only used when resuming, so `base == 0`). /// The first unfinished set's work page (only used when resuming, so `base == 0`).
private var resumePage: Int { private var resumePage: Int {
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1) let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
@@ -100,6 +99,19 @@ struct ExerciseProgressView: View {
return (0..<cycleCount).contains(c) ? c : nil return (0..<cycleCount).contains(c) ? c : nil
} }
/// Work-only progress model for the dot row: one marker per set, a dash on the set
/// being worked, and a doubled gap straddling the rest you're currently in.
private var workDots: WorkPhaseDots.Model? {
guard let c = currentCycleIndex else { return nil }
if c.isMultiple(of: 2) {
let set = c / 2
return .init(setCount: setCount, activeSet: set, restAfterSet: nil, completed: set)
} else {
let set = (c - 1) / 2
return .init(setCount: setCount, activeSet: nil, restAfterSet: set, completed: set + 1)
}
}
private var detail: String { private var detail: String {
guard let log else { return "" } guard let log else { return "" }
if LoadType(rawValue: log.loadType) == .duration { if LoadType(rawValue: log.loadType) == .duration {
@@ -108,6 +120,18 @@ struct ExerciseProgressView: View {
return "\(log.reps) reps" return "\(log.reps) reps"
} }
/// Timed exercise: the work phase counts *down* from its duration (and auto-advances),
/// rather than counting *up* until the user swipes on.
private var isDuration: Bool {
guard let log else { return false }
return LoadType(rawValue: log.loadType) == .duration
}
/// Per-set work duration for a timed exercise.
private var workDurationSeconds: Int {
max(1, log?.durationSeconds ?? 1)
}
/// One-line plan summary for the Ready page, e.g. "4 sets × 8 reps". /// One-line plan summary for the Ready page, e.g. "4 sets × 8 reps".
private var readySummary: String { private var readySummary: String {
let setsText = "\(setCount) set\(setCount == 1 ? "" : "s")" let setsText = "\(setCount) set\(setCount == 1 ? "" : "s")"
@@ -123,8 +147,8 @@ struct ExerciseProgressView: View {
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
if let cycleIndex = currentCycleIndex { if let dots = workDots {
PhaseProgressDots(count: cycleCount, currentIndex: cycleIndex) WorkPhaseDots(model: dots)
.padding(.bottom, 2) .padding(.bottom, 2)
} }
} }
@@ -142,7 +166,13 @@ struct ExerciseProgressView: View {
Button("Continue", role: .cancel) { } Button("Continue", role: .cancel) { }
} }
.onChange(of: currentPage) { _, newPage in .onChange(of: currentPage) { _, newPage in
recordProgress(for: newPage) // Swiping all the way back to the Ready page wipes the run; any other page
// records forward progress.
if showsReady && newPage == 0 {
resetExercise()
} else {
recordProgress(for: newPage)
}
} }
.onAppear { .onAppear {
// Jump to the first unfinished set. A paged TabView can settle on page 0 on // Jump to the first unfinished set. A paged TabView can settle on page 0 on
@@ -182,16 +212,35 @@ struct ExerciseProgressView: View {
onOneMore: addSet onOneMore: addSet
) )
} else if cycleIndex.isMultiple(of: 2) { } else if cycleIndex.isMultiple(of: 2) {
// Work phase. let setNumber = cycleIndex / 2 + 1
WorkPhaseView( if isDuration {
setNumber: cycleIndex / 2 + 1, // Timed work set count down from the planned duration, then
totalSets: setCount, // auto-advance (and buzz) the same way a rest does.
detail: detail, CountdownPhaseView(
isActive: isActive header: "\(setNumber) of \(setCount)",
) tint: .workTimer,
seconds: workDurationSeconds,
isActive: isActive
) {
withAnimation { advance(from: index) }
}
} else {
// Rep-based work set count up; the user swipes left when done.
WorkPhaseView(
setNumber: setNumber,
totalSets: setCount,
detail: detail,
isActive: isActive
)
}
} else { } else {
// Rest phase. Auto-advances to the next work page when the timer hits zero. // Rest phase. Auto-advances to the next work page when the timer hits zero.
RestPhaseView(isActive: isActive) { CountdownPhaseView(
header: "Rest",
tint: .restTimer,
seconds: restSeconds,
isActive: isActive
) {
withAnimation { advance(from: index) } withAnimation { advance(from: index) }
} }
} }
@@ -225,12 +274,6 @@ struct ExerciseProgressView: View {
/// Append a bonus set from the Finish page: mark every prior set done, grow the /// Append a bonus set from the Finish page: mark every prior set done, grow the
/// plan, and slide forward into the bonus set's work phase. /// plan, and slide forward into the bonus set's work phase.
///
/// Growing `setCount` and advancing `currentPage` in one animated transaction makes
/// the new work phase slide in from the right. (We land on work, not a rest, because
/// the Finish page's own auto-finish countdown already served as the between-set
/// breather and the rest page would otherwise sit at the very index the Finish
/// page occupied, which a paged TabView can't animate to.)
private func addSet() { private func addSet() {
let newCount = setCount + 1 let newCount = setCount + 1
@@ -273,6 +316,22 @@ struct ExerciseProgressView: View {
onChange() onChange()
} }
/// Swiping back to the **Ready?** page starts the exercise over from scratch.
private func resetExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
let log = doc.logs[i]
// Skip the write if it's already pristine (e.g. landing on Ready before any set).
guard log.currentStateIndex != 0
|| log.status != WorkoutStatus.notStarted.rawValue
|| log.completed else { return }
doc.logs[i].currentStateIndex = 0
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
doc.logs[i].completed = false
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
private func completeExercise() { private func completeExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = setCount doc.logs[i].currentStateIndex = setCount
@@ -313,26 +372,125 @@ struct ExerciseProgressView: View {
} }
} }
// MARK: - Phase Progress Dots // MARK: - Haptics
/// Compact progress row for the work/rest cycle: purple dots for work, teal for rest, /// The haptic vocabulary used across the flow (set start, countdown ping, rest end, done).
/// with the active phase drawn as a wider dash. Dots are deliberately a touch thick so private enum WorkoutHaptic {
/// the count of remaining sets reads at a glance. case start, tick, stop, success
private struct PhaseProgressDots: View {
let count: Int @MainActor
let currentIndex: Int func play() {
switch self {
case .start: WKInterfaceDevice.current().play(.start)
case .tick: WKInterfaceDevice.current().play(.notification)
case .stop: WKInterfaceDevice.current().play(.stop)
case .success: WKInterfaceDevice.current().play(.success)
}
}
}
// MARK: - Phase Colors
/// watchOS is always dark, so these are static (matching the iPhone's dark-mode tints):
/// brand purple for work, light gray for rest.
private extension Color {
static let workTimer = Color(red: 0.66, green: 0.45, blue: 0.96)
static let restTimer = Color(white: 0.74)
}
// MARK: - Phase Timer Layout
/// Shared skeleton for the work and rest pages so their timers use an identical font and
/// land at exactly the same spot: a header line, the big timer, then a footer line. The
/// footer reserves its height even when empty, keeping the timer centered the same way on
/// both pages.
private struct PhaseTimerLayout<Content: View>: View {
let header: String
let footer: String
let tint: Color
@ViewBuilder var timer: Content
var body: some View { var body: some View {
HStack(spacing: 4) { VStack(spacing: 4) {
ForEach(0..<count, id: \.self) { i in Text(header)
let isWork = i.isMultiple(of: 2) .font(.caption)
let isCurrent = i == currentIndex .foregroundStyle(.secondary)
Capsule()
.fill(isWork ? Color.workTint : Color.restTint) timer
.frame(width: isCurrent ? 12 : 6, height: 6) .font(.system(size: 50, weight: .bold, design: .rounded))
.opacity(isCurrent ? 1 : 0.45) .monospacedDigit()
.foregroundStyle(tint)
Text(footer.isEmpty ? " " : footer)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Work Phase Dots
/// Progress row with one marker per work set. The set being worked is drawn as a wider
/// dash; during a rest every marker is a plain dot and the gap straddling that rest grows
/// to about double, hinting at the pause between the set that just ended and the next one.
/// Completed sets are full strength, upcoming ones dimmed.
private struct WorkPhaseDots: View {
struct Model: Equatable {
let setCount: Int
/// The set currently being worked drawn as a dash. `nil` during a rest.
let activeSet: Int?
/// The set just completed; the gap *after* its dot doubles. `nil` during work.
let restAfterSet: Int?
/// How many sets are fully done (for dimming the upcoming ones).
let completed: Int
}
let model: Model
// Geometry tune freely.
private let dotWidth: CGFloat = 5
private let dashWidth: CGFloat = 13
private let markerHeight: CGFloat = 5
private let gap: CGFloat = 5
private var restGap: CGFloat { gap * 2 }
var body: some View {
HStack(spacing: 0) {
ForEach(0..<model.setCount, id: \.self) { i in
marker(for: i)
if i < model.setCount - 1 {
Color.clear.frame(width: gapWidth(after: i), height: markerHeight)
}
} }
} }
.animation(.easeInOut(duration: 0.3), value: model)
}
private func marker(for i: Int) -> some View {
let isActive = model.activeSet == i
let isDone = i < model.completed
return Capsule()
.fill(Color.workTimer)
.frame(width: isActive ? dashWidth : dotWidth, height: markerHeight)
.opacity(isActive || isDone ? 1 : 0.45)
}
private func gapWidth(after i: Int) -> CGFloat {
model.restAfterSet == i ? restGap : gap
}
}
// MARK: - Phase Button Styling
private extension View {
/// Chunky, rounded, heavy treatment shared by the Start / Done / One More buttons:
/// a plump label (echoing the counter digits) over a full-width body.
func phaseButtonLabel() -> some View {
self
.font(.system(.headline, design: .rounded, weight: .heavy))
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
} }
} }
@@ -343,22 +501,23 @@ private struct ReadyPhaseView: View {
let onStart: () -> Void let onStart: () -> Void
var body: some View { var body: some View {
VStack(spacing: 10) { VStack(spacing: 8) {
Text("Ready?") Text("Ready?")
.font(.system(size: 30, weight: .bold, design: .rounded)) .font(.system(size: 30, weight: .bold, design: .rounded))
if !summary.isEmpty { if !summary.isEmpty {
Text(summary) Text(summary)
.font(.subheadline) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Button(action: onStart) { Button(action: onStart) {
Label("Start", systemImage: "play.fill") Text("Start")
.frame(maxWidth: .infinity) .phaseButtonLabel()
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(.workTint) .tint(.workTimer)
.buttonBorderShape(.capsule)
.padding(.top, 4) .padding(.top, 4)
} }
.padding(.horizontal) .padding(.horizontal)
@@ -381,68 +540,51 @@ private struct WorkPhaseView: View {
@State private var startDate = Date() @State private var startDate = Date()
var body: some View { var body: some View {
VStack(spacing: 6) { PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) {
Text("\(setNumber) of \(totalSets)")
.font(.headline)
.foregroundStyle(.secondary)
Text(startDate, style: .timer) Text(startDate, style: .timer)
.font(.system(size: 48, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(Color.workTint)
if !detail.isEmpty {
Text(detail)
.font(.subheadline)
.foregroundStyle(.secondary)
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if isActive { restart() } } .onAppear { if isActive { restart() } }
.onChange(of: isActive) { _, active in if active { restart() } } .onChange(of: isActive) { _, active in if active { restart() } }
} }
private func restart() { private func restart() {
startDate = Date() startDate = Date()
WKInterfaceDevice.current().play(.start) WorkoutHaptic.start.play()
} }
} }
// MARK: - Rest Phase // MARK: - Countdown Phase
private struct RestPhaseView: View { /// A count-down phase used for both rests and timed work sets: counts down from
/// `seconds`, pings once per second in the final three, then buzzes and auto-advances at
/// zero. The header/tint distinguish the two uses (purple "N of M" work vs. gray "Rest").
///
/// The display is driven by a wall-clock window (so it keeps counting down in the
/// Always-On / wrist-down state), and the haptics + auto-advance are derived from
/// `endDate` rather than a decremented counter so they stay correct even after the
/// run-loop `Timer` was throttled while the wrist was down.
private struct CountdownPhaseView: View {
let header: String
var footer: String = ""
let tint: Color
let seconds: Int
let isActive: Bool let isActive: Bool
/// Invoked once the countdown reaches zero (auto-advance to the next work phase). /// Invoked once the countdown reaches zero (auto-advance to the next page).
let onFinished: () -> Void let onFinished: () -> Void
@AppStorage("restSeconds") private var restSeconds: Int = 45
/// Wall-clock window for the countdown. SwiftUI renders the remaining time from this
/// range (so the display keeps counting down in the Always-On / wrist-down state),
/// and the haptics + auto-advance below are derived from `endDate` rather than a
/// decremented counter so they stay correct even after the run-loop `Timer` was
/// throttled while the wrist was down.
@State private var startDate = Date() @State private var startDate = Date()
@State private var endDate = Date() @State private var endDate = Date()
/// Lowest remaining-second we've already pinged, so a burst of catch-up ticks after a /// Lowest remaining-second we've already pinged, so a burst of catch-up ticks doesn't
/// stall doesn't double-beep. /// double-buzz.
@State private var lastPingSecond = Int.max @State private var lastPingSecond = Int.max
/// Guards the auto-advance so it fires exactly once even if ticks pile up. /// Guards the auto-advance so it fires exactly once even if ticks pile up.
@State private var didFinish = false @State private var didFinish = false
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
VStack(spacing: 6) { PhaseTimerLayout(header: header, footer: footer, tint: tint) {
Text("Rest")
.font(.headline)
.foregroundStyle(.secondary)
Text(timerInterval: startDate...endDate, countsDown: true) Text(timerInterval: startDate...endDate, countsDown: true)
.font(.system(size: 54, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(Color.restTint)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if isActive { start() } } .onAppear { if isActive { start() } }
.onChange(of: isActive) { _, active in if active { start() } } .onChange(of: isActive) { _, active in if active { start() } }
.onReceive(ticker) { _ in tick() } .onReceive(ticker) { _ in tick() }
@@ -450,10 +592,10 @@ private struct RestPhaseView: View {
private func start() { private func start() {
startDate = Date() startDate = Date()
endDate = startDate.addingTimeInterval(Double(max(1, restSeconds))) endDate = startDate.addingTimeInterval(Double(max(1, seconds)))
lastPingSecond = Int.max lastPingSecond = Int.max
didFinish = false didFinish = false
WKInterfaceDevice.current().play(.start) WorkoutHaptic.start.play()
} }
private func tick() { private func tick() {
@@ -462,25 +604,24 @@ private struct RestPhaseView: View {
let remaining = Int(ceil(endDate.timeIntervalSinceNow)) let remaining = Int(ceil(endDate.timeIntervalSinceNow))
if remaining <= 0 { if remaining <= 0 {
// Time's up final cue and slide to the next work phase. If the wrist was // Time's up final cue and slide on. If the wrist was down the timer may have
// down the timer may have stalled; this then fires on the first tick once the // stalled; this then fires on the first tick once the app gets runtime again.
// app gets runtime again (e.g. the wrist comes up), never losing the rest.
didFinish = true didFinish = true
WKInterfaceDevice.current().play(.stop) WorkoutHaptic.stop.play()
onFinished() onFinished()
} else if remaining <= 3 && remaining < lastPingSecond { } else if remaining <= 3 && remaining < lastPingSecond {
// Once-per-second countdown ping for the final three seconds. // Once-per-second countdown ping for the final three seconds.
lastPingSecond = remaining lastPingSecond = remaining
WKInterfaceDevice.current().play(.notification) WorkoutHaptic.tick.play()
} }
} }
} }
// MARK: - Finish Phase // MARK: - Finish Phase
/// Terminal page after the last set. **Done** completes the exercise and dismisses /// Terminal page after the last set. **Done** completes the exercise and dismisses and
/// and fires automatically after a configurable countdown so the user doesn't have to /// fires automatically after a configurable countdown so the user doesn't have to tap with
/// tap with sweaty hands. **One More** appends a bonus set instead. /// sweaty hands. **One More** appends a bonus set instead.
private struct FinishPhaseView: View { private struct FinishPhaseView: View {
let isActive: Bool let isActive: Bool
let onDone: () -> Void let onDone: () -> Void
@@ -488,8 +629,8 @@ private struct FinishPhaseView: View {
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
/// Wall-clock deadline for the auto-Done (same Always-On rationale as the rest /// Wall-clock deadline for the auto-Done (same Always-On rationale as the rest timer).
/// timer). `remaining` is what the Done button shows. /// `remaining` is what the Done button shows.
@State private var endDate = Date() @State private var endDate = Date()
@State private var remaining = 0 @State private var remaining = 0
/// Fires the auto-Done exactly once, and latches off while the page isn't active. /// Fires the auto-Done exactly once, and latches off while the page isn't active.
@@ -498,22 +639,20 @@ private struct FinishPhaseView: View {
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
Text("Finished!")
.font(.headline)
.foregroundStyle(.secondary)
Button(action: fire) { Button(action: fire) {
Label(remaining > 0 ? "Done · \(remaining)" : "Done", systemImage: "checkmark") Label(remaining > 0 ? "Done · \(remaining)" : "Done", systemImage: "checkmark")
.frame(maxWidth: .infinity) .phaseButtonLabel()
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(Color.workTint) .tint(Color.workTimer)
.buttonBorderShape(.capsule)
Button(action: onOneMore) { Button(action: onOneMore) {
Label("One More", systemImage: "plus") Label("One More", systemImage: "plus")
.frame(maxWidth: .infinity) .phaseButtonLabel()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.buttonBorderShape(.capsule)
} }
.padding(.horizontal) .padding(.horizontal)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -539,7 +678,7 @@ private struct FinishPhaseView: View {
private func fire() { private func fire() {
guard !didFire else { return } guard !didFire else { return }
didFire = true didFire = true
WKInterfaceDevice.current().play(.success) WorkoutHaptic.success.play()
onDone() onDone()
} }
} }