krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.16.0: 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 ? URL(string: "hutch://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 VStack(alignment: .leading, spacing: 0) {
103 HStack(alignment: .top, spacing: 12) {
104 Link(destination: URL(string: "hutch://home")!) {
105 metricColumn(
106 count: snapshot.unreadInboxThreads,
107 label: "Unread",
108 systemImage: "tray",
109 tint: unreadTint(for: snapshot.unreadInboxThreads)
110 )
111 }
112 Link(destination: URL(string: "hutch://trackers")!) {
113 metricColumn(
114 count: snapshot.assignedOpenTickets,
115 label: "Assigned",
116 systemImage: "ticket"
117 )
118 }
119 Link(destination: URL(string: "hutch://builds")!) {
120 metricColumn(
121 count: snapshot.failedBuilds,
122 label: "Failed",
123 systemImage: "hammer",
124 tint: failedTint(for: snapshot.failedBuilds)
125 )
126 }
127 }
128 Spacer(minLength: 0)
129 }
130 .padding(.horizontal, 16)
131 .padding(.top, 18)
132 .padding(.bottom, 16)
133 }
134
135 private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
136 VStack(alignment: .leading, spacing: 18) {
137 Link(destination: URL(string: "hutch://home")!) {
138 metricRow(
139 count: snapshot.unreadInboxThreads,
140 label: "Unread threads",
141 systemImage: "tray",
142 tint: unreadTint(for: snapshot.unreadInboxThreads)
143 )
144 }
145 Link(destination: URL(string: "hutch://trackers")!) {
146 metricRow(
147 count: snapshot.assignedOpenTickets,
148 label: "Assigned tickets",
149 systemImage: "ticket"
150 )
151 }
152 Link(destination: URL(string: "hutch://builds")!) {
153 metricRow(
154 count: snapshot.failedBuilds,
155 label: "Failed builds",
156 systemImage: "hammer",
157 tint: failedTint(for: snapshot.failedBuilds)
158 )
159 }
160 Spacer(minLength: 0)
161 }
162 .padding(20)
163 }
164
165 private var clearState: some View {
166 Group {
167 switch family {
168 case .systemSmall:
169 smallClearState
170 case .systemMedium:
171 mediumClearState
172 case .systemLarge:
173 largeClearState
174 default:
175 smallClearState
176 }
177 }
178 }
179
180 private var fallbackState: some View {
181 VStack(alignment: .leading, spacing: 12) {
182 Spacer(minLength: 0)
183 Text("Open Hutch")
184 .font(.title3.weight(.semibold))
185 Text("Unable to load recent counts.")
186 .font(.caption)
187 .foregroundStyle(.secondary)
188 }
189 .padding()
190 }
191
192 private func compactMetric(count: Int?, label: String, tint: Color = .primary) -> some View {
193 HStack(alignment: .center, spacing: 6) {
194 Text(displayCount(count))
195 .font(.title2.weight(.semibold))
196 .monospacedDigit()
197 .foregroundStyle(tint)
198 Text(label)
199 .font(.subheadline)
200 .foregroundStyle(.secondary)
201 Spacer(minLength: 0)
202 }
203 }
204
205 private func metricColumn(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View {
206 VStack(alignment: .leading, spacing: 8) {
207 HStack(alignment: .center, spacing: 10) {
208 Image(systemName: systemImage)
209 .font(.title3.weight(.semibold))
210 .foregroundStyle(tint)
211 .frame(width: 24, alignment: .leading)
212 .padding(.trailing, 4)
213 Text(displayCount(count))
214 .font(.system(size: 28, weight: .semibold, design: .rounded))
215 .monospacedDigit()
216 .lineLimit(1)
217 .minimumScaleFactor(0.8)
218 .foregroundStyle(tint)
219 }
220 Text(label)
221 .font(.caption)
222 .foregroundStyle(.secondary)
223 .lineLimit(1)
224 .minimumScaleFactor(0.85)
225 .frame(height: 16, alignment: .topLeading)
226 }
227 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
228 }
229
230 private func metricRow(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View {
231 HStack(alignment: .center, spacing: 12) {
232 Image(systemName: systemImage)
233 .font(.title3.weight(.semibold))
234 .foregroundStyle(tint)
235 .frame(width: 24, alignment: .leading)
236 Text(displayCount(count))
237 .font(.system(size: 30, weight: .semibold, design: .rounded))
238 .monospacedDigit()
239 .foregroundStyle(tint)
240 Text(label)
241 .font(.body)
242 .foregroundStyle(.secondary)
243 Spacer(minLength: 0)
244 }
245 }
246
247 private var smallClearState: some View {
248 VStack(spacing: 6) {
249 Spacer(minLength: 0)
250 Image(systemName: "checkmark.circle.fill")
251 .font(.system(size: 52, weight: .regular))
252 .foregroundStyle(clearStateTint)
253 Text("0 items")
254 .font(.caption2.weight(.semibold))
255 .foregroundStyle(.secondary)
256 .lineLimit(1)
257 Spacer(minLength: 0)
258 }
259 .frame(maxWidth: .infinity, maxHeight: .infinity)
260 .padding()
261 }
262
263 private var mediumClearState: some View {
264 VStack(spacing: 10) {
265 Image(systemName: "checkmark.circle.fill")
266 .font(.system(size: 26, weight: .regular))
267 .foregroundStyle(clearStateTint)
268 Text("Nothing to review")
269 .font(.body.weight(.semibold))
270 .foregroundStyle(.primary)
271 .lineLimit(1)
272 .minimumScaleFactor(0.9)
273 }
274 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
275 .padding()
276 }
277
278 private var largeClearState: some View {
279 VStack(spacing: 12) {
280 Image(systemName: "checkmark.circle.fill")
281 .font(.system(size: 38, weight: .regular))
282 .foregroundStyle(clearStateTint)
283 Text("Nothing to review")
284 .font(.title3.weight(.semibold))
285 .foregroundStyle(.primary)
286 .lineLimit(1)
287 .minimumScaleFactor(0.9)
288 }
289 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
290 .padding()
291 }
292
293 private var clearStateTint: Color {
294 Color(.systemGreen).opacity(0.75)
295 }
296
297 private func displayCount(_ count: Int?) -> String {
298 guard let count else { return "—" }
299 return "\(count)"
300 }
301
302 private func unreadTint(for count: Int?) -> Color {
303 guard let count, count > 0 else { return .primary }
304 return .blue
305 }
306
307 private func failedTint(for count: Int?) -> Color {
308 guard let count else { return .primary }
309 return count > 0 ? .red : .green
310 }
311}
312
313@main
314struct HutchWidgets: WidgetBundle {
315 var body: some Widget {
316 NeedsAttentionWidget()
317 ContributionGraphWidget()
318 SystemStatusWidget()
319 }
320}