Save workouts to Apple Health with live watch metrics and a post-workout summary
Watch sessions now run an HKLiveWorkoutBuilder: heart rate and active energy are collected live (shown in an in-workout HUD), saved to Health as a real HKWorkout on completion, and carried back to the phone as WorkoutMetrics on the workout document. Phone-only workouts get an estimated Health workout (MET x bodyweight x duration) after a 10s debounce that a late-arriving watch session cancels; a launch sweep backfills workouts completed within the last day. Dedupe is keyed on metrics.healthKitWorkoutUUID plus the metrics source. Splits gain an activity type (strength, functional, HIIT, core, cardio, cycling) that categorizes the Health workout and picks the MET value. A post-workout summary sheet (duration, calories, avg/max HR, HR zones, total volume) fills in live and is also shown on completed workouts. Weights can now display in lb or kg (display-only relabel), synced to the watch over the existing application context. WorkoutDocument schema 1->2 (metrics are irreplaceable, so older apps must quarantine rather than strip them); cache schema 2 rebuilds the SwiftData store with the new metric columns. Deleting a workout in the app intentionally leaves its Health record in place.
This commit is contained in:
@@ -8,6 +8,14 @@ When the same workout or split is edited on two devices around the same time, al
|
||||
|
||||
**June 2026**
|
||||
|
||||
Your workouts now save to Apple Health and count toward your Activity rings — recorded straight from your Apple Watch with real heart rate and calories, or estimated on your iPhone when you train without your watch.
|
||||
|
||||
See your heart rate and calories live on the Apple Watch as you train, and get a summary the moment you finish — duration, active calories, average and max heart rate, heart-rate zones, and total volume — that you can also revisit on any past workout.
|
||||
|
||||
Each split can now be set to an activity type — strength, functional strength, HIIT, core, cardio, or cycling — so its workouts are categorized correctly in Apple Health.
|
||||
|
||||
You can now choose whether weights show in pounds or kilograms from Settings.
|
||||
|
||||
Starting a new workout while another is still going now asks whether to end the current one first or run both in parallel.
|
||||
|
||||
Discarding or deleting an in-progress workout on iPhone now takes your Apple Watch out of the workout too, so the watch no longer keeps waking to the workout app when you raise your wrist.
|
||||
|
||||
@@ -7,8 +7,9 @@ your own iCloud Drive.
|
||||
## Key Features
|
||||
|
||||
- **Workout splits** — organize exercises into reusable routines with custom
|
||||
colors and icons. Start with built-in starter splits (Upper Body / Core /
|
||||
Lower Body) generated from a bundled exercise catalog.
|
||||
colors, icons, and an activity type (strength, HIIT, cardio, …). Start with
|
||||
built-in starter splits (Upper Body / Core / Lower Body) generated from a
|
||||
bundled exercise catalog.
|
||||
- **Exercise library** — a bundled catalog of starter exercises (bodyweight and
|
||||
machine-based) to populate your splits.
|
||||
- **Run a workout** — start a session from a split, then tap an exercise to run it
|
||||
@@ -34,6 +35,12 @@ your own iCloud Drive.
|
||||
- **Watch face complication** — a launcher complication you can place on any Apple
|
||||
Watch face; tap it to open the app. Available in the circular, corner, inline, and
|
||||
rectangular accessory slots.
|
||||
- **Apple Health & Activity rings** — completed workouts are saved to Apple Health
|
||||
and credit your Move and Exercise rings: recorded live on the Apple Watch (heart
|
||||
rate + active energy), or estimated on the iPhone when you train without your
|
||||
watch. See live heart rate and calories on the watch, and a post-workout summary
|
||||
(duration, calories, avg/max heart rate, heart-rate zones, total volume) you can
|
||||
revisit on any past workout. Weights display in your choice of pounds or kilograms.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ enum WCPayload {
|
||||
static let workoutKey = "workout"
|
||||
static let restSecondsKey = "restSeconds"
|
||||
static let doneCountdownSecondsKey = "doneCountdownSeconds"
|
||||
static let weightUnitKey = "weightUnit"
|
||||
static let editingWorkoutIDKey = "editingWorkoutID"
|
||||
static let editingSplitIDKey = "editingSplitID"
|
||||
|
||||
@@ -25,12 +26,13 @@ enum WCPayload {
|
||||
/// has a workout's exercise (or a split) open in an editor, the watch parks any
|
||||
/// matching run and locks re-entry, so only one device owns the run at a time. They're
|
||||
/// part of the same latest-wins context — absent keys mean "not editing" (lock clear).
|
||||
static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument], restSeconds: Int, doneCountdownSeconds: Int, editingWorkoutID: String?, editingSplitID: String?) -> [String: Any] {
|
||||
static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument], restSeconds: Int, doneCountdownSeconds: Int, weightUnit: String, editingWorkoutID: String?, editingSplitID: String?) -> [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
|
||||
dict[doneCountdownSecondsKey] = doneCountdownSeconds
|
||||
dict[weightUnitKey] = weightUnit
|
||||
if let editingWorkoutID { dict[editingWorkoutIDKey] = editingWorkoutID }
|
||||
if let editingSplitID { dict[editingSplitIDKey] = editingSplitID }
|
||||
return dict
|
||||
@@ -50,6 +52,8 @@ enum WCPayload {
|
||||
|
||||
static func decodeDoneCountdownSeconds(_ dict: [String: Any]) -> Int? { dict[doneCountdownSecondsKey] as? Int }
|
||||
|
||||
static func decodeWeightUnit(_ dict: [String: Any]) -> String? { dict[weightUnitKey] as? String }
|
||||
|
||||
static func decodeEditingWorkoutID(_ dict: [String: Any]) -> String? { dict[editingWorkoutIDKey] as? String }
|
||||
|
||||
static func decodeEditingSplitID(_ dict: [String: Any]) -> String? { dict[editingSplitIDKey] as? String }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// HealthKitMapping.swift
|
||||
// Workouts (Shared)
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import HealthKit
|
||||
|
||||
// Bridges the app's HealthKit-free domain types to HealthKit, and centralizes the
|
||||
// authorization type sets so the watch (which records the session) and the phone
|
||||
// (which writes an estimated workout when no watch ran) request consistent scopes.
|
||||
|
||||
extension WorkoutActivityType {
|
||||
/// The HealthKit activity type used to tag a saved workout, so it lands in the
|
||||
/// right Apple Health / Fitness category and credits the rings appropriately.
|
||||
var hkActivityType: HKWorkoutActivityType {
|
||||
switch self {
|
||||
case .traditionalStrength: .traditionalStrengthTraining
|
||||
case .functionalStrength: .functionalStrengthTraining
|
||||
case .hiit: .highIntensityIntervalTraining
|
||||
case .coreTraining: .coreTraining
|
||||
case .cardio: .mixedCardio
|
||||
case .cycling: .cycling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Σ sets×reps×weight across weighted logs — the app-native "how much work" number.
|
||||
/// Computed in the stored weight unit (display-only units never change the value).
|
||||
enum WorkoutVolume {
|
||||
static func total(_ logs: [WorkoutLogDocument]) -> Double {
|
||||
logs.reduce(0) { sum, log in
|
||||
guard LoadType(rawValue: log.loadType) == .weight else { return sum }
|
||||
return sum + Double(log.sets * log.reps * log.weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The HealthKit scopes each side requests. Kept here so the share/read sets stay in
|
||||
/// sync with what the writers actually use.
|
||||
enum WorkoutHealthAuthorization {
|
||||
/// Watch: records a live session (writes the workout + energy, reads HR/energy back).
|
||||
static let watchShare: Set<HKSampleType> = [
|
||||
.workoutType(),
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
]
|
||||
static let watchRead: Set<HKObjectType> = [
|
||||
HKQuantityType(.heartRate),
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
HKCharacteristicType(.dateOfBirth), // → max HR for zone tracking
|
||||
]
|
||||
|
||||
/// Phone: writes an estimated workout when no watch session ran; reads body
|
||||
/// measurements to size the calorie estimate (and HR to display watch data).
|
||||
static let phoneShare: Set<HKSampleType> = [
|
||||
.workoutType(),
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
]
|
||||
static let phoneRead: Set<HKObjectType> = [
|
||||
HKQuantityType(.bodyMass),
|
||||
HKQuantityType(.heartRate),
|
||||
HKCharacteristicType(.dateOfBirth),
|
||||
HKCharacteristicType(.biologicalSex),
|
||||
]
|
||||
}
|
||||
@@ -23,6 +23,11 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var exercises: [ExerciseDocument]
|
||||
/// Raw `WorkoutActivityType`; nil → traditional strength training. Optional and
|
||||
/// deliberately NOT schema-bumped: it's a minor categorization preference, so an
|
||||
/// older app dropping it on rewrite (reverting to the default) is preferable to
|
||||
/// quarantining the user's whole routine.
|
||||
var activityType: Int?
|
||||
|
||||
static let currentSchema = 1
|
||||
|
||||
@@ -55,8 +60,16 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var logs: [WorkoutLogDocument]
|
||||
/// Health metrics captured for a finished workout (watch sensors) or estimated
|
||||
/// by the phone. Nil until the workout completes and a writer fills it in. The
|
||||
/// presence of `metrics.healthKitWorkoutUUID` is the dedupe signal that keeps the
|
||||
/// watch and the phone from both writing the same workout to Health.
|
||||
var metrics: WorkoutMetrics?
|
||||
|
||||
static let currentSchema = 1
|
||||
// Bumped 1→2 when `metrics` was added: the captured HR/calorie data is
|
||||
// irreplaceable, so the forward-gate must quarantine these files on older apps
|
||||
// rather than let them strip `metrics` on rewrite.
|
||||
static let currentSchema = 2
|
||||
|
||||
var relativePath: String { Self.relativePath(id: id, start: start) }
|
||||
|
||||
@@ -125,6 +138,24 @@ struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var date: Date
|
||||
}
|
||||
|
||||
// MARK: - Workout health metrics
|
||||
|
||||
/// Health metrics for one finished workout. Embedded in `WorkoutDocument` so they
|
||||
/// ride the existing iCloud + Watch-bridge paths with the rest of the workout.
|
||||
/// Every field except `source`/`recordedAt` is optional — a phone estimate carries
|
||||
/// only calories + volume, while a watch session fills in heart rate too.
|
||||
struct WorkoutMetrics: Codable, Sendable, Equatable {
|
||||
var activeEnergyKcal: Double?
|
||||
var avgHeartRate: Double? // bpm
|
||||
var maxHeartRate: Double? // bpm
|
||||
var minHeartRate: Double? // bpm
|
||||
var totalVolume: Double? // Σ sets×reps×weight across weighted logs
|
||||
var hrZoneSeconds: [Double]? // seconds spent in each of 5 HR zones (low→high)
|
||||
var healthKitWorkoutUUID: String?
|
||||
var source: MetricSource
|
||||
var recordedAt: Date
|
||||
}
|
||||
|
||||
// MARK: - Forward-compatibility gate
|
||||
|
||||
/// A file whose `schemaVersion` exceeds the reader's `currentSchema` was written
|
||||
|
||||
@@ -22,6 +22,7 @@ final class Split {
|
||||
var createdAt: Date = Date()
|
||||
var updatedAt: Date = Date()
|
||||
var jsonRelativePath: String = ""
|
||||
var activityTypeRaw: Int = 0
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \Exercise.split)
|
||||
var exercises: [Exercise] = []
|
||||
@@ -41,6 +42,11 @@ final class Split {
|
||||
static let unnamed = "Unnamed Split"
|
||||
|
||||
var exercisesArray: [Exercise] { exercises.sorted { $0.order < $1.order } }
|
||||
|
||||
var activityTypeEnum: WorkoutActivityType {
|
||||
get { WorkoutActivityType(rawValue: activityTypeRaw) ?? .traditionalStrength }
|
||||
set { activityTypeRaw = newValue.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Exercise
|
||||
@@ -107,6 +113,19 @@ final class Workout {
|
||||
var updatedAt: Date = Date()
|
||||
var jsonRelativePath: String = ""
|
||||
|
||||
// Health metrics — flat columns mirroring `WorkoutMetrics` (SwiftData @Model
|
||||
// can't store the nested Codable cleanly). Assemble via the `metrics` computed
|
||||
// property. All nil until a workout finishes and a writer fills them in.
|
||||
var metricActiveEnergyKcal: Double?
|
||||
var metricAvgHeartRate: Double?
|
||||
var metricMaxHeartRate: Double?
|
||||
var metricMinHeartRate: Double?
|
||||
var metricTotalVolume: Double?
|
||||
var metricHRZoneSeconds: [Double]?
|
||||
var metricHealthKitWorkoutUUID: String?
|
||||
var metricSourceRaw: String?
|
||||
var metricRecordedAt: Date?
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
|
||||
var logs: [WorkoutLog] = []
|
||||
|
||||
@@ -143,6 +162,38 @@ final class Workout {
|
||||
return start.formattedDate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Assembles the flat `metric*` columns into a `WorkoutMetrics`, or nil when none
|
||||
/// have been recorded. `source`/`recordedAt` are always set together when metrics
|
||||
/// exist, so they gate presence.
|
||||
var metrics: WorkoutMetrics? {
|
||||
get {
|
||||
guard let sourceRaw = metricSourceRaw,
|
||||
let source = MetricSource(rawValue: sourceRaw),
|
||||
let recordedAt = metricRecordedAt else { return nil }
|
||||
return WorkoutMetrics(
|
||||
activeEnergyKcal: metricActiveEnergyKcal,
|
||||
avgHeartRate: metricAvgHeartRate,
|
||||
maxHeartRate: metricMaxHeartRate,
|
||||
minHeartRate: metricMinHeartRate,
|
||||
totalVolume: metricTotalVolume,
|
||||
hrZoneSeconds: metricHRZoneSeconds,
|
||||
healthKitWorkoutUUID: metricHealthKitWorkoutUUID,
|
||||
source: source,
|
||||
recordedAt: recordedAt)
|
||||
}
|
||||
set {
|
||||
metricActiveEnergyKcal = newValue?.activeEnergyKcal
|
||||
metricAvgHeartRate = newValue?.avgHeartRate
|
||||
metricMaxHeartRate = newValue?.maxHeartRate
|
||||
metricMinHeartRate = newValue?.minHeartRate
|
||||
metricTotalVolume = newValue?.totalVolume
|
||||
metricHRZoneSeconds = newValue?.hrZoneSeconds
|
||||
metricHealthKitWorkoutUUID = newValue?.healthKitWorkoutUUID
|
||||
metricSourceRaw = newValue?.source.rawValue
|
||||
metricRecordedAt = newValue?.recordedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WorkoutLog
|
||||
|
||||
@@ -34,3 +34,84 @@ enum LoadType: Int, CaseIterable, Codable, Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of training a split represents — used to tag the Apple Health workout
|
||||
/// so it lands in the right category (and credits the rings correctly), and to pick
|
||||
/// a MET value when the phone has to estimate calories without watch sensor data.
|
||||
/// Persisted as its raw `Int` on `SplitDocument`; the `HKWorkoutActivityType`
|
||||
/// mapping lives in `Shared/HealthKit/HealthKitMapping.swift` to keep this enum
|
||||
/// HealthKit-free.
|
||||
enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
|
||||
case traditionalStrength = 0 // default
|
||||
case functionalStrength = 1
|
||||
case hiit = 2
|
||||
case coreTraining = 3
|
||||
case cardio = 4
|
||||
case cycling = 5
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .traditionalStrength: "Strength Training"
|
||||
case .functionalStrength: "Functional Strength"
|
||||
case .hiit: "HIIT"
|
||||
case .coreTraining: "Core Training"
|
||||
case .cardio: "Cardio"
|
||||
case .cycling: "Cycling"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .traditionalStrength: "dumbbell.fill"
|
||||
case .functionalStrength: "figure.strengthtraining.functional"
|
||||
case .hiit: "figure.highintensity.intervaltraining"
|
||||
case .coreTraining: "figure.core.training"
|
||||
case .cardio: "figure.run"
|
||||
case .cycling: "figure.outdoor.cycle"
|
||||
}
|
||||
}
|
||||
|
||||
/// Metabolic-equivalent value used only for the phone-side calorie estimate
|
||||
/// (`kcal ≈ MET × bodyweightKg × hours`) when no watch metrics are available.
|
||||
var met: Double {
|
||||
switch self {
|
||||
case .traditionalStrength: 5.0
|
||||
case .functionalStrength: 4.0
|
||||
case .hiit: 8.0
|
||||
case .coreTraining: 4.0
|
||||
case .cardio: 7.0
|
||||
case .cycling: 7.5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a workout's health metrics came from: real watch sensors, or a phone-side
|
||||
/// estimate written when no watch session ran. Persisted in `WorkoutMetrics`.
|
||||
enum MetricSource: String, Codable, Sendable {
|
||||
case watch
|
||||
case phoneEstimate
|
||||
}
|
||||
|
||||
/// Display unit for weights. The stored weight values are plain integers and are
|
||||
/// never rewritten when this changes — switching only relabels what's shown.
|
||||
enum WeightUnit: String, CaseIterable, Codable, Sendable {
|
||||
case lb
|
||||
case kg
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .lb: "Pounds (lb)"
|
||||
case .kg: "Kilograms (kg)"
|
||||
}
|
||||
}
|
||||
|
||||
var abbreviation: String {
|
||||
switch self {
|
||||
case .lb: "lb"
|
||||
case .kg: "kg"
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a stored weight value with the current unit's label, e.g. "135 lb".
|
||||
func format(_ value: Int) -> String { "\(value) \(abbreviation)" }
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ extension SplitDocument {
|
||||
self.init(schemaVersion: Self.currentSchema, id: split.id, name: split.name,
|
||||
color: split.color, systemImage: split.systemImage, order: split.order,
|
||||
createdAt: split.createdAt, updatedAt: split.updatedAt,
|
||||
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)))
|
||||
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)),
|
||||
activityType: split.activityTypeRaw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +46,8 @@ extension WorkoutDocument {
|
||||
self.init(schemaVersion: Self.currentSchema, id: workout.id, splitID: workout.splitID,
|
||||
splitName: workout.splitName, start: workout.start, end: workout.end,
|
||||
status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
|
||||
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)))
|
||||
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
|
||||
metrics: workout.metrics)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +85,7 @@ enum CacheMapper {
|
||||
split.createdAt = doc.createdAt
|
||||
split.updatedAt = doc.updatedAt
|
||||
split.jsonRelativePath = relativePath
|
||||
split.activityTypeRaw = doc.activityType ?? 0
|
||||
|
||||
let existing = Dictionary(split.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
var keep = Set<String>()
|
||||
@@ -141,6 +144,7 @@ enum CacheMapper {
|
||||
workout.createdAt = doc.createdAt
|
||||
workout.updatedAt = doc.updatedAt
|
||||
workout.jsonRelativePath = relativePath
|
||||
workout.metrics = doc.metrics
|
||||
|
||||
let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
var keep = Set<String>()
|
||||
|
||||
@@ -6,7 +6,8 @@ import SwiftData
|
||||
/// safe; `SyncEngine` repopulates it from the container on next launch.
|
||||
enum WorkoutsModelContainer {
|
||||
/// Bump whenever the cache schema changes — wipes and rebuilds from files.
|
||||
static let currentSchemaVersion = 1
|
||||
/// 2: added health-metric columns to `Workout` and `activityTypeRaw` to `Split`.
|
||||
static let currentSchemaVersion = 2
|
||||
private static let schemaVersionKey = "Workouts.persistenceSchemaVersion"
|
||||
private static let identityTokenKey = "Workouts.iCloudIdentityToken"
|
||||
|
||||
|
||||
@@ -176,6 +176,9 @@ final class WatchConnectivityBridge: NSObject {
|
||||
if let done = WCPayload.decodeDoneCountdownSeconds(dict) {
|
||||
UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey)
|
||||
}
|
||||
if let unit = WCPayload.decodeWeightUnit(dict) {
|
||||
UserDefaults.standard.set(unit, forKey: WCPayload.weightUnitKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) {
|
||||
@@ -244,12 +247,14 @@ extension WatchConnectivityBridge: WCSessionDelegate {
|
||||
let workouts = WCPayload.decodeWorkouts(applicationContext)
|
||||
let rest = WCPayload.decodeRestSeconds(applicationContext)
|
||||
let done = WCPayload.decodeDoneCountdownSeconds(applicationContext)
|
||||
let unit = WCPayload.decodeWeightUnit(applicationContext)
|
||||
let editingWorkoutID = WCPayload.decodeEditingWorkoutID(applicationContext)
|
||||
let editingSplitID = WCPayload.decodeEditingSplitID(applicationContext)
|
||||
Task { @MainActor in
|
||||
self.applyState(splits, workouts: workouts)
|
||||
if let rest { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) }
|
||||
if let done { UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey) }
|
||||
if let unit { UserDefaults.standard.set(unit, forKey: WCPayload.weightUnitKey) }
|
||||
// Absent keys mean "not editing" — set unconditionally so the lock clears.
|
||||
self.editingWorkoutID = editingWorkoutID
|
||||
self.editingSplitID = editingSplitID
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
<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>
|
||||
<string>Workouts reads your heart rate and active energy during a watch workout to show your live stats.</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>
|
||||
<string>Workouts records your watch workout and saves it to Apple Health (including heart rate and active energy) so it counts toward your Activity rings.</string>
|
||||
<key>WKBackgroundModes</key>
|
||||
<array>
|
||||
<string>workout-processing</string>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<dict>
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
<!-- healthkit.access is for clinical health-records access ONLY. The live
|
||||
workout session uses standard workout/quantity access, which needs no
|
||||
entries here — leave it empty. -->
|
||||
<key>com.apple.developer.healthkit.access</key>
|
||||
<array/>
|
||||
</dict>
|
||||
|
||||
@@ -72,10 +72,12 @@ struct ActiveWorkoutGateView: View {
|
||||
// Nothing to run yet — pull fresh state in case the phone just started one.
|
||||
if activeWorkouts.isEmpty { bridge.requestSync() }
|
||||
}
|
||||
.onChange(of: activeWorkouts.isEmpty) { _, noActiveWorkouts in
|
||||
// Everything finished (or was cleared) — release the HealthKit session that
|
||||
// was keeping the launched app alive.
|
||||
if noActiveWorkouts { sessionManager.end() }
|
||||
.onChange(of: activeWorkouts.map(\.id)) { previouslyActive, nowActive in
|
||||
// The active list just emptied — the run either completed or was discarded.
|
||||
// Route the HealthKit session accordingly.
|
||||
if nowActive.isEmpty, !previouslyActive.isEmpty {
|
||||
endSession(previouslyActive: previouslyActive)
|
||||
}
|
||||
}
|
||||
// The phone just entered (or left) an editor — if we're inside the now-locked run,
|
||||
// pop back to the gate so re-entry rebuilds a fresh working copy. Also pop if the run
|
||||
@@ -96,6 +98,35 @@ struct ActiveWorkoutGateView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The active list emptied: decide whether the run completed (save it to Health,
|
||||
/// attach the captured metrics, and forward them to the phone) or was discarded
|
||||
/// (throw the session data away). A completed run stays in the cache as `.completed`;
|
||||
/// a discarded one is tombstoned on the phone and pruned from the cache, so its
|
||||
/// absence from `workouts` is the discard signal.
|
||||
private func endSession(previouslyActive ids: [String]) {
|
||||
let finished = ids
|
||||
.compactMap { id in workouts.first { $0.id == id } }
|
||||
.first { $0.status == .completed }
|
||||
|
||||
guard let finished else {
|
||||
// Nothing survived as completed → the run was discarded. Don't save it.
|
||||
sessionManager.discard()
|
||||
return
|
||||
}
|
||||
|
||||
let doc = WorkoutDocument(from: finished)
|
||||
Task {
|
||||
guard var metrics = await sessionManager.finishAndSave() else { return }
|
||||
metrics.totalVolume = WorkoutVolume.total(doc.logs)
|
||||
var updated = doc
|
||||
updated.metrics = metrics
|
||||
updated.updatedAt = Date()
|
||||
// Carry the captured metrics back to the phone (and thus iCloud) over the
|
||||
// existing workout-update path.
|
||||
bridge.update(workout: updated)
|
||||
}
|
||||
}
|
||||
|
||||
/// If the run we're currently navigated into is no longer available — pruned from the
|
||||
/// cache (discarded/deleted on the phone, or aged out of the pushed set), or locked
|
||||
/// because the phone took over editing it — pop back to the gate.
|
||||
|
||||
@@ -10,6 +10,7 @@ import SwiftData
|
||||
|
||||
struct WorkoutLogListView: View {
|
||||
@Environment(WatchConnectivityBridge.self) private var bridge
|
||||
@Environment(WorkoutSessionManager.self) private var sessionManager
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
/// Working copy of the workout. We drive the UI from this and mutate it on
|
||||
@@ -48,6 +49,10 @@ struct WorkoutLogListView: View {
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if sessionManager.isRunning {
|
||||
Section { LiveMetricsHUD() }
|
||||
}
|
||||
|
||||
Section(header: Text(label)) {
|
||||
ForEach(sortedLogs) { log in
|
||||
Button {
|
||||
@@ -141,10 +146,41 @@ struct WorkoutLogListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live Metrics HUD
|
||||
|
||||
/// Compact live readout (heart rate + active calories) shown while a watch session is
|
||||
/// running. Reads the session manager from the environment itself so its per-second
|
||||
/// updates re-render only this row, not the editable log list above it. Renders
|
||||
/// nothing until the first sample arrives, and dims under Always-On like the timers.
|
||||
private struct LiveMetricsHUD: View {
|
||||
@Environment(WorkoutSessionManager.self) private var sessionManager
|
||||
@Environment(\.isLuminanceReduced) private var dimmed
|
||||
|
||||
var body: some View {
|
||||
let heartRate = sessionManager.currentHeartRate
|
||||
let energy = sessionManager.currentActiveEnergyKcal
|
||||
if heartRate != nil || energy != nil {
|
||||
HStack(spacing: 16) {
|
||||
if let heartRate {
|
||||
Label("\(Int(heartRate.rounded()))", systemImage: "heart.fill")
|
||||
.foregroundStyle(dimmed ? Color.secondary : Color.red)
|
||||
}
|
||||
if let energy {
|
||||
Label("\(Int(energy.rounded())) cal", systemImage: "flame.fill")
|
||||
.foregroundStyle(dimmed ? Color.secondary : Color.orange)
|
||||
}
|
||||
}
|
||||
.font(.caption.monospacedDigit())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout Log Row Label
|
||||
|
||||
struct WorkoutLogRowLabel: View {
|
||||
let log: WorkoutLogDocument
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
@@ -207,7 +243,7 @@ struct WorkoutLogRowLabel: View {
|
||||
return "\(log.sets) × \(secs) sec"
|
||||
}
|
||||
} else {
|
||||
return "\(log.sets) × \(log.reps) × \(log.weight) lbs"
|
||||
return "\(log.sets) × \(log.reps) × \(weightUnit.format(log.weight))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,6 +255,7 @@ struct ExercisePickerView: View {
|
||||
|
||||
let exercises: [Exercise]
|
||||
let onSelect: (Exercise) -> Void
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -266,7 +303,7 @@ struct ExercisePickerView: View {
|
||||
return "\(exercise.sets) × \(secs) sec"
|
||||
}
|
||||
} else {
|
||||
return "\(exercise.sets) × \(exercise.reps) × \(exercise.weight) lbs"
|
||||
return "\(exercise.sets) × \(exercise.reps) × \(weightUnit.format(exercise.weight))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,26 +9,50 @@ import Foundation
|
||||
import HealthKit
|
||||
import Observation
|
||||
|
||||
/// Runs an `HKWorkoutSession` for the duration of a watch workout.
|
||||
/// Runs an `HKWorkoutSession` (with a live builder) 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
|
||||
/// delegate a workout configuration; starting a session with it 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.
|
||||
/// down). The attached `HKLiveWorkoutBuilder` makes watchOS collect heart rate and
|
||||
/// active energy from the sensors, so finishing the session saves a real `HKWorkout`
|
||||
/// that feeds the Activity rings — and gives us the live HUD values and the metrics
|
||||
/// we send back to the phone.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class WorkoutSessionManager: NSObject {
|
||||
private let healthStore = HKHealthStore()
|
||||
private var session: HKWorkoutSession?
|
||||
private var builder: HKLiveWorkoutBuilder?
|
||||
|
||||
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.
|
||||
/// Live values for the in-workout HUD. Nil until the first sample arrives (or if
|
||||
/// the corresponding read authorization was denied).
|
||||
private(set) var currentHeartRate: Double?
|
||||
private(set) var currentActiveEnergyKcal: Double?
|
||||
|
||||
/// Seconds accumulated in each of 5 heart-rate zones (low→high). Built on the
|
||||
/// watch because it's the only place with the full HR stream. Empty/ignored when
|
||||
/// the user's age (hence max HR) is unknown.
|
||||
private var hrZoneSeconds = [Double](repeating: 0, count: 5)
|
||||
private var lastHRSampleDate: Date?
|
||||
private var maxHeartRate: Double?
|
||||
|
||||
/// Guards the terminal paths so a late delegate callback can't double-finish.
|
||||
private var isFinishing = false
|
||||
|
||||
/// Prompt for the authorization the live session needs: share workouts + active
|
||||
/// energy (so the session can write them), read heart rate + active energy (to
|
||||
/// surface them), and read date of birth (to derive HR zones). Called once at
|
||||
/// launch so a later phone-initiated launch starts cleanly.
|
||||
func requestAuthorization() {
|
||||
guard HKHealthStore.isHealthDataAvailable() else { return }
|
||||
healthStore.requestAuthorization(toShare: [.workoutType()], read: []) { _, _ in }
|
||||
healthStore.requestAuthorization(
|
||||
toShare: WorkoutHealthAuthorization.watchShare,
|
||||
read: WorkoutHealthAuthorization.watchRead
|
||||
) { _, _ in }
|
||||
}
|
||||
|
||||
/// Start the session for a phone-launched workout. Idempotent — a second launch
|
||||
@@ -37,25 +61,126 @@ final class WorkoutSessionManager: NSObject {
|
||||
guard HKHealthStore.isHealthDataAvailable(), session == nil else { return }
|
||||
do {
|
||||
let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
|
||||
let builder = session.associatedWorkoutBuilder()
|
||||
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
|
||||
session.delegate = self
|
||||
builder.delegate = self
|
||||
self.session = session
|
||||
session.startActivity(with: Date())
|
||||
self.builder = builder
|
||||
|
||||
resetMetricAccumulators()
|
||||
maxHeartRate = computeMaxHeartRate()
|
||||
|
||||
let startDate = Date()
|
||||
session.startActivity(with: startDate)
|
||||
Task { try? await builder.beginCollection(at: startDate) }
|
||||
isRunning = true
|
||||
} catch {
|
||||
session = nil
|
||||
isRunning = false
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
/// End the session — called when the workout finishes (no active workout remains).
|
||||
func end() {
|
||||
session?.end()
|
||||
/// Finish the session and save it to Health, returning the captured metrics so the
|
||||
/// caller can attach them to the workout and forward to the phone. `totalVolume`
|
||||
/// is left nil here — the caller fills it from the workout's logs.
|
||||
func finishAndSave() async -> WorkoutMetrics? {
|
||||
guard let session, let builder, !isFinishing else { clear(); return nil }
|
||||
isFinishing = true
|
||||
let endDate = Date()
|
||||
session.end()
|
||||
do {
|
||||
try await builder.endCollection(at: endDate)
|
||||
let saved = try await builder.finishWorkout()
|
||||
let metrics = makeMetrics(savedWorkout: saved, endDate: endDate)
|
||||
clear()
|
||||
return metrics
|
||||
} catch {
|
||||
clear()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// End the session and throw the data away — nothing is saved to Health. Used when
|
||||
/// the workout was discarded rather than completed.
|
||||
func discard() {
|
||||
guard let session, let builder, !isFinishing else { clear(); return }
|
||||
isFinishing = true
|
||||
session.end()
|
||||
builder.discardWorkout()
|
||||
clear()
|
||||
}
|
||||
|
||||
// MARK: - Metric helpers
|
||||
|
||||
private func makeMetrics(savedWorkout: HKWorkout?, endDate: Date) -> WorkoutMetrics {
|
||||
let hrUnit = HKUnit.count().unitDivided(by: .minute())
|
||||
let energy = builder?.statistics(for: HKQuantityType(.activeEnergyBurned))?
|
||||
.sumQuantity()?.doubleValue(for: .kilocalorie())
|
||||
let hr = builder?.statistics(for: HKQuantityType(.heartRate))
|
||||
let zones = (maxHeartRate != nil && hrZoneSeconds.contains { $0 > 0 }) ? hrZoneSeconds : nil
|
||||
|
||||
return WorkoutMetrics(
|
||||
activeEnergyKcal: energy,
|
||||
avgHeartRate: hr?.averageQuantity()?.doubleValue(for: hrUnit),
|
||||
maxHeartRate: hr?.maximumQuantity()?.doubleValue(for: hrUnit),
|
||||
minHeartRate: hr?.minimumQuantity()?.doubleValue(for: hrUnit),
|
||||
totalVolume: nil,
|
||||
hrZoneSeconds: zones,
|
||||
healthKitWorkoutUUID: savedWorkout?.uuid.uuidString,
|
||||
source: .watch,
|
||||
recordedAt: endDate
|
||||
)
|
||||
}
|
||||
|
||||
/// Pull the latest live stats off the builder for the HUD, and fold the elapsed
|
||||
/// time into the right HR zone.
|
||||
private func refreshLiveStats() {
|
||||
let hrUnit = HKUnit.count().unitDivided(by: .minute())
|
||||
let newHR = builder?.statistics(for: HKQuantityType(.heartRate))?
|
||||
.mostRecentQuantity()?.doubleValue(for: hrUnit)
|
||||
let now = Date()
|
||||
|
||||
if let maxHeartRate, let prevHR = currentHeartRate, let last = lastHRSampleDate {
|
||||
let dt = now.timeIntervalSince(last)
|
||||
if dt > 0, dt < 60 { hrZoneSeconds[zoneIndex(for: prevHR, maxHR: maxHeartRate)] += dt }
|
||||
}
|
||||
if let newHR { currentHeartRate = newHR }
|
||||
lastHRSampleDate = now
|
||||
|
||||
currentActiveEnergyKcal = builder?.statistics(for: HKQuantityType(.activeEnergyBurned))?
|
||||
.sumQuantity()?.doubleValue(for: .kilocalorie())
|
||||
}
|
||||
|
||||
private func zoneIndex(for hr: Double, maxHR: Double) -> Int {
|
||||
let ratio = hr / maxHR
|
||||
let thresholds = [0.6, 0.7, 0.8, 0.9]
|
||||
return thresholds.reduce(0) { $0 + (ratio >= $1 ? 1 : 0) }
|
||||
}
|
||||
|
||||
/// Max HR ≈ 220 − age, from the user's date of birth. Nil when unavailable (read
|
||||
/// denied or DOB not set), which suppresses zone tracking rather than faking it.
|
||||
private func computeMaxHeartRate() -> Double? {
|
||||
guard let comps = try? healthStore.dateOfBirthComponents(), let birthYear = comps.year else { return nil }
|
||||
let thisYear = Calendar.current.component(.year, from: Date())
|
||||
let age = thisYear - birthYear
|
||||
guard age > 0, age < 120 else { return nil }
|
||||
return Double(220 - age)
|
||||
}
|
||||
|
||||
private func resetMetricAccumulators() {
|
||||
currentHeartRate = nil
|
||||
currentActiveEnergyKcal = nil
|
||||
hrZoneSeconds = [Double](repeating: 0, count: 5)
|
||||
lastHRSampleDate = nil
|
||||
maxHeartRate = nil
|
||||
}
|
||||
|
||||
private func clear() {
|
||||
session = nil
|
||||
builder = nil
|
||||
isRunning = false
|
||||
isFinishing = false
|
||||
resetMetricAccumulators()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +194,22 @@ extension WorkoutSessionManager: HKWorkoutSessionDelegate {
|
||||
date: Date
|
||||
) {
|
||||
guard toState == .ended || toState == .stopped else { return }
|
||||
Task { @MainActor in self.clear() }
|
||||
// Only reset if we didn't drive the end ourselves (finishAndSave/discard set
|
||||
// `isFinishing` and clear on their own); this catches system-ended sessions.
|
||||
Task { @MainActor in if !self.isFinishing { self.clear() } }
|
||||
}
|
||||
|
||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
|
||||
Task { @MainActor in self.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HKLiveWorkoutBuilderDelegate
|
||||
|
||||
extension WorkoutSessionManager: HKLiveWorkoutBuilderDelegate {
|
||||
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
|
||||
Task { @MainActor in self.refreshLiveStats() }
|
||||
}
|
||||
|
||||
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ final class AppServices {
|
||||
let syncEngine: SyncEngine
|
||||
let watchBridge: PhoneConnectivityBridge
|
||||
let workoutLauncher = WorkoutLauncher()
|
||||
let workoutHealthWriter: WorkoutHealthWriter
|
||||
|
||||
/// Ephemeral live-run state fed by the watch, observed by the mirror UI. Not persisted.
|
||||
let liveRunState: LiveRunState
|
||||
@@ -25,6 +26,10 @@ final class AppServices {
|
||||
let liveRunState = LiveRunState()
|
||||
self.liveRunState = liveRunState
|
||||
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine, liveRunState: liveRunState)
|
||||
let writer = WorkoutHealthWriter(container: container, syncEngine: syncEngine)
|
||||
self.workoutHealthWriter = writer
|
||||
syncEngine.onWorkoutCompleted = { [weak writer] doc in writer?.workoutCompleted(doc) }
|
||||
syncEngine.onWatchMetricsArrived = { [weak writer] id in writer?.watchMetricsArrived(workoutID: id) }
|
||||
#if DEBUG
|
||||
if ScreenshotSeed.isActive { ScreenshotSeed.populate(container.mainContext) }
|
||||
#endif
|
||||
@@ -38,6 +43,10 @@ final class AppServices {
|
||||
guard let self else { return }
|
||||
await self.syncEngine.connect()
|
||||
self.watchBridge.activate()
|
||||
// Past the iCloud gate and cache is reconciled: request Health access and
|
||||
// back-fill any recently-completed workout that never reached Health.
|
||||
self.workoutHealthWriter.authorizeIfNeeded()
|
||||
self.workoutHealthWriter.sweepRecentlyCompleted()
|
||||
}
|
||||
bootstrapTask = task
|
||||
await task.value
|
||||
|
||||
@@ -87,11 +87,13 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
|
||||
let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45
|
||||
let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5
|
||||
let weightUnit = UserDefaults.standard.string(forKey: WCPayload.weightUnitKey) ?? WeightUnit.lb.rawValue
|
||||
let payload = WCPayload.encodeState(
|
||||
splits: splits.map(SplitDocument.init(from:)),
|
||||
workouts: workouts.map(WorkoutDocument.init(from:)),
|
||||
restSeconds: restSeconds,
|
||||
doneCountdownSeconds: doneCountdownSeconds,
|
||||
weightUnit: weightUnit,
|
||||
editingWorkoutID: editingWorkoutID,
|
||||
editingSplitID: editingSplitID
|
||||
)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//
|
||||
// WorkoutHealthWriter.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import HealthKit
|
||||
import SwiftData
|
||||
|
||||
/// Writes an *estimated* `HKWorkout` from the phone when a workout completes without
|
||||
/// a watch session having recorded it — so phone-only workouts still feed the Move
|
||||
/// ring. Mirrors what Apple's own "Add Workout" does: energy ≈ MET × bodyweight ×
|
||||
/// duration, with bodyweight read from Health.
|
||||
///
|
||||
/// Dedupe is the whole game: a workout must be written to Health by the watch OR the
|
||||
/// phone, never both. The signals are `WorkoutMetrics.source`/`healthKitWorkoutUUID`
|
||||
/// (already present → skip) plus a debounce that a late watch update cancels.
|
||||
@MainActor
|
||||
final class WorkoutHealthWriter {
|
||||
private let container: ModelContainer
|
||||
private let healthStore = HKHealthStore()
|
||||
private weak var syncEngine: SyncEngine?
|
||||
|
||||
/// Scheduled estimate tasks keyed by workout id — for debounce + cancellation.
|
||||
private var pending: [String: Task<Void, Never>] = [:]
|
||||
|
||||
/// Grace window before writing, so a watch session that finishes a beat later can
|
||||
/// cancel us (its metrics arrive over WatchConnectivity and call `watchMetricsArrived`).
|
||||
private let debounce: Duration = .seconds(10)
|
||||
|
||||
/// Default bodyweight when Health has none, in kg (~154 lb).
|
||||
private let fallbackBodyMassKg = 70.0
|
||||
|
||||
init(container: ModelContainer, syncEngine: SyncEngine) {
|
||||
self.container = container
|
||||
self.syncEngine = syncEngine
|
||||
}
|
||||
|
||||
private var context: ModelContext { container.mainContext }
|
||||
|
||||
private var isScreenshotRun: Bool {
|
||||
#if DEBUG
|
||||
return ScreenshotSeed.isActive
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Request the scopes the estimate needs: share workouts + active energy, read
|
||||
/// body measurements (to size the estimate) and heart rate (to show watch data).
|
||||
func authorizeIfNeeded() {
|
||||
guard HKHealthStore.isHealthDataAvailable(), !isScreenshotRun else { return }
|
||||
healthStore.requestAuthorization(
|
||||
toShare: WorkoutHealthAuthorization.phoneShare,
|
||||
read: WorkoutHealthAuthorization.phoneRead
|
||||
) { _, _ in }
|
||||
}
|
||||
|
||||
/// A workout was persisted as completed. If nothing has written it to Health yet,
|
||||
/// schedule an estimated write after the debounce.
|
||||
func workoutCompleted(_ doc: WorkoutDocument) {
|
||||
guard HKHealthStore.isHealthDataAvailable(), !isScreenshotRun else { return }
|
||||
guard doc.status == WorkoutStatus.completed.rawValue, doc.end != nil else { return }
|
||||
// Already recorded (watch session, or a prior estimate) → nothing to do.
|
||||
guard doc.metrics?.healthKitWorkoutUUID == nil, doc.metrics?.source != .watch else { return }
|
||||
guard pending[doc.id] == nil else { return }
|
||||
|
||||
let id = doc.id
|
||||
pending[id] = Task { [weak self] in
|
||||
try? await Task.sleep(for: self?.debounce ?? .seconds(10))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.pending[id] = nil
|
||||
await self.writeEstimate(workoutID: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The watch recorded this workout — cancel any pending phone estimate so we don't
|
||||
/// double-write.
|
||||
func watchMetricsArrived(workoutID id: String) {
|
||||
pending[id]?.cancel()
|
||||
pending[id] = nil
|
||||
}
|
||||
|
||||
/// Launch safety sweep: catch workouts that completed but never got a Health record
|
||||
/// (e.g., the app was killed during the debounce). Bounded to the last day so we
|
||||
/// don't retroactively flood Health with historical workouts; idempotent via the
|
||||
/// per-workout UUID guard.
|
||||
func sweepRecentlyCompleted() {
|
||||
guard HKHealthStore.isHealthDataAvailable(), !isScreenshotRun else { return }
|
||||
let cutoff = Date().addingTimeInterval(-24 * 60 * 60)
|
||||
let completedRaw = WorkoutStatus.completed.rawValue
|
||||
let all = (try? context.fetch(FetchDescriptor<Workout>())) ?? []
|
||||
for w in all where w.statusRaw == completedRaw && w.metricHealthKitWorkoutUUID == nil {
|
||||
guard let end = w.end, end >= cutoff else { continue }
|
||||
workoutCompleted(WorkoutDocument(from: w))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Estimate write
|
||||
|
||||
private func writeEstimate(workoutID id: String) async {
|
||||
guard healthStore.authorizationStatus(for: .workoutType()) == .sharingAuthorized else { return }
|
||||
// Re-read the freshest doc — watch metrics may have landed during the debounce.
|
||||
guard let workout = CacheMapper.fetchWorkout(id: id, in: context),
|
||||
workout.status == .completed, let end = workout.end else { return }
|
||||
if let m = workout.metrics, m.healthKitWorkoutUUID != nil || m.source == .watch { return }
|
||||
|
||||
let doc = WorkoutDocument(from: workout)
|
||||
let activity = activityType(for: doc)
|
||||
let bodyKg = await bodyMassKg()
|
||||
let hours = max(0, end.timeIntervalSince(doc.start)) / 3600
|
||||
let kcal = activity.met * bodyKg * hours
|
||||
|
||||
let config = HKWorkoutConfiguration()
|
||||
config.activityType = activity.hkActivityType
|
||||
config.locationType = .indoor
|
||||
let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: config, device: .local())
|
||||
|
||||
do {
|
||||
try await builder.beginCollection(at: doc.start)
|
||||
if kcal > 0 {
|
||||
let sample = HKQuantitySample(
|
||||
type: HKQuantityType(.activeEnergyBurned),
|
||||
quantity: HKQuantity(unit: .kilocalorie(), doubleValue: kcal),
|
||||
start: doc.start, end: end
|
||||
)
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
builder.add([sample]) { _, error in
|
||||
if let error { cont.resume(throwing: error) } else { cont.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
try await builder.endCollection(at: end)
|
||||
let saved = try await builder.finishWorkout()
|
||||
|
||||
// Persist the estimate's metrics so it's never written twice.
|
||||
var updated = doc
|
||||
updated.metrics = WorkoutMetrics(
|
||||
activeEnergyKcal: kcal > 0 ? kcal : nil,
|
||||
avgHeartRate: nil, maxHeartRate: nil, minHeartRate: nil,
|
||||
totalVolume: WorkoutVolume.total(doc.logs),
|
||||
hrZoneSeconds: nil,
|
||||
healthKitWorkoutUUID: saved?.uuid.uuidString,
|
||||
source: .phoneEstimate,
|
||||
recordedAt: end
|
||||
)
|
||||
updated.updatedAt = Date()
|
||||
await syncEngine?.save(workout: updated)
|
||||
} catch {
|
||||
// Leave it unmarked; the next launch sweep retries within the window.
|
||||
}
|
||||
}
|
||||
|
||||
private func activityType(for doc: WorkoutDocument) -> WorkoutActivityType {
|
||||
if let splitID = doc.splitID, let split = CacheMapper.fetchSplit(id: splitID, in: context) {
|
||||
return split.activityTypeEnum
|
||||
}
|
||||
return .traditionalStrength
|
||||
}
|
||||
|
||||
/// Most recent body mass from Health, in kg, falling back to a default when none.
|
||||
private func bodyMassKg() async -> Double {
|
||||
let fallback = fallbackBodyMassKg
|
||||
return await withCheckedContinuation { continuation in
|
||||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||||
let query = HKSampleQuery(
|
||||
sampleType: HKQuantityType(.bodyMass), predicate: nil, limit: 1, sortDescriptors: [sort]
|
||||
) { _, samples, _ in
|
||||
let kg = (samples?.first as? HKQuantitySample)?
|
||||
.quantity.doubleValue(for: .gramUnit(with: .kilo))
|
||||
continuation.resume(returning: kg ?? fallback)
|
||||
}
|
||||
healthStore.execute(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,23 +18,26 @@ import HealthKit
|
||||
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 {
|
||||
/// Configuration handed to watchOS; the watch starts a session with the same
|
||||
/// shape (see `WorkoutSessionManager`). The activity type comes from the split so
|
||||
/// the saved Health workout is categorized correctly.
|
||||
static func makeConfiguration(activityType: HKWorkoutActivityType) -> HKWorkoutConfiguration {
|
||||
let configuration = HKWorkoutConfiguration()
|
||||
configuration.activityType = .traditionalStrengthTraining
|
||||
configuration.activityType = activityType
|
||||
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() {
|
||||
/// or unreachable paired watch. Authorization is requested up front by
|
||||
/// `WorkoutHealthWriter` at launch, so we only need the workout-share scope that
|
||||
/// `startWatchApp(toHandle:)` itself requires.
|
||||
func launchWatchWorkout(activityType: HKWorkoutActivityType) {
|
||||
guard HKHealthStore.isHealthDataAvailable() else { return }
|
||||
Task {
|
||||
try? await healthStore.requestAuthorization(toShare: [.workoutType()], read: [])
|
||||
try? await healthStore.startWatchApp(toHandle: Self.makeConfiguration())
|
||||
try? await healthStore.startWatchApp(toHandle: Self.makeConfiguration(activityType: activityType))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
<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>
|
||||
<string>Workouts reads your heart rate and body measurements to show workout stats and estimate the calories you burned.</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>
|
||||
<string>Workouts saves your completed sessions to Apple Health (including active energy) so they count toward your Activity rings.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<dict>
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
<!-- healthkit.access is for clinical health-records access ONLY. Standard
|
||||
workout / quantity / characteristic access (what this app uses) needs no
|
||||
entries here — leave it empty; adding values triggers extra App Review. -->
|
||||
<key>com.apple.developer.healthkit.access</key>
|
||||
<array/>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
|
||||
@@ -12,11 +12,12 @@ enum SplitSeeder {
|
||||
let weight: Int
|
||||
}
|
||||
|
||||
/// One starter split: visual theme plus its ordered exercises.
|
||||
/// One starter split: visual theme, activity type, plus its ordered exercises.
|
||||
private struct SeedSplit {
|
||||
let name: String
|
||||
let color: String
|
||||
let icon: String
|
||||
let activity: WorkoutActivityType
|
||||
let exercises: [SeedExercise]
|
||||
}
|
||||
|
||||
@@ -27,17 +28,17 @@ enum SplitSeeder {
|
||||
/// Starter splits in display order, with sensible machine starting weights.
|
||||
/// Users adjust weights from the exercise screen.
|
||||
private static let starterSplits: [SeedSplit] = [
|
||||
SeedSplit(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", exercises: [
|
||||
SeedSplit(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: .traditionalStrength, exercises: [
|
||||
SeedExercise(name: "Lat Pull Down", weight: 110),
|
||||
SeedExercise(name: "Tricep Press", weight: 100),
|
||||
SeedExercise(name: "Chest Press", weight: 40),
|
||||
SeedExercise(name: "Seated Row", weight: 90),
|
||||
]),
|
||||
SeedSplit(name: "Core", color: "orange", icon: "figure.core.training", exercises: [
|
||||
SeedSplit(name: "Core", color: "orange", icon: "figure.core.training", activity: .coreTraining, exercises: [
|
||||
SeedExercise(name: "Abdominal", weight: 0),
|
||||
SeedExercise(name: "Rotary", weight: 0),
|
||||
]),
|
||||
SeedSplit(name: "Lower Body", color: "green", icon: "figure.run", exercises: [
|
||||
SeedSplit(name: "Lower Body", color: "green", icon: "figure.run", activity: .traditionalStrength, exercises: [
|
||||
SeedExercise(name: "Abductor", weight: 140),
|
||||
SeedExercise(name: "Adductor", weight: 140),
|
||||
SeedExercise(name: "Leg Press", weight: 160),
|
||||
@@ -59,7 +60,8 @@ enum SplitSeeder {
|
||||
return SplitDocument(
|
||||
schemaVersion: SplitDocument.currentSchema, id: ULID.make(),
|
||||
name: split.name, color: split.color, systemImage: split.icon, order: order,
|
||||
createdAt: Date(), updatedAt: Date(), exercises: exercises
|
||||
createdAt: Date(), updatedAt: Date(), exercises: exercises,
|
||||
activityType: split.activity.rawValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ struct ExerciseAddEditView: View {
|
||||
@State private var sets: Int
|
||||
@State private var weightReminderWeeks: Int
|
||||
@State private var weightLastUpdated: Date?
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
init(exercise: Exercise, split: Split) {
|
||||
self.exercise = exercise
|
||||
@@ -129,7 +130,7 @@ struct ExerciseAddEditView: View {
|
||||
.frame(height: 100)
|
||||
.pickerStyle(.wheel)
|
||||
|
||||
Text("lbs")
|
||||
Text(weightUnit.abbreviation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ struct ExerciseListView: View {
|
||||
private var workouts: [Workout]
|
||||
|
||||
@State private var showingActivePrompt = false
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
private var activeWorkouts: [Workout] {
|
||||
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
||||
@@ -41,7 +42,7 @@ struct ExerciseListView: View {
|
||||
ForEach(sortedExercises) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(weightUnit.format(item.weight))"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
|
||||
@@ -18,6 +18,7 @@ struct SettingsView: View {
|
||||
|
||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
@State private var showingAddSplitSheet = false
|
||||
|
||||
var body: some View {
|
||||
@@ -40,6 +41,12 @@ struct SettingsView: View {
|
||||
Text("\(doneCountdownSeconds)s").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Weight Unit", selection: $weightUnit) {
|
||||
ForEach(WeightUnit.allCases, id: \.self) { unit in
|
||||
Text(unit.displayName).tag(unit)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Workout")
|
||||
} footer: {
|
||||
@@ -142,6 +149,7 @@ struct SettingsView: View {
|
||||
}
|
||||
.onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() }
|
||||
.onChange(of: doneCountdownSeconds) { _, _ in services.watchBridge.pushAll() }
|
||||
.onChange(of: weightUnit) { _, _ in services.watchBridge.pushAll() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ struct SplitAddEditView: View {
|
||||
@State private var name: String = ""
|
||||
@State private var color: String = "indigo"
|
||||
@State private var systemImage: String = "dumbbell.fill"
|
||||
@State private var activityType: WorkoutActivityType = .traditionalStrength
|
||||
@State private var showingIconPicker: Bool = false
|
||||
@State private var showingDeleteConfirmation: Bool = false
|
||||
|
||||
@@ -33,6 +34,7 @@ struct SplitAddEditView: View {
|
||||
_name = State(initialValue: split.name)
|
||||
_color = State(initialValue: split.color)
|
||||
_systemImage = State(initialValue: split.systemImage)
|
||||
_activityType = State(initialValue: split.activityTypeEnum)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +73,18 @@ struct SplitAddEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Activity Type", selection: $activityType) {
|
||||
ForEach(WorkoutActivityType.allCases, id: \.self) { type in
|
||||
Label(type.displayName, systemImage: type.systemImage).tag(type)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Activity Type")
|
||||
} footer: {
|
||||
Text("Determines how this workout is categorized in Apple Health and how it credits your Activity rings.")
|
||||
}
|
||||
|
||||
if let split = split {
|
||||
Section(header: Text("Exercises")) {
|
||||
NavigationLink {
|
||||
@@ -136,6 +150,7 @@ struct SplitAddEditView: View {
|
||||
doc.name = name
|
||||
doc.color = color
|
||||
doc.systemImage = systemImage
|
||||
doc.activityType = activityType.rawValue
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
} else {
|
||||
@@ -150,7 +165,8 @@ struct SplitAddEditView: View {
|
||||
order: existing.count,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date(),
|
||||
exercises: []
|
||||
exercises: [],
|
||||
activityType: activityType.rawValue
|
||||
)
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ struct SplitDetailView: View {
|
||||
@State private var showingSplitEditSheet: Bool = false
|
||||
@State private var itemToEdit: Exercise? = nil
|
||||
@State private var itemToDelete: Exercise? = nil
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -36,7 +37,7 @@ struct SplitDetailView: View {
|
||||
ForEach(sortedExercises) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(weightUnit.format(item.weight))"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
|
||||
@@ -244,6 +244,7 @@ struct ExerciseView: View {
|
||||
|
||||
struct PlanTilesView: View {
|
||||
let log: WorkoutLogDocument
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
var body: some View {
|
||||
if LoadType(rawValue: log.loadType) == .duration {
|
||||
@@ -257,7 +258,7 @@ struct PlanTilesView: View {
|
||||
HStack(spacing: 0) {
|
||||
PlanTile(label: "Sets", value: "\(log.sets)")
|
||||
PlanTile(label: "Reps", value: "\(log.reps)")
|
||||
PlanTile(label: "Weight", value: "\(log.weight) lbs")
|
||||
PlanTile(label: "Weight", value: weightUnit.format(log.weight))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ struct PlanEditView: View {
|
||||
@State private var durationMinutes: Int = 0
|
||||
@State private var durationSeconds: Int = 0
|
||||
@State private var selectedLoadType: LoadType = .weight
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -77,7 +78,7 @@ struct PlanEditView: View {
|
||||
.foregroundColor(.secondary)
|
||||
Picker("Weight", selection: $weight) {
|
||||
ForEach(0...300, id: \.self) { num in
|
||||
Text("\(num) lbs").tag(num)
|
||||
Text(weightUnit.format(num)).tag(num)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
|
||||
@@ -27,6 +27,8 @@ struct WeightProgressionChartView: View {
|
||||
)
|
||||
}
|
||||
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
private var weightData: [WeightDataPoint] {
|
||||
logs.map { WeightDataPoint(date: $0.date, weight: $0.weight) }
|
||||
}
|
||||
@@ -97,11 +99,11 @@ struct WeightProgressionChartView: View {
|
||||
? Int((Double(weightDifference) / Double(firstWeight)) * 100)
|
||||
: 0
|
||||
if percentIncrease >= 20 {
|
||||
return "Amazing progress! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
|
||||
return "Amazing progress! You've increased your weight by \(weightUnit.format(weightDifference)) (\(percentIncrease)%)!"
|
||||
} else if percentIncrease >= 10 {
|
||||
return "Great job! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
|
||||
return "Great job! You've increased your weight by \(weightUnit.format(weightDifference)) (\(percentIncrease)%)!"
|
||||
} else {
|
||||
return "You're making progress! Weight increased by \(weightDifference) lbs. Keep it up!"
|
||||
return "You're making progress! Weight increased by \(weightUnit.format(weightDifference)). Keep it up!"
|
||||
}
|
||||
} else if weightDifference == 0 {
|
||||
return "You're maintaining consistent weight. Focus on form and consider increasing when ready!"
|
||||
|
||||
@@ -30,6 +30,8 @@ struct WorkoutLogListView: View {
|
||||
@State private var logToDelete: WorkoutLogDocument?
|
||||
@State private var addedLog: LogRoute?
|
||||
@State private var logToEdit: LogRoute?
|
||||
@State private var summaryRoute: LogRoute?
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
/// Drives a programmatic push keyed by a log id, used for the freshly-added
|
||||
/// exercise (→ progress flow) and the Edit swipe (→ detail/edit screen). Rows
|
||||
@@ -70,6 +72,12 @@ struct WorkoutLogListView: View {
|
||||
}
|
||||
} else {
|
||||
Form {
|
||||
if doc.status == WorkoutStatus.completed.rawValue, doc.metrics != nil {
|
||||
Section("Summary") {
|
||||
WorkoutMetricsView(workout: workout)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text(label)) {
|
||||
ForEach(sortedLogs) { log in
|
||||
NavigationLink {
|
||||
@@ -184,6 +192,12 @@ struct WorkoutLogListView: View {
|
||||
} message: {
|
||||
Text(endProgressMessage)
|
||||
}
|
||||
.sheet(item: $summaryRoute) { route in
|
||||
WorkoutSummaryView(workoutID: route.id) {
|
||||
summaryRoute = nil
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paged run flow, fully wired into the live channel: it broadcasts this device's
|
||||
@@ -314,7 +328,9 @@ struct WorkoutLogListView: View {
|
||||
doc.endKeepingProgress()
|
||||
let snapshot = doc
|
||||
Task { await sync.save(workout: snapshot) }
|
||||
dismiss()
|
||||
// Show the summary (metrics fill in live as they arrive) instead of leaving
|
||||
// immediately; dismissing the summary returns to the log list.
|
||||
summaryRoute = LogRoute(id: snapshot.id)
|
||||
}
|
||||
|
||||
/// Throw the workout away entirely (soft-delete via the tombstone path).
|
||||
@@ -336,7 +352,7 @@ struct WorkoutLogListView: View {
|
||||
return "\(log.sets) × \(secs) sec"
|
||||
}
|
||||
} else {
|
||||
return "\(log.sets) × \(log.reps) reps × \(log.weight) lbs"
|
||||
return "\(log.sets) × \(log.reps) reps × \(weightUnit.format(log.weight))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,6 +365,7 @@ struct SplitExercisePickerSheet: View {
|
||||
let split: Split?
|
||||
let existingExerciseNames: Set<String>
|
||||
let onExerciseSelected: (Exercise) -> Void
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
private var availableExercises: [Exercise] {
|
||||
guard let split else { return [] }
|
||||
@@ -369,7 +386,7 @@ struct SplitExercisePickerSheet: View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(exercise.name)
|
||||
.foregroundColor(.primary)
|
||||
Text("\(exercise.sets) × \(exercise.reps) × \(exercise.weight) lbs")
|
||||
Text("\(exercise.sets) × \(exercise.reps) × \(weightUnit.format(exercise.weight))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
@@ -237,8 +237,9 @@ 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()
|
||||
// Bring the Apple Watch up into the session so the user can run it from the wrist,
|
||||
// tagged with the split's activity type so the saved Health workout is categorized.
|
||||
services.workoutLauncher.launchWatchWorkout(activityType: split.activityTypeEnum.hkActivityType)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// WorkoutSummaryView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// Post-workout summary shown right after a workout finishes. Queried by id so the
|
||||
/// health metrics (calories, heart rate) fill in live as they arrive — from the
|
||||
/// watch over the bridge, or from the phone's estimate a few seconds after ending.
|
||||
struct WorkoutSummaryView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query private var workouts: [Workout]
|
||||
let onDone: () -> Void
|
||||
|
||||
init(workoutID: String, onDone: @escaping () -> Void) {
|
||||
self.onDone = onDone
|
||||
_workouts = Query(filter: #Predicate<Workout> { $0.id == workoutID })
|
||||
}
|
||||
|
||||
private var workout: Workout? { workouts.first }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let workout {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.green)
|
||||
.padding(.top, 8)
|
||||
Text(workout.splitName ?? Split.unnamed)
|
||||
.font(.title2.bold())
|
||||
|
||||
WorkoutMetricsView(workout: workout)
|
||||
|
||||
if workout.metrics == nil {
|
||||
Label("Saving to Apple Health…", systemImage: "heart.text.square")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
} else {
|
||||
// The workout was removed (e.g. discarded) — nothing to summarize.
|
||||
ContentUnavailableView("Workout Ended", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Summary")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { onDone() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metric tiles (shared by the summary sheet and the per-workout detail)
|
||||
|
||||
/// Renders the available health/strength metrics for a finished workout as a tile
|
||||
/// grid plus an optional heart-rate-zone bar. Reused by `WorkoutSummaryView` and the
|
||||
/// completed-workout detail section.
|
||||
struct WorkoutMetricsView: View {
|
||||
let workout: Workout
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
private var metrics: WorkoutMetrics? { workout.metrics }
|
||||
|
||||
private var durationSeconds: Int? {
|
||||
guard let end = workout.end else { return nil }
|
||||
return max(0, Int(end.timeIntervalSince(workout.start)))
|
||||
}
|
||||
|
||||
private var volume: Double {
|
||||
metrics?.totalVolume ?? WorkoutVolume.total(WorkoutDocument(from: workout).logs)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||
if let durationSeconds {
|
||||
MetricTile(icon: "clock.fill", tint: .blue, value: durationText(durationSeconds), label: "Duration")
|
||||
}
|
||||
if let kcal = metrics?.activeEnergyKcal {
|
||||
MetricTile(icon: "flame.fill", tint: .orange, value: "\(Int(kcal.rounded()))", label: "Active cal")
|
||||
}
|
||||
if let avg = metrics?.avgHeartRate {
|
||||
MetricTile(icon: "heart.fill", tint: .red, value: "\(Int(avg.rounded()))", label: "Avg BPM")
|
||||
}
|
||||
if let max = metrics?.maxHeartRate {
|
||||
MetricTile(icon: "bolt.heart.fill", tint: .pink, value: "\(Int(max.rounded()))", label: "Max BPM")
|
||||
}
|
||||
if volume > 0 {
|
||||
MetricTile(icon: "scalemass.fill", tint: .purple, value: volumeText, label: "Volume")
|
||||
}
|
||||
}
|
||||
|
||||
if let zones = metrics?.hrZoneSeconds, zones.contains(where: { $0 > 0 }) {
|
||||
HRZoneBar(zoneSeconds: zones)
|
||||
}
|
||||
|
||||
if metrics?.source == .phoneEstimate {
|
||||
Text("Calories estimated — wear your Apple Watch for measured stats.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var volumeText: String {
|
||||
"\(Int(volume.rounded())) \(weightUnit.abbreviation)"
|
||||
}
|
||||
|
||||
private func durationText(_ seconds: Int) -> String {
|
||||
let minutes = seconds / 60
|
||||
if minutes >= 60 { return "\(minutes / 60)h \(minutes % 60)m" }
|
||||
return "\(minutes)m \(seconds % 60)s"
|
||||
}
|
||||
}
|
||||
|
||||
struct MetricTile: View {
|
||||
let icon: String
|
||||
let tint: Color
|
||||
let value: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.title2)
|
||||
.foregroundStyle(tint)
|
||||
Text(value)
|
||||
.font(.title3.bold())
|
||||
.monospacedDigit()
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
/// Proportional stacked bar of time spent in each of the 5 heart-rate zones (low→high).
|
||||
struct HRZoneBar: View {
|
||||
let zoneSeconds: [Double]
|
||||
|
||||
private let colors: [Color] = [.blue, .green, .yellow, .orange, .red]
|
||||
|
||||
var body: some View {
|
||||
let total = max(zoneSeconds.reduce(0, +), 1)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Heart Rate Zones")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<5, id: \.self) { zone in
|
||||
if zoneSeconds[zone] > 0 {
|
||||
colors[zone]
|
||||
.frame(width: max(2, geo.size.width * (zoneSeconds[zone] / total)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 12)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user