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
+12
View File
@@ -4,6 +4,18 @@ All notable changes to this project are documented here.
## June 2026 ## 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 - New app icon: a tilted dumbbell on a purple gradient, full-bleed across iOS and
Watch (replaces the teal circular mark). Watch (replaces the teal circular mark).
- **2.0** — Re-platformed persistence onto an iCloud Drive document architecture: - **2.0** — Re-platformed persistence onto an iCloud Drive document architecture:
+5 -2
View File
@@ -15,8 +15,11 @@ your own iCloud Drive.
timed exercises, and mark exercises complete. timed exercises, and mark exercises complete.
- **Progress tracking** — weight-progression charts per exercise across past - **Progress tracking** — weight-progression charts per exercise across past
sessions. sessions.
- **Apple Watch companion** — start and run workouts from the wrist; changes sync - **Apple Watch companion** — starting a workout on the iPhone launches the watch
back to the phone. 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 - **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. Drive, synced across devices and visible in the Files app. iCloud is required.
+6 -1
View File
@@ -47,6 +47,10 @@ local SwiftData cache fed only by WatchConnectivity:
every cache change. every cache change.
- Watch → Phone: sends an updated `WorkoutDocument` (keyed by ULID); the phone - Watch → Phone: sends an updated `WorkoutDocument` (keyed by ULID); the phone
applies it through the file write path (the sole writer of iCloud Drive). 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 ## Seed Data
Starter splits (Upper Body / Core / Lower Body) are generated on demand from the 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 - 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). complete/skipped; edit the plan during a session (mirrored back to the split).
- Weight-progression charts per exercise across past sessions. - 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 ## Dependencies
- **Yams** — YAML parsing for the exercise catalogs. - **Yams** — YAML parsing for the exercise catalogs.
+5 -1
View File
@@ -9,16 +9,18 @@ enum WCPayload {
static let splitsKey = "splits" static let splitsKey = "splits"
static let workoutsKey = "workouts" static let workoutsKey = "workouts"
static let workoutKey = "workout" static let workoutKey = "workout"
static let restSecondsKey = "restSeconds"
static let workoutUpdateType = "workoutUpdate" // watch phone (one workout) static let workoutUpdateType = "workoutUpdate" // watch phone (one workout)
static let requestSyncType = "requestSync" // watch phone (please push state) static let requestSyncType = "requestSync" // watch phone (please push state)
// MARK: - Phone Watch (application context: latest-state-wins) // 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] = [:] var dict: [String: Any] = [:]
if let s = try? DocumentCoder.encoder.encode(splits) { dict[splitsKey] = s } if let s = try? DocumentCoder.encoder.encode(splits) { dict[splitsKey] = s }
if let w = try? DocumentCoder.encoder.encode(workouts) { dict[workoutsKey] = w } if let w = try? DocumentCoder.encoder.encode(workouts) { dict[workoutsKey] = w }
dict[restSecondsKey] = restSeconds
return dict return dict
} }
@@ -32,6 +34,8 @@ enum WCPayload {
return (try? DocumentCoder.decoder.decode([WorkoutDocument].self, from: data)) ?? [] 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) // MARK: - Watch Phone (a single updated workout)
static func encodeWorkoutUpdate(_ workout: WorkoutDocument) -> [String: Any] { static func encodeWorkoutUpdate(_ workout: WorkoutDocument) -> [String: Any] {
@@ -32,6 +32,7 @@ final class WatchConnectivityBridge: NSObject {
// Apply whatever the phone last pushed, then ask for a fresh push. // Apply whatever the phone last pushed, then ask for a fresh push.
applyState(WCPayload.decodeSplits(session.receivedApplicationContext), applyState(WCPayload.decodeSplits(session.receivedApplicationContext),
workouts: WCPayload.decodeWorkouts(session.receivedApplicationContext)) workouts: WCPayload.decodeWorkouts(session.receivedApplicationContext))
applyRestSeconds(session.receivedApplicationContext)
requestSync() 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]) { private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) {
guard !splits.isEmpty || !workouts.isEmpty else { return } guard !splits.isEmpty || !workouts.isEmpty else { return }
var liveSplitIDs = Set<String>() var liveSplitIDs = Set<String>()
@@ -93,6 +100,10 @@ extension WatchConnectivityBridge: WCSessionDelegate {
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
let splits = WCPayload.decodeSplits(applicationContext) let splits = WCPayload.decodeSplits(applicationContext)
let workouts = WCPayload.decodeWorkouts(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) }
}
} }
} }
+1 -1
View File
@@ -9,6 +9,6 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
var body: some View { var body: some View {
WorkoutLogsView() ActiveWorkoutGateView()
} }
} }
@@ -22,6 +22,14 @@
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>NSHealthShareUsageDescription</key>
<string>Workouts uses Health to run a workout session on your watch so the app stays active while you train.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Workouts uses Health to run a workout session on your watch so the app stays active while you train.</string>
<key>WKBackgroundModes</key>
<array>
<string>workout-processing</string>
</array>
<key>WKApplication</key> <key>WKApplication</key>
<true/> <true/>
<key>WKCompanionAppBundleIdentifier</key> <key>WKCompanionAppBundleIdentifier</key>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!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/>
</dict>
</plist>
@@ -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")
}
}
}
}
@@ -8,49 +8,64 @@
import SwiftUI import SwiftUI
import WatchKit 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 { struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
/// The shared working workout document owned by the parent. We mutate the /// The shared working workout document owned by the parent. We mutate the matching
/// matching log in place and ask the parent to forward each change through the /// log in place and ask the parent to forward each change through the bridge
/// bridge driving the UI from this doc (not the cache) avoids losing rapid /// driving the UI from this doc (not the cache) avoids losing rapid edits to the
/// taps to the read-after-write race. /// read-after-write race.
@Binding var doc: WorkoutDocument @Binding var doc: WorkoutDocument
let logID: String let logID: String
let onChange: () -> Void 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 @State private var showingCancelConfirm = false
init(doc: Binding<WorkoutDocument>, 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? { private var log: WorkoutLogDocument? {
doc.logs.first(where: { $0.id == logID }) doc.logs.first { $0.id == logID }
} }
private var totalSets: Int { /// Work, Rest, , Work N sets + (N1) rests = 2N 1 pages.
max(1, log?.sets ?? 1) private var totalPages: Int { setCount * 2 - 1 }
}
private var totalPages: Int { private var detail: String {
// Set 1, Rest 1, Set 2, Rest 2, ..., Set N, Done guard let log else { return "" }
// = N sets + (N-1) rests + 1 done = 2N if LoadType(rawValue: log.loadType) == .duration {
totalSets * 2 return Self.durationLabel(log.durationSeconds)
} }
return "\(log.reps) reps"
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
}
// Each set is at page index * 2 (Set1=0, Set2=2, Set3=4...)
return completedSets * 2
} }
var body: some View { var body: some View {
TabView(selection: $currentPage) { TabView(selection: $currentPage) {
ForEach(0..<totalPages, id: \.self) { index in ForEach(0..<totalPages, id: \.self) { index in
pageView(for: index) page(for: index)
.tag(index) .tag(index)
} }
} }
@@ -65,64 +80,77 @@ struct ExerciseProgressView: View {
} }
} }
.confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) { .confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) {
Button("Cancel Exercise", role: .destructive) { Button("Cancel Exercise", role: .destructive) { dismiss() }
dismiss()
}
Button("Continue", role: .cancel) { } Button("Continue", role: .cancel) { }
} }
.onAppear {
// Skip to first unfinished set
currentPage = firstUnfinishedSetPage
}
.onChange(of: currentPage) { _, newPage in .onChange(of: currentPage) { _, newPage in
updateProgress(for: newPage) recordProgress(for: newPage)
} }
} }
@ViewBuilder @ViewBuilder
private func pageView(for index: Int) -> some View { private func page(for index: Int) -> some View {
let lastPageIndex = totalPages - 1 let isActive = index == currentPage
if index.isMultiple(of: 2) {
if index == lastPageIndex { // Work phase. The last page (set N) finishes the exercise.
// Done page WorkPhaseView(
DonePageView { setNumber: index / 2 + 1,
completeExercise() totalSets: setCount,
dismiss() detail: detail,
} isLast: index == totalPages - 1,
} else if index % 2 == 0 { isActive: isActive,
// Set page (0, 2, 4, ...) onOneMore: addSet,
let setNumber = (index / 2) + 1 onDone: { completeExercise(); dismiss() }
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
) )
} else { } else {
// Rest page (1, 3, 5, ...) // Rest phase. Auto-advances to the next work page when the timer hits zero.
let restNumber = (index / 2) + 1 RestPhaseView(isActive: isActive) {
RestPageView(restNumber: restNumber) withAnimation { advance(from: index) }
}
} }
} }
private func updateProgress(for pageIndex: Int) { // MARK: - Mutations
// Calculate which set we're on based on page index
// Pages: Set1(0), Rest1(1), Set2(2), Rest2(3), Set3(4), Done(5) /// Programmatically move one page right (used by the rest auto-advance), guarding
// After completing Set 1 and moving to Rest 1, progress should be 1 /// against overrun if the user swiped away in the meantime.
let setIndex = (pageIndex + 1) / 2 private func advance(from index: Int) {
let clampedProgress = min(setIndex, totalSets) 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 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 doc.logs[i].currentStateIndex = completedSets
if completedSets >= setCount {
if clampedProgress >= totalSets {
doc.logs[i].status = WorkoutStatus.completed.rawValue doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true doc.logs[i].completed = true
} else if clampedProgress > 0 { } else if completedSets > 0 {
doc.logs[i].status = WorkoutStatus.inProgress.rawValue doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false doc.logs[i].completed = false
} }
@@ -134,7 +162,7 @@ struct ExerciseProgressView: View {
private func completeExercise() { private func completeExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } 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].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true doc.logs[i].completed = true
@@ -160,148 +188,135 @@ struct ExerciseProgressView: View {
doc.end = nil 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 setNumber: Int
let totalSets: Int let totalSets: Int
let reps: Int let detail: String
let isTimeBased: Bool let isLast: Bool
let durationMinutes: Int let isActive: Bool
let durationSeconds: Int let onOneMore: () -> Void
let onDone: () -> Void
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
@State private var elapsed = 0
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: 6) {
Text("Rest") Text("Set \(setNumber) of \(totalSets)")
.font(.headline) .font(.headline)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
Text(formattedTime) Text(clockString(elapsed))
.font(.system(size: 56, weight: .bold, design: .monospaced)) .font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundColor(.orange) .monospacedDigit()
.foregroundStyle(.green)
Text("Swipe to continue") Text(detail)
.font(.subheadline)
.foregroundStyle(.secondary)
if isLast {
VStack(spacing: 6) {
Button(action: onOneMore) {
Label("One More", systemImage: "plus")
.frame(maxWidth: .infinity)
}
.tint(.blue)
Button(action: onDone) {
Label("Done", systemImage: "checkmark")
.frame(maxWidth: .infinity)
}
.tint(.green)
}
.buttonStyle(.borderedProminent)
.padding(.top, 4)
} else {
Label("Swipe to rest", systemImage: "chevron.right")
.font(.caption2) .font(.caption2)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { .onAppear { if isActive { restart() } }
elapsedSeconds = 0 .onChange(of: isActive) { _, active in if active { restart() } }
.onReceive(ticker) { _ in if isActive { elapsed += 1 } }
}
private func restart() {
elapsed = 0
WKInterfaceDevice.current().play(.start) WKInterfaceDevice.current().play(.start)
} }
.onReceive(ticker) { _ in
elapsedSeconds += 1
checkHapticPing()
}
}
private var formattedTime: String {
let minutes = elapsedSeconds / 60
let seconds = elapsedSeconds % 60
return String(format: "%d:%02d", minutes, seconds)
}
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..<count {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.2) {
WKInterfaceDevice.current().play(.click)
}
}
}
} }
// MARK: - Done Page View // MARK: - Rest Phase
struct DonePageView: View { private struct RestPhaseView: View {
let onDone: () -> Void 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 { var body: some View {
VStack(spacing: 16) { VStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill") Text("Rest")
.font(.system(size: 60)) .font(.headline)
.foregroundColor(.green) .foregroundStyle(.secondary)
Text("Done!") Text(clockString(max(0, remaining)))
.font(.title2) .font(.system(size: 54, weight: .bold, design: .rounded))
.fontWeight(.bold) .monospacedDigit()
.foregroundStyle(.orange)
Text("Tap to finish") Label("Swipe to skip", systemImage: "chevron.right")
.font(.caption) .font(.caption2)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle()) .onAppear { if isActive { start() } }
.onTapGesture { .onChange(of: isActive) { _, active in if active { start() } }
WKInterfaceDevice.current().play(.success) .onReceive(ticker) { _ in tick() }
onDone()
} }
.onAppear {
WKInterfaceDevice.current().play(.success) 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)
}
@@ -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)
}
}
}
+28
View File
@@ -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)
}
}
@@ -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() }
}
}
+2
View File
@@ -11,11 +11,13 @@ import SwiftData
@main @main
struct WorkoutsWatchApp: App { struct WorkoutsWatchApp: App {
@State private var services = WatchAppServices() @State private var services = WatchAppServices()
@WKApplicationDelegateAdaptor(WatchAppDelegate.self) private var appDelegate
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environment(services.bridge) .environment(services.bridge)
.environment(appDelegate.sessionManager)
.modelContainer(services.container) .modelContainer(services.container)
.task { services.activate() } .task { services.activate() }
} }
+1
View File
@@ -11,6 +11,7 @@ final class AppServices {
let container: ModelContainer let container: ModelContainer
let syncEngine: SyncEngine let syncEngine: SyncEngine
let watchBridge: PhoneConnectivityBridge let watchBridge: PhoneConnectivityBridge
let workoutLauncher = WorkoutLauncher()
private var bootstrapTask: Task<Void, Never>? private var bootstrapTask: Task<Void, Never>?
@@ -42,9 +42,11 @@ final class PhoneConnectivityBridge: NSObject {
wDesc.fetchLimit = 25 wDesc.fetchLimit = 25
let workouts = (try? context.fetch(wDesc)) ?? [] let workouts = (try? context.fetch(wDesc)) ?? []
let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45
let payload = WCPayload.encodeState( let payload = WCPayload.encodeState(
splits: splits.map(SplitDocument.init(from:)), splits: splits.map(SplitDocument.init(from:)),
workouts: workouts.map(WorkoutDocument.init(from:)) workouts: workouts.map(WorkoutDocument.init(from:)),
restSeconds: restSeconds
) )
try? session.updateApplicationContext(payload) 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/> <false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <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> <key>UILaunchScreen</key>
<dict/> <dict/>
<key>UISupportedInterfaceOrientations</key> <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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
<key>com.apple.developer.icloud-container-identifiers</key> <key>com.apple.developer.icloud-container-identifiers</key>
<array> <array>
<string>iCloud.dev.rzen.indie.Workouts</string> <string>iCloud.dev.rzen.indie.Workouts</string>
@@ -12,14 +12,27 @@ import IndieAbout
struct SettingsView: View { struct SettingsView: View {
@Environment(SyncEngine.self) private var sync @Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(AppServices.self) private var services
@Query(sort: \Split.order) private var splits: [Split] @Query(sort: \Split.order) private var splits: [Split]
@AppStorage("restSeconds") private var restSeconds: Int = 45
@State private var showingAddSplitSheet = false @State private var showingAddSplitSheet = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { 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 // MARK: - Splits Section
Section(header: Text("Splits")) { Section(header: Text("Splits")) {
if splits.isEmpty { if splits.isEmpty {
@@ -94,6 +107,7 @@ struct SettingsView: View {
.sheet(isPresented: $showingAddSplitSheet) { .sheet(isPresented: $showingAddSplitSheet) {
SplitAddEditView(split: nil) SplitAddEditView(split: nil)
} }
.onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() }
} }
} }
} }
@@ -108,6 +108,7 @@ struct WorkoutLogsView: View {
struct SplitPickerSheet: View { struct SplitPickerSheet: View {
@Environment(SyncEngine.self) private var sync @Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)]) @Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
@@ -178,6 +179,8 @@ struct SplitPickerSheet: View {
) )
Task { await sync.save(workout: doc) } 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() dismiss()
} }
} }
+2
View File
@@ -69,10 +69,12 @@ targets:
- path: Workouts Watch App - path: Workouts Watch App
excludes: excludes:
- "Resources/Info-*.plist" - "Resources/Info-*.plist"
- "Resources/*.entitlements"
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp
INFOPLIST_FILE: "Workouts Watch App/Resources/Info-watchOS.plist" INFOPLIST_FILE: "Workouts Watch App/Resources/Info-watchOS.plist"
CODE_SIGN_ENTITLEMENTS: "Workouts Watch App/Resources/Workouts-watchOS.entitlements"
GENERATE_INFOPLIST_FILE: false GENERATE_INFOPLIST_FILE: false
SWIFT_STRICT_CONCURRENCY: complete SWIFT_STRICT_CONCURRENCY: complete
WATCHOS_DEPLOYMENT_TARGET: "26.0" WATCHOS_DEPLOYMENT_TARGET: "26.0"