Apple Fitness workout fixer + Strava uploader

Add Strava upload, workout activity copying, and platform limitations docs

- Add StravaManager with OAuth login, TCX generation, and upload via
Strava API (client ID/secret are placeholders)
- Add Strava connect/upload/disconnect UI to workout edit view
- Copy HKWorkoutActivity objects during trim so Apple Fitness can
display splits
- Expand authorization and migrated sample types (running dynamics,
effort scores, basal energy, step count, VO2 max)
- Fetch effort scores by time range and relate via special iOS 18 API
- Document all HealthKit and Strava platform limitations in README

+802 -30
+31 -12
README.md
··· 25 25 - **Activity type picker** with 20 supported workout types 26 26 - **Delete workout** with confirmation 27 27 28 - ### Data Warning 28 + ### Data-Preserving Trim 29 + When you trim a workout, Overrun creates a new workout and migrates all associated data within the trimmed time range: 30 + 31 + - **Heart rate, active/basal energy, distance, step count** samples 32 + - **Running dynamics** — speed, power, stride length, vertical oscillation, ground contact time 33 + - **VO2 max** and **physical effort** samples 34 + - **Workout effort scores** (iOS 18+, user-entered only) 35 + - **Workout routes** (GPS/location data) 36 + - **Workout events** (laps, segments, etc.) 37 + - **Metadata** (indoor/outdoor flag, weather, timezone, METs, etc.) 38 + - **Configuration** — activity type, indoor/outdoor location type, swimming config 39 + 40 + If the original workout was created by our app, it is automatically deleted after the copy is saved. If it was created by another app (e.g., Apple Watch), you'll be prompted to delete it manually in Apple Fitness. 41 + 42 + ## Known Limitations 43 + 44 + ### HealthKit / Apple Platform 45 + 46 + - **Cannot modify workouts in place.** HealthKit provides no API to change a workout's start/end dates, samples, or metadata. Trimming must create a new workout and delete the original. 47 + - **Cannot delete workouts from other apps.** HealthKit only allows an app to delete objects it created. Workouts from Apple Watch, Fitness+, or other apps must be deleted manually by the user (e.g., swipe left in Apple Fitness and choose "Delete Workout & Data"). 48 + - **Estimated workout effort is not copyable.** `estimatedWorkoutEffortScore` is computed by Apple's algorithms and cannot be written by third-party apps. It may or may not be recomputed by the system for the new workout. User-entered effort scores (`workoutEffortScore`, iOS 18+) are migrated via `relateWorkoutEffortSample`. 49 + - **Physical effort samples are system-generated.** `physicalEffort` samples are computed by the OS and cannot be created or associated by third-party apps. 50 + - **Activity icons in Apple Fitness are source-dependent.** Even though we correctly set `locationType` (e.g., `.indoor`) and carry over `HKMetadataKeyIndoorWorkout`, Apple Fitness may display a generic icon for workouts from third-party sources instead of the specialized icon (e.g., indoor run icon) shown for Apple Watch workouts. 51 + - **Apple Fitness may crash when sharing copied workouts.** This is a known Apple bug where Fitness crashes when trying to view interval details or share workouts created by third-party apps via `HKWorkoutBuilder`. This is not specific to Overrun. 52 + - **No deep link to specific workouts.** Apple provides no URL scheme to open a specific workout in Fitness or Health. `activitytoday://` opens Fitness to the activity rings view only. 53 + - **Running dynamics may not be present.** Running speed, power, stride length, vertical oscillation, and ground contact time are only recorded by Apple Watch Series 6+ / Ultra with watchOS 9+. Older devices or non-running workouts will not have these samples. 54 + 55 + ### Strava 29 56 30 - > **Trimming a workout is destructive and irreversible.** 31 - > 32 - > HealthKit does not allow modifying a workout's start/end dates in place. When you trim a workout, Overrun **deletes the original workout and creates a new one** with the trimmed time range. This means: 33 - > 34 - > - **Heart rate, energy, and distance samples** become orphaned — they remain in HealthKit but are no longer associated with any workout. The new trimmed workout has no linked samples. 35 - > - **Workout routes** (GPS/location data) are permanently lost. 36 - > - **Workout events** are permanently lost. 37 - > - **Device association** is lost. 38 - > - Only **metadata** (custom name, notes, etc.) is preserved. 39 - > 40 - > The app shows a confirmation dialog before saving. Make sure you want to proceed. 57 + - **Strava only imports workouts from Apple's native Workout app.** Strava checks the `sourceRevision` bundle identifier on each HealthKit workout and only accepts workouts from Apple's Workout app and Apple Fitness+. Workouts written by any third-party app (including Overrun) are intentionally excluded from Strava's HealthKit import list, regardless of how complete the data is. 58 + - **This is a Strava policy, not a data issue.** The workout data we write is fully valid and appears correctly in Apple Health and Apple Fitness. 59 + - **Workarounds:** Export as .FIT/.TCX/.GPX and upload to Strava via file upload, use bridge apps like HealthFit or RunGap, or use direct Strava API integration. 41 60 42 61 ## Requirements 43 62
+4
WorkoutEditor.xcodeproj/project.pbxproj
··· 15 15 06E7DFC72B3654D50025260F /* WorkoutEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E7DFC62B3654D50025260F /* WorkoutEditView.swift */; }; 16 16 AA000001AAAA000100000001 /* ActivityGraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000001AAAA000100000002 /* ActivityGraphView.swift */; }; 17 17 AA000002AAAA000200000001 /* RangeSliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002AAAA000200000002 /* RangeSliderView.swift */; }; 18 + AA000003AAAA000300000001 /* StravaManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000003AAAA000300000002 /* StravaManager.swift */; }; 18 19 /* End PBXBuildFile section */ 19 20 20 21 /* Begin PBXFileReference section */ ··· 30 31 06E7DFC82B3668D10025260F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 31 32 AA000001AAAA000100000002 /* ActivityGraphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityGraphView.swift; sourceTree = "<group>"; }; 32 33 AA000002AAAA000200000002 /* RangeSliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeSliderView.swift; sourceTree = "<group>"; }; 34 + AA000003AAAA000300000002 /* StravaManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StravaManager.swift; sourceTree = "<group>"; }; 33 35 /* End PBXFileReference section */ 34 36 35 37 /* Begin PBXFrameworksBuildPhase section */ ··· 79 81 AA000002AAAA000200000002 /* RangeSliderView.swift */, 80 82 06E7DFC42B3654500025260F /* WorkoutListView.swift */, 81 83 06E7DFC22B3653F70025260F /* HealthKitManager.swift */, 84 + AA000003AAAA000300000002 /* StravaManager.swift */, 82 85 06E7DFAF2B3608E00025260F /* Assets.xcassets */, 83 86 06E7DFB12B3608E00025260F /* Preview Content */, 84 87 ); ··· 169 172 06E7DFC72B3654D50025260F /* WorkoutEditView.swift in Sources */, 170 173 AA000001AAAA000100000001 /* ActivityGraphView.swift in Sources */, 171 174 AA000002AAAA000200000001 /* RangeSliderView.swift in Sources */, 175 + AA000003AAAA000300000001 /* StravaManager.swift in Sources */, 172 176 ); 173 177 runOnlyForDeploymentPostprocessing = 0; 174 178 };
+123 -15
WorkoutEditor/HealthKitManager.swift
··· 52 52 throw HealthKitError.healthDataNotAvailable 53 53 } 54 54 55 - let typesToRead: Set<HKObjectType> = [ 55 + var sampleTypes: Set<HKSampleType> = [ 56 56 HKObjectType.workoutType(), 57 57 HKQuantityType(.heartRate), 58 58 HKQuantityType(.activeEnergyBurned), 59 + HKQuantityType(.basalEnergyBurned), 59 60 HKQuantityType(.distanceWalkingRunning), 60 61 HKQuantityType(.distanceCycling), 61 - ] 62 - let typesToWrite: Set<HKSampleType> = [ 63 - HKObjectType.workoutType(), 64 - HKQuantityType(.heartRate), 65 - HKQuantityType(.activeEnergyBurned), 66 - HKQuantityType(.distanceWalkingRunning), 67 - HKQuantityType(.distanceCycling), 62 + HKQuantityType(.stepCount), 63 + HKQuantityType(.runningSpeed), 64 + HKQuantityType(.runningPower), 65 + HKQuantityType(.runningStrideLength), 66 + HKQuantityType(.runningVerticalOscillation), 67 + HKQuantityType(.runningGroundContactTime), 68 + HKQuantityType(.vo2Max), 69 + HKQuantityType(.physicalEffort), 68 70 HKSeriesType.workoutRoute(), 69 71 ] 72 + if #available(iOS 18.0, *) { 73 + sampleTypes.insert(HKQuantityType(.workoutEffortScore)) 74 + sampleTypes.insert(HKQuantityType(.estimatedWorkoutEffortScore)) 75 + } 76 + 77 + let typesToRead: Set<HKObjectType> = sampleTypes as Set<HKObjectType> 78 + let typesToWrite: Set<HKSampleType> = sampleTypes 70 79 71 80 try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) 72 81 } ··· 161 170 } 162 171 } 163 172 164 - private static let migratedSampleTypes: [HKQuantityType] = [ 165 - HKQuantityType(.heartRate), 166 - HKQuantityType(.activeEnergyBurned), 167 - HKQuantityType(.distanceWalkingRunning), 168 - HKQuantityType(.distanceCycling), 169 - ] 173 + private static var migratedSampleTypes: [HKQuantityType] { 174 + var types: [HKQuantityType] = [ 175 + HKQuantityType(.heartRate), 176 + HKQuantityType(.activeEnergyBurned), 177 + HKQuantityType(.basalEnergyBurned), 178 + HKQuantityType(.distanceWalkingRunning), 179 + HKQuantityType(.distanceCycling), 180 + HKQuantityType(.stepCount), 181 + HKQuantityType(.runningSpeed), 182 + HKQuantityType(.runningPower), 183 + HKQuantityType(.runningStrideLength), 184 + HKQuantityType(.runningVerticalOscillation), 185 + HKQuantityType(.runningGroundContactTime), 186 + HKQuantityType(.vo2Max), 187 + HKQuantityType(.physicalEffort), 188 + ] 189 + if #available(iOS 18.0, *) { 190 + types.append(HKQuantityType(.workoutEffortScore)) 191 + types.append(HKQuantityType(.estimatedWorkoutEffortScore)) 192 + } 193 + return types 194 + } 170 195 171 196 func fetchAssociatedSamples(for workout: HKWorkout, in range: ClosedRange<Date>) async -> [HKQuantitySample] { 172 197 var allSamples: [HKQuantitySample] = [] ··· 222 247 return allLocations 223 248 } 224 249 250 + /// Fetch user-entered workout effort score by time range (not associated via predicateForObjects). 251 + /// estimatedWorkoutEffortScore is Apple-computed and cannot be written by third-party apps. 252 + @available(iOS 18.0, *) 253 + private func fetchWorkoutEffortSample(in range: ClosedRange<Date>) async -> HKQuantitySample? { 254 + let timePredicate = HKQuery.predicateForSamples( 255 + withStart: range.lowerBound, 256 + end: range.upperBound, 257 + options: .strictStartDate 258 + ) 259 + let effortType = HKQuantityType(.workoutEffortScore) 260 + let descriptor = HKSampleQueryDescriptor( 261 + predicates: [.quantitySample(type: effortType, predicate: timePredicate)], 262 + sortDescriptors: [SortDescriptor(\HKQuantitySample.startDate, order: .forward)], 263 + limit: 1 264 + ) 265 + do { 266 + let samples = try await descriptor.result(for: healthStore) 267 + print("[Trim] workoutEffortScore query: \(samples.count) samples") 268 + return samples.first 269 + } catch { 270 + print("[Trim] Error fetching workoutEffortScore: \(error.localizedDescription)") 271 + return nil 272 + } 273 + } 274 + 225 275 struct TrimResult { 226 276 let originalDeleted: Bool 227 277 let originalSource: String ··· 248 298 249 299 let metadata = original.metadata 250 300 251 - // Build new workout 301 + // Build new workout, preserving location type from original 252 302 let configuration = HKWorkoutConfiguration() 253 303 configuration.activityType = activityType 304 + if let originalConfig = original.workoutActivities.first?.workoutConfiguration { 305 + configuration.locationType = originalConfig.locationType 306 + configuration.swimmingLocationType = originalConfig.swimmingLocationType 307 + configuration.lapLength = originalConfig.lapLength 308 + } else if let isIndoor = metadata?[HKMetadataKeyIndoorWorkout] as? Bool { 309 + configuration.locationType = isIndoor ? .indoor : .outdoor 310 + } 311 + 312 + print("[Trim] Activity: \(activityType.rawValue), locationType: \(configuration.locationType.rawValue)") 313 + print("[Trim] Samples fetched: \(samples.count)") 314 + let samplesByType = Dictionary(grouping: samples, by: { $0.quantityType.identifier }) 315 + for (type, typeSamples) in samplesByType { 316 + print("[Trim] \(type): \(typeSamples.count) samples") 317 + } 318 + print("[Trim] Route locations: \(locations.count)") 319 + print("[Trim] Events: \(filteredEvents.count)") 320 + print("[Trim] Metadata keys: \(metadata?.keys.joined(separator: ", ") ?? "none")") 254 321 255 322 let builder = HKWorkoutBuilder( 256 323 healthStore: healthStore, ··· 279 346 try await builder.addWorkoutEvents(filteredEvents) 280 347 } 281 348 349 + // Copy workout activities (needed for splits display in Apple Fitness) 350 + for activity in original.workoutActivities { 351 + let activityStart = max(activity.startDate, start) 352 + let activityEnd: Date 353 + if let end = activity.endDate { 354 + activityEnd = min(end, end) 355 + } else { 356 + activityEnd = end 357 + } 358 + if activityStart < activityEnd { 359 + let newActivity = HKWorkoutActivity( 360 + workoutConfiguration: activity.workoutConfiguration, 361 + start: activityStart, 362 + end: activityEnd, 363 + metadata: activity.metadata 364 + ) 365 + try await builder.addWorkoutActivity(newActivity) 366 + print("[Trim] Added workout activity: \(activity.workoutConfiguration.activityType.rawValue)") 367 + } 368 + } 369 + 282 370 if let metadata, !metadata.isEmpty { 283 371 try await builder.addMetadata(metadata) 284 372 } ··· 293 381 let routeBuilder = HKWorkoutRouteBuilder(healthStore: healthStore, device: original.device) 294 382 try await routeBuilder.insertRouteData(locations) 295 383 try await routeBuilder.finishRoute(with: newWorkout, metadata: nil) 384 + } 385 + 386 + // Relate user-entered effort score via special API (iOS 18+) 387 + if #available(iOS 18.0, *) { 388 + if let originalEffort = await fetchWorkoutEffortSample(in: range) { 389 + let newEffort = HKQuantitySample( 390 + type: originalEffort.quantityType, 391 + quantity: originalEffort.quantity, 392 + start: originalEffort.startDate, 393 + end: originalEffort.endDate, 394 + device: originalEffort.device, 395 + metadata: originalEffort.metadata 396 + ) 397 + do { 398 + try await healthStore.relateWorkoutEffortSample(newEffort, with: newWorkout, activity: nil) 399 + print("[Trim] Related workoutEffortScore to new workout") 400 + } catch { 401 + print("[Trim] Error relating effort: \(error.localizedDescription)") 402 + } 403 + } 296 404 } 297 405 298 406 // Try to delete original — may fail for workouts from other apps
+567
WorkoutEditor/StravaManager.swift
··· 1 + // 2 + // StravaManager.swift 3 + // WorkoutEditor 4 + // 5 + 6 + import AuthenticationServices 7 + import CoreLocation 8 + import Foundation 9 + import HealthKit 10 + import UIKit 11 + 12 + // MARK: - Configuration 13 + 14 + enum StravaConfig { 15 + static let clientID = "YOUR_CLIENT_ID" 16 + static let clientSecret = "YOUR_CLIENT_SECRET" 17 + static let redirectURI = "overrun://strava/callback" 18 + static let authorizeURL = "https://www.strava.com/oauth/mobile/authorize" 19 + static let tokenURL = "https://www.strava.com/oauth/token" 20 + static let uploadURL = "https://www.strava.com/api/v3/uploads" 21 + } 22 + 23 + // MARK: - Keychain Helper 24 + 25 + private enum KeychainHelper { 26 + static func save(_ data: Data, forKey key: String) { 27 + let query: [String: Any] = [ 28 + kSecClass as String: kSecClassGenericPassword, 29 + kSecAttrAccount as String: key, 30 + kSecAttrService as String: "com.overrun.strava" 31 + ] 32 + SecItemDelete(query as CFDictionary) 33 + var addQuery = query 34 + addQuery[kSecValueData as String] = data 35 + SecItemAdd(addQuery as CFDictionary, nil) 36 + } 37 + 38 + static func load(forKey key: String) -> Data? { 39 + let query: [String: Any] = [ 40 + kSecClass as String: kSecClassGenericPassword, 41 + kSecAttrAccount as String: key, 42 + kSecAttrService as String: "com.overrun.strava", 43 + kSecReturnData as String: true, 44 + kSecMatchLimit as String: kSecMatchLimitOne 45 + ] 46 + var result: AnyObject? 47 + SecItemCopyMatching(query as CFDictionary, &result) 48 + return result as? Data 49 + } 50 + 51 + static func delete(forKey key: String) { 52 + let query: [String: Any] = [ 53 + kSecClass as String: kSecClassGenericPassword, 54 + kSecAttrAccount as String: key, 55 + kSecAttrService as String: "com.overrun.strava" 56 + ] 57 + SecItemDelete(query as CFDictionary) 58 + } 59 + } 60 + 61 + // MARK: - Token Storage 62 + 63 + private struct StravaTokens: Codable { 64 + let accessToken: String 65 + let refreshToken: String 66 + let expiresAt: Int 67 + } 68 + 69 + // MARK: - Upload Status 70 + 71 + enum StravaUploadStatus: Equatable { 72 + case idle 73 + case uploading 74 + case processing 75 + case success(activityID: Int64) 76 + case error(String) 77 + } 78 + 79 + // MARK: - StravaManager 80 + 81 + @MainActor 82 + class StravaManager: ObservableObject { 83 + static let shared = StravaManager() 84 + 85 + @Published var isAuthenticated = false 86 + @Published var uploadStatus: StravaUploadStatus = .idle 87 + 88 + private let keychainKey = "strava_tokens" 89 + 90 + init() { 91 + isAuthenticated = loadTokens() != nil 92 + } 93 + 94 + // MARK: - OAuth 95 + 96 + func authenticate() { 97 + var components = URLComponents(string: StravaConfig.authorizeURL)! 98 + components.queryItems = [ 99 + URLQueryItem(name: "client_id", value: StravaConfig.clientID), 100 + URLQueryItem(name: "redirect_uri", value: StravaConfig.redirectURI), 101 + URLQueryItem(name: "response_type", value: "code"), 102 + URLQueryItem(name: "approval_prompt", value: "auto"), 103 + URLQueryItem(name: "scope", value: "activity:write,activity:read_all") 104 + ] 105 + 106 + guard let url = components.url else { return } 107 + 108 + let session = ASWebAuthenticationSession( 109 + url: url, 110 + callbackURLScheme: "overrun" 111 + ) { [weak self] callbackURL, error in 112 + Task { @MainActor in 113 + guard let self, let callbackURL, error == nil else { return } 114 + guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)? 115 + .queryItems?.first(where: { $0.name == "code" })?.value else { return } 116 + await self.exchangeToken(code: code) 117 + } 118 + } 119 + session.prefersEphemeralWebBrowserSession = false 120 + 121 + // Find the current window scene for the presentation context 122 + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, 123 + let window = windowScene.windows.first { 124 + let provider = WebAuthPresentationContext(anchor: window) 125 + session.presentationContextProvider = provider 126 + // Store the session and provider to prevent deallocation 127 + self.activeAuthSession = session 128 + self.authContextProvider = provider 129 + } 130 + 131 + session.start() 132 + } 133 + 134 + private var activeAuthSession: ASWebAuthenticationSession? 135 + private var authContextProvider: WebAuthPresentationContext? 136 + 137 + private func exchangeToken(code: String) async { 138 + guard let url = URL(string: StravaConfig.tokenURL) else { return } 139 + 140 + var request = URLRequest(url: url) 141 + request.httpMethod = "POST" 142 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") 143 + 144 + let body: [String: String] = [ 145 + "client_id": StravaConfig.clientID, 146 + "client_secret": StravaConfig.clientSecret, 147 + "code": code, 148 + "grant_type": "authorization_code" 149 + ] 150 + request.httpBody = try? JSONSerialization.data(withJSONObject: body) 151 + 152 + do { 153 + let (data, _) = try await URLSession.shared.data(for: request) 154 + try saveTokensFromResponse(data) 155 + isAuthenticated = true 156 + } catch { 157 + print("Token exchange failed: \(error)") 158 + } 159 + 160 + activeAuthSession = nil 161 + authContextProvider = nil 162 + } 163 + 164 + private func refreshAccessToken() async -> Bool { 165 + guard let tokens = loadTokens() else { return false } 166 + guard let url = URL(string: StravaConfig.tokenURL) else { return false } 167 + 168 + var request = URLRequest(url: url) 169 + request.httpMethod = "POST" 170 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") 171 + 172 + let body: [String: String] = [ 173 + "client_id": StravaConfig.clientID, 174 + "client_secret": StravaConfig.clientSecret, 175 + "refresh_token": tokens.refreshToken, 176 + "grant_type": "refresh_token" 177 + ] 178 + request.httpBody = try? JSONSerialization.data(withJSONObject: body) 179 + 180 + do { 181 + let (data, _) = try await URLSession.shared.data(for: request) 182 + try saveTokensFromResponse(data) 183 + return true 184 + } catch { 185 + print("Token refresh failed: \(error)") 186 + return false 187 + } 188 + } 189 + 190 + private func getValidAccessToken() async -> String? { 191 + guard let tokens = loadTokens() else { return nil } 192 + if Int(Date().timeIntervalSince1970) < tokens.expiresAt - 60 { 193 + return tokens.accessToken 194 + } 195 + if await refreshAccessToken() { 196 + return loadTokens()?.accessToken 197 + } 198 + return nil 199 + } 200 + 201 + func disconnect() { 202 + KeychainHelper.delete(forKey: keychainKey) 203 + isAuthenticated = false 204 + uploadStatus = .idle 205 + } 206 + 207 + // MARK: - Token Persistence 208 + 209 + private func saveTokensFromResponse(_ data: Data) throws { 210 + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] 211 + guard let accessToken = json["access_token"] as? String, 212 + let refreshToken = json["refresh_token"] as? String, 213 + let expiresAt = json["expires_at"] as? Int else { 214 + throw URLError(.cannotParseResponse) 215 + } 216 + let tokens = StravaTokens(accessToken: accessToken, refreshToken: refreshToken, expiresAt: expiresAt) 217 + let encoded = try JSONEncoder().encode(tokens) 218 + KeychainHelper.save(encoded, forKey: keychainKey) 219 + } 220 + 221 + private func loadTokens() -> StravaTokens? { 222 + guard let data = KeychainHelper.load(forKey: keychainKey) else { return nil } 223 + return try? JSONDecoder().decode(StravaTokens.self, from: data) 224 + } 225 + 226 + // MARK: - Upload 227 + 228 + func uploadWorkout(_ workout: HKWorkout) async { 229 + uploadStatus = .uploading 230 + 231 + guard let accessToken = await getValidAccessToken() else { 232 + uploadStatus = .error("Not authenticated. Please reconnect Strava.") 233 + isAuthenticated = false 234 + return 235 + } 236 + 237 + // Query HealthKit for heart rate and route data 238 + let heartRateSamples = await fetchHeartRateSamples(for: workout) 239 + let routeLocations = await fetchRouteLocations(for: workout) 240 + 241 + let tcxData = generateTCX( 242 + workout: workout, 243 + heartRateSamples: heartRateSamples, 244 + locations: routeLocations 245 + ) 246 + 247 + guard let url = URL(string: StravaConfig.uploadURL) else { 248 + uploadStatus = .error("Invalid upload URL") 249 + return 250 + } 251 + 252 + let boundary = UUID().uuidString 253 + var request = URLRequest(url: url) 254 + request.httpMethod = "POST" 255 + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 256 + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") 257 + 258 + let activityType = stravaActivityType(for: workout.workoutActivityType) 259 + 260 + var body = Data() 261 + // data_type field 262 + body.appendMultipartField(name: "data_type", value: "tcx", boundary: boundary) 263 + // activity_type field 264 + body.appendMultipartField(name: "activity_type", value: activityType, boundary: boundary) 265 + // file field 266 + body.appendMultipartFile(name: "file", filename: "workout.tcx", mimeType: "application/xml", data: tcxData, boundary: boundary) 267 + body.append("--\(boundary)--\r\n".data(using: .utf8)!) 268 + 269 + request.httpBody = body 270 + 271 + do { 272 + let (data, response) = try await URLSession.shared.data(for: request) 273 + guard let httpResponse = response as? HTTPURLResponse else { 274 + uploadStatus = .error("Invalid response") 275 + return 276 + } 277 + 278 + if httpResponse.statusCode == 201 { 279 + // Upload accepted — poll for processing status 280 + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 281 + let uploadID = json["id"] as? Int64 { 282 + await pollUploadStatus(uploadID: uploadID, accessToken: accessToken) 283 + } else { 284 + uploadStatus = .success(activityID: 0) 285 + } 286 + } else if httpResponse.statusCode == 401 { 287 + // Token might be expired, try refresh once 288 + if await refreshAccessToken(), let newToken = loadTokens()?.accessToken { 289 + request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization") 290 + let (retryData, retryResp) = try await URLSession.shared.data(for: request) 291 + if let retryHttp = retryResp as? HTTPURLResponse, retryHttp.statusCode == 201, 292 + let json = try? JSONSerialization.jsonObject(with: retryData) as? [String: Any], 293 + let uploadID = json["id"] as? Int64 { 294 + await pollUploadStatus(uploadID: uploadID, accessToken: newToken) 295 + } else { 296 + uploadStatus = .error("Upload failed after token refresh") 297 + } 298 + } else { 299 + uploadStatus = .error("Authentication expired. Please reconnect Strava.") 300 + isAuthenticated = false 301 + } 302 + } else { 303 + let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String 304 + ?? "Upload failed (HTTP \(httpResponse.statusCode))" 305 + uploadStatus = .error(message) 306 + } 307 + } catch { 308 + uploadStatus = .error(error.localizedDescription) 309 + } 310 + } 311 + 312 + private func pollUploadStatus(uploadID: Int64, accessToken: String) async { 313 + uploadStatus = .processing 314 + let checkURL = URL(string: "\(StravaConfig.uploadURL)/\(uploadID)")! 315 + var request = URLRequest(url: checkURL) 316 + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 317 + 318 + for _ in 0..<10 { 319 + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds 320 + do { 321 + let (data, _) = try await URLSession.shared.data(for: request) 322 + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { 323 + if let activityID = json["activity_id"] as? Int64, activityID != 0 { 324 + uploadStatus = .success(activityID: activityID) 325 + return 326 + } 327 + if let errorStr = json["error"] as? String, !errorStr.isEmpty { 328 + uploadStatus = .error(errorStr) 329 + return 330 + } 331 + // status is still "Your activity is still being processed." 332 + } 333 + } catch { 334 + // Keep polling 335 + } 336 + } 337 + // If we get here, assume success — Strava may still be processing 338 + uploadStatus = .success(activityID: 0) 339 + } 340 + 341 + // MARK: - HealthKit Queries 342 + 343 + private func fetchHeartRateSamples(for workout: HKWorkout) async -> [HKQuantitySample] { 344 + let healthStore = HKHealthStore() 345 + let hrType = HKQuantityType(.heartRate) 346 + let predicate = HKQuery.predicateForSamples( 347 + withStart: workout.startDate, 348 + end: workout.endDate, 349 + options: .strictStartDate 350 + ) 351 + return await withCheckedContinuation { continuation in 352 + let query = HKSampleQuery( 353 + sampleType: hrType, 354 + predicate: predicate, 355 + limit: HKObjectQueryNoLimit, 356 + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)] 357 + ) { _, samples, _ in 358 + continuation.resume(returning: (samples as? [HKQuantitySample]) ?? []) 359 + } 360 + healthStore.execute(query) 361 + } 362 + } 363 + 364 + private func fetchRouteLocations(for workout: HKWorkout) async -> [CLLocation] { 365 + let healthStore = HKHealthStore() 366 + let routeType = HKSeriesType.workoutRoute() 367 + let predicate = HKQuery.predicateForObjects(from: workout) 368 + 369 + let routes: [HKWorkoutRoute] = await withCheckedContinuation { continuation in 370 + let query = HKSampleQuery( 371 + sampleType: routeType, 372 + predicate: predicate, 373 + limit: HKObjectQueryNoLimit, 374 + sortDescriptors: nil 375 + ) { _, samples, _ in 376 + continuation.resume(returning: (samples as? [HKWorkoutRoute]) ?? []) 377 + } 378 + healthStore.execute(query) 379 + } 380 + 381 + guard let route = routes.first else { return [] } 382 + 383 + return await withCheckedContinuation { continuation in 384 + var allLocations: [CLLocation] = [] 385 + let query = HKWorkoutRouteQuery(route: route) { _, locations, done, _ in 386 + if let locations { allLocations.append(contentsOf: locations) } 387 + if done { continuation.resume(returning: allLocations) } 388 + } 389 + healthStore.execute(query) 390 + } 391 + } 392 + 393 + // MARK: - TCX Generation 394 + 395 + private func generateTCX( 396 + workout: HKWorkout, 397 + heartRateSamples: [HKQuantitySample], 398 + locations: [CLLocation] 399 + ) -> Data { 400 + let isoFormatter = ISO8601DateFormatter() 401 + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 402 + 403 + let activitySport = tcxSportName(for: workout.workoutActivityType) 404 + let startTime = isoFormatter.string(from: workout.startDate) 405 + 406 + var xml = """ 407 + <?xml version="1.0" encoding="UTF-8"?> 408 + <TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenter/v2" 409 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 410 + xsi:schemaLocation="http://www.garmin.com/xmlschemas/TrainingCenter/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd"> 411 + <Activities> 412 + <Activity Sport="\(activitySport)"> 413 + <Id>\(startTime)</Id> 414 + <Lap StartTime="\(startTime)"> 415 + <TotalTimeSeconds>\(workout.duration)</TotalTimeSeconds> 416 + 417 + """ 418 + 419 + if let distanceStats = workout.statistics(for: HKQuantityType(.distanceWalkingRunning)), 420 + let distance = distanceStats.sumQuantity() { 421 + xml += " <DistanceMeters>\(distance.doubleValue(for: .meter()))</DistanceMeters>\n" 422 + } else { 423 + xml += " <DistanceMeters>0</DistanceMeters>\n" 424 + } 425 + 426 + if let energyStats = workout.statistics(for: HKQuantityType(.activeEnergyBurned)), 427 + let calories = energyStats.sumQuantity() { 428 + xml += " <Calories>\(Int(calories.doubleValue(for: .kilocalorie())))</Calories>\n" 429 + } else { 430 + xml += " <Calories>0</Calories>\n" 431 + } 432 + 433 + xml += " <Intensity>Active</Intensity>\n" 434 + xml += " <TriggerMethod>Manual</TriggerMethod>\n" 435 + xml += " <Track>\n" 436 + 437 + // Build a timeline of trackpoints 438 + // Merge heart rate samples and GPS locations by time 439 + var trackpoints: [(date: Date, hr: Double?, location: CLLocation?)] = [] 440 + 441 + // Add GPS points 442 + for loc in locations { 443 + trackpoints.append((date: loc.timestamp, hr: nil, location: loc)) 444 + } 445 + 446 + // Add heart rate points 447 + let bpmUnit = HKUnit.count().unitDivided(by: .minute()) 448 + for sample in heartRateSamples { 449 + let bpm = sample.quantity.doubleValue(for: bpmUnit) 450 + trackpoints.append((date: sample.startDate, hr: bpm, location: nil)) 451 + } 452 + 453 + // If no trackpoints at all, add start and end markers 454 + if trackpoints.isEmpty { 455 + trackpoints.append((date: workout.startDate, hr: nil, location: nil)) 456 + trackpoints.append((date: workout.endDate, hr: nil, location: nil)) 457 + } 458 + 459 + // Sort by time 460 + trackpoints.sort { $0.date < $1.date } 461 + 462 + // Merge nearby trackpoints (within 2 seconds) 463 + var merged: [(date: Date, hr: Double?, location: CLLocation?)] = [] 464 + for tp in trackpoints { 465 + if let lastIdx = merged.indices.last, 466 + abs(merged[lastIdx].date.timeIntervalSince(tp.date)) < 2.0 { 467 + // Merge into existing 468 + if tp.hr != nil { merged[lastIdx].hr = tp.hr } 469 + if tp.location != nil { merged[lastIdx].location = tp.location } 470 + } else { 471 + merged.append(tp) 472 + } 473 + } 474 + 475 + // Calculate cumulative distance from GPS 476 + var cumulativeDistance: Double = 0 477 + var lastLocation: CLLocation? 478 + for tp in merged { 479 + let time = isoFormatter.string(from: tp.date) 480 + xml += " <Trackpoint>\n" 481 + xml += " <Time>\(time)</Time>\n" 482 + 483 + if let loc = tp.location { 484 + if let prev = lastLocation { 485 + cumulativeDistance += loc.distance(from: prev) 486 + } 487 + lastLocation = loc 488 + xml += " <Position>\n" 489 + xml += " <LatitudeDegrees>\(loc.coordinate.latitude)</LatitudeDegrees>\n" 490 + xml += " <LongitudeDegrees>\(loc.coordinate.longitude)</LongitudeDegrees>\n" 491 + xml += " </Position>\n" 492 + xml += " <AltitudeMeters>\(loc.altitude)</AltitudeMeters>\n" 493 + xml += " <DistanceMeters>\(cumulativeDistance)</DistanceMeters>\n" 494 + } 495 + 496 + if let hr = tp.hr { 497 + xml += " <HeartRateBpm>\n" 498 + xml += " <Value>\(Int(hr))</Value>\n" 499 + xml += " </HeartRateBpm>\n" 500 + } 501 + 502 + xml += " </Trackpoint>\n" 503 + } 504 + 505 + xml += """ 506 + </Track> 507 + </Lap> 508 + </Activity> 509 + </Activities> 510 + </TrainingCenterDatabase> 511 + """ 512 + 513 + return Data(xml.utf8) 514 + } 515 + 516 + // MARK: - Activity Type Mapping 517 + 518 + private func stravaActivityType(for hkType: HKWorkoutActivityType) -> String { 519 + switch hkType { 520 + case .running: return "run" 521 + case .cycling: return "ride" 522 + case .swimming: return "swim" 523 + case .walking: return "walk" 524 + case .hiking: return "hike" 525 + case .crossTraining: return "crossfit" 526 + case .yoga: return "yoga" 527 + case .rowing: return "rowing" 528 + case .elliptical: return "elliptical" 529 + default: return "workout" 530 + } 531 + } 532 + 533 + private func tcxSportName(for hkType: HKWorkoutActivityType) -> String { 534 + switch hkType { 535 + case .running: return "Running" 536 + case .cycling: return "Biking" 537 + default: return "Other" 538 + } 539 + } 540 + } 541 + 542 + // MARK: - ASWebAuthenticationSession Presentation 543 + 544 + private class WebAuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { 545 + let anchor: ASPresentationAnchor 546 + init(anchor: ASPresentationAnchor) { self.anchor = anchor } 547 + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { anchor } 548 + } 549 + 550 + // MARK: - Multipart Helpers 551 + 552 + private extension Data { 553 + mutating func appendMultipartField(name: String, value: String, boundary: String) { 554 + append("--\(boundary)\r\n".data(using: .utf8)!) 555 + append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!) 556 + append("\(value)\r\n".data(using: .utf8)!) 557 + } 558 + 559 + mutating func appendMultipartFile(name: String, filename: String, mimeType: String, data: Data, boundary: String) { 560 + append("--\(boundary)\r\n".data(using: .utf8)!) 561 + append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) 562 + append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) 563 + append(data) 564 + append("\r\n".data(using: .utf8)!) 565 + } 566 + } 567 +
+77 -3
WorkoutEditor/WorkoutEditView.swift
··· 14 14 @State private var showDeleteAlert = false 15 15 @State private var showCopyAlert = false 16 16 @State private var copyAlertSource = "" 17 + @State private var isSaving = false 17 18 @State private var errorMessage: String? 19 + @StateObject private var stravaManager = StravaManager.shared 18 20 19 21 init(workout: HKWorkout) { 20 22 self.workout = workout ··· 132 134 Button { 133 135 saveChanges() 134 136 } label: { 135 - Text("Save Trimmed Copy") 136 - .frame(maxWidth: .infinity) 137 + if isSaving { 138 + ProgressView() 139 + .frame(maxWidth: .infinity) 140 + } else { 141 + Text("Save Trimmed Copy") 142 + .frame(maxWidth: .infinity) 143 + } 137 144 } 138 145 .buttonStyle(.borderedProminent) 139 - .disabled(!isValid) 146 + .disabled(!isValid || isSaving) 140 147 141 148 Button(role: .destructive) { 142 149 showDeleteAlert = true ··· 145 152 .frame(maxWidth: .infinity) 146 153 } 147 154 .buttonStyle(.bordered) 155 + .disabled(isSaving) 148 156 } 149 157 .padding(.horizontal) 150 158 ··· 153 161 .foregroundStyle(.red) 154 162 .padding(.horizontal) 155 163 } 164 + 165 + Divider() 166 + .padding(.horizontal) 167 + 168 + // Strava 169 + VStack(spacing: 12) { 170 + if stravaManager.isAuthenticated { 171 + stravaUploadView 172 + Button("Disconnect Strava", role: .destructive) { 173 + stravaManager.disconnect() 174 + } 175 + .font(.caption) 176 + } else { 177 + Button { 178 + stravaManager.authenticate() 179 + } label: { 180 + Label("Connect Strava", systemImage: "link") 181 + .frame(maxWidth: .infinity) 182 + } 183 + .buttonStyle(.bordered) 184 + } 185 + } 186 + .padding(.horizontal) 156 187 } 157 188 .padding(.vertical) 158 189 } ··· 183 214 } 184 215 } 185 216 217 + @ViewBuilder 218 + private var stravaUploadView: some View { 219 + switch stravaManager.uploadStatus { 220 + case .idle: 221 + Button { 222 + Task { await stravaManager.uploadWorkout(workout) } 223 + } label: { 224 + Label("Upload to Strava", systemImage: "arrow.up.circle") 225 + .frame(maxWidth: .infinity) 226 + } 227 + .buttonStyle(.bordered) 228 + case .uploading: 229 + HStack { 230 + ProgressView() 231 + Text("Uploading...").padding(.leading, 8) 232 + } 233 + case .processing: 234 + HStack { 235 + ProgressView() 236 + Text("Processing on Strava...").padding(.leading, 8) 237 + } 238 + case .success(let activityID): 239 + Label( 240 + activityID != 0 ? "Uploaded (ID: \(activityID))" : "Uploaded successfully", 241 + systemImage: "checkmark.circle.fill" 242 + ) 243 + .foregroundStyle(.green) 244 + case .error(let message): 245 + VStack(spacing: 4) { 246 + Label("Upload failed", systemImage: "xmark.circle.fill") 247 + .foregroundStyle(.red) 248 + Text(message) 249 + .font(.caption) 250 + .foregroundStyle(.secondary) 251 + Button("Retry") { 252 + Task { await stravaManager.uploadWorkout(workout) } 253 + } 254 + } 255 + } 256 + } 257 + 186 258 private func formattedDuration(_ duration: TimeInterval) -> String { 187 259 let formatter = DateComponentsFormatter() 188 260 formatter.allowedUnits = [.hour, .minute, .second] ··· 191 263 } 192 264 193 265 private func saveChanges() { 266 + isSaving = true 194 267 Task { 268 + defer { isSaving = false } 195 269 do { 196 270 let result = try await HealthKitManager.shared.saveTrimmedWorkout( 197 271 original: workout,