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