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}