Make Apple Health workout recording watch-only
The phone no longer writes estimated Health workouts: the watch, which runs the live session, is the sole recorder. Replaces WorkoutHealthWriter with a WorkoutHealthDeleter that only removes a legacy phone-estimate workout when its record is deleted here, drops the MET calorie table and the phone's write/read Health scopes, and keeps phoneEstimate decodable for existing documents. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -9,12 +9,13 @@ 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.
|
||||
// authorization type sets. The watch records the live session (shares the workout +
|
||||
// energy, reads HR back); the phone only ever deletes a legacy phone-estimate workout
|
||||
// it previously authored, so it needs nothing but workout-share.
|
||||
|
||||
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.
|
||||
/// The HealthKit activity type used to tag the watch's 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
|
||||
@@ -52,16 +53,10 @@ enum WorkoutHealthAuthorization {
|
||||
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).
|
||||
/// Phone: shares only the workout type, the minimum HealthKit requires to delete a
|
||||
/// legacy phone-estimate `HKWorkout` this app authored. The phone reads nothing and
|
||||
/// no longer writes any workouts of its own (recording is watch-only).
|
||||
static let phoneShare: Set<HKSampleType> = [
|
||||
.workoutType(),
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
]
|
||||
static let phoneRead: Set<HKObjectType> = [
|
||||
HKQuantityType(.bodyMass),
|
||||
HKQuantityType(.heartRate),
|
||||
HKCharacteristicType(.dateOfBirth),
|
||||
HKCharacteristicType(.biologicalSex),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,9 +35,8 @@ 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.
|
||||
/// The kind of training a split represents — used to tag the watch's Apple Health
|
||||
/// workout so it lands in the right category (and credits the rings correctly).
|
||||
/// Persisted as its raw `Int` on `SplitDocument`; the `HKWorkoutActivityType`
|
||||
/// mapping lives in `Shared/HealthKit/HealthKitMapping.swift` to keep this enum
|
||||
/// HealthKit-free.
|
||||
@@ -70,23 +69,13 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
|
||||
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`.
|
||||
/// Where a workout's health metrics came from: real watch sensors, or — for records
|
||||
/// written by app versions up to 2.x — a phone-side MET estimate made when no watch
|
||||
/// session ran. The phone no longer writes estimates (recording is watch-only), but
|
||||
/// `phoneEstimate` is retained so legacy documents on disk still decode. Persisted in
|
||||
/// `WorkoutMetrics`.
|
||||
enum MetricSource: String, Codable, Sendable {
|
||||
case watch
|
||||
case phoneEstimate
|
||||
|
||||
@@ -12,7 +12,7 @@ final class AppServices {
|
||||
let syncEngine: SyncEngine
|
||||
let watchBridge: PhoneConnectivityBridge
|
||||
let workoutLauncher = WorkoutLauncher()
|
||||
let workoutHealthWriter: WorkoutHealthWriter
|
||||
let workoutHealthDeleter = WorkoutHealthDeleter()
|
||||
|
||||
/// Ephemeral live-run state fed by the watch, observed by the mirror UI. Not persisted.
|
||||
let liveRunState: LiveRunState
|
||||
@@ -26,10 +26,6 @@ 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
|
||||
@@ -43,10 +39,9 @@ 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()
|
||||
// Past the iCloud gate: request the workout-share scope so the user can still
|
||||
// delete legacy phone-estimate workouts from Health when deleting them here.
|
||||
self.workoutHealthDeleter.authorizeIfNeeded()
|
||||
}
|
||||
bootstrapTask = task
|
||||
await task.value
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// WorkoutHealthDeleter.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import HealthKit
|
||||
import os
|
||||
|
||||
/// Removes a workout's Apple Health record when the user deletes that workout in the app.
|
||||
///
|
||||
/// Apple Health recording is **watch-only**: the watch's `HKWorkoutSession`
|
||||
/// (`WorkoutSessionManager`) is the sole recorder, and the phone writes nothing to
|
||||
/// Health. This helper exists purely so that *legacy* phone-estimate workouts — the
|
||||
/// MET-based `HKWorkout`s that app versions up to 2.x wrote from the phone — can still
|
||||
/// be pruned from Health when the user deletes them here.
|
||||
///
|
||||
/// HealthKit only permits deleting samples this app authored, so those phone estimates
|
||||
/// delete cleanly while watch-recorded workouts (authored by the watch app's separate
|
||||
/// HealthKit source) are refused — the delete flow directs the user to the Health app.
|
||||
@MainActor
|
||||
final class WorkoutHealthDeleter {
|
||||
private let healthStore = HKHealthStore()
|
||||
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "health")
|
||||
|
||||
private var isScreenshotRun: Bool {
|
||||
#if DEBUG
|
||||
return ScreenshotSeed.isActive
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Request the single scope a delete needs: share (write) access to the workout
|
||||
/// type. HealthKit requires write authorization for a sample's type in order to
|
||||
/// delete a sample this app authored. No read scopes — the phone reads nothing
|
||||
/// from Health (heart rate and calories come from the watch over WatchConnectivity).
|
||||
func authorizeIfNeeded() {
|
||||
guard HKHealthStore.isHealthDataAvailable(), !isScreenshotRun else { return }
|
||||
healthStore.requestAuthorization(
|
||||
toShare: WorkoutHealthAuthorization.phoneShare,
|
||||
read: []
|
||||
) { _, _ in }
|
||||
}
|
||||
|
||||
/// Best-effort removal of a workout's Health record, on the user's request when
|
||||
/// deleting the workout in the app. Only samples this app authored (legacy phone
|
||||
/// estimates) can be deleted; watch-recorded workouts are refused by HealthKit and
|
||||
/// remain in Health until removed there.
|
||||
func deleteFromHealth(uuidString: String) {
|
||||
guard HKHealthStore.isHealthDataAvailable(), let uuid = UUID(uuidString: uuidString) else { return }
|
||||
guard healthStore.authorizationStatus(for: .workoutType()) == .sharingAuthorized else {
|
||||
log.notice("Skipping Health delete: workout sharing not authorized")
|
||||
return
|
||||
}
|
||||
let healthStore = self.healthStore
|
||||
let log = self.log
|
||||
let query = HKSampleQuery(
|
||||
sampleType: .workoutType(),
|
||||
predicate: HKQuery.predicateForObject(with: uuid),
|
||||
limit: 1, sortDescriptors: nil
|
||||
) { _, samples, error in
|
||||
if let error {
|
||||
log.error("Health workout lookup failed: \(error.localizedDescription, privacy: .public)")
|
||||
return
|
||||
}
|
||||
guard let workout = samples?.first else { return }
|
||||
healthStore.delete([workout]) { _, error in
|
||||
if let error {
|
||||
log.error("Health workout delete failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
healthStore.execute(query)
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Best-effort removal of a workout's Health record, on the user's request when
|
||||
/// deleting the workout in the app. HealthKit only allows deleting samples this
|
||||
/// app authored, so phone estimates delete cleanly while watch-recorded workouts
|
||||
/// (authored by the watch app's separate HealthKit source) may be refused — that
|
||||
/// refusal is swallowed; the record then stays in Health until removed there.
|
||||
func deleteFromHealth(uuidString: String) {
|
||||
guard HKHealthStore.isHealthDataAvailable(), let uuid = UUID(uuidString: uuidString) else { return }
|
||||
let healthStore = self.healthStore
|
||||
let query = HKSampleQuery(
|
||||
sampleType: .workoutType(),
|
||||
predicate: HKQuery.predicateForObject(with: uuid),
|
||||
limit: 1, sortDescriptors: nil
|
||||
) { _, samples, _ in
|
||||
guard let workout = samples?.first else { return }
|
||||
healthStore.delete([workout]) { _, _ in }
|
||||
}
|
||||
healthStore.execute(query)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
}
|
||||
}
|
||||
|
||||
/// The split's configured activity type, following the seed clone-on-edit
|
||||
/// redirect (the estimate can run seconds to hours after the workout, so the
|
||||
/// split may have forked in between). Generic strength when the split is gone.
|
||||
private func activityType(for doc: WorkoutDocument) -> WorkoutActivityType {
|
||||
if let splitID = doc.splitID {
|
||||
let resolved = syncEngine?.currentSplitID(for: splitID) ?? splitID
|
||||
if let split = CacheMapper.fetchSplit(id: resolved, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,8 @@ final class WorkoutLauncher {
|
||||
|
||||
/// 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. Authorization is requested up front by
|
||||
/// `WorkoutHealthWriter` at launch, so we only need the workout-share scope that
|
||||
/// `startWatchApp(toHandle:)` itself requires.
|
||||
/// or unreachable paired watch. Requests the workout-share scope that
|
||||
/// `startWatchApp(toHandle:)` itself requires (also enough for a legacy Health delete).
|
||||
func launchWatchWorkout(activityType: HKWorkoutActivityType) {
|
||||
guard HKHealthStore.isHealthDataAvailable() else { return }
|
||||
Task {
|
||||
|
||||
@@ -51,14 +51,6 @@ final class SyncEngine {
|
||||
/// this to push fresh state to the watch.
|
||||
var onCacheChanged: (() -> Void)?
|
||||
|
||||
/// Called when a completed workout is persisted. The HealthKit writer uses this to
|
||||
/// schedule an estimated Health workout if nothing recorded it.
|
||||
var onWorkoutCompleted: ((WorkoutDocument) -> Void)?
|
||||
|
||||
/// Called when a workout carrying watch-recorded metrics is persisted, so the
|
||||
/// writer can cancel any pending phone estimate for it.
|
||||
var onWatchMetricsArrived: ((String) -> Void)?
|
||||
|
||||
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
|
||||
private let modelContainer: ModelContainer
|
||||
private var store: DocumentFileStore?
|
||||
@@ -351,10 +343,6 @@ final class SyncEngine {
|
||||
} catch {
|
||||
report("Failed to save workout", error)
|
||||
}
|
||||
// Drive the HealthKit writer: a watch-recorded doc cancels any pending estimate;
|
||||
// a completed doc (re)considers an estimate (the writer dedupes on metrics).
|
||||
if doc.metrics?.source == .watch { onWatchMetricsArrived?(doc.id) }
|
||||
if doc.status == WorkoutStatus.completed.rawValue { onWorkoutCompleted?(doc) }
|
||||
}
|
||||
|
||||
func delete(split: Split) async {
|
||||
|
||||
@@ -94,7 +94,7 @@ struct WorkoutLogsView: View {
|
||||
// the UUID before the delete prunes the cache entity.
|
||||
if let healthUUID = workout.metricHealthKitWorkoutUUID {
|
||||
Button("Delete + Remove from Apple Health", role: .destructive) {
|
||||
services.workoutHealthWriter.deleteFromHealth(uuidString: healthUUID)
|
||||
services.workoutHealthDeleter.deleteFromHealth(uuidString: healthUUID)
|
||||
Task { await sync.delete(workout: workout) }
|
||||
itemToDelete = nil
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ 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.
|
||||
/// health metrics (calories, heart rate) fill in live as they arrive from the watch
|
||||
/// over the WatchConnectivity bridge.
|
||||
struct WorkoutSummaryView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query private var workouts: [Workout]
|
||||
|
||||
Reference in New Issue
Block a user