krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.12.0: HutchWidgetExtension/ContributionGraphWidget.swift · raw
1import SwiftUI
2import WidgetKit
3
4struct 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
19struct 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
76struct 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
89private 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
134private 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
154private 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
190private 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
234private 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
250struct 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
272private enum ContributionGraphIndexingState: String, Decodable {
273 case pending
274 case indexed
275 case error
276}
277
278enum 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
316struct 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
332private 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
347private 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
384private 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
397private extension ContributionGraphDay {
398 init(date: Date, count: Int, score _: Double) {
399 self.date = date
400 self.count = count
401 }
402}
403
404private 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}