diff --git a/WorkoutEditor/HealthKitManager.swift b/WorkoutEditor/HealthKitManager.swift index 653830e..4d54617 100644 --- a/WorkoutEditor/HealthKitManager.swift +++ b/WorkoutEditor/HealthKitManager.swift @@ -1,3 +1,4 @@ +import CoreLocation import Foundation import HealthKit @@ -61,6 +62,10 @@ class HealthKitManager { let typesToWrite: Set = [ HKObjectType.workoutType(), HKQuantityType(.heartRate), + HKQuantityType(.activeEnergyBurned), + HKQuantityType(.distanceWalkingRunning), + HKQuantityType(.distanceCycling), + HKSeriesType.workoutRoute(), ] try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) @@ -156,6 +161,136 @@ class HealthKitManager { } } + private static let migratedSampleTypes: [HKQuantityType] = [ + HKQuantityType(.heartRate), + HKQuantityType(.activeEnergyBurned), + HKQuantityType(.distanceWalkingRunning), + HKQuantityType(.distanceCycling), + ] + + func fetchAssociatedSamples(for workout: HKWorkout, in range: ClosedRange) async -> [HKQuantitySample] { + var allSamples: [HKQuantitySample] = [] + let workoutPredicate = HKQuery.predicateForObjects(from: workout) + + for sampleType in Self.migratedSampleTypes { + let descriptor = HKSampleQueryDescriptor( + predicates: [.quantitySample(type: sampleType, predicate: workoutPredicate)], + sortDescriptors: [SortDescriptor(\HKQuantitySample.startDate, order: .forward)] + ) + do { + let results = try await descriptor.result(for: healthStore) + let filtered = results.filter { range.contains($0.startDate) } + allSamples.append(contentsOf: filtered) + } catch { + print("Error fetching \(sampleType): \(error.localizedDescription)") + } + } + return allSamples + } + + func fetchRouteLocations(for workout: HKWorkout, in range: ClosedRange) async -> [CLLocation] { + let workoutPredicate = HKQuery.predicateForObjects(from: workout) + let routeType = HKSeriesType.workoutRoute() + let descriptor = HKSampleQueryDescriptor( + predicates: [.sample(type: routeType, predicate: workoutPredicate)], + sortDescriptors: [SortDescriptor(\HKWorkoutRoute.startDate, order: .forward)] + ) + + var allLocations: [CLLocation] = [] + do { + let results = try await descriptor.result(for: healthStore) + let routes = results.compactMap { $0 as? HKWorkoutRoute } + for route in routes { + let locations = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CLLocation], Error>) in + var accumulated: [CLLocation] = [] + let query = HKWorkoutRouteQuery(route: route) { _, locations, done, error in + if let error { + continuation.resume(throwing: error) + return + } + if let locations { accumulated.append(contentsOf: locations) } + if done { continuation.resume(returning: accumulated) } + } + self.healthStore.execute(query) + } + let filtered = locations.filter { range.contains($0.timestamp) } + allLocations.append(contentsOf: filtered) + } + } catch { + print("Error fetching route locations: \(error.localizedDescription)") + } + return allLocations + } + + func saveTrimmedWorkout( + original: HKWorkout, + activityType: HKWorkoutActivityType, + start: Date, + end: Date + ) async throws { + let range = start...end + + // Fetch associated data in parallel + async let samplesTask = fetchAssociatedSamples(for: original, in: range) + async let locationsTask = fetchRouteLocations(for: original, in: range) + let (samples, locations) = await (samplesTask, locationsTask) + + // Filter workout events to trimmed range + let filteredEvents = original.workoutEvents?.filter { event in + range.contains(event.dateInterval.start) + } ?? [] + + // Build new workout + let configuration = HKWorkoutConfiguration() + configuration.activityType = activityType + + let builder = HKWorkoutBuilder( + healthStore: healthStore, + configuration: configuration, + device: original.device + ) + + try await builder.beginCollection(at: start) + + // Recreate samples with new objects + if !samples.isEmpty { + let newSamples: [HKQuantitySample] = samples.map { s in + HKQuantitySample( + type: s.quantityType, + quantity: s.quantity, + start: s.startDate, + end: s.endDate, + device: s.device, + metadata: s.metadata + ) + } + try await builder.addSamples(newSamples) + } + + if !filteredEvents.isEmpty { + try await builder.addWorkoutEvents(filteredEvents) + } + + if let metadata = original.metadata, !metadata.isEmpty { + try await builder.addMetadata(metadata) + } + + try await builder.endCollection(at: end) + guard let newWorkout = try await builder.finishWorkout() else { + throw HealthKitError.workoutCreationFailed + } + + // Build route if we have locations + if !locations.isEmpty { + let routeBuilder = HKWorkoutRouteBuilder(healthStore: healthStore, device: original.device) + try await routeBuilder.insertRouteData(locations) + try await routeBuilder.finishRoute(with: newWorkout, metadata: nil) + } + + // Delete original + try await healthStore.delete(original) + } + func saveWorkout(activityType: HKWorkoutActivityType, start: Date, end: Date, metadata: [String: Any]? = nil) async throws { let configuration = HKWorkoutConfiguration() configuration.activityType = activityType @@ -225,11 +360,14 @@ class HealthKitManager { enum HealthKitError: LocalizedError { case healthDataNotAvailable + case workoutCreationFailed var errorDescription: String? { switch self { case .healthDataNotAvailable: return "Health data is not available on this device." + case .workoutCreationFailed: + return "Failed to create the new workout." } } } diff --git a/WorkoutEditor/WorkoutEditView.swift b/WorkoutEditor/WorkoutEditView.swift index ba9efaf..db4197e 100644 --- a/WorkoutEditor/WorkoutEditView.swift +++ b/WorkoutEditor/WorkoutEditView.swift @@ -183,13 +183,12 @@ struct WorkoutEditView: View { private func saveChanges() { Task { do { - try await HealthKitManager.shared.saveWorkout( + try await HealthKitManager.shared.saveTrimmedWorkout( + original: workout, activityType: editedActivityType, start: trimmedStartDate, - end: trimmedEndDate, - metadata: workout.metadata as [String: Any]? + end: trimmedEndDate ) - try await HealthKitManager.shared.deleteWorkout(workout) await HealthKitManager.shared.loadWorkouts() dismiss() } catch {