Apple Fitness workout fixer + Strava uploader
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}