krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.11.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 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}