Apple Fitness workout fixer + Strava uploader
at main 217 lines 7.8 kB view raw
1import SwiftUI 2import HealthKit 3 4// MARK: - Animated Gradient Header 5 6private struct AnimatedGradientText: View { 7 let text: String 8 9 private static let iconColors: [(r: Double, g: Double, b: Double)] = [ 10 (0, 0.83, 1), // Cyan 11 (0.72, 0.90, 0), // Lime green 12 (1, 0, 0.33), // Hot pink 13 ] 14 15 private static let cycleDuration: TimeInterval = 6 16 17 private static func gradientColors(at date: Date) -> [Color] { 18 let t = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: cycleDuration) / cycleDuration 19 let count = Double(iconColors.count) 20 // Generate 3 evenly-spaced gradient stops, each offset by t 21 return (0..<3).map { i in 22 let pos = (t + Double(i) / 3).truncatingRemainder(dividingBy: 1.0) * count 23 let idx = Int(pos) 24 let frac = pos - Double(idx) 25 let c0 = iconColors[idx % iconColors.count] 26 let c1 = iconColors[(idx + 1) % iconColors.count] 27 return Color( 28 red: c0.r + (c1.r - c0.r) * frac, 29 green: c0.g + (c1.g - c0.g) * frac, 30 blue: c0.b + (c1.b - c0.b) * frac 31 ) 32 } 33 } 34 35 var body: some View { 36 TimelineView(.animation) { timeline in 37 let colors = Self.gradientColors(at: timeline.date) 38 Text(text) 39 .font(.largeTitle.bold()) 40 .overlay { 41 LinearGradient( 42 colors: colors, 43 startPoint: .leading, 44 endPoint: .trailing 45 ) 46 .mask { 47 Text(text) 48 .font(.largeTitle.bold()) 49 } 50 } 51 } 52 } 53} 54 55enum WorkoutSort: CaseIterable { 56 case date 57 case longest 58 case shortest 59 60 var label: LocalizedStringKey { 61 switch self { 62 case .date: "Date" 63 case .longest: "Longest" 64 case .shortest: "Shortest" 65 } 66 } 67} 68 69struct WorkoutListView: View { 70 var manager = HealthKitManager.shared 71 @Environment(\.scenePhase) private var scenePhase 72 @State private var searchText = "" 73 @State private var sortMode: WorkoutSort = .date 74 75 private var filteredWorkouts: [HKWorkout] { 76 var workouts: [HKWorkout] 77 if searchText.isEmpty { 78 workouts = manager.loadedWorkouts 79 } else { 80 workouts = manager.loadedWorkouts.filter { 81 $0.workoutActivityType.activityTypeName 82 .localizedCaseInsensitiveContains(searchText) 83 } 84 } 85 86 switch sortMode { 87 case .date: 88 return workouts 89 case .longest: 90 return workouts.sorted { $0.duration > $1.duration } 91 case .shortest: 92 return workouts.sorted { $0.duration < $1.duration } 93 } 94 } 95 96 private var groupedWorkouts: [(String, [HKWorkout])] { 97 if sortMode != .date { 98 return [("", filteredWorkouts)] 99 } 100 101 let calendar = Calendar.current 102 let formatter = DateFormatter() 103 formatter.dateFormat = "MMMM yyyy" 104 105 let grouped: [DateComponents: [HKWorkout]] = Dictionary(grouping: filteredWorkouts) { workout in 106 calendar.dateComponents([.year, .month], from: workout.startDate) 107 } 108 109 let sorted: [(DateComponents, [HKWorkout])] = grouped.sorted { lhs, rhs in 110 let lDate = calendar.date(from: lhs.key) ?? .distantPast 111 let rDate = calendar.date(from: rhs.key) ?? .distantPast 112 return lDate > rDate 113 } 114 115 return sorted.map { comps, workouts in 116 let date = calendar.date(from: comps) ?? Date() 117 let label = formatter.string(from: date) 118 return (label, workouts) 119 } 120 } 121 122 var body: some View { 123 NavigationStack { 124 List { 125 Section { 126 HStack { 127 Text("Sort by") 128 .font(.subheadline) 129 .foregroundStyle(.secondary) 130 Spacer() 131 Picker("Sort", selection: $sortMode) { 132 ForEach(WorkoutSort.allCases, id: \.self) { mode in 133 Text(mode.label).tag(mode) 134 } 135 } 136 .pickerStyle(.segmented) 137 .frame(width: 220) 138 } 139 } header: { 140 VStack(alignment: .leading, spacing: 4) { 141 AnimatedGradientText(text: "Overrun") 142 Text("Select a workout to fix...") 143 .font(.subheadline) 144 .foregroundStyle(.secondary) 145 } 146 .textCase(nil) 147 } 148 149 ForEach(groupedWorkouts, id: \.0) { section in 150 Section(section.0) { 151 ForEach(section.1, id: \.uuid) { workout in 152 NavigationLink(value: workout) { 153 VStack(alignment: .leading, spacing: 4) { 154 Text(workout.workoutActivityType.activityTypeDescription) 155 .font(.headline) 156 HStack { 157 Text(workout.startDate.formatted(date: .abbreviated, time: .shortened)) 158 .font(.subheadline) 159 .foregroundStyle(.secondary) 160 Spacer() 161 Text(workout.formattedDuration) 162 .font(.subheadline) 163 .foregroundStyle(.secondary) 164 } 165 } 166 .padding(.vertical, 2) 167 } 168 } 169 } 170 } 171 172 if manager.canLoadMore && searchText.isEmpty { 173 ProgressView() 174 .frame(maxWidth: .infinity) 175 .listRowSeparator(.hidden) 176 .onAppear { 177 Task { await manager.loadMoreWorkouts() } 178 } 179 } 180 } 181 .listSectionSpacing(.compact) 182 .searchable(text: $searchText, prompt: "Search by activity type") 183 .navigationDestination(for: HKWorkout.self) { workout in 184 WorkoutEditView(workout: workout) 185 } 186 .navigationTitle("Overrun") 187 .navigationBarTitleDisplayMode(.inline) 188 .toolbar(.hidden, for: .navigationBar) 189 .refreshable { 190 await manager.loadWorkouts() 191 } 192 .onChange(of: scenePhase) { _, phase in 193 if phase == .active { 194 Task { await manager.loadWorkouts() } 195 } 196 } 197 .overlay { 198 if manager.loadedWorkouts.isEmpty { 199 ContentUnavailableView("No Workouts", systemImage: "figure.run", description: Text("Your workouts will appear here.")) 200 } 201 } 202 } 203 } 204} 205 206extension HKWorkout { 207 var formattedDuration: String { 208 let formatter = DateComponentsFormatter() 209 formatter.allowedUnits = [.hour, .minute] 210 formatter.unitsStyle = .abbreviated 211 return formatter.string(from: duration) ?? "" 212 } 213} 214 215#Preview { 216 WorkoutListView() 217}