krz/hutch

an ios client for sourcehut

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

v2.13.0: Hutch/Views/Lookup/ContributionCalendarView.swift · raw

  1import SwiftUI
  2
  3struct ContributionProfileCard: View {
  4    let actor: String
  5    let weeks: [ContributionWeek]
  6    let stats: ContributionStatsResponse?
  7    let isLoading: Bool
  8    let error: String?
  9    var isIndexedButEmpty = false
 10
 11    var body: some View {
 12        VStack(alignment: .leading, spacing: 12) {
 13            HStack {
 14                VStack(alignment: .leading, spacing: 2) {
 15                    Text("Contribution Activity")
 16                        .font(.headline)
 17                    Text(actor)
 18                        .font(.caption)
 19                        .foregroundStyle(.secondary)
 20                }
 21
 22                Spacer()
 23            }
 24
 25            if isLoading && weeks.isEmpty {
 26                HStack(spacing: 10) {
 27                    ProgressView()
 28                        .controlSize(.small)
 29                    Text("Loading activity…")
 30                        .font(.subheadline)
 31                        .foregroundStyle(.secondary)
 32                }
 33            } else if let error, weeks.isEmpty {
 34                Label(error, systemImage: "exclamationmark.triangle")
 35                    .font(.subheadline)
 36                    .foregroundStyle(.secondary)
 37            } else if isIndexedButEmpty || weeks.isEmpty || stats?.totalEvents == 0 {
 38                Text("Contribution activity may still be indexing.")
 39                    .font(.subheadline)
 40                    .foregroundStyle(.secondary)
 41            } else {
 42                ContributionScrollableHeatmap(
 43                    weeks: weeks,
 44                    squareSize: 10,
 45                    spacing: 3,
 46                    showsWeekdayLabels: false,
 47                    showsMonthLabels: false
 48                )
 49
 50                ContributionLegendView()
 51
 52                if let stats {
 53                    HStack(spacing: 14) {
 54                        ContributionMetricChip(value: "\(stats.totalEvents)", title: "Events")
 55                        ContributionMetricChip(value: "\(stats.activeDays)", title: "Days")
 56                        ContributionMetricChip(value: "\(stats.longestStreak)", title: "Streak")
 57                    }
 58                }
 59            }
 60        }
 61        .padding(.vertical, 4)
 62    }
 63}
 64
 65private struct ContributionScrollableHeatmap: View {
 66    let weeks: [ContributionWeek]
 67    let squareSize: CGFloat
 68    let spacing: CGFloat
 69    let showsWeekdayLabels: Bool
 70    let showsMonthLabels: Bool
 71
 72    @State private var didApplyInitialScroll = false
 73
 74    private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"]
 75
 76    var body: some View {
 77        ScrollViewReader { proxy in
 78            ScrollView(.horizontal, showsIndicators: false) {
 79                VStack(alignment: .leading, spacing: 6) {
 80                    if showsMonthLabels {
 81                        HStack(spacing: spacing) {
 82                            if showsWeekdayLabels {
 83                                Color.clear
 84                                    .frame(width: 10)
 85                            }
 86
 87                            ForEach(Array(weeks.enumerated()), id: \.element.startDate) { index, week in
 88                                Text(monthLabel(for: week, index: index))
 89                                    .font(.caption2)
 90                                    .foregroundStyle(.secondary)
 91                                    .lineLimit(1)
 92                                    .fixedSize(horizontal: true, vertical: false)
 93                                    .frame(width: squareSize, alignment: .leading)
 94                            }
 95                        }
 96                    }
 97
 98                    HStack(alignment: .top, spacing: 8) {
 99                        if showsWeekdayLabels {
100                            VStack(alignment: .trailing, spacing: spacing) {
101                                ForEach(Array(weekdayLabels.enumerated()), id: \.offset) { _, label in
102                                    Text(label)
103                                        .font(.caption2)
104                                        .foregroundStyle(.tertiary)
105                                        .frame(width: 10, height: squareSize, alignment: .center)
106                                }
107                            }
108                        }
109
110                        HStack(alignment: .top, spacing: spacing) {
111                            ForEach(weeks, id: \.startDate) { week in
112                                VStack(spacing: spacing) {
113                                    ForEach(week.days) { day in
114                                        ContributionDayCell(day: day, size: squareSize)
115                                    }
116                                }
117                                .id(week.startDate)
118                            }
119                        }
120                    }
121                }
122                .padding(.vertical, 2)
123            }
124            .onAppear {
125                guard !didApplyInitialScroll, let lastWeek = weeks.last?.startDate else { return }
126                didApplyInitialScroll = true
127
128                DispatchQueue.main.async {
129                    proxy.scrollTo(lastWeek, anchor: .trailing)
130                }
131            }
132        }
133        .accessibilityElement(children: .contain)
134    }
135
136    private func monthLabel(for week: ContributionWeek, index: Int) -> String {
137        let month = week.startDate.formatted(.dateTime.month(.abbreviated))
138        if index == 0 {
139            return month
140        }
141
142        let previousMonth = weeks[index - 1].startDate.formatted(.dateTime.month(.abbreviated))
143        return previousMonth == month ? "" : month
144    }
145}
146
147private struct ContributionDayCell: View {
148    let day: ContributionDay
149    let size: CGFloat
150
151    var body: some View {
152        RoundedRectangle(cornerRadius: 3, style: .continuous)
153            .fill(day.intensity.color)
154            .frame(width: size, height: size)
155            .overlay {
156                RoundedRectangle(cornerRadius: 3, style: .continuous)
157                    .stroke(Color.primary.opacity(day.intensity == .empty ? 0.08 : 0), lineWidth: 0.5)
158            }
159            .accessibilityLabel(day.accessibilityLabel)
160    }
161}
162
163
164private struct ContributionMetricChip: View {
165    let value: String
166    let title: String
167
168    var body: some View {
169        VStack(alignment: .leading, spacing: 2) {
170            Text(value)
171                .font(.headline)
172            Text(title)
173                .font(.caption)
174                .foregroundStyle(.secondary)
175        }
176        .frame(maxWidth: .infinity, alignment: .leading)
177        .padding(10)
178        .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
179    }
180}
181
182private struct ContributionLegendView: View {
183    var body: some View {
184        HStack(spacing: 8) {
185            Text("Less")
186                .font(.caption)
187                .foregroundStyle(.secondary)
188
189            ForEach(ContributionIntensity.allCases, id: \.rawValue) { intensity in
190                RoundedRectangle(cornerRadius: 3, style: .continuous)
191                    .fill(intensity.color)
192                    .frame(width: 12, height: 12)
193            }
194
195            Text("More")
196                .font(.caption)
197                .foregroundStyle(.secondary)
198        }
199    }
200}
201
202private extension ContributionIntensity {
203    var color: Color {
204        switch self {
205        case .empty:
206            Color(uiColor: .secondarySystemFill)
207        case .level1:
208            Color(red: 0.82, green: 0.92, blue: 0.83)
209        case .level2:
210            Color(red: 0.58, green: 0.83, blue: 0.61)
211        case .level3:
212            Color(red: 0.25, green: 0.69, blue: 0.36)
213        case .level4:
214            Color(red: 0.12, green: 0.47, blue: 0.21)
215        }
216    }
217}
218
219private extension ContributionDay {
220    var accessibilityLabel: String {
221        let contributionLabel = count == 1 ? "1 contribution" : "\(count) contributions"
222        return "\(date.formatted(date: .long, time: .omitted)): \(contributionLabel), score \(score.formatted(.number.precision(.fractionLength(0...2))))"
223    }
224}