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 {
44 } 44 }
45 .configurationDisplayName("Needs Attention") 45 .configurationDisplayName("Needs Attention")
46 .description("Unread inbox threads, assigned tickets, and failed builds.") 46 .description("Unread inbox threads, assigned tickets, and failed builds.")
47 .supportedFamilies([.systemSmall, .systemMedium]) 47 .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
48 } 48 }
49} 49}
50 50
@@ -61,6 +61,8 @@ private struct NeedsAttentionWidgetView: View {
61 switch family { 61 switch family {
62 case .systemMedium: 62 case .systemMedium:
63 mediumView(snapshot: snapshot) 63 mediumView(snapshot: snapshot)
64 case .systemLarge:
65 largeView(snapshot: snapshot)
64 default: 66 default:
65 smallView(snapshot: snapshot) 67 smallView(snapshot: snapshot)
66 } 68 }
@@ -75,17 +77,6 @@ private struct NeedsAttentionWidgetView: View {
75 } 77 }
76 } 78 }
77 79
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
89 private func smallView(snapshot: NeedsAttentionSnapshot) -> some View { 80 private func smallView(snapshot: NeedsAttentionSnapshot) -> some View {
90 VStack(alignment: .leading, spacing: 12) { 81 VStack(alignment: .leading, spacing: 12) {
91 Spacer(minLength: 0) 82 Spacer(minLength: 0)
@@ -108,9 +99,7 @@ private struct NeedsAttentionWidgetView: View {
108 } 99 }
109 100
110 private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View { 101 private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
111 VStack(alignment: .leading, spacing: 18) { 102 VStack(alignment: .leading, spacing: 0) {
112 header
113 .padding(.top, 6)
114 HStack(alignment: .top, spacing: 12) { 103 HStack(alignment: .top, spacing: 12) {
115 metricColumn( 104 metricColumn(
116 count: snapshot.unreadInboxThreads, 105 count: snapshot.unreadInboxThreads,
@@ -137,23 +126,47 @@ private struct NeedsAttentionWidgetView: View {
137 .padding(.bottom, 16) 126 .padding(.bottom, 16)
138 } 127 }
139 128
140 private var clearState: some View { 129 private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
141 VStack(alignment: .leading, spacing: 12) { 130 VStack(alignment: .leading, spacing: 18) {
142 header 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 )
143 Spacer(minLength: 0) 148 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)
150 } 149 }
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 }
152 } 166 }
153 167
154 private var fallbackState: some View { 168 private var fallbackState: some View {
155 VStack(alignment: .leading, spacing: 12) { 169 VStack(alignment: .leading, spacing: 12) {
156 header
157 Spacer(minLength: 0) 170 Spacer(minLength: 0)
158 Text("Open Hutch") 171 Text("Open Hutch")
159 .font(.title3.weight(.semibold)) 172 .font(.title3.weight(.semibold))
@@ -202,6 +215,73 @@ private struct NeedsAttentionWidgetView: View {
202 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 215 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
203 } 216 }
204 217
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
205 private func displayCount(_ count: Int?) -> String { 285 private func displayCount(_ count: Int?) -> String {
206 guard let count else { return "—" } 286 guard let count else { return "—" }
207 return "\(count)" 287 return "\(count)"