krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.8.2: HutchWidgetExtension/NeedsAttentionWidget.swift · raw
1import SwiftUI
2import WidgetKit
3
4struct NeedsAttentionEntry: TimelineEntry {
5 let date: Date
6 let snapshot: NeedsAttentionSnapshot?
7}
8
9struct NeedsAttentionTimelineProvider: TimelineProvider {
10 func placeholder(in _: Context) -> NeedsAttentionEntry {
11 NeedsAttentionEntry(
12 date: .now,
13 snapshot: NeedsAttentionSnapshot(
14 unreadInboxThreads: 3,
15 assignedOpenTickets: 2,
16 failedBuilds: 1,
17 updatedAt: .now
18 )
19 )
20 }
21
22 func getSnapshot(in _: Context, completion: @escaping (NeedsAttentionEntry) -> Void) {
23 completion(
24 NeedsAttentionEntry(
25 date: .now,
26 snapshot: NeedsAttentionSnapshotStore.load()
27 )
28 )
29 }
30
31 func getTimeline(in _: Context, completion: @escaping (Timeline<NeedsAttentionEntry>) -> Void) {
32 let refreshDate = Calendar.current.date(byAdding: .minute, value: 20, to: .now) ?? .now.addingTimeInterval(1200)
33 let entry = NeedsAttentionEntry(date: .now, snapshot: NeedsAttentionSnapshotStore.load())
34 completion(Timeline(entries: [entry], policy: .after(refreshDate)))
35 }
36}
37
38struct NeedsAttentionWidget: Widget {
39 static let kind = NeedsAttentionWidgetConfiguration.kind
40
41 var body: some WidgetConfiguration {
42 StaticConfiguration(kind: Self.kind, provider: NeedsAttentionTimelineProvider()) { entry in
43 NeedsAttentionWidgetView(entry: entry)
44 }
45 .configurationDisplayName("Needs Attention")
46 .description("Unread inbox threads, assigned tickets, and failed builds.")
47 .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
48 }
49}
50
51private struct NeedsAttentionWidgetView: View {
52 let entry: NeedsAttentionEntry
53 @Environment(\.widgetFamily) private var family
54
55 var body: some View {
56 Group {
57 if let snapshot = entry.snapshot, snapshot.hasAnyData {
58 if snapshot.allCountsAreZero {
59 clearState
60 } else {
61 switch family {
62 case .systemMedium:
63 mediumView(snapshot: snapshot)
64 case .systemLarge:
65 largeView(snapshot: snapshot)
66 default:
67 smallView(snapshot: snapshot)
68 }
69 }
70 } else {
71 fallbackState
72 }
73 }
74 .widgetURL(family == .systemSmall ? HutchDeepLinkURL.home : nil)
75 .containerBackground(for: .widget) {
76 Color(.systemBackground)
77 }
78 }
79
80 private func smallView(snapshot: NeedsAttentionSnapshot) -> some View {
81 VStack(alignment: .leading, spacing: 12) {
82 Spacer(minLength: 0)
83 VStack(alignment: .leading, spacing: 8) {
84 compactMetric(
85 count: snapshot.unreadInboxThreads,
86 label: "Unread",
87 tint: unreadTint(for: snapshot.unreadInboxThreads)
88 )
89 compactMetric(count: snapshot.assignedOpenTickets, label: "Assigned")
90 compactMetric(
91 count: snapshot.failedBuilds,
92 label: "Failed",
93 tint: failedTint(for: snapshot.failedBuilds)
94 )
95 }
96 Spacer(minLength: 0)
97 }
98 .padding()
99 }
100
101 private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
102 HStack(alignment: .center, spacing: 12) {
103 Link(destination: HutchDeepLinkURL.home) {
104 metricColumn(
105 count: snapshot.unreadInboxThreads,
106 label: "Unread",
107 systemImage: "tray",
108 tint: unreadTint(for: snapshot.unreadInboxThreads)
109 )
110 }
111 Link(destination: HutchDeepLinkURL.trackers) {
112 metricColumn(
113 count: snapshot.assignedOpenTickets,
114 label: "Assigned",
115 systemImage: "ticket"
116 )
117 }
118 Link(destination: HutchDeepLinkURL.builds) {
119 metricColumn(
120 count: snapshot.failedBuilds,
121 label: "Failed",
122 systemImage: "hammer",
123 tint: failedTint(for: snapshot.failedBuilds)
124 )
125 }
126 }
127 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
128 .padding(.horizontal, 16)
129 .padding(.vertical, 16)
130 }
131
132 private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
133 VStack(alignment: .leading, spacing: 18) {
134 Link(destination: HutchDeepLinkURL.home) {
135 metricRow(
136 count: snapshot.unreadInboxThreads,
137 label: "Unread threads",
138 systemImage: "tray",
139 tint: unreadTint(for: snapshot.unreadInboxThreads)
140 )
141 }
142 Link(destination: HutchDeepLinkURL.trackers) {
143 metricRow(
144 count: snapshot.assignedOpenTickets,
145 label: "Assigned tickets",
146 systemImage: "ticket"
147 )
148 }
149 Link(destination: HutchDeepLinkURL.builds) {
150 metricRow(
151 count: snapshot.failedBuilds,
152 label: "Failed builds",
153 systemImage: "hammer",
154 tint: failedTint(for: snapshot.failedBuilds)
155 )
156 }
157 Spacer(minLength: 0)
158 }
159 .padding(20)
160 }
161
162 private var clearState: some View {
163 Group {
164 switch family {
165 case .systemSmall:
166 smallClearState
167 case .systemMedium:
168 mediumClearState
169 case .systemLarge:
170 largeClearState
171 default:
172 smallClearState
173 }
174 }
175 }
176
177 private var fallbackState: some View {
178 VStack(alignment: .leading, spacing: 12) {
179 Spacer(minLength: 0)
180 Text("Open Hutch")
181 .font(.title3.weight(.semibold))
182 Text("Unable to load recent counts.")
183 .font(.caption)
184 .foregroundStyle(.secondary)
185 }
186 .padding()
187 }
188
189 private func compactMetric(count: Int?, label: String, tint: Color = .primary) -> some View {
190 HStack(alignment: .center, spacing: 6) {
191 Text(displayCount(count))
192 .font(.title2.weight(.semibold))
193 .monospacedDigit()
194 .foregroundStyle(tint)
195 Text(label)
196 .font(.subheadline)
197 .foregroundStyle(.secondary)
198 Spacer(minLength: 0)
199 }
200 }
201
202 private func metricColumn(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View {
203 VStack(alignment: .center, spacing: 8) {
204 HStack(alignment: .center, spacing: 10) {
205 Image(systemName: systemImage)
206 .font(.title3.weight(.semibold))
207 .foregroundStyle(tint)
208 .frame(width: 24, alignment: .center)
209 Text(displayCount(count))
210 .font(.system(size: 28, weight: .semibold, design: .rounded))
211 .monospacedDigit()
212 .lineLimit(1)
213 .minimumScaleFactor(0.8)
214 .foregroundStyle(tint)
215 }
216 Text(label)
217 .font(.caption)
218 .foregroundStyle(.secondary)
219 .lineLimit(1)
220 .minimumScaleFactor(0.85)
221 .frame(height: 16, alignment: .center)
222 }
223 .frame(maxWidth: .infinity, alignment: .center)
224 }
225
226 private func metricRow(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View {
227 HStack(alignment: .center, spacing: 12) {
228 Image(systemName: systemImage)
229 .font(.title3.weight(.semibold))
230 .foregroundStyle(tint)
231 .frame(width: 24, alignment: .leading)
232 Text(displayCount(count))
233 .font(.system(size: 30, weight: .semibold, design: .rounded))
234 .monospacedDigit()
235 .foregroundStyle(tint)
236 Text(label)
237 .font(.body)
238 .foregroundStyle(.secondary)
239 Spacer(minLength: 0)
240 }
241 }
242
243 private var smallClearState: some View {
244 VStack(spacing: 6) {
245 Spacer(minLength: 0)
246 Image(systemName: "checkmark.circle.fill")
247 .font(.system(size: 52, weight: .regular))
248 .foregroundStyle(clearStateTint)
249 Text("0 items")
250 .font(.caption2.weight(.semibold))
251 .foregroundStyle(.secondary)
252 .lineLimit(1)
253 Spacer(minLength: 0)
254 }
255 .frame(maxWidth: .infinity, maxHeight: .infinity)
256 .padding()
257 }
258
259 private var mediumClearState: some View {
260 VStack(spacing: 10) {
261 Image(systemName: "checkmark.circle.fill")
262 .font(.system(size: 26, weight: .regular))
263 .foregroundStyle(clearStateTint)
264 Text("Nothing to review")
265 .font(.body.weight(.semibold))
266 .foregroundStyle(.primary)
267 .lineLimit(1)
268 .minimumScaleFactor(0.9)
269 }
270 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
271 .padding()
272 }
273
274 private var largeClearState: some View {
275 VStack(spacing: 12) {
276 Image(systemName: "checkmark.circle.fill")
277 .font(.system(size: 38, weight: .regular))
278 .foregroundStyle(clearStateTint)
279 Text("Nothing to review")
280 .font(.title3.weight(.semibold))
281 .foregroundStyle(.primary)
282 .lineLimit(1)
283 .minimumScaleFactor(0.9)
284 }
285 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
286 .padding()
287 }
288
289 private var clearStateTint: Color {
290 Color(.systemGreen).opacity(0.75)
291 }
292
293 private func displayCount(_ count: Int?) -> String {
294 guard let count else { return "—" }
295 return "\(count)"
296 }
297
298 private func unreadTint(for count: Int?) -> Color {
299 guard let count, count > 0 else { return .primary }
300 return .blue
301 }
302
303 private func failedTint(for count: Int?) -> Color {
304 guard let count else { return .primary }
305 return count > 0 ? .red : .green
306 }
307}
308
309@main
310struct HutchWidgets: WidgetBundle {
311 var body: some Widget {
312 NeedsAttentionWidget()
313 ContributionGraphWidget()
314 SystemStatusWidget()
315 }
316}