Apple Fitness workout fixer + Strava uploader

Replace edit view with activity graph + range slider for workout trimming

- Add ActivityGraphView (Swift Charts line/area graph with dimming overlays)
- Add RangeSliderView (two-thumb slider for selecting trim range)
- Rewrite WorkoutEditView: graph, slider, time labels, activity picker
- Expand HealthKitManager with HR/energy/distance queries and realistic sample data
- Add destructive action warning to save confirmation
- Remove WorkoutAddView (not a use-case)

+502 -174
+8 -4
WorkoutEditor.xcodeproj/project.pbxproj
··· 8 8 9 9 /* Begin PBXBuildFile section */ 10 10 06E7DFAC2B3608DD0025260F /* WorkoutEditorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E7DFAB2B3608DD0025260F /* WorkoutEditorApp.swift */; }; 11 - 06E7DFAE2B3608DD0025260F /* WorkoutAddView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E7DFAD2B3608DD0025260F /* WorkoutAddView.swift */; }; 12 11 06E7DFB02B3608E00025260F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 06E7DFAF2B3608E00025260F /* Assets.xcassets */; }; 13 12 06E7DFB32B3608E00025260F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 06E7DFB22B3608E00025260F /* Preview Assets.xcassets */; }; 14 13 06E7DFC32B3653F70025260F /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E7DFC22B3653F70025260F /* HealthKitManager.swift */; }; 15 14 06E7DFC52B3654500025260F /* WorkoutListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E7DFC42B3654500025260F /* WorkoutListView.swift */; }; 16 15 06E7DFC72B3654D50025260F /* WorkoutEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E7DFC62B3654D50025260F /* WorkoutEditView.swift */; }; 16 + AA000001AAAA000100000001 /* ActivityGraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000001AAAA000100000002 /* ActivityGraphView.swift */; }; 17 + AA000002AAAA000200000001 /* RangeSliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002AAAA000200000002 /* RangeSliderView.swift */; }; 17 18 /* End PBXBuildFile section */ 18 19 19 20 /* Begin PBXFileReference section */ 20 21 063E686B2B45161B0048778C /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = System/Library/Frameworks/HealthKit.framework; sourceTree = SDKROOT; }; 21 22 06E7DFA82B3608DD0025260F /* WorkoutEditor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WorkoutEditor.app; sourceTree = BUILT_PRODUCTS_DIR; }; 22 23 06E7DFAB2B3608DD0025260F /* WorkoutEditorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEditorApp.swift; sourceTree = "<group>"; }; 23 - 06E7DFAD2B3608DD0025260F /* WorkoutAddView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutAddView.swift; sourceTree = "<group>"; }; 24 24 06E7DFAF2B3608E00025260F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 25 + AA000001AAAA000100000002 /* ActivityGraphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityGraphView.swift; sourceTree = "<group>"; }; 26 + AA000002AAAA000200000002 /* RangeSliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeSliderView.swift; sourceTree = "<group>"; }; 25 27 06E7DFB22B3608E00025260F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; }; 26 28 06E7DFB92B3609EA0025260F /* WorkoutEditor.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WorkoutEditor.entitlements; sourceTree = "<group>"; }; 27 29 06E7DFC22B3653F70025260F /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = "<group>"; }; ··· 72 74 06E7DFC82B3668D10025260F /* Info.plist */, 73 75 06E7DFB92B3609EA0025260F /* WorkoutEditor.entitlements */, 74 76 06E7DFAB2B3608DD0025260F /* WorkoutEditorApp.swift */, 75 - 06E7DFAD2B3608DD0025260F /* WorkoutAddView.swift */, 76 77 06E7DFC62B3654D50025260F /* WorkoutEditView.swift */, 78 + AA000001AAAA000100000002 /* ActivityGraphView.swift */, 79 + AA000002AAAA000200000002 /* RangeSliderView.swift */, 77 80 06E7DFC42B3654500025260F /* WorkoutListView.swift */, 78 81 06E7DFC22B3653F70025260F /* HealthKitManager.swift */, 79 82 06E7DFAF2B3608E00025260F /* Assets.xcassets */, ··· 160 163 isa = PBXSourcesBuildPhase; 161 164 buildActionMask = 2147483647; 162 165 files = ( 163 - 06E7DFAE2B3608DD0025260F /* WorkoutAddView.swift in Sources */, 164 166 06E7DFAC2B3608DD0025260F /* WorkoutEditorApp.swift in Sources */, 165 167 06E7DFC52B3654500025260F /* WorkoutListView.swift in Sources */, 166 168 06E7DFC32B3653F70025260F /* HealthKitManager.swift in Sources */, 167 169 06E7DFC72B3654D50025260F /* WorkoutEditView.swift in Sources */, 170 + AA000001AAAA000100000001 /* ActivityGraphView.swift in Sources */, 171 + AA000002AAAA000200000001 /* RangeSliderView.swift in Sources */, 168 172 ); 169 173 runOnlyForDeploymentPostprocessing = 0; 170 174 };
+71
WorkoutEditor/ActivityGraphView.swift
··· 1 + import SwiftUI 2 + import Charts 3 + 4 + struct ActivityGraphView: View { 5 + let samples: [IntensitySample] 6 + let metric: IntensityMetric 7 + let lowerBound: Double 8 + let upperBound: Double 9 + 10 + var body: some View { 11 + Chart { 12 + ForEach(samples) { sample in 13 + AreaMark( 14 + x: .value("Time", sample.date), 15 + y: .value(metric.label, sample.value) 16 + ) 17 + .foregroundStyle( 18 + .linearGradient( 19 + colors: [.blue.opacity(0.3), .blue.opacity(0.05)], 20 + startPoint: .top, 21 + endPoint: .bottom 22 + ) 23 + ) 24 + 25 + LineMark( 26 + x: .value("Time", sample.date), 27 + y: .value(metric.label, sample.value) 28 + ) 29 + .foregroundStyle(.blue) 30 + .lineStyle(StrokeStyle(lineWidth: 1.5)) 31 + } 32 + 33 + // Dimming overlay for trimmed start region 34 + if lowerBound > 0, let startDate = samples.first?.date, let endDate = samples.last?.date { 35 + let trimStart = startDate.addingTimeInterval( 36 + lowerBound * endDate.timeIntervalSince(startDate) 37 + ) 38 + RectangleMark( 39 + xStart: .value("", startDate), 40 + xEnd: .value("", trimStart) 41 + ) 42 + .foregroundStyle(Color(.systemBackground).opacity(0.7)) 43 + } 44 + 45 + // Dimming overlay for trimmed end region 46 + if upperBound < 1, let startDate = samples.first?.date, let endDate = samples.last?.date { 47 + let trimEnd = startDate.addingTimeInterval( 48 + upperBound * endDate.timeIntervalSince(startDate) 49 + ) 50 + RectangleMark( 51 + xStart: .value("", trimEnd), 52 + xEnd: .value("", endDate) 53 + ) 54 + .foregroundStyle(Color(.systemBackground).opacity(0.7)) 55 + } 56 + } 57 + .chartXAxis(.hidden) 58 + .chartYAxis { 59 + AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { value in 60 + AxisGridLine() 61 + AxisValueLabel { 62 + if let v = value.as(Double.self) { 63 + Text("\(Int(v))") 64 + .font(.caption2) 65 + } 66 + } 67 + } 68 + } 69 + .chartYAxisLabel(metric.label, position: .leading) 70 + } 71 + }
+155 -2
WorkoutEditor/HealthKitManager.swift
··· 1 1 import Foundation 2 2 import HealthKit 3 3 4 + struct IntensitySample: Identifiable { 5 + let id = UUID() 6 + let date: Date 7 + let value: Double 8 + } 9 + 10 + enum IntensityMetric { 11 + case heartRate 12 + case activeEnergy 13 + case distance 14 + 15 + var unit: HKUnit { 16 + switch self { 17 + case .heartRate: .count().unitDivided(by: .minute()) 18 + case .activeEnergy: .kilocalorie() 19 + case .distance: .meter() 20 + } 21 + } 22 + 23 + var label: String { 24 + switch self { 25 + case .heartRate: "BPM" 26 + case .activeEnergy: "kcal" 27 + case .distance: "m" 28 + } 29 + } 30 + 31 + var quantityType: HKQuantityType { 32 + switch self { 33 + case .heartRate: HKQuantityType(.heartRate) 34 + case .activeEnergy: HKQuantityType(.activeEnergyBurned) 35 + case .distance: HKQuantityType(.distanceWalkingRunning) 36 + } 37 + } 38 + } 39 + 4 40 @Observable 5 41 class HealthKitManager { 6 42 static let shared = HealthKitManager() ··· 13 49 throw HealthKitError.healthDataNotAvailable 14 50 } 15 51 16 - let typesToRead: Set<HKObjectType> = [HKObjectType.workoutType()] 17 - let typesToWrite: Set<HKSampleType> = [HKObjectType.workoutType()] 52 + let typesToRead: Set<HKObjectType> = [ 53 + HKObjectType.workoutType(), 54 + HKQuantityType(.heartRate), 55 + HKQuantityType(.activeEnergyBurned), 56 + HKQuantityType(.distanceWalkingRunning), 57 + HKQuantityType(.distanceCycling), 58 + ] 59 + let typesToWrite: Set<HKSampleType> = [ 60 + HKObjectType.workoutType(), 61 + HKQuantityType(.heartRate), 62 + ] 18 63 19 64 try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) 20 65 } ··· 37 82 } 38 83 } 39 84 85 + func fetchIntensitySamples(for workout: HKWorkout) async -> (samples: [IntensitySample], metric: IntensityMetric) { 86 + let metrics: [IntensityMetric] = [.heartRate, .activeEnergy, .distance] 87 + 88 + for metric in metrics { 89 + let predicate = HKQuery.predicateForSamples( 90 + withStart: workout.startDate, 91 + end: workout.endDate, 92 + options: .strictStartDate 93 + ) 94 + let sortDescriptor = SortDescriptor(\HKQuantitySample.startDate, order: .forward) 95 + let descriptor = HKSampleQueryDescriptor( 96 + predicates: [.quantitySample(type: metric.quantityType, predicate: predicate)], 97 + sortDescriptors: [sortDescriptor], 98 + limit: 5000 99 + ) 100 + 101 + do { 102 + let results = try await descriptor.result(for: healthStore) 103 + if !results.isEmpty { 104 + var samples = results.map { sample in 105 + IntensitySample( 106 + date: sample.startDate, 107 + value: sample.quantity.doubleValue(for: metric.unit) 108 + ) 109 + } 110 + if samples.count > 500 { 111 + samples = downsample(samples, to: 500) 112 + } 113 + return (samples, metric) 114 + } 115 + } catch { 116 + print("Error fetching \(metric.label): \(error.localizedDescription)") 117 + } 118 + } 119 + 120 + return ([], .heartRate) 121 + } 122 + 123 + private func downsample(_ samples: [IntensitySample], to target: Int) -> [IntensitySample] { 124 + let stride = Double(samples.count) / Double(target) 125 + return (0..<target).map { i in 126 + samples[min(Int(Double(i) * stride), samples.count - 1)] 127 + } 128 + } 129 + 40 130 func saveWorkout(activityType: HKWorkoutActivityType, start: Date, end: Date, metadata: [String: Any]? = nil) async throws { 41 131 let configuration = HKWorkoutConfiguration() 42 132 configuration.activityType = activityType ··· 55 145 func deleteWorkout(_ workout: HKWorkout) async throws { 56 146 try await healthStore.delete(workout) 57 147 } 148 + 149 + #if DEBUG 150 + func insertSampleWorkouts() async throws { 151 + let samples: [(HKWorkoutActivityType, TimeInterval, TimeInterval)] = [ 152 + (.running, -3600 * 25, 60 * 60), // yesterday, 30 min active + 30 min forgot-to-stop 153 + (.cycling, -3600 * 50, 90 * 60), 154 + (.swimming, -3600 * 74, 30 * 60), 155 + (.hiking, -3600 * 100, 120 * 60), 156 + ] 157 + 158 + for (type, startOffset, duration) in samples { 159 + let start = Date(timeIntervalSinceNow: startOffset) 160 + let end = start.addingTimeInterval(duration) 161 + 162 + let configuration = HKWorkoutConfiguration() 163 + configuration.activityType = type 164 + 165 + let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: configuration, device: nil) 166 + try await builder.beginCollection(at: start) 167 + 168 + // Generate realistic heart rate samples with a drop-off at the end 169 + let hrType = HKQuantityType(.heartRate) 170 + let bpmUnit = HKUnit.count().unitDivided(by: .minute()) 171 + let sampleInterval: TimeInterval = 5 // every 5 seconds 172 + let totalSamples = Int(duration / sampleInterval) 173 + var hrSamples: [HKQuantitySample] = [] 174 + 175 + for i in 0..<totalSamples { 176 + let elapsed = Double(i) / Double(totalSamples) 177 + let sampleDate = start.addingTimeInterval(Double(i) * sampleInterval) 178 + 179 + let bpm: Double 180 + if elapsed < 0.05 { 181 + // Ramp up 182 + bpm = 70 + (elapsed / 0.05) * 80 183 + } else if elapsed < 0.47 { 184 + // Sustained effort (~28 min of a 60 min workout, or ~70% of shorter ones) 185 + bpm = 145 + Double.random(in: -15...15) 186 + } else if elapsed < 0.5 { 187 + // Sharp drop-off 188 + let dropProgress = (elapsed - 0.47) / 0.03 189 + bpm = 145 - dropProgress * 80 190 + } else { 191 + // Resting / idle tail (forgot to stop — whole second half) 192 + bpm = 62 + Double.random(in: -5...8) 193 + } 194 + 195 + let quantity = HKQuantity(unit: bpmUnit, doubleValue: max(bpm, 50)) 196 + let sample = HKQuantitySample( 197 + type: hrType, 198 + quantity: quantity, 199 + start: sampleDate, 200 + end: sampleDate.addingTimeInterval(sampleInterval) 201 + ) 202 + hrSamples.append(sample) 203 + } 204 + 205 + try await builder.addSamples(hrSamples) 206 + try await builder.endCollection(at: end) 207 + try await builder.finishWorkout() 208 + } 209 + } 210 + #endif 58 211 } 59 212 60 213 enum HealthKitError: LocalizedError {
+64
WorkoutEditor/RangeSliderView.swift
··· 1 + import SwiftUI 2 + 3 + struct RangeSliderView: View { 4 + @Binding var lowerBound: Double 5 + @Binding var upperBound: Double 6 + 7 + private let thumbSize: CGFloat = 24 8 + private let trackHeight: CGFloat = 6 9 + private let minimumGap: Double = 0.02 10 + 11 + var body: some View { 12 + GeometryReader { geometry in 13 + let width = geometry.size.width - thumbSize 14 + ZStack(alignment: .leading) { 15 + // Background track 16 + Capsule() 17 + .fill(Color(UIColor.systemGray4)) 18 + .frame(height: trackHeight) 19 + .padding(.horizontal, thumbSize / 2) 20 + 21 + // Selected range 22 + Capsule() 23 + .fill(Color.accentColor) 24 + .frame( 25 + width: CGFloat(upperBound - lowerBound) * width, 26 + height: trackHeight 27 + ) 28 + .offset(x: thumbSize / 2 + CGFloat(lowerBound) * width) 29 + 30 + // Lower thumb 31 + thumb() 32 + .offset(x: CGFloat(lowerBound) * width) 33 + .gesture( 34 + DragGesture() 35 + .onChanged { value in 36 + let new = Double(value.location.x / width) 37 + lowerBound = min(max(new, 0), upperBound - minimumGap) 38 + } 39 + ) 40 + 41 + // Upper thumb 42 + thumb() 43 + .offset(x: CGFloat(upperBound) * width) 44 + .gesture( 45 + DragGesture() 46 + .onChanged { value in 47 + let new = Double(value.location.x / width) 48 + upperBound = max(min(new, 1), lowerBound + minimumGap) 49 + } 50 + ) 51 + } 52 + .frame(height: 44) 53 + } 54 + .frame(height: 44) 55 + } 56 + 57 + private func thumb() -> some View { 58 + Circle() 59 + .fill(.white) 60 + .shadow(color: .black.opacity(0.15), radius: 3, y: 1) 61 + .frame(width: thumbSize, height: thumbSize) 62 + .contentShape(Rectangle().size(width: 44, height: 44)) 63 + } 64 + }
-115
WorkoutEditor/WorkoutAddView.swift
··· 1 - import SwiftUI 2 - import HealthKit 3 - 4 - struct WorkoutAddView: View { 5 - @Environment(\.dismiss) private var dismiss 6 - 7 - @State private var selectedActivityType: HKWorkoutActivityType = .running 8 - @State private var startTime = Date() 9 - @State private var endTime = Date() 10 - @State private var errorMessage: String? 11 - 12 - private var isValid: Bool { 13 - endTime > startTime 14 - } 15 - 16 - var body: some View { 17 - Form { 18 - Section("New Workout Details") { 19 - DatePicker("Start", selection: $startTime) 20 - DatePicker("End", selection: $endTime) 21 - 22 - Picker("Activity Type", selection: $selectedActivityType) { 23 - ForEach(HKWorkoutActivityType.commonTypes, id: \.self) { type in 24 - Text(type.activityTypeDescription).tag(type) 25 - } 26 - } 27 - } 28 - 29 - if !isValid { 30 - Section { 31 - Label("End time must be after start time.", systemImage: "exclamationmark.triangle") 32 - .foregroundStyle(.red) 33 - } 34 - } 35 - 36 - Section { 37 - Button("Save Workout") { 38 - saveWorkout() 39 - } 40 - .disabled(!isValid) 41 - } 42 - 43 - if let errorMessage { 44 - Section { 45 - Label(errorMessage, systemImage: "xmark.circle") 46 - .foregroundStyle(.red) 47 - } 48 - } 49 - } 50 - .navigationTitle("Add Workout") 51 - .navigationBarTitleDisplayMode(.inline) 52 - } 53 - 54 - private func saveWorkout() { 55 - Task { 56 - do { 57 - let metadata = [HKMetadataKeyTimeZone: TimeZone.current.identifier] 58 - try await HealthKitManager.shared.saveWorkout( 59 - activityType: selectedActivityType, 60 - start: startTime, 61 - end: endTime, 62 - metadata: metadata 63 - ) 64 - await HealthKitManager.shared.loadWorkouts() 65 - dismiss() 66 - } catch { 67 - errorMessage = error.localizedDescription 68 - } 69 - } 70 - } 71 - } 72 - 73 - // MARK: - Activity Type Helpers 74 - 75 - extension HKWorkoutActivityType { 76 - static let commonTypes: [HKWorkoutActivityType] = [ 77 - .running, .walking, .hiking, .cycling, .swimming, 78 - .yoga, .functionalStrengthTraining, .traditionalStrengthTraining, 79 - .crossTraining, .elliptical, .rowing, .stairClimbing, 80 - .highIntensityIntervalTraining, .socialDance, .cooldown, 81 - .coreTraining, .pilates, .kickboxing, .boxing, .climbing 82 - ] 83 - 84 - var activityTypeDescription: String { 85 - switch self { 86 - case .running: "Running" 87 - case .walking: "Walking" 88 - case .hiking: "Hiking" 89 - case .cycling: "Cycling" 90 - case .swimming: "Swimming" 91 - case .yoga: "Yoga" 92 - case .functionalStrengthTraining: "Functional Strength" 93 - case .traditionalStrengthTraining: "Strength Training" 94 - case .crossTraining: "Cross Training" 95 - case .elliptical: "Elliptical" 96 - case .rowing: "Rowing" 97 - case .stairClimbing: "Stair Climbing" 98 - case .highIntensityIntervalTraining: "HIIT" 99 - case .socialDance: "Dance" 100 - case .cooldown: "Cooldown" 101 - case .coreTraining: "Core Training" 102 - case .pilates: "Pilates" 103 - case .kickboxing: "Kickboxing" 104 - case .boxing: "Boxing" 105 - case .climbing: "Climbing" 106 - default: "Other" 107 - } 108 - } 109 - } 110 - 111 - #Preview { 112 - NavigationStack { 113 - WorkoutAddView() 114 - } 115 - }
+183 -42
WorkoutEditor/WorkoutEditView.swift
··· 5 5 let workout: HKWorkout 6 6 @Environment(\.dismiss) private var dismiss 7 7 8 - @State private var editedStartTime: Date 9 - @State private var editedEndTime: Date 8 + @State private var lowerBound: Double = 0 9 + @State private var upperBound: Double = 1 10 10 @State private var editedActivityType: HKWorkoutActivityType 11 + @State private var intensitySamples: [IntensitySample] = [] 12 + @State private var intensityMetric: IntensityMetric = .heartRate 13 + @State private var isLoadingSamples = true 11 14 @State private var showSaveAlert = false 12 15 @State private var showDeleteAlert = false 13 16 @State private var errorMessage: String? 14 17 15 18 init(workout: HKWorkout) { 16 19 self.workout = workout 17 - _editedStartTime = State(initialValue: workout.startDate) 18 - _editedEndTime = State(initialValue: workout.endDate) 19 20 _editedActivityType = State(initialValue: workout.workoutActivityType) 20 21 } 21 22 23 + private var workoutDuration: TimeInterval { 24 + workout.endDate.timeIntervalSince(workout.startDate) 25 + } 26 + 27 + private var trimmedStartDate: Date { 28 + workout.startDate.addingTimeInterval(lowerBound * workoutDuration) 29 + } 30 + 31 + private var trimmedEndDate: Date { 32 + workout.startDate.addingTimeInterval(upperBound * workoutDuration) 33 + } 34 + 35 + private var trimmedDuration: TimeInterval { 36 + trimmedEndDate.timeIntervalSince(trimmedStartDate) 37 + } 38 + 22 39 private var isValid: Bool { 23 - editedEndTime > editedStartTime 40 + trimmedDuration > 0 24 41 } 25 42 26 43 var body: some View { 27 - Form { 28 - Section("Workout Details") { 29 - DatePicker("Start", selection: $editedStartTime) 30 - DatePicker("End", selection: $editedEndTime) 44 + ScrollView { 45 + VStack(spacing: 20) { 46 + // Activity graph 47 + Group { 48 + if isLoadingSamples { 49 + ProgressView("Loading activity data...") 50 + .frame(height: 200) 51 + } else if intensitySamples.isEmpty { 52 + ContentUnavailableView( 53 + "No Activity Data", 54 + systemImage: "waveform.slash", 55 + description: Text("No heart rate or activity samples found for this workout.") 56 + ) 57 + .frame(height: 200) 58 + } else { 59 + ActivityGraphView( 60 + samples: intensitySamples, 61 + metric: intensityMetric, 62 + lowerBound: lowerBound, 63 + upperBound: upperBound 64 + ) 65 + .frame(height: 200) 66 + } 67 + } 68 + .padding(.horizontal) 69 + 70 + // Range slider 71 + RangeSliderView(lowerBound: $lowerBound, upperBound: $upperBound) 72 + .padding(.horizontal) 73 + 74 + // Time labels 75 + HStack { 76 + VStack(alignment: .leading) { 77 + Text("Start") 78 + .font(.caption) 79 + .foregroundStyle(.secondary) 80 + Text(trimmedStartDate.formatted(date: .omitted, time: .shortened)) 81 + .font(.subheadline.monospacedDigit()) 82 + } 31 83 32 - Picker("Activity Type", selection: $editedActivityType) { 33 - ForEach(HKWorkoutActivityType.commonTypes, id: \.self) { type in 34 - Text(type.activityTypeDescription).tag(type) 84 + Spacer() 85 + 86 + VStack { 87 + Text("Duration") 88 + .font(.caption) 89 + .foregroundStyle(.secondary) 90 + Text(formattedDuration(trimmedDuration)) 91 + .font(.subheadline.monospacedDigit()) 92 + } 93 + 94 + Spacer() 95 + 96 + VStack(alignment: .trailing) { 97 + Text("End") 98 + .font(.caption) 99 + .foregroundStyle(.secondary) 100 + Text(trimmedEndDate.formatted(date: .omitted, time: .shortened)) 101 + .font(.subheadline.monospacedDigit()) 35 102 } 36 103 } 37 - } 104 + .padding(.horizontal) 38 105 39 - if !isValid { 40 - Section { 41 - Label("End time must be after start time.", systemImage: "exclamationmark.triangle") 42 - .foregroundStyle(.red) 106 + Divider() 107 + .padding(.horizontal) 108 + 109 + // Activity type picker 110 + HStack { 111 + Text("Activity Type") 112 + Spacer() 113 + Picker("Activity Type", selection: $editedActivityType) { 114 + ForEach(HKWorkoutActivityType.commonTypes, id: \.self) { type in 115 + Text(type.activityTypeDescription).tag(type) 116 + } 117 + } 118 + .labelsHidden() 43 119 } 44 - } 120 + .padding(.horizontal) 121 + 122 + Divider() 123 + .padding(.horizontal) 45 124 46 - Section { 47 - Button("Save Changes") { 48 - showSaveAlert = true 49 - } 50 - .disabled(!isValid) 51 - .alert("Save Changes", isPresented: $showSaveAlert) { 52 - Button("Save") { saveChanges() } 53 - Button("Cancel", role: .cancel) {} 54 - } message: { 55 - Text("Are you sure you want to save these changes?") 56 - } 125 + // Action buttons 126 + VStack(spacing: 12) { 127 + Button { 128 + showSaveAlert = true 129 + } label: { 130 + Text("Save Changes") 131 + .frame(maxWidth: .infinity) 132 + } 133 + .buttonStyle(.borderedProminent) 134 + .disabled(!isValid) 57 135 58 - Button("Delete Workout", role: .destructive) { 59 - showDeleteAlert = true 60 - } 61 - .alert("Delete Workout", isPresented: $showDeleteAlert) { 62 - Button("Delete", role: .destructive) { deleteWorkout() } 63 - Button("Cancel", role: .cancel) {} 64 - } message: { 65 - Text("This action cannot be undone.") 136 + Button(role: .destructive) { 137 + showDeleteAlert = true 138 + } label: { 139 + Text("Delete Workout") 140 + .frame(maxWidth: .infinity) 141 + } 142 + .buttonStyle(.bordered) 66 143 } 67 - } 144 + .padding(.horizontal) 68 145 69 - if let errorMessage { 70 - Section { 146 + if let errorMessage { 71 147 Label(errorMessage, systemImage: "xmark.circle") 72 148 .foregroundStyle(.red) 149 + .padding(.horizontal) 73 150 } 74 151 } 152 + .padding(.vertical) 75 153 } 76 154 .navigationTitle(workout.workoutActivityType.activityTypeDescription) 77 155 .navigationBarTitleDisplayMode(.inline) 156 + .task { 157 + let result = await HealthKitManager.shared.fetchIntensitySamples(for: workout) 158 + intensitySamples = result.samples 159 + intensityMetric = result.metric 160 + isLoadingSamples = false 161 + } 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 + .alert("Delete Workout", isPresented: $showDeleteAlert) { 169 + Button("Delete", role: .destructive) { deleteWorkout() } 170 + Button("Cancel", role: .cancel) {} 171 + } message: { 172 + Text("This action cannot be undone.") 173 + } 174 + } 175 + 176 + private func formattedDuration(_ duration: TimeInterval) -> String { 177 + let formatter = DateComponentsFormatter() 178 + formatter.allowedUnits = [.hour, .minute, .second] 179 + formatter.unitsStyle = .abbreviated 180 + return formatter.string(from: duration) ?? "" 78 181 } 79 182 80 183 private func saveChanges() { ··· 82 185 do { 83 186 try await HealthKitManager.shared.saveWorkout( 84 187 activityType: editedActivityType, 85 - start: editedStartTime, 86 - end: editedEndTime, 188 + start: trimmedStartDate, 189 + end: trimmedEndDate, 87 190 metadata: workout.metadata as [String: Any]? 88 191 ) 89 192 try await HealthKitManager.shared.deleteWorkout(workout) ··· 104 207 } catch { 105 208 errorMessage = error.localizedDescription 106 209 } 210 + } 211 + } 212 + } 213 + 214 + // MARK: - Activity Type Helpers 215 + 216 + extension HKWorkoutActivityType { 217 + static let commonTypes: [HKWorkoutActivityType] = [ 218 + .running, .walking, .hiking, .cycling, .swimming, 219 + .yoga, .functionalStrengthTraining, .traditionalStrengthTraining, 220 + .crossTraining, .elliptical, .rowing, .stairClimbing, 221 + .highIntensityIntervalTraining, .socialDance, .cooldown, 222 + .coreTraining, .pilates, .kickboxing, .boxing, .climbing 223 + ] 224 + 225 + var activityTypeDescription: String { 226 + switch self { 227 + case .running: "Running" 228 + case .walking: "Walking" 229 + case .hiking: "Hiking" 230 + case .cycling: "Cycling" 231 + case .swimming: "Swimming" 232 + case .yoga: "Yoga" 233 + case .functionalStrengthTraining: "Functional Strength" 234 + case .traditionalStrengthTraining: "Strength Training" 235 + case .crossTraining: "Cross Training" 236 + case .elliptical: "Elliptical" 237 + case .rowing: "Rowing" 238 + case .stairClimbing: "Stair Climbing" 239 + case .highIntensityIntervalTraining: "HIIT" 240 + case .socialDance: "Dance" 241 + case .cooldown: "Cooldown" 242 + case .coreTraining: "Core Training" 243 + case .pilates: "Pilates" 244 + case .kickboxing: "Kickboxing" 245 + case .boxing: "Boxing" 246 + case .climbing: "Climbing" 247 + default: "Other" 107 248 } 108 249 } 109 250 }
+15 -1
WorkoutEditor/WorkoutEditorApp.swift
··· 1 1 import SwiftUI 2 + import os 3 + 4 + private let logger = Logger(subsystem: "com.workouteditor", category: "app") 2 5 3 6 @main 4 7 struct WorkoutEditorApp: App { ··· 8 11 .task { 9 12 do { 10 13 try await HealthKitManager.shared.requestAuthorization() 14 + logger.notice("Auth succeeded") 11 15 await HealthKitManager.shared.loadWorkouts() 16 + logger.notice("Loaded \(HealthKitManager.shared.loadedWorkouts.count) workouts") 17 + #if DEBUG 18 + // Reset sample data 19 + for workout in HealthKitManager.shared.loadedWorkouts { 20 + try await HealthKitManager.shared.deleteWorkout(workout) 21 + } 22 + try await HealthKitManager.shared.insertSampleWorkouts() 23 + await HealthKitManager.shared.loadWorkouts() 24 + logger.notice("Reset samples: \(HealthKitManager.shared.loadedWorkouts.count) workouts") 25 + #endif 12 26 } catch { 13 - print("HealthKit authorization error: \(error.localizedDescription)") 27 + logger.error("HealthKit error: \(error)") 14 28 } 15 29 } 16 30 }
+6 -10
WorkoutEditor/WorkoutListView.swift
··· 3 3 4 4 struct WorkoutListView: View { 5 5 var manager = HealthKitManager.shared 6 + @Environment(\.scenePhase) private var scenePhase 6 7 7 8 var body: some View { 8 9 NavigationStack { ··· 28 29 WorkoutEditView(workout: workout) 29 30 } 30 31 .navigationTitle("Workouts") 31 - .toolbar { 32 - ToolbarItem(placement: .topBarTrailing) { 33 - NavigationLink(value: "add") { 34 - Image(systemName: "plus") 35 - } 36 - } 37 - } 38 - .navigationDestination(for: String.self) { _ in 39 - WorkoutAddView() 40 - } 41 32 .refreshable { 42 33 await manager.loadWorkouts() 34 + } 35 + .onChange(of: scenePhase) { _, phase in 36 + if phase == .active { 37 + Task { await manager.loadWorkouts() } 38 + } 43 39 } 44 40 .overlay { 45 41 if manager.loadedWorkouts.isEmpty {