Apple Fitness workout fixer + Strava uploader

Preserve workout data (samples, routes, events) when trimming

Previously trimming deleted the original and created a blank workout,
losing all heart rate, energy, distance samples, routes, and events.
Now saveTrimmedWorkout fetches associated data, recreates it on the
new workout, and builds routes before deleting the original.

+141 -4
+138
WorkoutEditor/HealthKitManager.swift
··· 1 + import CoreLocation 1 2 import Foundation 2 3 import HealthKit 3 4 ··· 61 62 let typesToWrite: Set<HKSampleType> = [ 62 63 HKObjectType.workoutType(), 63 64 HKQuantityType(.heartRate), 65 + HKQuantityType(.activeEnergyBurned), 66 + HKQuantityType(.distanceWalkingRunning), 67 + HKQuantityType(.distanceCycling), 68 + HKSeriesType.workoutRoute(), 64 69 ] 65 70 66 71 try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) ··· 156 161 } 157 162 } 158 163 164 + private static let migratedSampleTypes: [HKQuantityType] = [ 165 + HKQuantityType(.heartRate), 166 + HKQuantityType(.activeEnergyBurned), 167 + HKQuantityType(.distanceWalkingRunning), 168 + HKQuantityType(.distanceCycling), 169 + ] 170 + 171 + func fetchAssociatedSamples(for workout: HKWorkout, in range: ClosedRange<Date>) async -> [HKQuantitySample] { 172 + var allSamples: [HKQuantitySample] = [] 173 + let workoutPredicate = HKQuery.predicateForObjects(from: workout) 174 + 175 + for sampleType in Self.migratedSampleTypes { 176 + let descriptor = HKSampleQueryDescriptor( 177 + predicates: [.quantitySample(type: sampleType, predicate: workoutPredicate)], 178 + sortDescriptors: [SortDescriptor(\HKQuantitySample.startDate, order: .forward)] 179 + ) 180 + do { 181 + let results = try await descriptor.result(for: healthStore) 182 + let filtered = results.filter { range.contains($0.startDate) } 183 + allSamples.append(contentsOf: filtered) 184 + } catch { 185 + print("Error fetching \(sampleType): \(error.localizedDescription)") 186 + } 187 + } 188 + return allSamples 189 + } 190 + 191 + func fetchRouteLocations(for workout: HKWorkout, in range: ClosedRange<Date>) async -> [CLLocation] { 192 + let workoutPredicate = HKQuery.predicateForObjects(from: workout) 193 + let routeType = HKSeriesType.workoutRoute() 194 + let descriptor = HKSampleQueryDescriptor( 195 + predicates: [.sample(type: routeType, predicate: workoutPredicate)], 196 + sortDescriptors: [SortDescriptor(\HKWorkoutRoute.startDate, order: .forward)] 197 + ) 198 + 199 + var allLocations: [CLLocation] = [] 200 + do { 201 + let results = try await descriptor.result(for: healthStore) 202 + let routes = results.compactMap { $0 as? HKWorkoutRoute } 203 + for route in routes { 204 + let locations = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CLLocation], Error>) in 205 + var accumulated: [CLLocation] = [] 206 + let query = HKWorkoutRouteQuery(route: route) { _, locations, done, error in 207 + if let error { 208 + continuation.resume(throwing: error) 209 + return 210 + } 211 + if let locations { accumulated.append(contentsOf: locations) } 212 + if done { continuation.resume(returning: accumulated) } 213 + } 214 + self.healthStore.execute(query) 215 + } 216 + let filtered = locations.filter { range.contains($0.timestamp) } 217 + allLocations.append(contentsOf: filtered) 218 + } 219 + } catch { 220 + print("Error fetching route locations: \(error.localizedDescription)") 221 + } 222 + return allLocations 223 + } 224 + 225 + func saveTrimmedWorkout( 226 + original: HKWorkout, 227 + activityType: HKWorkoutActivityType, 228 + start: Date, 229 + end: Date 230 + ) async throws { 231 + let range = start...end 232 + 233 + // Fetch associated data in parallel 234 + async let samplesTask = fetchAssociatedSamples(for: original, in: range) 235 + async let locationsTask = fetchRouteLocations(for: original, in: range) 236 + let (samples, locations) = await (samplesTask, locationsTask) 237 + 238 + // Filter workout events to trimmed range 239 + let filteredEvents = original.workoutEvents?.filter { event in 240 + range.contains(event.dateInterval.start) 241 + } ?? [] 242 + 243 + // Build new workout 244 + let configuration = HKWorkoutConfiguration() 245 + configuration.activityType = activityType 246 + 247 + let builder = HKWorkoutBuilder( 248 + healthStore: healthStore, 249 + configuration: configuration, 250 + device: original.device 251 + ) 252 + 253 + try await builder.beginCollection(at: start) 254 + 255 + // Recreate samples with new objects 256 + if !samples.isEmpty { 257 + let newSamples: [HKQuantitySample] = samples.map { s in 258 + HKQuantitySample( 259 + type: s.quantityType, 260 + quantity: s.quantity, 261 + start: s.startDate, 262 + end: s.endDate, 263 + device: s.device, 264 + metadata: s.metadata 265 + ) 266 + } 267 + try await builder.addSamples(newSamples) 268 + } 269 + 270 + if !filteredEvents.isEmpty { 271 + try await builder.addWorkoutEvents(filteredEvents) 272 + } 273 + 274 + if let metadata = original.metadata, !metadata.isEmpty { 275 + try await builder.addMetadata(metadata) 276 + } 277 + 278 + try await builder.endCollection(at: end) 279 + guard let newWorkout = try await builder.finishWorkout() else { 280 + throw HealthKitError.workoutCreationFailed 281 + } 282 + 283 + // Build route if we have locations 284 + if !locations.isEmpty { 285 + let routeBuilder = HKWorkoutRouteBuilder(healthStore: healthStore, device: original.device) 286 + try await routeBuilder.insertRouteData(locations) 287 + try await routeBuilder.finishRoute(with: newWorkout, metadata: nil) 288 + } 289 + 290 + // Delete original 291 + try await healthStore.delete(original) 292 + } 293 + 159 294 func saveWorkout(activityType: HKWorkoutActivityType, start: Date, end: Date, metadata: [String: Any]? = nil) async throws { 160 295 let configuration = HKWorkoutConfiguration() 161 296 configuration.activityType = activityType ··· 225 360 226 361 enum HealthKitError: LocalizedError { 227 362 case healthDataNotAvailable 363 + case workoutCreationFailed 228 364 229 365 var errorDescription: String? { 230 366 switch self { 231 367 case .healthDataNotAvailable: 232 368 return "Health data is not available on this device." 369 + case .workoutCreationFailed: 370 + return "Failed to create the new workout." 233 371 } 234 372 } 235 373 }
+3 -4
WorkoutEditor/WorkoutEditView.swift
··· 183 183 private func saveChanges() { 184 184 Task { 185 185 do { 186 - try await HealthKitManager.shared.saveWorkout( 186 + try await HealthKitManager.shared.saveTrimmedWorkout( 187 + original: workout, 187 188 activityType: editedActivityType, 188 189 start: trimmedStartDate, 189 - end: trimmedEndDate, 190 - metadata: workout.metadata as [String: Any]? 190 + end: trimmedEndDate 191 191 ) 192 - try await HealthKitManager.shared.deleteWorkout(workout) 193 192 await HealthKitManager.shared.loadWorkouts() 194 193 dismiss() 195 194 } catch {