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:
@@ -8,22 +8,20 @@
|
||||
import SwiftUI
|
||||
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
|
||||
///
|
||||
/// A **Ready?** page leads in *only* when the exercise hasn't been started yet (a
|
||||
/// resumed exercise jumps straight to its first unfinished set). The **work** phase
|
||||
/// counts *up* (a stopwatch for the current set, tinted with the brand purple); the
|
||||
/// user swipes left when they're done. The **rest** phase counts *down* from the
|
||||
/// configurable rest time (light teal), beeps once per second in the final three
|
||||
/// seconds, then auto-advances to the next work phase. Every work phase — including
|
||||
/// the last — looks the same; sliding past the final set reaches a **Finish** page
|
||||
/// offering **One More** (append a bonus set and keep going) and **Done** (which also
|
||||
/// auto-fires after a configurable countdown, completing the exercise).
|
||||
/// A **Ready?** page leads in *only* when the exercise hasn't been started yet (a resumed
|
||||
/// exercise jumps straight to its first unfinished set). Rep-based **work** phases count
|
||||
/// *up* (brand purple) and the user swipes on when done; **timed** work phases and
|
||||
/// **rests** count *down* (rest in gray), ping once per second in the final three
|
||||
/// seconds, then auto-advance. Sliding past the final set reaches a **Finish** page with
|
||||
/// **One More** and an auto-firing **Done**. Swiping all the way back to Ready resets the
|
||||
/// run from scratch.
|
||||
///
|
||||
/// A row of phase dots tracks progress: purple for work, teal for rest, with the
|
||||
/// current phase drawn as a wider dash.
|
||||
/// A dot row tracks progress with one marker per work set — the active set drawn as a
|
||||
/// wider dash, with the gap widening at the rest you're currently in.
|
||||
struct ExerciseProgressView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@@ -35,16 +33,20 @@ struct ExerciseProgressView: View {
|
||||
let logID: String
|
||||
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`).
|
||||
@State private var setCount: Int
|
||||
@State private var currentPage: Int
|
||||
@State private var showingCancelConfirm = false
|
||||
@State private var didRestorePage = false
|
||||
|
||||
/// True when this run opens on the lead-in **Ready?** page (the exercise hadn't
|
||||
/// been started). A resumed exercise — or the screenshot host — skips it. Constant
|
||||
/// for the lifetime of the view, so all the page-index math below is stable.
|
||||
private let showsReady: Bool
|
||||
/// True when this run opens on the lead-in **Ready?** page (the exercise hadn't been
|
||||
/// started). A resumed exercise — or the screenshot host — skips it. Held in `@State`
|
||||
/// so it stays fixed for the life of the screen (same reasoning as the iPhone view),
|
||||
/// 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
|
||||
/// 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 ready = debugInitialPage == nil && notStarted
|
||||
self.showsReady = ready
|
||||
_showsReady = State(initialValue: ready)
|
||||
|
||||
let base = ready ? 1 : 0
|
||||
// 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`).
|
||||
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`).
|
||||
private var resumePage: Int {
|
||||
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
|
||||
@@ -100,6 +99,19 @@ struct ExerciseProgressView: View {
|
||||
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 {
|
||||
guard let log else { return "" }
|
||||
if LoadType(rawValue: log.loadType) == .duration {
|
||||
@@ -108,6 +120,18 @@ struct ExerciseProgressView: View {
|
||||
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".
|
||||
private var readySummary: String {
|
||||
let setsText = "\(setCount) set\(setCount == 1 ? "" : "s")"
|
||||
@@ -123,8 +147,8 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.overlay(alignment: .bottom) {
|
||||
if let cycleIndex = currentCycleIndex {
|
||||
PhaseProgressDots(count: cycleCount, currentIndex: cycleIndex)
|
||||
if let dots = workDots {
|
||||
WorkPhaseDots(model: dots)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
}
|
||||
@@ -142,8 +166,14 @@ struct ExerciseProgressView: View {
|
||||
Button("Continue", role: .cancel) { }
|
||||
}
|
||||
.onChange(of: currentPage) { _, newPage in
|
||||
// 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 {
|
||||
// Jump to the first unfinished set. A paged TabView can settle on page 0 on
|
||||
// first layout, so re-assert once more after this run loop. (The Ready page
|
||||
@@ -182,16 +212,35 @@ struct ExerciseProgressView: View {
|
||||
onOneMore: addSet
|
||||
)
|
||||
} else if cycleIndex.isMultiple(of: 2) {
|
||||
// Work phase.
|
||||
let setNumber = cycleIndex / 2 + 1
|
||||
if isDuration {
|
||||
// Timed work set — count down from the planned duration, then
|
||||
// auto-advance (and buzz) the same way a rest does.
|
||||
CountdownPhaseView(
|
||||
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: cycleIndex / 2 + 1,
|
||||
setNumber: setNumber,
|
||||
totalSets: setCount,
|
||||
detail: detail,
|
||||
isActive: isActive
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 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) }
|
||||
}
|
||||
}
|
||||
@@ -225,12 +274,6 @@ struct ExerciseProgressView: View {
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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() {
|
||||
let newCount = setCount + 1
|
||||
|
||||
@@ -273,6 +316,22 @@ struct ExerciseProgressView: View {
|
||||
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() {
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||
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,
|
||||
/// with the active phase drawn as a wider dash. Dots are deliberately a touch thick so
|
||||
/// the count of remaining sets reads at a glance.
|
||||
private struct PhaseProgressDots: View {
|
||||
let count: Int
|
||||
let currentIndex: Int
|
||||
/// The haptic vocabulary used across the flow (set start, countdown ping, rest end, done).
|
||||
private enum WorkoutHaptic {
|
||||
case start, tick, stop, success
|
||||
|
||||
@MainActor
|
||||
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 {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<count, id: \.self) { i in
|
||||
let isWork = i.isMultiple(of: 2)
|
||||
let isCurrent = i == currentIndex
|
||||
Capsule()
|
||||
.fill(isWork ? Color.workTint : Color.restTint)
|
||||
.frame(width: isCurrent ? 12 : 6, height: 6)
|
||||
.opacity(isCurrent ? 1 : 0.45)
|
||||
VStack(spacing: 4) {
|
||||
Text(header)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
timer
|
||||
.font(.system(size: 50, weight: .bold, design: .rounded))
|
||||
.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
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
VStack(spacing: 8) {
|
||||
Text("Ready?")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
|
||||
if !summary.isEmpty {
|
||||
Text(summary)
|
||||
.font(.subheadline)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button(action: onStart) {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
Text("Start")
|
||||
.phaseButtonLabel()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.workTint)
|
||||
.tint(.workTimer)
|
||||
.buttonBorderShape(.capsule)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
@@ -381,68 +540,51 @@ private struct WorkPhaseView: View {
|
||||
@State private var startDate = Date()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Text("\(setNumber) of \(totalSets)")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) {
|
||||
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() } }
|
||||
.onChange(of: isActive) { _, active in if active { restart() } }
|
||||
}
|
||||
|
||||
private func restart() {
|
||||
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
|
||||
/// 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
|
||||
|
||||
@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 endDate = Date()
|
||||
/// Lowest remaining-second we've already pinged, so a burst of catch-up ticks after a
|
||||
/// stall doesn't double-beep.
|
||||
/// Lowest remaining-second we've already pinged, so a burst of catch-up ticks doesn't
|
||||
/// double-buzz.
|
||||
@State private var lastPingSecond = Int.max
|
||||
/// Guards the auto-advance so it fires exactly once even if ticks pile up.
|
||||
@State private var didFinish = false
|
||||
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Text("Rest")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
PhaseTimerLayout(header: header, footer: footer, tint: tint) {
|
||||
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() } }
|
||||
.onChange(of: isActive) { _, active in if active { start() } }
|
||||
.onReceive(ticker) { _ in tick() }
|
||||
@@ -450,10 +592,10 @@ private struct RestPhaseView: View {
|
||||
|
||||
private func start() {
|
||||
startDate = Date()
|
||||
endDate = startDate.addingTimeInterval(Double(max(1, restSeconds)))
|
||||
endDate = startDate.addingTimeInterval(Double(max(1, seconds)))
|
||||
lastPingSecond = Int.max
|
||||
didFinish = false
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
WorkoutHaptic.start.play()
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
@@ -462,25 +604,24 @@ private struct RestPhaseView: View {
|
||||
let remaining = Int(ceil(endDate.timeIntervalSinceNow))
|
||||
|
||||
if remaining <= 0 {
|
||||
// Time's up — final cue and slide to the next work phase. If the wrist was
|
||||
// down the timer may have stalled; this then fires on the first tick once the
|
||||
// app gets runtime again (e.g. the wrist comes up), never losing the rest.
|
||||
// Time's up — final cue and slide on. If the wrist was down the timer may have
|
||||
// stalled; this then fires on the first tick once the app gets runtime again.
|
||||
didFinish = true
|
||||
WKInterfaceDevice.current().play(.stop)
|
||||
WorkoutHaptic.stop.play()
|
||||
onFinished()
|
||||
} else if remaining <= 3 && remaining < lastPingSecond {
|
||||
// Once-per-second countdown ping for the final three seconds.
|
||||
lastPingSecond = remaining
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
WorkoutHaptic.tick.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Finish Phase
|
||||
|
||||
/// Terminal page after the last set. **Done** completes the exercise and dismisses —
|
||||
/// and fires automatically after a configurable countdown so the user doesn't have to
|
||||
/// tap with sweaty hands. **One More** appends a bonus set instead.
|
||||
/// Terminal page after the last set. **Done** completes the exercise and dismisses — and
|
||||
/// fires automatically after a configurable countdown so the user doesn't have to tap with
|
||||
/// sweaty hands. **One More** appends a bonus set instead.
|
||||
private struct FinishPhaseView: View {
|
||||
let isActive: Bool
|
||||
let onDone: () -> Void
|
||||
@@ -488,8 +629,8 @@ private struct FinishPhaseView: View {
|
||||
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
|
||||
/// Wall-clock deadline for the auto-Done (same Always-On rationale as the rest
|
||||
/// timer). `remaining` is what the Done button shows.
|
||||
/// Wall-clock deadline for the auto-Done (same Always-On rationale as the rest timer).
|
||||
/// `remaining` is what the Done button shows.
|
||||
@State private var endDate = Date()
|
||||
@State private var remaining = 0
|
||||
/// 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 {
|
||||
VStack(spacing: 8) {
|
||||
Text("Finished!")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button(action: fire) {
|
||||
Label(remaining > 0 ? "Done · \(remaining)" : "Done", systemImage: "checkmark")
|
||||
.frame(maxWidth: .infinity)
|
||||
.phaseButtonLabel()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.workTint)
|
||||
.tint(Color.workTimer)
|
||||
.buttonBorderShape(.capsule)
|
||||
|
||||
Button(action: onOneMore) {
|
||||
Label("One More", systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
.phaseButtonLabel()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -539,7 +678,7 @@ private struct FinishPhaseView: View {
|
||||
private func fire() {
|
||||
guard !didFire else { return }
|
||||
didFire = true
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
WorkoutHaptic.success.play()
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user