From 899ae265a49a1fe7cd0ecf22022c05b76f16b366 Mon Sep 17 00:00:00 2001 From: dietrich ayala Date: Wed, 25 Feb 2026 00:53:39 +0100 Subject: [PATCH] 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 --- README.md | 45 +- WorkoutEditor.xcodeproj/project.pbxproj | 4 + WorkoutEditor/HealthKitManager.swift | 138 +++++- WorkoutEditor/StravaManager.swift | 567 ++++++++++++++++++++++++ WorkoutEditor/WorkoutEditView.swift | 80 +++- 5 files changed, 803 insertions(+), 31 deletions(-) create mode 100644 WorkoutEditor/StravaManager.swift diff --git a/README.md b/README.md index cf46acb..44d7a8b 100644 --- a/README.md +++ b/README.md @@ -25,19 +25,38 @@ An iOS app for trimming and managing HealthKit workouts. Built for the common pr - **Activity type picker** with 20 supported workout types - **Delete workout** with confirmation -### Data Warning - -> **Trimming a workout is destructive and irreversible.** -> -> 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: -> -> - **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. -> - **Workout routes** (GPS/location data) are permanently lost. -> - **Workout events** are permanently lost. -> - **Device association** is lost. -> - Only **metadata** (custom name, notes, etc.) is preserved. -> -> The app shows a confirmation dialog before saving. Make sure you want to proceed. +### Data-Preserving Trim +When you trim a workout, Overrun creates a new workout and migrates all associated data within the trimmed time range: + +- **Heart rate, active/basal energy, distance, step count** samples +- **Running dynamics** — speed, power, stride length, vertical oscillation, ground contact time +- **VO2 max** and **physical effort** samples +- **Workout effort scores** (iOS 18+, user-entered only) +- **Workout routes** (GPS/location data) +- **Workout events** (laps, segments, etc.) +- **Metadata** (indoor/outdoor flag, weather, timezone, METs, etc.) +- **Configuration** — activity type, indoor/outdoor location type, swimming config + +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. + +## Known Limitations + +### HealthKit / Apple Platform + +- **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. +- **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"). +- **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`. +- **Physical effort samples are system-generated.** `physicalEffort` samples are computed by the OS and cannot be created or associated by third-party apps. +- **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. +- **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. +- **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. +- **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. + +### Strava + +- **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. +- **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. +- **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. ## Requirements diff --git a/WorkoutEditor.xcodeproj/project.pbxproj b/WorkoutEditor.xcodeproj/project.pbxproj index ff6f6e5..1c6a4e2 100644 --- a/WorkoutEditor.xcodeproj/project.pbxproj +++ b/WorkoutEditor.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 06E7DFC72B3654D50025260F /* WorkoutEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E7DFC62B3654D50025260F /* WorkoutEditView.swift */; }; AA000001AAAA000100000001 /* ActivityGraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000001AAAA000100000002 /* ActivityGraphView.swift */; }; AA000002AAAA000200000001 /* RangeSliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002AAAA000200000002 /* RangeSliderView.swift */; }; + AA000003AAAA000300000001 /* StravaManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000003AAAA000300000002 /* StravaManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -30,6 +31,7 @@ 06E7DFC82B3668D10025260F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; AA000001AAAA000100000002 /* ActivityGraphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityGraphView.swift; sourceTree = ""; }; AA000002AAAA000200000002 /* RangeSliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeSliderView.swift; sourceTree = ""; }; + AA000003AAAA000300000002 /* StravaManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StravaManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -79,6 +81,7 @@ AA000002AAAA000200000002 /* RangeSliderView.swift */, 06E7DFC42B3654500025260F /* WorkoutListView.swift */, 06E7DFC22B3653F70025260F /* HealthKitManager.swift */, + AA000003AAAA000300000002 /* StravaManager.swift */, 06E7DFAF2B3608E00025260F /* Assets.xcassets */, 06E7DFB12B3608E00025260F /* Preview Content */, ); @@ -169,6 +172,7 @@ 06E7DFC72B3654D50025260F /* WorkoutEditView.swift in Sources */, AA000001AAAA000100000001 /* ActivityGraphView.swift in Sources */, AA000002AAAA000200000001 /* RangeSliderView.swift in Sources */, + AA000003AAAA000300000001 /* StravaManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/WorkoutEditor/HealthKitManager.swift b/WorkoutEditor/HealthKitManager.swift index 8b87089..29c7d3c 100644 --- a/WorkoutEditor/HealthKitManager.swift +++ b/WorkoutEditor/HealthKitManager.swift @@ -52,21 +52,30 @@ class HealthKitManager { throw HealthKitError.healthDataNotAvailable } - let typesToRead: Set = [ - HKObjectType.workoutType(), - HKQuantityType(.heartRate), - HKQuantityType(.activeEnergyBurned), - HKQuantityType(.distanceWalkingRunning), - HKQuantityType(.distanceCycling), - ] - let typesToWrite: Set = [ + 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) } @@ -161,12 +170,28 @@ class HealthKitManager { } } - private static let migratedSampleTypes: [HKQuantityType] = [ - HKQuantityType(.heartRate), - HKQuantityType(.activeEnergyBurned), - HKQuantityType(.distanceWalkingRunning), - HKQuantityType(.distanceCycling), - ] + private static var migratedSampleTypes: [HKQuantityType] { + var types: [HKQuantityType] = [ + 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), + ] + if #available(iOS 18.0, *) { + types.append(HKQuantityType(.workoutEffortScore)) + types.append(HKQuantityType(.estimatedWorkoutEffortScore)) + } + return types + } func fetchAssociatedSamples(for workout: HKWorkout, in range: ClosedRange) async -> [HKQuantitySample] { var allSamples: [HKQuantitySample] = [] @@ -222,6 +247,31 @@ class HealthKitManager { 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 originalDeleted: Bool let originalSource: String @@ -248,9 +298,26 @@ class HealthKitManager { let metadata = original.metadata - // Build new workout + // 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, @@ -279,6 +346,27 @@ class HealthKitManager { try await builder.addWorkoutEvents(filteredEvents) } + // Copy workout activities (needed for splits display in Apple Fitness) + for activity in original.workoutActivities { + let activityStart = max(activity.startDate, start) + let activityEnd: Date + if let end = activity.endDate { + activityEnd = min(end, end) + } else { + activityEnd = end + } + if activityStart < activityEnd { + 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)") + } + } + if let metadata, !metadata.isEmpty { try await builder.addMetadata(metadata) } @@ -295,6 +383,26 @@ class HealthKitManager { 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 { diff --git a/WorkoutEditor/StravaManager.swift b/WorkoutEditor/StravaManager.swift new file mode 100644 index 0000000..9547a25 --- /dev/null +++ b/WorkoutEditor/StravaManager.swift @@ -0,0 +1,567 @@ +// +// StravaManager.swift +// WorkoutEditor +// + +import AuthenticationServices +import CoreLocation +import Foundation +import HealthKit +import UIKit + +// MARK: - Configuration + +enum StravaConfig { + static let clientID = "YOUR_CLIENT_ID" + static let clientSecret = "YOUR_CLIENT_SECRET" + static let redirectURI = "overrun://strava/callback" + static let authorizeURL = "https://www.strava.com/oauth/mobile/authorize" + static let tokenURL = "https://www.strava.com/oauth/token" + static let uploadURL = "https://www.strava.com/api/v3/uploads" +} + +// MARK: - Keychain Helper + +private enum KeychainHelper { + static func save(_ data: Data, forKey key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: "com.overrun.strava" + ] + SecItemDelete(query as CFDictionary) + var addQuery = query + addQuery[kSecValueData as String] = data + SecItemAdd(addQuery as CFDictionary, nil) + } + + static func load(forKey key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: "com.overrun.strava", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + SecItemCopyMatching(query as CFDictionary, &result) + return result as? Data + } + + static func delete(forKey key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: "com.overrun.strava" + ] + SecItemDelete(query as CFDictionary) + } +} + +// MARK: - Token Storage + +private struct StravaTokens: Codable { + let accessToken: String + let refreshToken: String + let expiresAt: Int +} + +// MARK: - Upload Status + +enum StravaUploadStatus: Equatable { + case idle + case uploading + case processing + case success(activityID: Int64) + case error(String) +} + +// MARK: - StravaManager + +@MainActor +class StravaManager: ObservableObject { + static let shared = StravaManager() + + @Published var isAuthenticated = false + @Published var uploadStatus: StravaUploadStatus = .idle + + private let keychainKey = "strava_tokens" + + init() { + isAuthenticated = loadTokens() != nil + } + + // MARK: - OAuth + + func authenticate() { + var components = URLComponents(string: StravaConfig.authorizeURL)! + components.queryItems = [ + URLQueryItem(name: "client_id", value: StravaConfig.clientID), + URLQueryItem(name: "redirect_uri", value: StravaConfig.redirectURI), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "approval_prompt", value: "auto"), + URLQueryItem(name: "scope", value: "activity:write,activity:read_all") + ] + + guard let url = components.url else { return } + + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: "overrun" + ) { [weak self] callbackURL, error in + Task { @MainActor in + guard let self, let callbackURL, error == nil else { return } + guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "code" })?.value else { return } + await self.exchangeToken(code: code) + } + } + session.prefersEphemeralWebBrowserSession = false + + // Find the current window scene for the presentation context + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + let provider = WebAuthPresentationContext(anchor: window) + session.presentationContextProvider = provider + // Store the session and provider to prevent deallocation + self.activeAuthSession = session + self.authContextProvider = provider + } + + session.start() + } + + private var activeAuthSession: ASWebAuthenticationSession? + private var authContextProvider: WebAuthPresentationContext? + + private func exchangeToken(code: String) async { + guard let url = URL(string: StravaConfig.tokenURL) else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: String] = [ + "client_id": StravaConfig.clientID, + "client_secret": StravaConfig.clientSecret, + "code": code, + "grant_type": "authorization_code" + ] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + do { + let (data, _) = try await URLSession.shared.data(for: request) + try saveTokensFromResponse(data) + isAuthenticated = true + } catch { + print("Token exchange failed: \(error)") + } + + activeAuthSession = nil + authContextProvider = nil + } + + private func refreshAccessToken() async -> Bool { + guard let tokens = loadTokens() else { return false } + guard let url = URL(string: StravaConfig.tokenURL) else { return false } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: String] = [ + "client_id": StravaConfig.clientID, + "client_secret": StravaConfig.clientSecret, + "refresh_token": tokens.refreshToken, + "grant_type": "refresh_token" + ] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + do { + let (data, _) = try await URLSession.shared.data(for: request) + try saveTokensFromResponse(data) + return true + } catch { + print("Token refresh failed: \(error)") + return false + } + } + + private func getValidAccessToken() async -> String? { + guard let tokens = loadTokens() else { return nil } + if Int(Date().timeIntervalSince1970) < tokens.expiresAt - 60 { + return tokens.accessToken + } + if await refreshAccessToken() { + return loadTokens()?.accessToken + } + return nil + } + + func disconnect() { + KeychainHelper.delete(forKey: keychainKey) + isAuthenticated = false + uploadStatus = .idle + } + + // MARK: - Token Persistence + + private func saveTokensFromResponse(_ data: Data) throws { + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + guard let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String, + let expiresAt = json["expires_at"] as? Int else { + throw URLError(.cannotParseResponse) + } + let tokens = StravaTokens(accessToken: accessToken, refreshToken: refreshToken, expiresAt: expiresAt) + let encoded = try JSONEncoder().encode(tokens) + KeychainHelper.save(encoded, forKey: keychainKey) + } + + private func loadTokens() -> StravaTokens? { + guard let data = KeychainHelper.load(forKey: keychainKey) else { return nil } + return try? JSONDecoder().decode(StravaTokens.self, from: data) + } + + // MARK: - Upload + + func uploadWorkout(_ workout: HKWorkout) async { + uploadStatus = .uploading + + guard let accessToken = await getValidAccessToken() else { + uploadStatus = .error("Not authenticated. Please reconnect Strava.") + isAuthenticated = false + return + } + + // Query HealthKit for heart rate and route data + let heartRateSamples = await fetchHeartRateSamples(for: workout) + let routeLocations = await fetchRouteLocations(for: workout) + + let tcxData = generateTCX( + workout: workout, + heartRateSamples: heartRateSamples, + locations: routeLocations + ) + + guard let url = URL(string: StravaConfig.uploadURL) else { + uploadStatus = .error("Invalid upload URL") + return + } + + let boundary = UUID().uuidString + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + let activityType = stravaActivityType(for: workout.workoutActivityType) + + var body = Data() + // data_type field + body.appendMultipartField(name: "data_type", value: "tcx", boundary: boundary) + // activity_type field + body.appendMultipartField(name: "activity_type", value: activityType, boundary: boundary) + // file field + body.appendMultipartFile(name: "file", filename: "workout.tcx", mimeType: "application/xml", data: tcxData, boundary: boundary) + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + + request.httpBody = body + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + uploadStatus = .error("Invalid response") + return + } + + if httpResponse.statusCode == 201 { + // Upload accepted — poll for processing status + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let uploadID = json["id"] as? Int64 { + await pollUploadStatus(uploadID: uploadID, accessToken: accessToken) + } else { + uploadStatus = .success(activityID: 0) + } + } else if httpResponse.statusCode == 401 { + // Token might be expired, try refresh once + if await refreshAccessToken(), let newToken = loadTokens()?.accessToken { + request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization") + let (retryData, retryResp) = try await URLSession.shared.data(for: request) + if let retryHttp = retryResp as? HTTPURLResponse, retryHttp.statusCode == 201, + let json = try? JSONSerialization.jsonObject(with: retryData) as? [String: Any], + let uploadID = json["id"] as? Int64 { + await pollUploadStatus(uploadID: uploadID, accessToken: newToken) + } else { + uploadStatus = .error("Upload failed after token refresh") + } + } else { + uploadStatus = .error("Authentication expired. Please reconnect Strava.") + isAuthenticated = false + } + } else { + let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String + ?? "Upload failed (HTTP \(httpResponse.statusCode))" + uploadStatus = .error(message) + } + } catch { + uploadStatus = .error(error.localizedDescription) + } + } + + private func pollUploadStatus(uploadID: Int64, accessToken: String) async { + uploadStatus = .processing + let checkURL = URL(string: "\(StravaConfig.uploadURL)/\(uploadID)")! + var request = URLRequest(url: checkURL) + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + for _ in 0..<10 { + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + do { + let (data, _) = try await URLSession.shared.data(for: request) + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let activityID = json["activity_id"] as? Int64, activityID != 0 { + uploadStatus = .success(activityID: activityID) + return + } + if let errorStr = json["error"] as? String, !errorStr.isEmpty { + uploadStatus = .error(errorStr) + return + } + // status is still "Your activity is still being processed." + } + } catch { + // Keep polling + } + } + // If we get here, assume success — Strava may still be processing + uploadStatus = .success(activityID: 0) + } + + // MARK: - HealthKit Queries + + private func fetchHeartRateSamples(for workout: HKWorkout) async -> [HKQuantitySample] { + let healthStore = HKHealthStore() + let hrType = HKQuantityType(.heartRate) + let predicate = HKQuery.predicateForSamples( + withStart: workout.startDate, + end: workout.endDate, + options: .strictStartDate + ) + return await withCheckedContinuation { continuation in + let query = HKSampleQuery( + sampleType: hrType, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)] + ) { _, samples, _ in + continuation.resume(returning: (samples as? [HKQuantitySample]) ?? []) + } + healthStore.execute(query) + } + } + + private func fetchRouteLocations(for workout: HKWorkout) async -> [CLLocation] { + let healthStore = HKHealthStore() + let routeType = HKSeriesType.workoutRoute() + let predicate = HKQuery.predicateForObjects(from: workout) + + let routes: [HKWorkoutRoute] = await withCheckedContinuation { continuation in + let query = HKSampleQuery( + sampleType: routeType, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: nil + ) { _, samples, _ in + continuation.resume(returning: (samples as? [HKWorkoutRoute]) ?? []) + } + healthStore.execute(query) + } + + guard let route = routes.first else { return [] } + + return await withCheckedContinuation { continuation in + var allLocations: [CLLocation] = [] + let query = HKWorkoutRouteQuery(route: route) { _, locations, done, _ in + if let locations { allLocations.append(contentsOf: locations) } + if done { continuation.resume(returning: allLocations) } + } + healthStore.execute(query) + } + } + + // MARK: - TCX Generation + + private func generateTCX( + workout: HKWorkout, + heartRateSamples: [HKQuantitySample], + locations: [CLLocation] + ) -> Data { + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + let activitySport = tcxSportName(for: workout.workoutActivityType) + let startTime = isoFormatter.string(from: workout.startDate) + + var xml = """ + + + + + \(startTime) + + \(workout.duration) + + """ + + if let distanceStats = workout.statistics(for: HKQuantityType(.distanceWalkingRunning)), + let distance = distanceStats.sumQuantity() { + xml += " \(distance.doubleValue(for: .meter()))\n" + } else { + xml += " 0\n" + } + + if let energyStats = workout.statistics(for: HKQuantityType(.activeEnergyBurned)), + let calories = energyStats.sumQuantity() { + xml += " \(Int(calories.doubleValue(for: .kilocalorie())))\n" + } else { + xml += " 0\n" + } + + xml += " Active\n" + xml += " Manual\n" + xml += " \n" + + // Build a timeline of trackpoints + // Merge heart rate samples and GPS locations by time + var trackpoints: [(date: Date, hr: Double?, location: CLLocation?)] = [] + + // Add GPS points + for loc in locations { + trackpoints.append((date: loc.timestamp, hr: nil, location: loc)) + } + + // Add heart rate points + let bpmUnit = HKUnit.count().unitDivided(by: .minute()) + for sample in heartRateSamples { + let bpm = sample.quantity.doubleValue(for: bpmUnit) + trackpoints.append((date: sample.startDate, hr: bpm, location: nil)) + } + + // If no trackpoints at all, add start and end markers + if trackpoints.isEmpty { + trackpoints.append((date: workout.startDate, hr: nil, location: nil)) + trackpoints.append((date: workout.endDate, hr: nil, location: nil)) + } + + // Sort by time + trackpoints.sort { $0.date < $1.date } + + // Merge nearby trackpoints (within 2 seconds) + var merged: [(date: Date, hr: Double?, location: CLLocation?)] = [] + for tp in trackpoints { + if let lastIdx = merged.indices.last, + abs(merged[lastIdx].date.timeIntervalSince(tp.date)) < 2.0 { + // Merge into existing + if tp.hr != nil { merged[lastIdx].hr = tp.hr } + if tp.location != nil { merged[lastIdx].location = tp.location } + } else { + merged.append(tp) + } + } + + // Calculate cumulative distance from GPS + var cumulativeDistance: Double = 0 + var lastLocation: CLLocation? + for tp in merged { + let time = isoFormatter.string(from: tp.date) + xml += " \n" + xml += " \n" + + if let loc = tp.location { + if let prev = lastLocation { + cumulativeDistance += loc.distance(from: prev) + } + lastLocation = loc + xml += " \n" + xml += " \(loc.coordinate.latitude)\n" + xml += " \(loc.coordinate.longitude)\n" + xml += " \n" + xml += " \(loc.altitude)\n" + xml += " \(cumulativeDistance)\n" + } + + if let hr = tp.hr { + xml += " \n" + xml += " \(Int(hr))\n" + xml += " \n" + } + + xml += " \n" + } + + xml += """ + + + + + + """ + + return Data(xml.utf8) + } + + // MARK: - Activity Type Mapping + + private func stravaActivityType(for hkType: HKWorkoutActivityType) -> String { + switch hkType { + case .running: return "run" + case .cycling: return "ride" + case .swimming: return "swim" + case .walking: return "walk" + case .hiking: return "hike" + case .crossTraining: return "crossfit" + case .yoga: return "yoga" + case .rowing: return "rowing" + case .elliptical: return "elliptical" + default: return "workout" + } + } + + private func tcxSportName(for hkType: HKWorkoutActivityType) -> String { + switch hkType { + case .running: return "Running" + case .cycling: return "Biking" + default: return "Other" + } + } +} + +// MARK: - ASWebAuthenticationSession Presentation + +private class WebAuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { + let anchor: ASPresentationAnchor + init(anchor: ASPresentationAnchor) { self.anchor = anchor } + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { anchor } +} + +// MARK: - Multipart Helpers + +private extension Data { + mutating func appendMultipartField(name: String, value: String, boundary: String) { + append("--\(boundary)\r\n".data(using: .utf8)!) + append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!) + append("\(value)\r\n".data(using: .utf8)!) + } + + mutating func appendMultipartFile(name: String, filename: String, mimeType: String, data: Data, boundary: String) { + append("--\(boundary)\r\n".data(using: .utf8)!) + append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) + append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) + append(data) + append("\r\n".data(using: .utf8)!) + } +} + diff --git a/WorkoutEditor/WorkoutEditView.swift b/WorkoutEditor/WorkoutEditView.swift index f10600c..18aeb09 100644 --- a/WorkoutEditor/WorkoutEditView.swift +++ b/WorkoutEditor/WorkoutEditView.swift @@ -14,7 +14,9 @@ struct WorkoutEditView: View { @State private var showDeleteAlert = false @State private var showCopyAlert = false @State private var copyAlertSource = "" + @State private var isSaving = false @State private var errorMessage: String? + @StateObject private var stravaManager = StravaManager.shared init(workout: HKWorkout) { self.workout = workout @@ -132,11 +134,16 @@ struct WorkoutEditView: View { Button { saveChanges() } label: { - Text("Save Trimmed Copy") - .frame(maxWidth: .infinity) + if isSaving { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Save Trimmed Copy") + .frame(maxWidth: .infinity) + } } .buttonStyle(.borderedProminent) - .disabled(!isValid) + .disabled(!isValid || isSaving) Button(role: .destructive) { showDeleteAlert = true @@ -145,6 +152,7 @@ struct WorkoutEditView: View { .frame(maxWidth: .infinity) } .buttonStyle(.bordered) + .disabled(isSaving) } .padding(.horizontal) @@ -153,6 +161,29 @@ struct WorkoutEditView: View { .foregroundStyle(.red) .padding(.horizontal) } + + Divider() + .padding(.horizontal) + + // Strava + VStack(spacing: 12) { + if stravaManager.isAuthenticated { + stravaUploadView + Button("Disconnect Strava", role: .destructive) { + stravaManager.disconnect() + } + .font(.caption) + } else { + Button { + stravaManager.authenticate() + } label: { + Label("Connect Strava", systemImage: "link") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + } + .padding(.horizontal) } .padding(.vertical) } @@ -183,6 +214,47 @@ struct WorkoutEditView: View { } } + @ViewBuilder + private var stravaUploadView: some View { + switch stravaManager.uploadStatus { + case .idle: + Button { + Task { await stravaManager.uploadWorkout(workout) } + } label: { + Label("Upload to Strava", systemImage: "arrow.up.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + case .uploading: + HStack { + ProgressView() + Text("Uploading...").padding(.leading, 8) + } + case .processing: + HStack { + ProgressView() + Text("Processing on Strava...").padding(.leading, 8) + } + case .success(let activityID): + Label( + activityID != 0 ? "Uploaded (ID: \(activityID))" : "Uploaded successfully", + systemImage: "checkmark.circle.fill" + ) + .foregroundStyle(.green) + case .error(let message): + VStack(spacing: 4) { + Label("Upload failed", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + Button("Retry") { + Task { await stravaManager.uploadWorkout(workout) } + } + } + } + } + private func formattedDuration(_ duration: TimeInterval) -> String { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] @@ -191,7 +263,9 @@ struct WorkoutEditView: View { } private func saveChanges() { + isSaving = true Task { + defer { isSaving = false } do { let result = try await HealthKitManager.shared.saveTrimmedWorkout( original: workout, -- 2.51.2