Seeding now covers existing installs, not just empty containers: after the same settle delay as auto-seed, connect() branches to reconcileSeeds(), driven by a pure tested planner — upgrade an older seed revision in place (safe: clone-on-edit guarantees no user content at seed ULIDs), skip up-to-date/quarantined files, respect delete-veto stubs, and write missing seeds unless a same-name legacy split exists. Settings gains Restore Starter Splits (the one deliberate veto lift, writing current bundle bytes) and a dev duplicate-cleanup tool backed by a fail-closed scanner. The HealthKit estimate now follows the clone redirect when resolving a workout's split. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
204 lines
9.0 KiB
Swift
204 lines
9.0 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
}
|
||
}
|