krz/hutch

an ios client for sourcehut

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

367a9d8e9592d0c604356e74edea97e0791b32cd

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-01T17:04:31Z

fix: add large widget and apply UX fixes for all widget sizes

Implements: https://todo.sr.ht/~ccleberg/Hutch/8
 HutchWidgetExtension/NeedsAttentionWidget.swift | 132 +++++++++++++++++++-----
 1 file changed, 106 insertions(+), 26 deletions(-)

diff --git a/HutchWidgetExtension/NeedsAttentionWidget.swift b/HutchWidgetExtension/NeedsAttentionWidget.swift
index 0e177a7..6191d20 100644
--- a/HutchWidgetExtension/NeedsAttentionWidget.swift
+++ b/HutchWidgetExtension/NeedsAttentionWidget.swift
@@ -44,7 +44,7 @@ struct NeedsAttentionWidget: Widget {
         }
         .configurationDisplayName("Needs Attention")
         .description("Unread inbox threads, assigned tickets, and failed builds.")
-        .supportedFamilies([.systemSmall, .systemMedium])
+        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
     }
 }
 
@@ -61,6 +61,8 @@ private struct NeedsAttentionWidgetView: View {
                     switch family {
                     case .systemMedium:
                         mediumView(snapshot: snapshot)
+                    case .systemLarge:
+                        largeView(snapshot: snapshot)
                     default:
                         smallView(snapshot: snapshot)
                     }
@@ -75,17 +77,6 @@ private struct NeedsAttentionWidgetView: View {
         }
     }
 
-    private var header: some View {
-        VStack(alignment: .leading, spacing: 1) {
-            Text("Hutch")
-                .font(.caption.weight(.semibold))
-                .foregroundStyle(.secondary)
-            Text("Needs Attention")
-                .font(.headline)
-                .lineLimit(1)
-        }
-    }
-
     private func smallView(snapshot: NeedsAttentionSnapshot) -> some View {
         VStack(alignment: .leading, spacing: 12) {
             Spacer(minLength: 0)
@@ -108,9 +99,7 @@ private struct NeedsAttentionWidgetView: View {
     }
 
     private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
-        VStack(alignment: .leading, spacing: 18) {
-            header
-                .padding(.top, 6)
+        VStack(alignment: .leading, spacing: 0) {
             HStack(alignment: .top, spacing: 12) {
                 metricColumn(
                     count: snapshot.unreadInboxThreads,
@@ -137,23 +126,47 @@ private struct NeedsAttentionWidgetView: View {
         .padding(.bottom, 16)
     }
 
-    private var clearState: some View {
-        VStack(alignment: .leading, spacing: 12) {
-            header
+    private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
+        VStack(alignment: .leading, spacing: 18) {
+            metricRow(
+                count: snapshot.unreadInboxThreads,
+                label: "Unread threads",
+                systemImage: "tray",
+                tint: unreadTint(for: snapshot.unreadInboxThreads)
+            )
+            metricRow(
+                count: snapshot.assignedOpenTickets,
+                label: "Assigned tickets",
+                systemImage: "ticket"
+            )
+            metricRow(
+                count: snapshot.failedBuilds,
+                label: "Failed builds",
+                systemImage: "hammer",
+                tint: failedTint(for: snapshot.failedBuilds)
+            )
             Spacer(minLength: 0)
-            Text("All clear")
-                .font(.title3.weight(.semibold))
-            Text("No unread threads, assigned tickets, or failed builds.")
-                .font(.caption)
-                .foregroundStyle(.secondary)
-                .fixedSize(horizontal: false, vertical: true)
         }
-        .padding()
+        .padding(20)
+    }
+
+    private var clearState: some View {
+        Group {
+            switch family {
+            case .systemSmall:
+                smallClearState
+            case .systemMedium:
+                mediumClearState
+            case .systemLarge:
+                largeClearState
+            default:
+                smallClearState
+            }
+        }
     }
 
     private var fallbackState: some View {
         VStack(alignment: .leading, spacing: 12) {
-            header
             Spacer(minLength: 0)
             Text("Open Hutch")
                 .font(.title3.weight(.semibold))
@@ -202,6 +215,73 @@ private struct NeedsAttentionWidgetView: View {
         .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
     }
 
+    private func metricRow(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View {
+        HStack(alignment: .center, spacing: 12) {
+            Image(systemName: systemImage)
+                .font(.title3.weight(.semibold))
+                .foregroundStyle(tint)
+                .frame(width: 24, alignment: .leading)
+            Text(displayCount(count))
+                .font(.system(size: 30, weight: .semibold, design: .rounded))
+                .monospacedDigit()
+                .foregroundStyle(tint)
+            Text(label)
+                .font(.body)
+                .foregroundStyle(.secondary)
+            Spacer(minLength: 0)
+        }
+    }
+
+    private var smallClearState: some View {
+        VStack(spacing: 6) {
+            Spacer(minLength: 0)
+            Image(systemName: "checkmark.circle.fill")
+                .font(.system(size: 52, weight: .regular))
+                .foregroundStyle(clearStateTint)
+            Text("0 items")
+                .font(.caption2.weight(.semibold))
+                .foregroundStyle(.secondary)
+                .lineLimit(1)
+            Spacer(minLength: 0)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+        .padding()
+    }
+
+    private var mediumClearState: some View {
+        VStack(spacing: 10) {
+            Image(systemName: "checkmark.circle.fill")
+                .font(.system(size: 26, weight: .regular))
+                .foregroundStyle(clearStateTint)
+            Text("Nothing to review")
+                .font(.body.weight(.semibold))
+                .foregroundStyle(.primary)
+                .lineLimit(1)
+                .minimumScaleFactor(0.9)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
+        .padding()
+    }
+
+    private var largeClearState: some View {
+        VStack(spacing: 12) {
+            Image(systemName: "checkmark.circle.fill")
+                .font(.system(size: 38, weight: .regular))
+                .foregroundStyle(clearStateTint)
+            Text("Nothing to review")
+                .font(.title3.weight(.semibold))
+                .foregroundStyle(.primary)
+                .lineLimit(1)
+                .minimumScaleFactor(0.9)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
+        .padding()
+    }
+
+    private var clearStateTint: Color {
+        Color(.systemGreen).opacity(0.75)
+    }
+
     private func displayCount(_ count: Int?) -> String {
         guard let count else { return "—" }
         return "\(count)"