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
+1
View File
@@ -11,6 +11,7 @@ final class AppServices {
let container: ModelContainer
let syncEngine: SyncEngine
let watchBridge: PhoneConnectivityBridge
let workoutLauncher = WorkoutLauncher()
private var bootstrapTask: Task<Void, Never>?
@@ -42,9 +42,11 @@ final class PhoneConnectivityBridge: NSObject {
wDesc.fetchLimit = 25
let workouts = (try? context.fetch(wDesc)) ?? []
let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45
let payload = WCPayload.encodeState(
splits: splits.map(SplitDocument.init(from:)),
workouts: workouts.map(WorkoutDocument.init(from:))
workouts: workouts.map(WorkoutDocument.init(from:)),
restSeconds: restSeconds
)
try? session.updateApplicationContext(payload)
}
+40
View File
@@ -0,0 +1,40 @@
//
// WorkoutLauncher.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
import HealthKit
/// Launches the companion Watch app into a workout when a session starts on the phone.
///
/// An iPhone app can't foreground its Watch app on its own the only sanctioned path
/// is HealthKit's `startWatchApp(toHandle:)`, which hands watchOS a workout
/// configuration and brings the Watch app up to run a matching `HKWorkoutSession`.
/// Calling it requires authorization to *share* workouts first.
@MainActor
final class WorkoutLauncher {
private let healthStore = HKHealthStore()
/// Strength/HIIT configuration handed to watchOS; the watch starts a session with
/// the same shape (see `WorkoutSessionManager`).
static func makeConfiguration() -> HKWorkoutConfiguration {
let configuration = HKWorkoutConfiguration()
configuration.activityType = .traditionalStrengthTraining
configuration.locationType = .indoor
return configuration
}
/// Ask watchOS to launch the Watch app into the workout. Best-effort: no-ops where
/// HealthKit is unavailable (e.g. iPad without it) and silently tolerates a missing
/// or unreachable paired watch.
func launchWatchWorkout() {
guard HKHealthStore.isHealthDataAvailable() else { return }
Task {
try? await healthStore.requestAuthorization(toShare: [.workoutType()], read: [])
try? await healthStore.startWatchApp(toHandle: Self.makeConfiguration())
}
}
}
+4
View File
@@ -24,6 +24,10 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSHealthShareUsageDescription</key>
<string>Workouts uses Health so it can launch your Apple Watch into the session when you start a workout on your iPhone.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Workouts uses Health so it can launch your Apple Watch into the session when you start a workout on your iPhone.</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.dev.rzen.indie.Workouts</string>
@@ -12,14 +12,27 @@ import IndieAbout
struct SettingsView: View {
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Environment(AppServices.self) private var services
@Query(sort: \Split.order) private var splits: [Split]
@AppStorage("restSeconds") private var restSeconds: Int = 45
@State private var showingAddSplitSheet = false
var body: some View {
NavigationStack {
Form {
// MARK: - Workout Section
Section(header: Text("Workout")) {
Stepper(value: $restSeconds, in: 10...180, step: 5) {
HStack {
Text("Rest Between Sets")
Spacer()
Text("\(restSeconds)s").foregroundColor(.secondary)
}
}
}
// MARK: - Splits Section
Section(header: Text("Splits")) {
if splits.isEmpty {
@@ -94,6 +107,7 @@ struct SettingsView: View {
.sheet(isPresented: $showingAddSplitSheet) {
SplitAddEditView(split: nil)
}
.onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() }
}
}
}
@@ -108,6 +108,7 @@ struct WorkoutLogsView: View {
struct SplitPickerSheet: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Environment(\.dismiss) private var dismiss
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
@@ -178,6 +179,8 @@ struct SplitPickerSheet: View {
)
Task { await sync.save(workout: doc) }
// Bring the Apple Watch up into the session so the user can run it from the wrist.
services.workoutLauncher.launchWatchWorkout()
dismiss()
}
}