Offer to also remove a deleted workout from Apple Health

Closes the last open loop of the HealthKit feature (decision C from
HealthKit-Delete-Loop.md, now retired): the history-list delete dialog
gains a "Delete + Remove from Apple Health" option, shown only when the
workout has a Health record. Removal is best-effort via the workout's
stored HKWorkout UUID; watch-recorded workouts are authored by the
watch app's separate HealthKit source and may be refused, so the dialog
notes they may need deleting in the Health app instead. Discarding an
in-progress workout is unaffected (no Health record exists yet).
This commit is contained in:
2026-07-05 07:55:03 -04:00
parent 0ad93a09da
commit 3e63adf363
3 changed files with 35 additions and 0 deletions
@@ -83,6 +83,25 @@ final class WorkoutHealthWriter {
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
@@ -12,6 +12,7 @@ import SwiftData
struct WorkoutLogsView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
@@ -88,9 +89,22 @@ struct WorkoutLogsView: View {
Task { await sync.delete(workout: workout) }
itemToDelete = nil
}
// Only offered when the workout actually has a Health record. Capture
// 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)
Task { await sync.delete(workout: workout) }
itemToDelete = nil
}
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
}
} message: { workout in
if workout.metricSourceRaw == MetricSource.watch.rawValue {
Text("This workout was recorded by Apple Watch; if it can't be removed from Apple Health here, delete it in the Health app.")
}
}
}
}