diff options
| author | Christian Cleberg <[email protected]> | 2026-04-11 19:47:40 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-11 19:47:40 -0500 |
| commit | 61164b6dd8343c8699d091dd5fccc55beb0b90b9 (patch) | |
| tree | 0a2befeb986966d42062802e2a29d795be907fee /Hutch/Views/Lookup/ContributionCalendarView.swift | |
| parent | 27bfcbbed6acde158ec40c5bb46e45e76c81a319 (diff) | |
| download | hutch-2.11.0.tar.gz hutch-2.11.0.tar.bz2 hutch-2.11.0.zip | |
feat: add contribution calendar to user profilesv2.11.0
Add a trailing 365-day contribution heatmap to user profile views,
backed by HutchStatsService. Includes calendar model, view model,
SwiftUI calendar view, app configuration, and unit tests.
Diffstat (limited to 'Hutch/Views/Lookup/ContributionCalendarView.swift')
| -rw-r--r-- | Hutch/Views/Lookup/ContributionCalendarView.swift | 224 |
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))))" + } +} |
