krz/hutch

an ios client for sourcehut

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

b25ac7621d1a7a2bbdf19588d855da531a185c55

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-12T00:55:28Z

fix: respect contribution graphs toggle in widget

Mirror the contributionGraphsEnabled setting to shared app group
defaults so the widget extension can read it. Show a descriptive
message when contributions are disabled instead of placeholder data.
 Hutch/Views/Settings/SettingsView.swift            |  3 ++
 HutchWidgetExtension/ContributionGraphWidget.swift | 46 ++++++++++++++++++++--
 Shared/ContributionWidgetContext.swift             | 10 +++++
 3 files changed, 55 insertions(+), 4 deletions(-)

diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index a249807..4fb3f9c 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -49,6 +49,9 @@ struct SettingsView: View {
         Section {
             Toggle("Swipe actions", isOn: $swipeActionsEnabled)
             Toggle("Contribution graphs", isOn: $contributionGraphsEnabled)
+                .onChange(of: contributionGraphsEnabled) { _, newValue in
+                    ContributionWidgetContextStore.setEnabled(newValue)
+                }
         } header: {
             Text("Behavior")
         } footer: {
diff --git a/HutchWidgetExtension/ContributionGraphWidget.swift b/HutchWidgetExtension/ContributionGraphWidget.swift
index c690f54..5dbf6a2 100644
--- a/HutchWidgetExtension/ContributionGraphWidget.swift
+++ b/HutchWidgetExtension/ContributionGraphWidget.swift
@@ -9,6 +9,7 @@ struct ContributionGraphEntry: TimelineEntry {
 
     enum State {
         case placeholder
+        case disabled
         case unavailable
         case empty
         case indexing
@@ -41,6 +42,10 @@ struct ContributionGraphTimelineProvider: TimelineProvider {
     }
 
     private func loadEntry() async -> ContributionGraphEntry {
+        guard ContributionWidgetContextStore.isEnabled() else {
+            return ContributionGraphEntry(date: .now, actor: nil, state: .disabled, weeks: [])
+        }
+
         guard let actor = ContributionWidgetContextStore.loadActor(), !actor.isEmpty else {
             return ContributionGraphEntry(date: .now, actor: nil, state: .unavailable, weeks: [])
         }
@@ -91,6 +96,29 @@ private struct ContributionGraphWidgetView: View {
     @Environment(\.widgetFamily) private var family
 
     var body: some View {
+        Group {
+            switch entry.state {
+            case .disabled:
+                messageView(
+                    title: "Contributions Disabled",
+                    detail: "Enable contribution graphs in Hutch settings to use this widget."
+                )
+            case .unavailable:
+                messageView(
+                    title: "Open Hutch",
+                    detail: "Sign in and open your profile to load contribution data."
+                )
+            default:
+                graphView
+            }
+        }
+        .widgetURL(URL(string: "hutch://home"))
+        .containerBackground(for: .widget) {
+            Color(.systemBackground)
+        }
+    }
+
+    private var graphView: some View {
         GeometryReader { geometry in
             let cellSize = ContributionGraphSizing.baseCellSize(availableHeight: geometry.size.height)
             let layout = ContributionGraphSizing.layout(availableSize: geometry.size, cellSize: cellSize)
@@ -108,17 +136,27 @@ private struct ContributionGraphWidgetView: View {
             .frame(width: actualSize.width, height: actualSize.height)
             .frame(maxWidth: .infinity, maxHeight: .infinity)
         }
-        .widgetURL(URL(string: "hutch://home"))
-        .containerBackground(for: .widget) {
-            Color(.systemBackground)
+    }
+
+    private func messageView(title: String, detail: String) -> some View {
+        VStack(spacing: 6) {
+            Text(title)
+                .font(family == .systemSmall ? .headline : .title3.weight(.semibold))
+                .multilineTextAlignment(.center)
+            Text(detail)
+                .font(.caption)
+                .foregroundStyle(.secondary)
+                .multilineTextAlignment(.center)
         }
+        .padding()
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
     }
 
     private func displayedWeeks(columnCount: Int) -> [ContributionGraphWeek] {
         var baseWeeks = switch entry.state {
         case .populated, .indexing, .empty:
             entry.weeks
-        case .placeholder, .unavailable:
+        case .placeholder, .disabled, .unavailable:
             ContributionGraphSampleData.placeholderWeeks
         }
 
diff --git a/Shared/ContributionWidgetContext.swift b/Shared/ContributionWidgetContext.swift
index 3fadc8a..2404698 100644
--- a/Shared/ContributionWidgetContext.swift
+++ b/Shared/ContributionWidgetContext.swift
@@ -9,11 +9,21 @@ enum ContributionGraphWidgetConfiguration {
 
 enum ContributionWidgetContextStore {
     private static let actorKey = "contributionWidget.actor"
+    private static let enabledKey = "contributionWidget.enabled"
 
     static func loadActor(defaults: UserDefaults? = sharedDefaults()) -> String? {
         defaults?.string(forKey: actorKey)
     }
 
+    static func isEnabled(defaults: UserDefaults? = sharedDefaults()) -> Bool {
+        defaults?.object(forKey: enabledKey) == nil ? true : defaults?.bool(forKey: enabledKey) ?? true
+    }
+
+    static func setEnabled(_ enabled: Bool, defaults: UserDefaults? = sharedDefaults()) {
+        defaults?.set(enabled, forKey: enabledKey)
+        reloadWidgetTimelines()
+    }
+
     static func saveActor(_ actor: String, defaults: UserDefaults? = sharedDefaults()) {
         defaults?.set(actor, forKey: actorKey)
         reloadWidgetTimelines()