krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.2.1: 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}