Apple Fitness workout fixer + Strava uploader

Improve trim UX for workouts from other apps

Always create the trimmed copy, then attempt to delete the original.
If deletion fails (workout from another source), show an alert
explaining the copy was saved and guiding the user to delete the
original in Apple Fitness via "Delete Workout & Data".

+48 -17
+21 -5
WorkoutEditor/HealthKitManager.swift
··· 222 222 return allLocations 223 223 } 224 224 225 + struct TrimResult { 226 + let originalDeleted: Bool 227 + let originalSource: String 228 + } 229 + 225 230 func saveTrimmedWorkout( 226 231 original: HKWorkout, 227 232 activityType: HKWorkoutActivityType, 228 233 start: Date, 229 234 end: Date 230 - ) async throws { 235 + ) async throws -> TrimResult { 231 236 let range = start...end 237 + let originalSource = original.sourceRevision.source.name 232 238 233 - // Fetch associated data in parallel 239 + // Fetch associated data 234 240 async let samplesTask = fetchAssociatedSamples(for: original, in: range) 235 241 async let locationsTask = fetchRouteLocations(for: original, in: range) 236 242 let (samples, locations) = await (samplesTask, locationsTask) ··· 239 245 let filteredEvents = original.workoutEvents?.filter { event in 240 246 range.contains(event.dateInterval.start) 241 247 } ?? [] 248 + 249 + let metadata = original.metadata 242 250 243 251 // Build new workout 244 252 let configuration = HKWorkoutConfiguration() ··· 271 279 try await builder.addWorkoutEvents(filteredEvents) 272 280 } 273 281 274 - if let metadata = original.metadata, !metadata.isEmpty { 282 + if let metadata, !metadata.isEmpty { 275 283 try await builder.addMetadata(metadata) 276 284 } 277 285 ··· 287 295 try await routeBuilder.finishRoute(with: newWorkout, metadata: nil) 288 296 } 289 297 290 - // Delete original 291 - try await healthStore.delete(original) 298 + // Try to delete original — may fail for workouts from other apps 299 + var deleted = false 300 + do { 301 + try await healthStore.delete(original) 302 + deleted = true 303 + } catch { 304 + print("Could not delete original workout: \(error.localizedDescription)") 305 + } 306 + 307 + return TrimResult(originalDeleted: deleted, originalSource: originalSource) 292 308 } 293 309 294 310 func saveWorkout(activityType: HKWorkoutActivityType, start: Date, end: Date, metadata: [String: Any]? = nil) async throws {
+27 -12
WorkoutEditor/WorkoutEditView.swift
··· 11 11 @State private var intensitySamples: [IntensitySample] = [] 12 12 @State private var intensityMetric: IntensityMetric = .heartRate 13 13 @State private var isLoadingSamples = true 14 - @State private var showSaveAlert = false 15 14 @State private var showDeleteAlert = false 15 + @State private var showCopyAlert = false 16 + @State private var copyAlertSource = "" 16 17 @State private var errorMessage: String? 17 18 18 19 init(workout: HKWorkout) { ··· 122 123 Divider() 123 124 .padding(.horizontal) 124 125 125 - // Action buttons 126 + // Save explanation and buttons 126 127 VStack(spacing: 12) { 128 + Text("Saving creates a trimmed copy with all workout data in Apple Health. To remove the original, swipe left on it in Apple Fitness and choose \"Delete Workout & Data\".") 129 + .font(.caption) 130 + .foregroundStyle(.secondary) 131 + 127 132 Button { 128 - showSaveAlert = true 133 + saveChanges() 129 134 } label: { 130 - Text("Save Changes") 135 + Text("Save Trimmed Copy") 131 136 .frame(maxWidth: .infinity) 132 137 } 133 138 .buttonStyle(.borderedProminent) ··· 159 164 intensityMetric = result.metric 160 165 isLoadingSamples = false 161 166 } 162 - .alert("Save Changes", isPresented: $showSaveAlert) { 163 - Button("Save", role: .destructive) { saveChanges() } 164 - Button("Cancel", role: .cancel) {} 165 - } message: { 166 - Text("This will delete the original workout and replace it with the trimmed version.\n\nTHIS ACTION CANNOT BE UNDONE.") 167 - } 168 167 .alert("Delete Workout", isPresented: $showDeleteAlert) { 169 168 Button("Delete", role: .destructive) { deleteWorkout() } 170 169 Button("Cancel", role: .cancel) {} 171 170 } message: { 172 171 Text("This action cannot be undone.") 173 172 } 173 + .alert("Trimmed Copy Created", isPresented: $showCopyAlert) { 174 + Button("Open Fitness") { 175 + if let url = URL(string: "activitytoday://") { 176 + UIApplication.shared.open(url) 177 + } 178 + dismiss() 179 + } 180 + Button("OK") { dismiss() } 181 + } message: { 182 + Text("A trimmed copy with all workout data has been saved to Apple Health.\n\nTo remove the original, swipe left on it in Apple Fitness and choose \"Delete Workout & Data\". This is safe — the trimmed copy keeps its own data.") 183 + } 174 184 } 175 185 176 186 private func formattedDuration(_ duration: TimeInterval) -> String { ··· 183 193 private func saveChanges() { 184 194 Task { 185 195 do { 186 - try await HealthKitManager.shared.saveTrimmedWorkout( 196 + let result = try await HealthKitManager.shared.saveTrimmedWorkout( 187 197 original: workout, 188 198 activityType: editedActivityType, 189 199 start: trimmedStartDate, 190 200 end: trimmedEndDate 191 201 ) 192 202 await HealthKitManager.shared.loadWorkouts() 193 - dismiss() 203 + if result.originalDeleted { 204 + dismiss() 205 + } else { 206 + copyAlertSource = result.originalSource 207 + showCopyAlert = true 208 + } 194 209 } catch { 195 210 errorMessage = error.localizedDescription 196 211 }