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