summaryrefslogtreecommitdiff
path: root/Hutch/Views/Lookup/ContributionCalendarView.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Lookup/ContributionCalendarView.swift')
-rw-r--r--Hutch/Views/Lookup/ContributionCalendarView.swift224
1 files changed, 224 insertions, 0 deletions
diff --git a/Hutch/Views/Lookup/ContributionCalendarView.swift b/Hutch/Views/Lookup/ContributionCalendarView.swift
new file mode 100644
index 0000000..d52deab
--- /dev/null
+++ b/Hutch/Views/Lookup/ContributionCalendarView.swift
@@ -0,0 +1,224 @@
+import SwiftUI
+
+struct ContributionProfileCard: View {
+ let actor: String
+ let weeks: [ContributionWeek]
+ let stats: ContributionStatsResponse?
+ let isLoading: Bool
+ let error: String?
+ var isIndexedButEmpty = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Contribution Activity")
+ .font(.headline)
+ Text(actor)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ }
+
+ if isLoading && weeks.isEmpty {
+ HStack(spacing: 10) {
+ ProgressView()
+ .controlSize(.small)
+ Text("Loading activity…")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ } else if let error, weeks.isEmpty {
+ Label(error, systemImage: "exclamationmark.triangle")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ } else if isIndexedButEmpty || weeks.isEmpty || stats?.totalEvents == 0 {
+ Text("Contribution activity may still be indexing.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ } else {
+ ContributionScrollableHeatmap(
+ weeks: weeks,
+ squareSize: 10,
+ spacing: 3,
+ showsWeekdayLabels: false,
+ showsMonthLabels: false
+ )
+
+ ContributionLegendView()
+
+ if let stats {
+ HStack(spacing: 14) {
+ ContributionMetricChip(value: "\(stats.totalEvents)", title: "Events")
+ ContributionMetricChip(value: "\(stats.activeDays)", title: "Days")
+ ContributionMetricChip(value: "\(stats.longestStreak)", title: "Streak")
+ }
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+private struct ContributionScrollableHeatmap: View {
+ let weeks: [ContributionWeek]
+ let squareSize: CGFloat
+ let spacing: CGFloat
+ let showsWeekdayLabels: Bool
+ let showsMonthLabels: Bool
+
+ @State private var didApplyInitialScroll = false
+
+ private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"]
+
+ var body: some View {
+ ScrollViewReader { proxy in
+ ScrollView(.horizontal, showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 6) {
+ if showsMonthLabels {
+ HStack(spacing: spacing) {
+ if showsWeekdayLabels {
+ Color.clear
+ .frame(width: 10)
+ }
+
+ ForEach(Array(weeks.enumerated()), id: \.element.startDate) { index, week in
+ Text(monthLabel(for: week, index: index))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .fixedSize(horizontal: true, vertical: false)
+ .frame(width: squareSize, alignment: .leading)
+ }
+ }
+ }
+
+ HStack(alignment: .top, spacing: 8) {
+ if showsWeekdayLabels {
+ VStack(alignment: .trailing, spacing: spacing) {
+ ForEach(Array(weekdayLabels.enumerated()), id: \.offset) { _, label in
+ Text(label)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ .frame(width: 10, height: squareSize, alignment: .center)
+ }
+ }
+ }
+
+ HStack(alignment: .top, spacing: spacing) {
+ ForEach(weeks, id: \.startDate) { week in
+ VStack(spacing: spacing) {
+ ForEach(week.days) { day in
+ ContributionDayCell(day: day, size: squareSize)
+ }
+ }
+ .id(week.startDate)
+ }
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+ .onAppear {
+ guard !didApplyInitialScroll, let lastWeek = weeks.last?.startDate else { return }
+ didApplyInitialScroll = true
+
+ DispatchQueue.main.async {
+ proxy.scrollTo(lastWeek, anchor: .trailing)
+ }
+ }
+ }
+ .accessibilityElement(children: .contain)
+ }
+
+ private func monthLabel(for week: ContributionWeek, index: Int) -> String {
+ let month = week.startDate.formatted(.dateTime.month(.abbreviated))
+ if index == 0 {
+ return month
+ }
+
+ let previousMonth = weeks[index - 1].startDate.formatted(.dateTime.month(.abbreviated))
+ return previousMonth == month ? "" : month
+ }
+}
+
+private struct ContributionDayCell: View {
+ let day: ContributionDay
+ let size: CGFloat
+
+ var body: some View {
+ RoundedRectangle(cornerRadius: 3, style: .continuous)
+ .fill(day.intensity.color)
+ .frame(width: size, height: size)
+ .overlay {
+ RoundedRectangle(cornerRadius: 3, style: .continuous)
+ .stroke(Color.primary.opacity(day.intensity == .empty ? 0.08 : 0), lineWidth: 0.5)
+ }
+ .accessibilityLabel(day.accessibilityLabel)
+ }
+}
+
+
+private struct ContributionMetricChip: View {
+ let value: String
+ let title: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(value)
+ .font(.headline)
+ Text(title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(10)
+ .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ }
+}
+
+private struct ContributionLegendView: View {
+ var body: some View {
+ HStack(spacing: 8) {
+ Text("Less")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ ForEach(ContributionIntensity.allCases, id: \.rawValue) { intensity in
+ RoundedRectangle(cornerRadius: 3, style: .continuous)
+ .fill(intensity.color)
+ .frame(width: 12, height: 12)
+ }
+
+ Text("More")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+private extension ContributionIntensity {
+ var color: Color {
+ switch self {
+ case .empty:
+ Color(uiColor: .secondarySystemFill)
+ case .level1:
+ Color(red: 0.82, green: 0.92, blue: 0.83)
+ case .level2:
+ Color(red: 0.58, green: 0.83, blue: 0.61)
+ case .level3:
+ Color(red: 0.25, green: 0.69, blue: 0.36)
+ case .level4:
+ Color(red: 0.12, green: 0.47, blue: 0.21)
+ }
+ }
+}
+
+private extension ContributionDay {
+ var accessibilityLabel: String {
+ let contributionLabel = count == 1 ? "1 contribution" : "\(count) contributions"
+ return "\(date.formatted(date: .long, time: .omitted)): \(contributionLabel), score \(score.formatted(.number.precision(.fractionLength(0...2))))"
+ }
+}