Commit 9d4296fb07

9d4296fb075152ec4a436ba940877cc57b392e87

parent: 9869de71ef

Unsigned

cmc <hello@cleberg.net> · 2026-04-01 17:04 UTC

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

Implements: https://todo.sr.ht/~ccleberg/Hutch/8

Layout: unified · split

HutchWidgetExtension/NeedsAttentionWidget.swift +106 −26
@@ -44,7 +44,7 @@ struct NeedsAttentionWidget: Widget {
4444 }
4545 .configurationDisplayName("Needs Attention")
4646 .description("Unread inbox threads, assigned tickets, and failed builds.")
47 .supportedFamilies([.systemSmall, .systemMedium])
47 .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
4848 }
4949}
5050
@@ -61,6 +61,8 @@ private struct NeedsAttentionWidgetView: View {
6161 switch family {
6262 case .systemMedium:
6363 mediumView(snapshot: snapshot)
64 case .systemLarge:
65 largeView(snapshot: snapshot)
6466 default:
6567 smallView(snapshot: snapshot)
6668 }
@@ -75,17 +77,6 @@ private struct NeedsAttentionWidgetView: View {
7577 }
7678 }
7779
78 private var header: some View {
79 VStack(alignment: .leading, spacing: 1) {
80 Text("Hutch")
81 .font(.caption.weight(.semibold))
82 .foregroundStyle(.secondary)
83 Text("Needs Attention")
84 .font(.headline)
85 .lineLimit(1)
86 }
87 }
88
8980 private func smallView(snapshot: NeedsAttentionSnapshot) -> some View {
9081 VStack(alignment: .leading, spacing: 12) {
9182 Spacer(minLength: 0)
@@ -108,9 +99,7 @@ private struct NeedsAttentionWidgetView: View {
10899 }
109100
110101 private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
111 VStack(alignment: .leading, spacing: 18) {
112 header
113 .padding(.top, 6)
102 VStack(alignment: .leading, spacing: 0) {
114103 HStack(alignment: .top, spacing: 12) {
115104 metricColumn(
116105 count: snapshot.unreadInboxThreads,
@@ -137,23 +126,47 @@ private struct NeedsAttentionWidgetView: View {
137126 .padding(.bottom, 16)
138127 }
139128
140 private var clearState: some View {
141 VStack(alignment: .leading, spacing: 12) {
142 header
129 private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
130 VStack(alignment: .leading, spacing: 18) {
131 metricRow(
132 count: snapshot.unreadInboxThreads,
133 label: "Unread threads",
134 systemImage: "tray",
135 tint: unreadTint(for: snapshot.unreadInboxThreads)
136 )
137 metricRow(
138 count: snapshot.assignedOpenTickets,
139 label: "Assigned tickets",
140 systemImage: "ticket"
141 )
142 metricRow(
143 count: snapshot.failedBuilds,
144 label: "Failed builds",
145 systemImage: "hammer",
146 tint: failedTint(for: snapshot.failedBuilds)
147 )
143148 Spacer(minLength: 0)
144 Text("All clear")
145 .font(.title3.weight(.semibold))
146 Text("No unread threads, assigned tickets, or failed builds.")
147 .font(.caption)
148 .foregroundStyle(.secondary)
149 .fixedSize(horizontal: false, vertical: true)
150149 }
151 .padding()
150 .padding(20)
151 }
152
153 private var clearState: some View {
154 Group {
155 switch family {
156 case .systemSmall:
157 smallClearState
158 case .systemMedium:
159 mediumClearState
160 case .systemLarge:
161 largeClearState
162 default:
163 smallClearState
164 }
165 }
152166 }
153167
154168 private var fallbackState: some View {
155169 VStack(alignment: .leading, spacing: 12) {
156 header
157170 Spacer(minLength: 0)
158171 Text("Open Hutch")
159172 .font(.title3.weight(.semibold))
@@ -202,6 +215,73 @@ private struct NeedsAttentionWidgetView: View {
202215 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
203216 }
204217
218 private func metricRow(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View {
219 HStack(alignment: .center, spacing: 12) {
220 Image(systemName: systemImage)
221 .font(.title3.weight(.semibold))
222 .foregroundStyle(tint)
223 .frame(width: 24, alignment: .leading)
224 Text(displayCount(count))
225 .font(.system(size: 30, weight: .semibold, design: .rounded))
226 .monospacedDigit()
227 .foregroundStyle(tint)
228 Text(label)
229 .font(.body)
230 .foregroundStyle(.secondary)
231 Spacer(minLength: 0)
232 }
233 }
234
235 private var smallClearState: some View {
236 VStack(spacing: 6) {
237 Spacer(minLength: 0)
238 Image(systemName: "checkmark.circle.fill")
239 .font(.system(size: 52, weight: .regular))
240 .foregroundStyle(clearStateTint)
241 Text("0 items")
242 .font(.caption2.weight(.semibold))
243 .foregroundStyle(.secondary)
244 .lineLimit(1)
245 Spacer(minLength: 0)
246 }
247 .frame(maxWidth: .infinity, maxHeight: .infinity)
248 .padding()
249 }
250
251 private var mediumClearState: some View {
252 VStack(spacing: 10) {
253 Image(systemName: "checkmark.circle.fill")
254 .font(.system(size: 26, weight: .regular))
255 .foregroundStyle(clearStateTint)
256 Text("Nothing to review")
257 .font(.body.weight(.semibold))
258 .foregroundStyle(.primary)
259 .lineLimit(1)
260 .minimumScaleFactor(0.9)
261 }
262 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
263 .padding()
264 }
265
266 private var largeClearState: some View {
267 VStack(spacing: 12) {
268 Image(systemName: "checkmark.circle.fill")
269 .font(.system(size: 38, weight: .regular))
270 .foregroundStyle(clearStateTint)
271 Text("Nothing to review")
272 .font(.title3.weight(.semibold))
273 .foregroundStyle(.primary)
274 .lineLimit(1)
275 .minimumScaleFactor(0.9)
276 }
277 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
278 .padding()
279 }
280
281 private var clearStateTint: Color {
282 Color(.systemGreen).opacity(0.75)
283 }
284
205285 private func displayCount(_ count: Int?) -> String {
206286 guard let count else { return "—" }
207287 return "\(count)"