import CoreLocation import Foundation import HealthKit struct IntensitySample: Identifiable { let id = UUID() let date: Date let value: Double } enum IntensityMetric { case heartRate case activeEnergy case distance var unit: HKUnit { switch self { case .heartRate: .count().unitDivided(by: .minute()) case .activeEnergy: .kilocalorie() case .distance: .meter() } } var label: String { switch self { case .heartRate: "BPM" case .activeEnergy: "kcal" case .distance: "m" } } var quantityType: HKQuantityType { switch self { case .heartRate: HKQuantityType(.heartRate) case .activeEnergy: HKQuantityType(.activeEnergyBurned) case .distance: HKQuantityType(.distanceWalkingRunning) } } } @Observable class HealthKitManager { static let shared = HealthKitManager() var loadedWorkouts: [HKWorkout] = [] var canLoadMore = false private let healthStore = HKHealthStore() private let pageSize = 50 private var observerQuery: HKObserverQuery? func startObservingWorkouts() { guard observerQuery == nil else { return } let query = HKObserverQuery(sampleType: HKObjectType.workoutType(), predicate: nil) { [weak self] _, _, _ in guard let self else { return } Task { await self.loadWorkouts() } } healthStore.execute(query) observerQuery = query } func requestAuthorization() async throws { guard HKHealthStore.isHealthDataAvailable() else { throw HealthKitError.healthDataNotAvailable } var sampleTypes: Set = [ HKObjectType.workoutType(), HKQuantityType(.heartRate), HKQuantityType(.activeEnergyBurned), HKQuantityType(.basalEnergyBurned), HKQuantityType(.distanceWalkingRunning), HKQuantityType(.distanceCycling), HKQuantityType(.stepCount), HKQuantityType(.runningSpeed), HKQuantityType(.runningPower), HKQuantityType(.runningStrideLength), HKQuantityType(.runningVerticalOscillation), HKQuantityType(.runningGroundContactTime), HKQuantityType(.vo2Max), HKQuantityType(.physicalEffort), HKSeriesType.workoutRoute(), ] if #available(iOS 18.0, *) { sampleTypes.insert(HKQuantityType(.workoutEffortScore)) sampleTypes.insert(HKQuantityType(.estimatedWorkoutEffortScore)) } let typesToRead: Set = sampleTypes as Set let typesToWrite: Set = sampleTypes try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) } func loadWorkouts() async { let sortDescriptor = SortDescriptor(\HKWorkout.startDate, order: .reverse) let descriptor = HKSampleQueryDescriptor( predicates: [.workout()], sortDescriptors: [sortDescriptor], limit: pageSize ) do { let results = try await descriptor.result(for: healthStore) await MainActor.run { self.loadedWorkouts = results self.canLoadMore = results.count == pageSize } } catch { print("Error loading workouts: \(error.localizedDescription)") } } func loadMoreWorkouts() async { guard canLoadMore, let oldest = loadedWorkouts.last else { return } let predicate = HKQuery.predicateForSamples( withStart: nil, end: oldest.startDate, options: .strictEndDate ) let sortDescriptor = SortDescriptor(\HKWorkout.startDate, order: .reverse) let descriptor = HKSampleQueryDescriptor( predicates: [.workout(predicate)], sortDescriptors: [sortDescriptor], limit: pageSize ) do { let results = try await descriptor.result(for: healthStore) await MainActor.run { self.loadedWorkouts.append(contentsOf: results) self.canLoadMore = results.count == pageSize } } catch { print("Error loading more workouts: \(error.localizedDescription)") } } func fetchIntensitySamples(for workout: HKWorkout) async -> (samples: [IntensitySample], metric: IntensityMetric) { let metrics: [IntensityMetric] = [.heartRate, .activeEnergy, .distance] for metric in metrics { let predicate = HKQuery.predicateForSamples( withStart: workout.startDate, end: workout.endDate, options: .strictStartDate ) let sortDescriptor = SortDescriptor(\HKQuantitySample.startDate, order: .forward) let descriptor = HKSampleQueryDescriptor( predicates: [.quantitySample(type: metric.quantityType, predicate: predicate)], sortDescriptors: [sortDescriptor], limit: 5000 ) do { let results = try await descriptor.result(for: healthStore) if !results.isEmpty { var samples = results.map { sample in IntensitySample( date: sample.startDate, value: sample.quantity.doubleValue(for: metric.unit) ) } if samples.count > 500 { samples = downsample(samples, to: 500) } return (samples, metric) } } catch { print("Error fetching \(metric.label): \(error.localizedDescription)") } } return ([], .heartRate) } private func downsample(_ samples: [IntensitySample], to target: Int) -> [IntensitySample] { let stride = Double(samples.count) / Double(target) return (0..) 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 } /// Fetch user-entered workout effort score by time range (not associated via predicateForObjects). /// estimatedWorkoutEffortScore is Apple-computed and cannot be written by third-party apps. @available(iOS 18.0, *) private func fetchWorkoutEffortSample(in range: ClosedRange) async -> HKQuantitySample? { let timePredicate = HKQuery.predicateForSamples( withStart: range.lowerBound, end: range.upperBound, options: .strictStartDate ) let effortType = HKQuantityType(.workoutEffortScore) let descriptor = HKSampleQueryDescriptor( predicates: [.quantitySample(type: effortType, predicate: timePredicate)], sortDescriptors: [SortDescriptor(\HKQuantitySample.startDate, order: .forward)], limit: 1 ) do { let samples = try await descriptor.result(for: healthStore) print("[Trim] workoutEffortScore query: \(samples.count) samples") return samples.first } catch { print("[Trim] Error fetching workoutEffortScore: \(error.localizedDescription)") return nil } } struct TrimResult { let trimmedWorkout: HKWorkout let originalDeleted: Bool let originalSource: String } func saveTrimmedWorkout( original: HKWorkout, activityType: HKWorkoutActivityType, start: Date, end: Date ) async throws -> TrimResult { let range = start...end let originalSource = original.sourceRevision.source.name // Fetch associated data 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) } ?? [] let metadata = original.metadata // Build new workout, preserving location type from original let configuration = HKWorkoutConfiguration() configuration.activityType = activityType if let originalConfig = original.workoutActivities.first?.workoutConfiguration { configuration.locationType = originalConfig.locationType configuration.swimmingLocationType = originalConfig.swimmingLocationType configuration.lapLength = originalConfig.lapLength } else if let isIndoor = metadata?[HKMetadataKeyIndoorWorkout] as? Bool { configuration.locationType = isIndoor ? .indoor : .outdoor } print("[Trim] Activity: \(activityType.rawValue), locationType: \(configuration.locationType.rawValue)") print("[Trim] Samples fetched: \(samples.count)") let samplesByType = Dictionary(grouping: samples, by: { $0.quantityType.identifier }) for (type, typeSamples) in samplesByType { print("[Trim] \(type): \(typeSamples.count) samples") } print("[Trim] Route locations: \(locations.count)") print("[Trim] Events: \(filteredEvents.count)") print("[Trim] Metadata keys: \(metadata?.keys.joined(separator: ", ") ?? "none")") let builder = HKWorkoutBuilder( healthStore: healthStore, configuration: configuration, device: original.device ) try await builder.beginCollection(at: start) // Add workout activities first so builder can associate samples with them for activity in original.workoutActivities { let activityStart = max(activity.startDate, start) let activityEnd = min(activity.endDate ?? end, end) guard activityStart < activityEnd else { continue } do { let newActivity = HKWorkoutActivity( workoutConfiguration: activity.workoutConfiguration, start: activityStart, end: activityEnd, metadata: activity.metadata ) try await builder.addWorkoutActivity(newActivity) print("[Trim] Added workout activity: \(activity.workoutConfiguration.activityType.rawValue)") } catch { print("[Trim] Could not copy workout activity: \(error.localizedDescription)") } } // 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, !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) } // Relate user-entered effort score via special API (iOS 18+) if #available(iOS 18.0, *) { if let originalEffort = await fetchWorkoutEffortSample(in: range) { let newEffort = HKQuantitySample( type: originalEffort.quantityType, quantity: originalEffort.quantity, start: originalEffort.startDate, end: originalEffort.endDate, device: originalEffort.device, metadata: originalEffort.metadata ) do { try await healthStore.relateWorkoutEffortSample(newEffort, with: newWorkout, activity: nil) print("[Trim] Related workoutEffortScore to new workout") } catch { print("[Trim] Error relating effort: \(error.localizedDescription)") } } } // Try to delete original — may fail for workouts from other apps var deleted = false do { try await healthStore.delete(original) deleted = true } catch { print("Could not delete original workout: \(error.localizedDescription)") } return TrimResult(trimmedWorkout: newWorkout, originalDeleted: deleted, originalSource: originalSource) } func saveWorkout(activityType: HKWorkoutActivityType, start: Date, end: Date, metadata: [String: Any]? = nil) async throws { let configuration = HKWorkoutConfiguration() configuration.activityType = activityType let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: configuration, device: nil) try await builder.beginCollection(at: start) try await builder.endCollection(at: end) if let metadata = metadata { try await builder.addMetadata(metadata) } try await builder.finishWorkout() } func deleteWorkout(_ workout: HKWorkout) async throws { try await healthStore.delete(workout) } #if DEBUG func insertSampleWorkouts() async throws { let activityTypes: [HKWorkoutActivityType] = [ .running, .cycling, .swimming, .hiking, .walking, .yoga, .functionalStrengthTraining, .coreTraining, .elliptical, .rowing, ] let durations: [TimeInterval] = [ 20 * 60, 30 * 60, 45 * 60, 60 * 60, 75 * 60, 90 * 60, 120 * 60, ] // Generate 200 workouts spread over the past ~12 months let now = Date() let calendar = Calendar.current for i in 0..<200 { let daysAgo = Int(Double(i) * 1.8) + Int.random(in: 0...1) let hour = [6, 7, 8, 12, 17, 18, 19][i % 7] guard let day = calendar.date(byAdding: .day, value: -daysAgo, to: now), let start = calendar.date(bySettingHour: hour, minute: Int.random(in: 0...59), second: 0, of: day) else { continue } let activityType = activityTypes[i % activityTypes.count] let duration = durations[i % durations.count] let end = start.addingTimeInterval(duration) let configuration = HKWorkoutConfiguration() configuration.activityType = activityType let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: configuration, device: nil) try await builder.beginCollection(at: start) try await builder.endCollection(at: end) try await builder.finishWorkout() } } func deleteAllWorkouts() async throws { let descriptor = HKSampleQueryDescriptor( predicates: [.workout()], sortDescriptors: [SortDescriptor(\HKWorkout.startDate, order: .reverse)] ) let all = try await descriptor.result(for: healthStore) for workout in all { try await healthStore.delete(workout) } } #endif } 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." } } }