From d5915a9552905310f1409eef7ef4cb3c2a05ea79 Mon Sep 17 00:00:00 2001 From: rzen Date: Fri, 19 Jun 2026 16:16:44 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 12 + README.md | 7 +- REQUIREMENTS.md | 7 +- Shared/Connectivity/WCPayload.swift | 6 +- .../WatchConnectivityBridge.swift | 13 +- Workouts Watch App/ContentView.swift | 2 +- .../Resources/Info-watchOS.plist | 8 + .../Resources/Workouts-watchOS.entitlements | 10 + .../Views/ActiveWorkoutGateView.swift | 58 +++ .../Views/ExerciseProgressView.swift | 379 +++++++++--------- .../Views/WorkoutLogsView.swift | 94 ----- Workouts Watch App/WatchAppDelegate.swift | 28 ++ .../WorkoutSessionManager.swift | 78 ++++ Workouts Watch App/WorkoutsApp.swift | 2 + Workouts/AppServices.swift | 1 + .../PhoneConnectivityBridge.swift | 4 +- Workouts/HealthKit/WorkoutLauncher.swift | 40 ++ Workouts/Resources/Info-iOS.plist | 4 + Workouts/Resources/Workouts-iOS.entitlements | 4 + Workouts/Views/Settings/SettingsView.swift | 14 + .../Views/WorkoutLogs/WorkoutLogsView.swift | 3 + project.yml | 2 + 22 files changed, 493 insertions(+), 283 deletions(-) create mode 100644 Workouts Watch App/Resources/Workouts-watchOS.entitlements create mode 100644 Workouts Watch App/Views/ActiveWorkoutGateView.swift delete mode 100644 Workouts Watch App/Views/WorkoutLogsView.swift create mode 100644 Workouts Watch App/WatchAppDelegate.swift create mode 100644 Workouts Watch App/WorkoutSessionManager.swift create mode 100644 Workouts/HealthKit/WorkoutLauncher.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 888b3dc..4f770ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project are documented here. ## June 2026 +- Redesigned the Apple Watch app into a focused workout runner: it opens directly + on the active workout's exercise list (or prompts you to start one on iPhone), + and each exercise runs as a horizontally-paged HIIT cycle — a count-up work + phase, swipe to a count-down rest that pings once per second in the final three + seconds then auto-advances to the next set, and **One More** / **Done** buttons + on the final set. +- Added a configurable rest-between-sets duration (iPhone Settings, default 45s), + synced to the watch over WatchConnectivity. +- Starting a workout on the iPhone now launches the Apple Watch app straight into + the session via HealthKit (a one-time Health permission); the watch holds an + `HKWorkoutSession` to stay active while you train and releases it when the + workout finishes. - New app icon: a tilted dumbbell on a purple gradient, full-bleed across iOS and Watch (replaces the teal circular mark). - **2.0** — Re-platformed persistence onto an iCloud Drive document architecture: diff --git a/README.md b/README.md index b0eb9a2..d06a644 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,11 @@ your own iCloud Drive. timed exercises, and mark exercises complete. - **Progress tracking** — weight-progression charts per exercise across past sessions. -- **Apple Watch companion** — start and run workouts from the wrist; changes sync - back to the phone. +- **Apple Watch companion** — starting a workout on the iPhone launches the watch + app straight into it; run the session from your wrist as a HIIT cycle: count-up + work phases, count-down rests with final-three-second haptics and auto-advance, + and **One More** / **Done** on the last set. Rest time is configurable; changes + sync back to the phone. - **iCloud Drive sync** — your data lives as human-readable JSON in your iCloud Drive, synced across devices and visible in the Files app. iCloud is required. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index a3ca2aa..b4fd688 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -47,6 +47,10 @@ local SwiftData cache fed only by WatchConnectivity: every cache change. - Watch → Phone: sends an updated `WorkoutDocument` (keyed by ULID); the phone applies it through the file write path (the sole writer of iCloud Drive). +- Starting a workout on the phone launches the watch app into it via HealthKit + (`startWatchApp(toHandle:)`); the watch runs an `HKWorkoutSession` for foreground + runtime and ends it when no active workout remains. Requires the HealthKit + capability and a one-time Health permission on both devices. ## Seed Data Starter splits (Upper Body / Core / Lower Body) are generated on demand from the @@ -60,7 +64,8 @@ auto-seeded. The catalogs also back the in-workout exercise picker. - Start a workout from a split; track per-exercise set/rest/done progress; mark complete/skipped; edit the plan during a session (mirrored back to the split). - Weight-progression charts per exercise across past sessions. -- Apple Watch: run workouts from the wrist; changes sync back to the phone. +- Apple Watch: starting a workout on the phone launches the watch into it; run the + session from the wrist (HIIT-style work/rest cycles); changes sync back to the phone. ## Dependencies - **Yams** — YAML parsing for the exercise catalogs. diff --git a/Shared/Connectivity/WCPayload.swift b/Shared/Connectivity/WCPayload.swift index 72e4b11..2bbf675 100644 --- a/Shared/Connectivity/WCPayload.swift +++ b/Shared/Connectivity/WCPayload.swift @@ -9,16 +9,18 @@ enum WCPayload { static let splitsKey = "splits" static let workoutsKey = "workouts" static let workoutKey = "workout" + static let restSecondsKey = "restSeconds" static let workoutUpdateType = "workoutUpdate" // watch → phone (one workout) static let requestSyncType = "requestSync" // watch → phone (please push state) // MARK: - Phone → Watch (application context: latest-state-wins) - static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument]) -> [String: Any] { + static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument], restSeconds: Int) -> [String: Any] { var dict: [String: Any] = [:] if let s = try? DocumentCoder.encoder.encode(splits) { dict[splitsKey] = s } if let w = try? DocumentCoder.encoder.encode(workouts) { dict[workoutsKey] = w } + dict[restSecondsKey] = restSeconds return dict } @@ -32,6 +34,8 @@ enum WCPayload { return (try? DocumentCoder.decoder.decode([WorkoutDocument].self, from: data)) ?? [] } + static func decodeRestSeconds(_ dict: [String: Any]) -> Int? { dict[restSecondsKey] as? Int } + // MARK: - Watch → Phone (a single updated workout) static func encodeWorkoutUpdate(_ workout: WorkoutDocument) -> [String: Any] { diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index 897d073..09521a5 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -32,6 +32,7 @@ final class WatchConnectivityBridge: NSObject { // Apply whatever the phone last pushed, then ask for a fresh push. applyState(WCPayload.decodeSplits(session.receivedApplicationContext), workouts: WCPayload.decodeWorkouts(session.receivedApplicationContext)) + applyRestSeconds(session.receivedApplicationContext) requestSync() } @@ -62,6 +63,12 @@ final class WatchConnectivityBridge: NSObject { } } + private func applyRestSeconds(_ dict: [String: Any]) { + if let rest = WCPayload.decodeRestSeconds(dict) { + UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) + } + } + private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) { guard !splits.isEmpty || !workouts.isEmpty else { return } var liveSplitIDs = Set() @@ -93,6 +100,10 @@ extension WatchConnectivityBridge: WCSessionDelegate { nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { let splits = WCPayload.decodeSplits(applicationContext) let workouts = WCPayload.decodeWorkouts(applicationContext) - Task { @MainActor in self.applyState(splits, workouts: workouts) } + let rest = WCPayload.decodeRestSeconds(applicationContext) + Task { @MainActor in + self.applyState(splits, workouts: workouts) + if let rest { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) } + } } } diff --git a/Workouts Watch App/ContentView.swift b/Workouts Watch App/ContentView.swift index 48bcba0..c0adea3 100644 --- a/Workouts Watch App/ContentView.swift +++ b/Workouts Watch App/ContentView.swift @@ -9,6 +9,6 @@ import SwiftUI struct ContentView: View { var body: some View { - WorkoutLogsView() + ActiveWorkoutGateView() } } diff --git a/Workouts Watch App/Resources/Info-watchOS.plist b/Workouts Watch App/Resources/Info-watchOS.plist index e4a8383..9cc17a6 100644 --- a/Workouts Watch App/Resources/Info-watchOS.plist +++ b/Workouts Watch App/Resources/Info-watchOS.plist @@ -22,6 +22,14 @@ $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption + NSHealthShareUsageDescription + Workouts uses Health to run a workout session on your watch so the app stays active while you train. + NSHealthUpdateUsageDescription + Workouts uses Health to run a workout session on your watch so the app stays active while you train. + WKBackgroundModes + + workout-processing + WKApplication WKCompanionAppBundleIdentifier diff --git a/Workouts Watch App/Resources/Workouts-watchOS.entitlements b/Workouts Watch App/Resources/Workouts-watchOS.entitlements new file mode 100644 index 0000000..2ab14a2 --- /dev/null +++ b/Workouts Watch App/Resources/Workouts-watchOS.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + + diff --git a/Workouts Watch App/Views/ActiveWorkoutGateView.swift b/Workouts Watch App/Views/ActiveWorkoutGateView.swift new file mode 100644 index 0000000..dbc214e --- /dev/null +++ b/Workouts Watch App/Views/ActiveWorkoutGateView.swift @@ -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") + } + } + } +} diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index 21c2975..0543b07 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -8,49 +8,64 @@ 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 - /// taps to the read-after-write race. + /// 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 - @State private var currentPage: Int = 0 + /// 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, 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(where: { $0.id == logID }) + doc.logs.first { $0.id == logID } } - private var totalSets: Int { - max(1, log?.sets ?? 1) - } + /// Work₁, Rest₁, …, Workₙ ⇒ N sets + (N−1) rests = 2N − 1 pages. + private var totalPages: Int { setCount * 2 - 1 } - private var totalPages: Int { - // Set 1, Rest 1, Set 2, Rest 2, ..., Set N, Done - // = N sets + (N-1) rests + 1 done = 2N - totalSets * 2 - } - - private var firstUnfinishedSetPage: Int { - // currentStateIndex is the number of completed sets - let completedSets = log?.currentStateIndex ?? 0 - if completedSets >= totalSets { - // All done, go to done page - return totalPages - 1 + private var detail: String { + guard let log else { return "" } + if LoadType(rawValue: log.loadType) == .duration { + return Self.durationLabel(log.durationSeconds) } - // Each set is at page index * 2 (Set1=0, Set2=2, Set3=4...) - return completedSets * 2 + return "\(log.reps) reps" } var body: some View { TabView(selection: $currentPage) { ForEach(0.. some View { - let lastPageIndex = totalPages - 1 - - if index == lastPageIndex { - // Done page - DonePageView { - completeExercise() - dismiss() - } - } else if index % 2 == 0 { - // Set page (0, 2, 4, ...) - let setNumber = (index / 2) + 1 - SetPageView( - setNumber: setNumber, - totalSets: totalSets, - reps: log?.reps ?? 0, - isTimeBased: LoadType(rawValue: log?.loadType ?? LoadType.weight.rawValue) == .duration, - durationMinutes: (log?.durationSeconds ?? 0) / 60, - durationSeconds: (log?.durationSeconds ?? 0) % 60 + 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 page (1, 3, 5, ...) - let restNumber = (index / 2) + 1 - RestPageView(restNumber: restNumber) + // Rest phase. Auto-advances to the next work page when the timer hits zero. + RestPhaseView(isActive: isActive) { + withAnimation { advance(from: index) } + } } } - private func updateProgress(for pageIndex: Int) { - // Calculate which set we're on based on page index - // Pages: Set1(0), Rest1(1), Set2(2), Rest2(3), Set3(4), Done(5) - // After completing Set 1 and moving to Rest 1, progress should be 1 - let setIndex = (pageIndex + 1) / 2 - let clampedProgress = min(setIndex, totalSets) + // 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 clampedProgress != doc.logs[i].currentStateIndex else { return } + guard completedSets != doc.logs[i].currentStateIndex else { return } - doc.logs[i].currentStateIndex = clampedProgress - - if clampedProgress >= totalSets { + doc.logs[i].currentStateIndex = completedSets + if completedSets >= setCount { doc.logs[i].status = WorkoutStatus.completed.rawValue doc.logs[i].completed = true - } else if clampedProgress > 0 { + } else if completedSets > 0 { doc.logs[i].status = WorkoutStatus.inProgress.rawValue doc.logs[i].completed = false } @@ -134,7 +162,7 @@ struct ExerciseProgressView: View { private func completeExercise() { guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } - doc.logs[i].currentStateIndex = totalSets + doc.logs[i].currentStateIndex = setCount doc.logs[i].status = WorkoutStatus.completed.rawValue doc.logs[i].completed = true @@ -160,148 +188,135 @@ struct ExerciseProgressView: View { 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: - Set Page View +// MARK: - Work Phase -struct SetPageView: View { +private struct WorkPhaseView: View { let setNumber: Int let totalSets: Int - let reps: Int - let isTimeBased: Bool - let durationMinutes: Int - let durationSeconds: Int - - var body: some View { - VStack(spacing: 8) { - Text("Set \(setNumber) of \(totalSets)") - .font(.headline) - .foregroundColor(.secondary) - - Text("\(setNumber)") - .font(.system(size: 72, weight: .bold, design: .rounded)) - .foregroundColor(.green) - - if isTimeBased { - Text(formattedDuration) - .font(.title3) - .foregroundColor(.secondary) - } else { - Text("\(reps) reps") - .font(.title3) - .foregroundColor(.secondary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - WKInterfaceDevice.current().play(.start) - } - } - - private var formattedDuration: String { - if durationMinutes > 0 && durationSeconds > 0 { - return "\(durationMinutes)m \(durationSeconds)s" - } else if durationMinutes > 0 { - return "\(durationMinutes) min" - } else { - return "\(durationSeconds) sec" - } - } -} - -// MARK: - Rest Page View - -struct RestPageView: View { - let restNumber: Int - - @State private var elapsedSeconds: Int = 0 + 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: 8) { - Text("Rest") + VStack(spacing: 6) { + Text("Set \(setNumber) of \(totalSets)") .font(.headline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) - Text(formattedTime) - .font(.system(size: 56, weight: .bold, design: .monospaced)) - .foregroundColor(.orange) + Text(clockString(elapsed)) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.green) - Text("Swipe to continue") - .font(.caption2) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - elapsedSeconds = 0 - WKInterfaceDevice.current().play(.start) - } - .onReceive(ticker) { _ in - elapsedSeconds += 1 - checkHapticPing() - } - } + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) - private var formattedTime: String { - let minutes = elapsedSeconds / 60 - let seconds = elapsedSeconds % 60 - return String(format: "%d:%02d", minutes, seconds) - } + if isLast { + VStack(spacing: 6) { + Button(action: onOneMore) { + Label("One More", systemImage: "plus") + .frame(maxWidth: .infinity) + } + .tint(.blue) - private func checkHapticPing() { - // Haptic ping every 10 seconds with pattern: - // 10s: single, 20s: double, 30s: triple, 40s: single, 50s: double, etc. - guard elapsedSeconds > 0 && elapsedSeconds % 10 == 0 else { return } - - let cyclePosition = (elapsedSeconds / 10) % 3 - let pingCount: Int - switch cyclePosition { - case 1: pingCount = 1 // 10s, 40s, 70s... - case 2: pingCount = 2 // 20s, 50s, 80s... - case 0: pingCount = 3 // 30s, 60s, 90s... - default: pingCount = 1 - } - - playHapticPings(count: pingCount) - } - - private func playHapticPings(count: Int) { - for i in 0.. Void +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: 16) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.green) + VStack(spacing: 6) { + Text("Rest") + .font(.headline) + .foregroundStyle(.secondary) - Text("Done!") - .font(.title2) - .fontWeight(.bold) + Text(clockString(max(0, remaining))) + .font(.system(size: 54, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.orange) - Text("Tap to finish") - .font(.caption) - .foregroundColor(.secondary) + Label("Swipe to skip", systemImage: "chevron.right") + .font(.caption2) + .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) - .onTapGesture { - WKInterfaceDevice.current().play(.success) - onDone() - } - .onAppear { - WKInterfaceDevice.current().play(.success) + .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) +} diff --git a/Workouts Watch App/Views/WorkoutLogsView.swift b/Workouts Watch App/Views/WorkoutLogsView.swift deleted file mode 100644 index 41bcb75..0000000 --- a/Workouts Watch App/Views/WorkoutLogsView.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// WorkoutLogsView.swift -// Workouts Watch App -// -// Copyright 2025 Rouslan Zenetl. All Rights Reserved. -// - -import SwiftUI -import SwiftData - -struct WorkoutLogsView: View { - @Environment(WatchConnectivityBridge.self) private var bridge - - @Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout] - - var body: some View { - NavigationStack { - List { - ForEach(workouts) { workout in - NavigationLink(destination: WorkoutLogListView(workout: workout)) { - WorkoutRow(workout: workout) - } - } - } - .overlay { - if workouts.isEmpty { - ContentUnavailableView( - "No Workouts", - systemImage: "list.bullet.clipboard", - description: Text("Tap sync or start a workout from iPhone.") - ) - } - } - .navigationTitle("Workouts") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - bridge.requestSync() - } label: { - Image(systemName: "arrow.triangle.2.circlepath") - } - } - } - .task { - if workouts.isEmpty { - bridge.requestSync() - } - } - } - } -} - -// MARK: - Workout Row - -struct WorkoutRow: View { - let workout: Workout - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(workout.splitName ?? Split.unnamed) - .font(.headline) - .lineLimit(1) - - HStack { - Text(workout.start.formatDate()) - .font(.caption2) - .foregroundColor(.secondary) - - Spacer() - - statusIndicator - } - } - .padding(.vertical, 4) - } - - @ViewBuilder - private var statusIndicator: some View { - switch workout.status { - case .completed: - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - case .inProgress: - Image(systemName: "circle.dotted") - .foregroundColor(.orange) - case .notStarted: - Image(systemName: "circle") - .foregroundColor(.secondary) - case .skipped: - Image(systemName: "xmark.circle") - .foregroundColor(.secondary) - } - } -} diff --git a/Workouts Watch App/WatchAppDelegate.swift b/Workouts Watch App/WatchAppDelegate.swift new file mode 100644 index 0000000..ebf8491 --- /dev/null +++ b/Workouts Watch App/WatchAppDelegate.swift @@ -0,0 +1,28 @@ +// +// WatchAppDelegate.swift +// Workouts Watch App +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import WatchKit +import HealthKit + +/// Bridges watchOS lifecycle into the workout session. When the phone starts a workout +/// it calls `startWatchApp(toHandle:)`; watchOS launches (or foregrounds) this app and +/// delivers the configuration to `handle(_:)`, where we start a session to claim +/// foreground runtime. The session manager is shared with the view tree via the +/// environment (see `WorkoutsWatchApp`). +@MainActor +final class WatchAppDelegate: NSObject, WKApplicationDelegate { + let sessionManager = WorkoutSessionManager() + + func applicationDidFinishLaunching() { + sessionManager.requestAuthorization() + } + + func handle(_ workoutConfiguration: HKWorkoutConfiguration) { + sessionManager.start(with: workoutConfiguration) + } +} diff --git a/Workouts Watch App/WorkoutSessionManager.swift b/Workouts Watch App/WorkoutSessionManager.swift new file mode 100644 index 0000000..eab4ab5 --- /dev/null +++ b/Workouts Watch App/WorkoutSessionManager.swift @@ -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() } + } +} diff --git a/Workouts Watch App/WorkoutsApp.swift b/Workouts Watch App/WorkoutsApp.swift index 5a859bd..67dee55 100644 --- a/Workouts Watch App/WorkoutsApp.swift +++ b/Workouts Watch App/WorkoutsApp.swift @@ -11,11 +11,13 @@ import SwiftData @main struct WorkoutsWatchApp: App { @State private var services = WatchAppServices() + @WKApplicationDelegateAdaptor(WatchAppDelegate.self) private var appDelegate var body: some Scene { WindowGroup { ContentView() .environment(services.bridge) + .environment(appDelegate.sessionManager) .modelContainer(services.container) .task { services.activate() } } diff --git a/Workouts/AppServices.swift b/Workouts/AppServices.swift index 30e1d44..744a068 100644 --- a/Workouts/AppServices.swift +++ b/Workouts/AppServices.swift @@ -11,6 +11,7 @@ final class AppServices { let container: ModelContainer let syncEngine: SyncEngine let watchBridge: PhoneConnectivityBridge + let workoutLauncher = WorkoutLauncher() private var bootstrapTask: Task? diff --git a/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index 588da2e..efdaac1 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -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) } diff --git a/Workouts/HealthKit/WorkoutLauncher.swift b/Workouts/HealthKit/WorkoutLauncher.swift new file mode 100644 index 0000000..5c34e70 --- /dev/null +++ b/Workouts/HealthKit/WorkoutLauncher.swift @@ -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()) + } + } +} diff --git a/Workouts/Resources/Info-iOS.plist b/Workouts/Resources/Info-iOS.plist index 9c3600b..ae41e10 100644 --- a/Workouts/Resources/Info-iOS.plist +++ b/Workouts/Resources/Info-iOS.plist @@ -24,6 +24,10 @@ LSRequiresIPhoneOS + NSHealthShareUsageDescription + Workouts uses Health so it can launch your Apple Watch into the session when you start a workout on your iPhone. + NSHealthUpdateUsageDescription + Workouts uses Health so it can launch your Apple Watch into the session when you start a workout on your iPhone. UILaunchScreen UISupportedInterfaceOrientations diff --git a/Workouts/Resources/Workouts-iOS.entitlements b/Workouts/Resources/Workouts-iOS.entitlements index 92b6405..135f67d 100644 --- a/Workouts/Resources/Workouts-iOS.entitlements +++ b/Workouts/Resources/Workouts-iOS.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + com.apple.developer.icloud-container-identifiers iCloud.dev.rzen.indie.Workouts diff --git a/Workouts/Views/Settings/SettingsView.swift b/Workouts/Views/Settings/SettingsView.swift index d2d377d..d1d4be4 100644 --- a/Workouts/Views/Settings/SettingsView.swift +++ b/Workouts/Views/Settings/SettingsView.swift @@ -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() } } } } diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift index fd784e1..655bd3d 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift @@ -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() } } diff --git a/project.yml b/project.yml index a6047b8..f765a8c 100644 --- a/project.yml +++ b/project.yml @@ -69,10 +69,12 @@ targets: - path: Workouts Watch App excludes: - "Resources/Info-*.plist" + - "Resources/*.entitlements" settings: base: PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp INFOPLIST_FILE: "Workouts Watch App/Resources/Info-watchOS.plist" + CODE_SIGN_ENTITLEMENTS: "Workouts Watch App/Resources/Workouts-watchOS.entitlements" GENERATE_INFOPLIST_FILE: false SWIFT_STRICT_CONCURRENCY: complete WATCHOS_DEPLOYMENT_TARGET: "26.0"