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
This commit is contained in:
2026-06-19 16:16:44 -04:00
parent 3ed7b9272c
commit d5915a9552
22 changed files with 493 additions and 283 deletions
@@ -0,0 +1,58 @@
//
// ActiveWorkoutGateView.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
/// Root of the watch app. The watch only runs the workout that's currently active
/// on the phone there's no history browsing here. The "active" workout is the most
/// recent cached one that isn't finished (`notStarted` or `inProgress`); a workout is
/// created on the phone as `notStarted` the moment a split is picked, and flips to
/// `completed` once every exercise is done, at which point we fall back to the gate.
struct ActiveWorkoutGateView: View {
@Environment(WatchConnectivityBridge.self) private var bridge
@Environment(WorkoutSessionManager.self) private var sessionManager
@Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout]
private var activeWorkout: Workout? {
workouts.first { $0.status == .inProgress || $0.status == .notStarted }
}
var body: some View {
NavigationStack {
if let workout = activeWorkout {
WorkoutLogListView(workout: workout)
} else {
emptyState
}
}
.task {
// Nothing to run yet pull fresh state in case the phone just started one.
if activeWorkout == nil { bridge.requestSync() }
}
.onChange(of: activeWorkout == nil) { _, noActiveWorkout in
// The workout finished (or was cleared) release the HealthKit session that
// was keeping the launched app alive.
if noActiveWorkout { sessionManager.end() }
}
}
private var emptyState: some View {
ContentUnavailableView {
Label("Start a Workout", systemImage: "iphone")
} description: {
Text("Begin a workout on your iPhone to run it here.")
} actions: {
Button {
bridge.requestSync()
} label: {
Label("Refresh", systemImage: "arrow.triangle.2.circlepath")
}
}
}
}