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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+6
-1
@@ -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.
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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<String>()
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@ import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
WorkoutLogsView()
|
||||
ActiveWorkoutGateView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<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>
|
||||
<true/>
|
||||
<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 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<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? {
|
||||
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..<totalPages, id: \.self) { index in
|
||||
pageView(for: index)
|
||||
page(for: index)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
@@ -65,64 +80,77 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) {
|
||||
Button("Cancel Exercise", role: .destructive) {
|
||||
dismiss()
|
||||
}
|
||||
Button("Cancel Exercise", role: .destructive) { dismiss() }
|
||||
Button("Continue", role: .cancel) { }
|
||||
}
|
||||
.onAppear {
|
||||
// Skip to first unfinished set
|
||||
currentPage = firstUnfinishedSetPage
|
||||
}
|
||||
.onChange(of: currentPage) { _, newPage in
|
||||
updateProgress(for: newPage)
|
||||
recordProgress(for: newPage)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func pageView(for index: Int) -> 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..<count {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.2) {
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
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)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear { if isActive { restart() } }
|
||||
.onChange(of: isActive) { _, active in if active { restart() } }
|
||||
.onReceive(ticker) { _ in if isActive { elapsed += 1 } }
|
||||
}
|
||||
|
||||
private func restart() {
|
||||
elapsed = 0
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Done Page View
|
||||
// MARK: - Rest Phase
|
||||
|
||||
struct DonePageView: View {
|
||||
let onDone: () -> 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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user