Apple Fitness workout fixer + Strava uploader
1import CoreLocation
2import Foundation
3import HealthKit
4
5struct IntensitySample: Identifiable {
6 let id = UUID()
7 let date: Date
8 let value: Double
9}
10
11enum IntensityMetric {
12 case heartRate
13 case activeEnergy
14 case distance
15
16 var unit: HKUnit {
17 switch self {
18 case .heartRate: .count().unitDivided(by: .minute())
19 case .activeEnergy: .kilocalorie()
20 case .distance: .meter()
21 }
22 }
23
24 var label: String {
25 switch self {
26 case .heartRate: "BPM"
27 case .activeEnergy: "kcal"
28 case .distance: "m"
29 }
30 }
31
32 var quantityType: HKQuantityType {
33 switch self {
34 case .heartRate: HKQuantityType(.heartRate)
35 case .activeEnergy: HKQuantityType(.activeEnergyBurned)
36 case .distance: HKQuantityType(.distanceWalkingRunning)
37 }
38 }
39}
40
41@Observable
42class HealthKitManager {
43 static let shared = HealthKitManager()
44
45 var loadedWorkouts: [HKWorkout] = []
46 var canLoadMore = false
47 private let healthStore = HKHealthStore()
48 private let pageSize = 50
49 private var observerQuery: HKObserverQuery?
50
51 func startObservingWorkouts() {
52 guard observerQuery == nil else { return }
53 let query = HKObserverQuery(sampleType: HKObjectType.workoutType(), predicate: nil) { [weak self] _, _, _ in
54 guard let self else { return }
55 Task { await self.loadWorkouts() }
56 }
57 healthStore.execute(query)
58 observerQuery = query
59 }
60
61 func requestAuthorization() async throws {
62 guard HKHealthStore.isHealthDataAvailable() else {
63 throw HealthKitError.healthDataNotAvailable
64 }
65
66 var sampleTypes: Set<HKSampleType> = [
67 HKObjectType.workoutType(),
68 HKQuantityType(.heartRate),
69 HKQuantityType(.activeEnergyBurned),
70 HKQuantityType(.basalEnergyBurned),
71 HKQuantityType(.distanceWalkingRunning),
72 HKQuantityType(.distanceCycling),
73 HKQuantityType(.stepCount),
74 HKQuantityType(.runningSpeed),
75 HKQuantityType(.runningPower),
76 HKQuantityType(.runningStrideLength),
77 HKQuantityType(.runningVerticalOscillation),
78 HKQuantityType(.runningGroundContactTime),
79 HKQuantityType(.vo2Max),
80 HKQuantityType(.physicalEffort),
81 HKSeriesType.workoutRoute(),
82 ]
83 if #available(iOS 18.0, *) {
84 sampleTypes.insert(HKQuantityType(.workoutEffortScore))
85 sampleTypes.insert(HKQuantityType(.estimatedWorkoutEffortScore))
86 }
87
88 let typesToRead: Set<HKObjectType> = sampleTypes as Set<HKObjectType>
89 let typesToWrite: Set<HKSampleType> = sampleTypes
90
91 try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead)
92 }
93
94 func loadWorkouts() async {
95 let sortDescriptor = SortDescriptor(\HKWorkout.startDate, order: .reverse)
96 let descriptor = HKSampleQueryDescriptor(
97 predicates: [.workout()],
98 sortDescriptors: [sortDescriptor],
99 limit: pageSize
100 )
101
102 do {
103 let results = try await descriptor.result(for: healthStore)
104 await MainActor.run {
105 self.loadedWorkouts = results
106 self.canLoadMore = results.count == pageSize
107 }
108 } catch {
109 print("Error loading workouts: \(error.localizedDescription)")
110 }
111 }
112
113 func loadMoreWorkouts() async {
114 guard canLoadMore, let oldest = loadedWorkouts.last else { return }
115
116 let predicate = HKQuery.predicateForSamples(
117 withStart: nil,
118 end: oldest.startDate,
119 options: .strictEndDate
120 )
121 let sortDescriptor = SortDescriptor(\HKWorkout.startDate, order: .reverse)
122 let descriptor = HKSampleQueryDescriptor(
123 predicates: [.workout(predicate)],
124 sortDescriptors: [sortDescriptor],
125 limit: pageSize
126 )
127
128 do {
129 let results = try await descriptor.result(for: healthStore)
130 await MainActor.run {
131 self.loadedWorkouts.append(contentsOf: results)
132 self.canLoadMore = results.count == pageSize
133 }
134 } catch {
135 print("Error loading more workouts: \(error.localizedDescription)")
136 }
137 }
138
139 func fetchIntensitySamples(for workout: HKWorkout) async -> (samples: [IntensitySample], metric: IntensityMetric) {
140 let metrics: [IntensityMetric] = [.heartRate, .activeEnergy, .distance]
141
142 for metric in metrics {
143 let predicate = HKQuery.predicateForSamples(
144 withStart: workout.startDate,
145 end: workout.endDate,
146 options: .strictStartDate
147 )
148 let sortDescriptor = SortDescriptor(\HKQuantitySample.startDate, order: .forward)
149 let descriptor = HKSampleQueryDescriptor(
150 predicates: [.quantitySample(type: metric.quantityType, predicate: predicate)],
151 sortDescriptors: [sortDescriptor],
152 limit: 5000
153 )
154
155 do {
156 let results = try await descriptor.result(for: healthStore)
157 if !results.isEmpty {
158 var samples = results.map { sample in
159 IntensitySample(
160 date: sample.startDate,
161 value: sample.quantity.doubleValue(for: metric.unit)
162 )
163 }
164 if samples.count > 500 {
165 samples = downsample(samples, to: 500)
166 }
167 return (samples, metric)
168 }
169 } catch {
170 print("Error fetching \(metric.label): \(error.localizedDescription)")
171 }
172 }
173
174 return ([], .heartRate)
175 }
176
177 private func downsample(_ samples: [IntensitySample], to target: Int) -> [IntensitySample] {
178 let stride = Double(samples.count) / Double(target)
179 return (0..<target).map { i in
180 samples[min(Int(Double(i) * stride), samples.count - 1)]
181 }
182 }
183
184 private static var migratedSampleTypes: [HKQuantityType] {
185 var types: [HKQuantityType] = [
186 HKQuantityType(.heartRate),
187 HKQuantityType(.activeEnergyBurned),
188 HKQuantityType(.basalEnergyBurned),
189 HKQuantityType(.distanceWalkingRunning),
190 HKQuantityType(.distanceCycling),
191 HKQuantityType(.stepCount),
192 HKQuantityType(.runningSpeed),
193 HKQuantityType(.runningPower),
194 HKQuantityType(.runningStrideLength),
195 HKQuantityType(.runningVerticalOscillation),
196 HKQuantityType(.runningGroundContactTime),
197 HKQuantityType(.vo2Max),
198 HKQuantityType(.physicalEffort),
199 ]
200 if #available(iOS 18.0, *) {
201 types.append(HKQuantityType(.workoutEffortScore))
202 types.append(HKQuantityType(.estimatedWorkoutEffortScore))
203 }
204 return types
205 }
206
207 func fetchAssociatedSamples(for workout: HKWorkout, in range: ClosedRange<Date>) async -> [HKQuantitySample] {
208 var allSamples: [HKQuantitySample] = []
209 let workoutPredicate = HKQuery.predicateForObjects(from: workout)
210
211 for sampleType in Self.migratedSampleTypes {
212 let descriptor = HKSampleQueryDescriptor(
213 predicates: [.quantitySample(type: sampleType, predicate: workoutPredicate)],
214 sortDescriptors: [SortDescriptor(\HKQuantitySample.startDate, order: .forward)]
215 )
216 do {
217 let results = try await descriptor.result(for: healthStore)
218 let filtered = results.filter { range.contains($0.startDate) }
219 allSamples.append(contentsOf: filtered)
220 } catch {
221 print("Error fetching \(sampleType): \(error.localizedDescription)")
222 }
223 }
224 return allSamples
225 }
226
227 func fetchRouteLocations(for workout: HKWorkout, in range: ClosedRange<Date>) async -> [CLLocation] {
228 let workoutPredicate = HKQuery.predicateForObjects(from: workout)
229 let routeType = HKSeriesType.workoutRoute()
230 let descriptor = HKSampleQueryDescriptor(
231 predicates: [.sample(type: routeType, predicate: workoutPredicate)],
232 sortDescriptors: [SortDescriptor(\HKWorkoutRoute.startDate, order: .forward)]
233 )
234
235 var allLocations: [CLLocation] = []
236 do {
237 let results = try await descriptor.result(for: healthStore)
238 let routes = results.compactMap { $0 as? HKWorkoutRoute }
239 for route in routes {
240 let locations = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CLLocation], Error>) in
241 var accumulated: [CLLocation] = []
242 let query = HKWorkoutRouteQuery(route: route) { _, locations, done, error in
243 if let error {
244 continuation.resume(throwing: error)
245 return
246 }
247 if let locations { accumulated.append(contentsOf: locations) }
248 if done { continuation.resume(returning: accumulated) }
249 }
250 self.healthStore.execute(query)
251 }
252 let filtered = locations.filter { range.contains($0.timestamp) }
253 allLocations.append(contentsOf: filtered)
254 }
255 } catch {
256 print("Error fetching route locations: \(error.localizedDescription)")
257 }
258 return allLocations
259 }
260
261 /// Fetch user-entered workout effort score by time range (not associated via predicateForObjects).
262 /// estimatedWorkoutEffortScore is Apple-computed and cannot be written by third-party apps.
263 @available(iOS 18.0, *)
264 private func fetchWorkoutEffortSample(in range: ClosedRange<Date>) async -> HKQuantitySample? {
265 let timePredicate = HKQuery.predicateForSamples(
266 withStart: range.lowerBound,
267 end: range.upperBound,
268 options: .strictStartDate
269 )
270 let effortType = HKQuantityType(.workoutEffortScore)
271 let descriptor = HKSampleQueryDescriptor(
272 predicates: [.quantitySample(type: effortType, predicate: timePredicate)],
273 sortDescriptors: [SortDescriptor(\HKQuantitySample.startDate, order: .forward)],
274 limit: 1
275 )
276 do {
277 let samples = try await descriptor.result(for: healthStore)
278 print("[Trim] workoutEffortScore query: \(samples.count) samples")
279 return samples.first
280 } catch {
281 print("[Trim] Error fetching workoutEffortScore: \(error.localizedDescription)")
282 return nil
283 }
284 }
285
286 struct TrimResult {
287 let trimmedWorkout: HKWorkout
288 let originalDeleted: Bool
289 let originalSource: String
290 }
291
292 func saveTrimmedWorkout(
293 original: HKWorkout,
294 activityType: HKWorkoutActivityType,
295 start: Date,
296 end: Date
297 ) async throws -> TrimResult {
298 let range = start...end
299 let originalSource = original.sourceRevision.source.name
300
301 // Fetch associated data
302 async let samplesTask = fetchAssociatedSamples(for: original, in: range)
303 async let locationsTask = fetchRouteLocations(for: original, in: range)
304 let (samples, locations) = await (samplesTask, locationsTask)
305
306 // Filter workout events to trimmed range
307 let filteredEvents = original.workoutEvents?.filter { event in
308 range.contains(event.dateInterval.start)
309 } ?? []
310
311 let metadata = original.metadata
312
313 // Build new workout, preserving location type from original
314 let configuration = HKWorkoutConfiguration()
315 configuration.activityType = activityType
316 if let originalConfig = original.workoutActivities.first?.workoutConfiguration {
317 configuration.locationType = originalConfig.locationType
318 configuration.swimmingLocationType = originalConfig.swimmingLocationType
319 configuration.lapLength = originalConfig.lapLength
320 } else if let isIndoor = metadata?[HKMetadataKeyIndoorWorkout] as? Bool {
321 configuration.locationType = isIndoor ? .indoor : .outdoor
322 }
323
324 print("[Trim] Activity: \(activityType.rawValue), locationType: \(configuration.locationType.rawValue)")
325 print("[Trim] Samples fetched: \(samples.count)")
326 let samplesByType = Dictionary(grouping: samples, by: { $0.quantityType.identifier })
327 for (type, typeSamples) in samplesByType {
328 print("[Trim] \(type): \(typeSamples.count) samples")
329 }
330 print("[Trim] Route locations: \(locations.count)")
331 print("[Trim] Events: \(filteredEvents.count)")
332 print("[Trim] Metadata keys: \(metadata?.keys.joined(separator: ", ") ?? "none")")
333
334 let builder = HKWorkoutBuilder(
335 healthStore: healthStore,
336 configuration: configuration,
337 device: original.device
338 )
339
340 try await builder.beginCollection(at: start)
341
342 // Add workout activities first so builder can associate samples with them
343 for activity in original.workoutActivities {
344 let activityStart = max(activity.startDate, start)
345 let activityEnd = min(activity.endDate ?? end, end)
346 guard activityStart < activityEnd else { continue }
347 do {
348 let newActivity = HKWorkoutActivity(
349 workoutConfiguration: activity.workoutConfiguration,
350 start: activityStart,
351 end: activityEnd,
352 metadata: activity.metadata
353 )
354 try await builder.addWorkoutActivity(newActivity)
355 print("[Trim] Added workout activity: \(activity.workoutConfiguration.activityType.rawValue)")
356 } catch {
357 print("[Trim] Could not copy workout activity: \(error.localizedDescription)")
358 }
359 }
360
361 // Recreate samples with new objects
362 if !samples.isEmpty {
363 let newSamples: [HKQuantitySample] = samples.map { s in
364 HKQuantitySample(
365 type: s.quantityType,
366 quantity: s.quantity,
367 start: s.startDate,
368 end: s.endDate,
369 device: s.device,
370 metadata: s.metadata
371 )
372 }
373 try await builder.addSamples(newSamples)
374 }
375
376 if !filteredEvents.isEmpty {
377 try await builder.addWorkoutEvents(filteredEvents)
378 }
379
380 if let metadata, !metadata.isEmpty {
381 try await builder.addMetadata(metadata)
382 }
383
384 try await builder.endCollection(at: end)
385 guard let newWorkout = try await builder.finishWorkout() else {
386 throw HealthKitError.workoutCreationFailed
387 }
388
389 // Build route if we have locations
390 if !locations.isEmpty {
391 let routeBuilder = HKWorkoutRouteBuilder(healthStore: healthStore, device: original.device)
392 try await routeBuilder.insertRouteData(locations)
393 try await routeBuilder.finishRoute(with: newWorkout, metadata: nil)
394 }
395
396 // Relate user-entered effort score via special API (iOS 18+)
397 if #available(iOS 18.0, *) {
398 if let originalEffort = await fetchWorkoutEffortSample(in: range) {
399 let newEffort = HKQuantitySample(
400 type: originalEffort.quantityType,
401 quantity: originalEffort.quantity,
402 start: originalEffort.startDate,
403 end: originalEffort.endDate,
404 device: originalEffort.device,
405 metadata: originalEffort.metadata
406 )
407 do {
408 try await healthStore.relateWorkoutEffortSample(newEffort, with: newWorkout, activity: nil)
409 print("[Trim] Related workoutEffortScore to new workout")
410 } catch {
411 print("[Trim] Error relating effort: \(error.localizedDescription)")
412 }
413 }
414 }
415
416 // Try to delete original — may fail for workouts from other apps
417 var deleted = false
418 do {
419 try await healthStore.delete(original)
420 deleted = true
421 } catch {
422 print("Could not delete original workout: \(error.localizedDescription)")
423 }
424
425 return TrimResult(trimmedWorkout: newWorkout, originalDeleted: deleted, originalSource: originalSource)
426 }
427
428 func saveWorkout(activityType: HKWorkoutActivityType, start: Date, end: Date, metadata: [String: Any]? = nil) async throws {
429 let configuration = HKWorkoutConfiguration()
430 configuration.activityType = activityType
431
432 let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: configuration, device: nil)
433 try await builder.beginCollection(at: start)
434 try await builder.endCollection(at: end)
435
436 if let metadata = metadata {
437 try await builder.addMetadata(metadata)
438 }
439
440 try await builder.finishWorkout()
441 }
442
443 func deleteWorkout(_ workout: HKWorkout) async throws {
444 try await healthStore.delete(workout)
445 }
446
447 #if DEBUG
448 func insertSampleWorkouts() async throws {
449 let activityTypes: [HKWorkoutActivityType] = [
450 .running, .cycling, .swimming, .hiking, .walking,
451 .yoga, .functionalStrengthTraining, .coreTraining,
452 .elliptical, .rowing,
453 ]
454 let durations: [TimeInterval] = [
455 20 * 60, 30 * 60, 45 * 60, 60 * 60, 75 * 60, 90 * 60, 120 * 60,
456 ]
457
458 // Generate 200 workouts spread over the past ~12 months
459 let now = Date()
460 let calendar = Calendar.current
461 for i in 0..<200 {
462 let daysAgo = Int(Double(i) * 1.8) + Int.random(in: 0...1)
463 let hour = [6, 7, 8, 12, 17, 18, 19][i % 7]
464 guard let day = calendar.date(byAdding: .day, value: -daysAgo, to: now),
465 let start = calendar.date(bySettingHour: hour, minute: Int.random(in: 0...59), second: 0, of: day)
466 else { continue }
467
468 let activityType = activityTypes[i % activityTypes.count]
469 let duration = durations[i % durations.count]
470 let end = start.addingTimeInterval(duration)
471
472 let configuration = HKWorkoutConfiguration()
473 configuration.activityType = activityType
474
475 let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: configuration, device: nil)
476 try await builder.beginCollection(at: start)
477 try await builder.endCollection(at: end)
478 try await builder.finishWorkout()
479 }
480 }
481
482 func deleteAllWorkouts() async throws {
483 let descriptor = HKSampleQueryDescriptor(
484 predicates: [.workout()],
485 sortDescriptors: [SortDescriptor(\HKWorkout.startDate, order: .reverse)]
486 )
487 let all = try await descriptor.result(for: healthStore)
488 for workout in all {
489 try await healthStore.delete(workout)
490 }
491 }
492 #endif
493}
494
495enum HealthKitError: LocalizedError {
496 case healthDataNotAvailable
497 case workoutCreationFailed
498
499 var errorDescription: String? {
500 switch self {
501 case .healthDataNotAvailable:
502 return "Health data is not available on this device."
503 case .workoutCreationFailed:
504 return "Failed to create the new workout."
505 }
506 }
507}