| @@ -0,0 +1,415 @@ |
| |
1 | import SwiftUI |
| |
2 | import WidgetKit |
| |
3 | |
| |
4 | struct ContributionGraphEntry: TimelineEntry { |
| |
5 | let date: Date |
| |
6 | let actor: String? |
| |
7 | let state: State |
| |
8 | let weeks: [ContributionGraphWeek] |
| |
9 | |
| |
10 | enum State { |
| |
11 | case placeholder |
| |
12 | case unavailable |
| |
13 | case empty |
| |
14 | case indexing |
| |
15 | case populated |
| |
16 | } |
| |
17 | } |
| |
18 | |
| |
19 | struct ContributionGraphTimelineProvider: TimelineProvider { |
| |
20 | func placeholder(in _: Context) -> ContributionGraphEntry { |
| |
21 | ContributionGraphEntry( |
| |
22 | date: .now, |
| |
23 | actor: "~ccleberg", |
| |
24 | state: .placeholder, |
| |
25 | weeks: ContributionGraphSampleData.placeholderWeeks |
| |
26 | ) |
| |
27 | } |
| |
28 | |
| |
29 | func getSnapshot(in _: Context, completion: @escaping (ContributionGraphEntry) -> Void) { |
| |
30 | Task { |
| |
31 | completion(await loadEntry()) |
| |
32 | } |
| |
33 | } |
| |
34 | |
| |
35 | func getTimeline(in _: Context, completion: @escaping (Timeline<ContributionGraphEntry>) -> Void) { |
| |
36 | Task { |
| |
37 | let entry = await loadEntry() |
| |
38 | let refreshDate = Calendar.contributionCalendar.date(byAdding: .hour, value: 1, to: Date()) ?? Date().addingTimeInterval(3600) |
| |
39 | completion(Timeline(entries: [entry], policy: .after(refreshDate))) |
| |
40 | } |
| |
41 | } |
| |
42 | |
| |
43 | private func loadEntry() async -> ContributionGraphEntry { |
| |
44 | guard let actor = ContributionWidgetContextStore.loadActor(), !actor.isEmpty else { |
| |
45 | return ContributionGraphEntry(date: .now, actor: nil, state: .unavailable, weeks: []) |
| |
46 | } |
| |
47 | |
| |
48 | do { |
| |
49 | let response = try await ContributionGraphWidgetService().fetchCalendar(actor: actor) |
| |
50 | let state: ContributionGraphEntry.State |
| |
51 | if response.totalCount > 0 { |
| |
52 | state = .populated |
| |
53 | } else { |
| |
54 | switch response.indexingState { |
| |
55 | case .pending: |
| |
56 | state = .indexing |
| |
57 | case .error: |
| |
58 | state = .unavailable |
| |
59 | case .indexed: |
| |
60 | state = .empty |
| |
61 | } |
| |
62 | } |
| |
63 | |
| |
64 | return ContributionGraphEntry( |
| |
65 | date: .now, |
| |
66 | actor: actor, |
| |
67 | state: state, |
| |
68 | weeks: ContributionGraphLayout.weekColumns(from: response.days) |
| |
69 | ) |
| |
70 | } catch { |
| |
71 | return ContributionGraphEntry(date: .now, actor: actor, state: .unavailable, weeks: []) |
| |
72 | } |
| |
73 | } |
| |
74 | } |
| |
75 | |
| |
76 | struct ContributionGraphWidget: Widget { |
| |
77 | static let kind = ContributionGraphWidgetConfiguration.kind |
| |
78 | |
| |
79 | var body: some WidgetConfiguration { |
| |
80 | StaticConfiguration(kind: Self.kind, provider: ContributionGraphTimelineProvider()) { entry in |
| |
81 | ContributionGraphWidgetView(entry: entry) |
| |
82 | } |
| |
83 | .configurationDisplayName("Contribution Graph") |
| |
84 | .description("A trailing 365-day SourceHut contribution heatmap for your current profile.") |
| |
85 | .supportedFamilies([.systemSmall, .systemMedium]) |
| |
86 | } |
| |
87 | } |
| |
88 | |
| |
89 | private struct ContributionGraphWidgetView: View { |
| |
90 | let entry: ContributionGraphEntry |
| |
91 | @Environment(\.widgetFamily) private var family |
| |
92 | |
| |
93 | var body: some View { |
| |
94 | GeometryReader { geometry in |
| |
95 | let cellSize = ContributionGraphSizing.baseCellSize(availableHeight: geometry.size.height) |
| |
96 | let layout = ContributionGraphSizing.layout(availableSize: geometry.size, cellSize: cellSize) |
| |
97 | let weeks = displayedWeeks(columnCount: layout.columns) |
| |
98 | |
| |
99 | let actualSize = ContributionGraphSizing.contentSize( |
| |
100 | columns: weeks.count, cellSize: layout.cellSize, spacing: layout.spacing |
| |
101 | ) |
| |
102 | |
| |
103 | ContributionGraphGridView( |
| |
104 | weeks: weeks, |
| |
105 | squareSize: layout.cellSize, |
| |
106 | spacing: layout.spacing |
| |
107 | ) |
| |
108 | .frame(width: actualSize.width, height: actualSize.height) |
| |
109 | .frame(maxWidth: .infinity, maxHeight: .infinity) |
| |
110 | } |
| |
111 | .widgetURL(URL(string: "hutch://home")) |
| |
112 | .containerBackground(for: .widget) { |
| |
113 | Color(.systemBackground) |
| |
114 | } |
| |
115 | } |
| |
116 | |
| |
117 | private func displayedWeeks(columnCount: Int) -> [ContributionGraphWeek] { |
| |
118 | var baseWeeks = switch entry.state { |
| |
119 | case .populated, .indexing, .empty: |
| |
120 | entry.weeks |
| |
121 | case .placeholder, .unavailable: |
| |
122 | ContributionGraphSampleData.placeholderWeeks |
| |
123 | } |
| |
124 | |
| |
125 | // Drop any trailing week where every day has zero contributions |
| |
126 | while let last = baseWeeks.last, last.days.allSatisfy({ $0.count == 0 }) { |
| |
127 | baseWeeks.removeLast() |
| |
128 | } |
| |
129 | |
| |
130 | return Array(baseWeeks.suffix(columnCount)) |
| |
131 | } |
| |
132 | } |
| |
133 | |
| |
134 | private struct ContributionGraphGridView: View { |
| |
135 | let weeks: [ContributionGraphWeek] |
| |
136 | let squareSize: CGFloat |
| |
137 | let spacing: CGFloat |
| |
138 | |
| |
139 | var body: some View { |
| |
140 | HStack(alignment: .top, spacing: spacing) { |
| |
141 | ForEach(weeks, id: \.startDate) { week in |
| |
142 | VStack(spacing: spacing) { |
| |
143 | ForEach(Array(week.slots.enumerated()), id: \.offset) { _, day in |
| |
144 | RoundedRectangle(cornerRadius: squareSize * 0.2, style: .continuous) |
| |
145 | .fill((day?.intensity ?? .empty).color) |
| |
146 | .frame(width: squareSize, height: squareSize) |
| |
147 | } |
| |
148 | } |
| |
149 | } |
| |
150 | } |
| |
151 | } |
| |
152 | } |
| |
153 | |
| |
154 | private enum ContributionGraphSizing { |
| |
155 | static let rowCount = 7 |
| |
156 | static let cellSpacing: CGFloat = 3 |
| |
157 | |
| |
158 | struct GridLayout { |
| |
159 | let rows: Int |
| |
160 | let columns: Int |
| |
161 | let cellSize: CGFloat |
| |
162 | let spacing: CGFloat |
| |
163 | |
| |
164 | var contentSize: CGSize { |
| |
165 | let w = CGFloat(columns) * cellSize + CGFloat(max(0, columns - 1)) * spacing |
| |
166 | let h = CGFloat(rows) * cellSize + CGFloat(max(0, rows - 1)) * spacing |
| |
167 | return CGSize(width: w, height: h) |
| |
168 | } |
| |
169 | } |
| |
170 | |
| |
171 | /// Cell size derived from available height so 7 rows fill it exactly. |
| |
172 | static func baseCellSize(availableHeight: CGFloat) -> CGFloat { |
| |
173 | floor((availableHeight - cellSpacing * CGFloat(rowCount - 1)) / CGFloat(rowCount)) |
| |
174 | } |
| |
175 | |
| |
176 | /// Compute layout for any family: 7 rows, as many columns as fit at the given cell size. |
| |
177 | static func layout(availableSize: CGSize, cellSize: CGFloat) -> GridLayout { |
| |
178 | let columns = max(1, Int(floor((availableSize.width + cellSpacing) / (cellSize + cellSpacing)))) |
| |
179 | return GridLayout(rows: rowCount, columns: columns, cellSize: cellSize, spacing: cellSpacing) |
| |
180 | } |
| |
181 | |
| |
182 | /// Content size for the actual number of displayed columns (may differ from layout max). |
| |
183 | static func contentSize(columns: Int, cellSize: CGFloat, spacing: CGFloat) -> CGSize { |
| |
184 | let w = CGFloat(columns) * cellSize + CGFloat(max(0, columns - 1)) * spacing |
| |
185 | let h = CGFloat(rowCount) * cellSize + CGFloat(max(0, rowCount - 1)) * spacing |
| |
186 | return CGSize(width: w, height: h) |
| |
187 | } |
| |
188 | } |
| |
189 | |
| |
190 | private struct ContributionGraphWidgetService { |
| |
191 | private let baseURL = URL(string: "https://hutch-stats.zerolabs.sh")! |
| |
192 | |
| |
193 | func fetchCalendar(actor: String) async throws -> ContributionGraphResponse { |
| |
194 | guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { |
| |
195 | throw URLError(.badURL) |
| |
196 | } |
| |
197 | |
| |
198 | components.path = "/api/contributions/\(actor)" |
| |
199 | let range = trailingRange(endingOn: Date()) |
| |
200 | components.queryItems = [ |
| |
201 | URLQueryItem(name: "from", value: Self.rangeFormatter.string(from: range.lowerBound)), |
| |
202 | URLQueryItem(name: "to", value: Self.rangeFormatter.string(from: range.upperBound)) |
| |
203 | ] |
| |
204 | |
| |
205 | guard let url = components.url else { |
| |
206 | throw URLError(.badURL) |
| |
207 | } |
| |
208 | |
| |
209 | let (data, response) = try await URLSession.shared.data(from: url) |
| |
210 | if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { |
| |
211 | throw URLError(.badServerResponse) |
| |
212 | } |
| |
213 | |
| |
214 | return try JSONDecoder().decode(ContributionGraphResponse.self, from: data) |
| |
215 | } |
| |
216 | |
| |
217 | private func trailingRange(endingOn endDate: Date) -> ClosedRange<Date> { |
| |
218 | let normalizedEndDate = Calendar.contributionCalendar.startOfDay(for: endDate) |
| |
219 | let oneYearBack = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: normalizedEndDate) ?? normalizedEndDate |
| |
220 | let normalizedStartDate = Calendar.contributionCalendar.date(byAdding: .day, value: 1, to: oneYearBack) ?? oneYearBack |
| |
221 | return normalizedStartDate...normalizedEndDate |
| |
222 | } |
| |
223 | |
| |
224 | private static let rangeFormatter: DateFormatter = { |
| |
225 | let formatter = DateFormatter() |
| |
226 | formatter.calendar = .contributionCalendar |
| |
227 | formatter.locale = Locale(identifier: "en_US_POSIX") |
| |
228 | formatter.timeZone = TimeZone(secondsFromGMT: 0) |
| |
229 | formatter.dateFormat = "yyyy-MM-dd" |
| |
230 | return formatter |
| |
231 | }() |
| |
232 | } |
| |
233 | |
| |
234 | private struct ContributionGraphResponse: Decodable { |
| |
235 | let actor: String |
| |
236 | let indexingState: ContributionGraphIndexingState |
| |
237 | let days: [ContributionGraphDay] |
| |
238 | |
| |
239 | enum CodingKeys: String, CodingKey { |
| |
240 | case actor |
| |
241 | case indexingState = "indexing_state" |
| |
242 | case days |
| |
243 | } |
| |
244 | |
| |
245 | var totalCount: Int { |
| |
246 | days.reduce(0) { $0 + $1.count } |
| |
247 | } |
| |
248 | } |
| |
249 | |
| |
250 | struct ContributionGraphDay: Decodable, Identifiable { |
| |
251 | var id: Date { date } |
| |
252 | |
| |
253 | let date: Date |
| |
254 | let count: Int |
| |
255 | |
| |
256 | enum CodingKeys: String, CodingKey { |
| |
257 | case date |
| |
258 | case count |
| |
259 | } |
| |
260 | |
| |
261 | var intensity: ContributionGraphIntensity { |
| |
262 | ContributionGraphIntensity(count: count) |
| |
263 | } |
| |
264 | |
| |
265 | init(from decoder: Decoder) throws { |
| |
266 | let container = try decoder.container(keyedBy: CodingKeys.self) |
| |
267 | date = try ContributionGraphDateParser.decodeDateString(from: container, forKey: .date) |
| |
268 | count = try container.decode(Int.self, forKey: .count) |
| |
269 | } |
| |
270 | } |
| |
271 | |
| |
272 | private enum ContributionGraphIndexingState: String, Decodable { |
| |
273 | case pending |
| |
274 | case indexed |
| |
275 | case error |
| |
276 | } |
| |
277 | |
| |
278 | enum ContributionGraphIntensity: Int, CaseIterable { |
| |
279 | case empty = 0 |
| |
280 | case level1 = 1 |
| |
281 | case level2 = 2 |
| |
282 | case level3 = 3 |
| |
283 | case level4 = 4 |
| |
284 | |
| |
285 | init(count: Int) { |
| |
286 | switch count { |
| |
287 | case ..<1: |
| |
288 | self = .empty |
| |
289 | case 1: |
| |
290 | self = .level1 |
| |
291 | case 2...3: |
| |
292 | self = .level2 |
| |
293 | case 4...6: |
| |
294 | self = .level3 |
| |
295 | default: |
| |
296 | self = .level4 |
| |
297 | } |
| |
298 | } |
| |
299 | |
| |
300 | var color: Color { |
| |
301 | switch self { |
| |
302 | case .empty: |
| |
303 | Color(uiColor: .secondarySystemFill) |
| |
304 | case .level1: |
| |
305 | Color(red: 0.82, green: 0.92, blue: 0.83) |
| |
306 | case .level2: |
| |
307 | Color(red: 0.58, green: 0.83, blue: 0.61) |
| |
308 | case .level3: |
| |
309 | Color(red: 0.25, green: 0.69, blue: 0.36) |
| |
310 | case .level4: |
| |
311 | Color(red: 0.12, green: 0.47, blue: 0.21) |
| |
312 | } |
| |
313 | } |
| |
314 | } |
| |
315 | |
| |
316 | struct ContributionGraphWeek { |
| |
317 | let startDate: Date |
| |
318 | let days: [ContributionGraphDay] |
| |
319 | |
| |
320 | var slots: [ContributionGraphDay?] { |
| |
321 | let calendar = Calendar.contributionCalendar |
| |
322 | let indexedDays = Dictionary(uniqueKeysWithValues: days.map { day in |
| |
323 | (calendar.component(.weekday, from: day.date), day) |
| |
324 | }) |
| |
325 | |
| |
326 | return (1...7).map { weekday in |
| |
327 | indexedDays[weekday] |
| |
328 | } |
| |
329 | } |
| |
330 | } |
| |
331 | |
| |
332 | private enum ContributionGraphLayout { |
| |
333 | static func weekColumns(from days: [ContributionGraphDay]) -> [ContributionGraphWeek] { |
| |
334 | let groupedDays = Dictionary(grouping: days) { day in |
| |
335 | Calendar.contributionCalendar.startOfWeek(for: day.date) |
| |
336 | } |
| |
337 | |
| |
338 | return groupedDays.keys.sorted().map { weekStart in |
| |
339 | ContributionGraphWeek( |
| |
340 | startDate: weekStart, |
| |
341 | days: groupedDays[weekStart, default: []].sorted { $0.date < $1.date } |
| |
342 | ) |
| |
343 | } |
| |
344 | } |
| |
345 | } |
| |
346 | |
| |
347 | private enum ContributionGraphDateParser { |
| |
348 | static func parse(_ rawValue: String) -> Date? { |
| |
349 | let parts = rawValue.split(separator: "-", omittingEmptySubsequences: false) |
| |
350 | guard |
| |
351 | parts.count == 3, |
| |
352 | let year = Int(parts[0]), |
| |
353 | let month = Int(parts[1]), |
| |
354 | let day = Int(parts[2]) |
| |
355 | else { |
| |
356 | return nil |
| |
357 | } |
| |
358 | |
| |
359 | var components = DateComponents() |
| |
360 | components.calendar = .contributionCalendar |
| |
361 | components.timeZone = TimeZone(secondsFromGMT: 0) |
| |
362 | components.year = year |
| |
363 | components.month = month |
| |
364 | components.day = day |
| |
365 | return components.date |
| |
366 | } |
| |
367 | |
| |
368 | static func decodeDateString<Key: CodingKey>( |
| |
369 | from container: KeyedDecodingContainer<Key>, |
| |
370 | forKey key: Key |
| |
371 | ) throws -> Date { |
| |
372 | let rawValue = try container.decode(String.self, forKey: key) |
| |
373 | guard let date = parse(rawValue) else { |
| |
374 | throw DecodingError.dataCorruptedError( |
| |
375 | forKey: key, |
| |
376 | in: container, |
| |
377 | debugDescription: "Invalid contribution date: \(rawValue)" |
| |
378 | ) |
| |
379 | } |
| |
380 | return date |
| |
381 | } |
| |
382 | } |
| |
383 | |
| |
384 | private enum ContributionGraphSampleData { |
| |
385 | static let placeholderWeeks: [ContributionGraphWeek] = { |
| |
386 | let startDate = Calendar.contributionCalendar.startOfDay(for: .now) |
| |
387 | let days = (0..<150).compactMap { offset -> ContributionGraphDay? in |
| |
388 | guard let date = Calendar.contributionCalendar.date(byAdding: .day, value: -offset, to: startDate) else { |
| |
389 | return nil |
| |
390 | } |
| |
391 | return ContributionGraphDay(date: date, count: (offset % 8), score: 0) |
| |
392 | } |
| |
393 | return ContributionGraphLayout.weekColumns(from: days) |
| |
394 | }() |
| |
395 | } |
| |
396 | |
| |
397 | private extension ContributionGraphDay { |
| |
398 | init(date: Date, count: Int, score _: Double) { |
| |
399 | self.date = date |
| |
400 | self.count = count |
| |
401 | } |
| |
402 | } |
| |
403 | |
| |
404 | private extension Calendar { |
| |
405 | static var contributionCalendar: Calendar { |
| |
406 | var calendar = Calendar(identifier: .gregorian) |
| |
407 | calendar.firstWeekday = 1 |
| |
408 | calendar.timeZone = TimeZone(secondsFromGMT: 0)! |
| |
409 | return calendar |
| |
410 | } |
| |
411 | |
| |
412 | func startOfWeek(for date: Date) -> Date { |
| |
413 | dateInterval(of: .weekOfYear, for: date)?.start ?? startOfDay(for: date) |
| |
414 | } |
| |
415 | } |