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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user