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
@@ -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) }
}
}
}
+1 -1
View File
@@ -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 + (N1) 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)
}
}
}
+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
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() }
}