Files
workouts/Workouts Watch App/Views/ExerciseProgressView.swift
T
rzen d5915a9552 Add HIIT watch runner, rest-time setting, and HealthKit watch auto-launch
- Redesign the watch app into an active-workout runner: a root gate shows the
  in-progress workout's exercises or prompts to start one on iPhone, and each
  exercise runs as a horizontally-paged HIIT cycle (count-up work, count-down
  rest with final-three-second haptics + auto-advance, One More / Done on the
  last set). Replaces the old history list.
- Add a configurable rest-between-sets duration in iPhone Settings (default 45s),
  synced to the watch over WatchConnectivity.
- Launch the watch app into the session when a workout starts on the phone via
  HealthKit (startWatchApp); the watch runs an HKWorkoutSession for foreground
  runtime and ends it when the workout finishes. Adds the HealthKit entitlement +
  Health usage strings on both targets and WKBackgroundModes on the watch.

Claude-Session: https://claude.ai/code/session_018gg69MaUetDNzWzBXisfMV
2026-06-19 16:16:44 -04:00

323 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// ExerciseProgressView.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import WatchKit
/// Runs a single exercise as a horizontally-paged HIIT cycle:
///
/// Work Rest Work Rest Rest Work
///
/// The **work** phase counts *up* (a stopwatch for the current set); the user swipes
/// left when they're done. The **rest** phase counts *down* from the configurable rest
/// time, beeps once per second in the final three seconds, and then auto-advances to
/// the next work phase. The final work phase has no rest after it instead it offers
/// **One More** (append a bonus set and keep going) and **Done** (mark the exercise
/// complete and return to the list).
struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss
/// The shared working workout document owned by the parent. We mutate the matching
/// log in place and ask the parent to forward each change through the bridge
/// driving the UI from this doc (not the cache) avoids losing rapid edits to the
/// read-after-write race.
@Binding var doc: WorkoutDocument
let logID: String
let onChange: () -> Void
/// 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
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void) {
self._doc = doc
self.logID = logID
self.onChange = onChange
let log = doc.wrappedValue.logs.first { $0.id == logID }
let sets = max(1, log?.sets ?? 1)
_setCount = State(initialValue: sets)
// Resume on the first unfinished set's work page (clamped to the last set).
let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
_currentPage = State(initialValue: completed * 2)
}
private var log: WorkoutLogDocument? {
doc.logs.first { $0.id == logID }
}
/// Work, Rest, , Work N sets + (N1) rests = 2N 1 pages.
private var totalPages: Int { setCount * 2 - 1 }
private var detail: String {
guard let log else { return "" }
if LoadType(rawValue: log.loadType) == .duration {
return Self.durationLabel(log.durationSeconds)
}
return "\(log.reps) reps"
}
var body: some View {
TabView(selection: $currentPage) {
ForEach(0..<totalPages, id: \.self) { index in
page(for: index)
.tag(index)
}
}
.tabViewStyle(.page)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
showingCancelConfirm = true
} label: {
Image(systemName: "xmark")
}
}
}
.confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) {
Button("Cancel Exercise", role: .destructive) { dismiss() }
Button("Continue", role: .cancel) { }
}
.onChange(of: currentPage) { _, newPage in
recordProgress(for: newPage)
}
}
@ViewBuilder
private func page(for index: Int) -> some View {
let isActive = index == currentPage
if index.isMultiple(of: 2) {
// Work phase. The last page (set N) finishes the exercise.
WorkPhaseView(
setNumber: index / 2 + 1,
totalSets: setCount,
detail: detail,
isLast: index == totalPages - 1,
isActive: isActive,
onOneMore: addSet,
onDone: { completeExercise(); dismiss() }
)
} else {
// Rest phase. Auto-advances to the next work page when the timer hits zero.
RestPhaseView(isActive: isActive) {
withAnimation { advance(from: index) }
}
}
}
// MARK: - Mutations
/// Programmatically move one page right (used by the rest auto-advance), guarding
/// against overrun if the user swiped away in the meantime.
private func advance(from index: Int) {
guard currentPage == index, index + 1 < totalPages else { return }
currentPage = index + 1
}
/// Append a bonus set: grow the plan, record the just-finished set as done, and
/// slide into the rest period that now follows the previously-final work page.
private func addSet() {
let restPage = currentPage + 1 // the rest page that becomes valid after the bump
setCount += 1
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
doc.logs[i].sets = setCount
doc.logs[i].currentStateIndex = setCount - 1 // the old final set is complete
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
withAnimation { currentPage = restPage }
}
/// Page index completed-set count: Work(0)0, Rest(1)1, Work(2)1,
/// i.e. `(pageIndex + 1) / 2`. Reaching set N as *completed* only happens via Done.
private func recordProgress(for pageIndex: Int) {
let completedSets = min((pageIndex + 1) / 2, setCount)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
guard completedSets != doc.logs[i].currentStateIndex else { return }
doc.logs[i].currentStateIndex = completedSets
if completedSets >= setCount {
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
} else if completedSets > 0 {
doc.logs[i].status = WorkoutStatus.inProgress.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
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
private func recomputeWorkoutStatus() {
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
let anyInProgress = statuses.contains { $0 == .inProgress }
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
if allCompleted {
doc.status = WorkoutStatus.completed.rawValue
doc.end = Date()
} else if anyInProgress || !allNotStarted {
doc.status = WorkoutStatus.inProgress.rawValue
doc.end = nil
} else {
doc.status = WorkoutStatus.notStarted.rawValue
doc.end = nil
}
}
// MARK: - Formatting
static func durationLabel(_ seconds: Int) -> String {
let mins = seconds / 60
let secs = seconds % 60
if mins > 0 && secs > 0 { return "\(mins)m \(secs)s" }
if mins > 0 { return "\(mins) min" }
return "\(secs) sec"
}
}
// MARK: - Work Phase
private struct WorkPhaseView: View {
let setNumber: Int
let totalSets: Int
let detail: String
let isLast: Bool
let isActive: Bool
let onOneMore: () -> Void
let onDone: () -> Void
@State private var elapsed = 0
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 6) {
Text("Set \(setNumber) of \(totalSets)")
.font(.headline)
.foregroundStyle(.secondary)
Text(clockString(elapsed))
.font(.system(size: 48, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.green)
Text(detail)
.font(.subheadline)
.foregroundStyle(.secondary)
if isLast {
VStack(spacing: 6) {
Button(action: onOneMore) {
Label("One More", systemImage: "plus")
.frame(maxWidth: .infinity)
}
.tint(.blue)
Button(action: onDone) {
Label("Done", systemImage: "checkmark")
.frame(maxWidth: .infinity)
}
.tint(.green)
}
.buttonStyle(.borderedProminent)
.padding(.top, 4)
} else {
Label("Swipe to rest", systemImage: "chevron.right")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if isActive { restart() } }
.onChange(of: isActive) { _, active in if active { restart() } }
.onReceive(ticker) { _ in if isActive { elapsed += 1 } }
}
private func restart() {
elapsed = 0
WKInterfaceDevice.current().play(.start)
}
}
// MARK: - Rest Phase
private struct RestPhaseView: View {
let isActive: Bool
/// Invoked once the countdown reaches zero (auto-advance to the next work phase).
let onFinished: () -> Void
@AppStorage("restSeconds") private var restSeconds: Int = 45
@State private var remaining = 0
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)
Text(clockString(max(0, remaining)))
.font(.system(size: 54, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.orange)
Label("Swipe to skip", systemImage: "chevron.right")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if isActive { start() } }
.onChange(of: isActive) { _, active in if active { start() } }
.onReceive(ticker) { _ in tick() }
}
private func start() {
remaining = max(1, restSeconds)
WKInterfaceDevice.current().play(.start)
}
private func tick() {
guard isActive, remaining > 0 else { return }
remaining -= 1
if remaining == 0 {
// Time's up final cue and slide to the next work phase.
WKInterfaceDevice.current().play(.stop)
onFinished()
} else if remaining <= 3 {
// Once-per-second countdown ping for the final three seconds.
WKInterfaceDevice.current().play(.notification)
}
}
}
// MARK: - Shared
private func clockString(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}