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,78 @@
//
// WorkoutSessionManager.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
import HealthKit
import Observation
/// Runs an `HKWorkoutSession` for the duration of a watch workout.
///
/// When the phone launches us via `startWatchApp(toHandle:)`, watchOS hands the app
/// delegate a workout configuration; starting a session with it is what grants the
/// freshly-launched app foreground runtime (and keeps it alive while the wrist is
/// down). We don't record samples the session exists purely to host the UI.
@Observable
@MainActor
final class WorkoutSessionManager: NSObject {
private let healthStore = HKHealthStore()
private var session: HKWorkoutSession?
private(set) var isRunning = false
/// Prompt for workout-sharing authorization. Called once at launch so a later
/// phone-initiated launch can start a session without first hitting a permission
/// wall.
func requestAuthorization() {
guard HKHealthStore.isHealthDataAvailable() else { return }
healthStore.requestAuthorization(toShare: [.workoutType()], read: []) { _, _ in }
}
/// Start the session for a phone-launched workout. Idempotent a second launch
/// while one is already running is ignored.
func start(with configuration: HKWorkoutConfiguration) {
guard HKHealthStore.isHealthDataAvailable(), session == nil else { return }
do {
let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
session.delegate = self
self.session = session
session.startActivity(with: Date())
isRunning = true
} catch {
session = nil
isRunning = false
}
}
/// End the session called when the workout finishes (no active workout remains).
func end() {
session?.end()
clear()
}
private func clear() {
session = nil
isRunning = false
}
}
// MARK: - HKWorkoutSessionDelegate
extension WorkoutSessionManager: HKWorkoutSessionDelegate {
nonisolated func workoutSession(
_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date
) {
guard toState == .ended || toState == .stopped else { return }
Task { @MainActor in self.clear() }
}
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
Task { @MainActor in self.clear() }
}
}