krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.7.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(URL(string: "hutch://home"))
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 metricColumn(
105 count: snapshot.unreadInboxThreads,
106 label: "Unread",
107 systemImage: "tray",
108 tint: unreadTint(for: snapshot.unreadInboxThreads)
109 )
110 metricColumn(
111 count: snapshot.assignedOpenTickets,
112 label: "Assigned",
113 systemImage: "ticket"
114 )
115 metricColumn(
116 count: snapshot.failedBuilds,
117 label: "Failed",
118 systemImage: "hammer",
119 tint: failedTint(for: snapshot.failedBuilds)
120 )
121 }
122 Spacer(minLength: 0)
123 }
124 .padding(.horizontal, 16)
125 .padding(.top, 18)
126 .padding(.bottom, 16)
127 }
128
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 )
148 Spacer(minLength: 0)
149 }
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 }
166 }
167
168 private var fallbackState: some View {
169 VStack(alignment: .leading, spacing: 12) {
170 Spacer(minLength: 0)
171 Text("Open Hutch")
172 .font(.title3.weight(.semibold))
173 Text("Unable to load recent counts.")
174 .font(.caption)
175 .foregroundStyle(.secondary)
176 }
177 .padding()
178 }
179
180 private func compactMetric(count: Int?, label: String, tint: Color = .primary) -> some View {
181 HStack(alignment: .center, spacing: 6) {
182 Text(displayCount(count))
183 .font(.title2.weight(.semibold))
184 .monospacedDigit()
185 .foregroundStyle(tint)
186 Text(label)
187 .font(.subheadline)
188 .foregroundStyle(.secondary)
189 Spacer(minLength: 0)
190 }
191 }
192
193 private func metricColumn(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View {
194 VStack(alignment: .leading, spacing: 8) {
195 HStack(alignment: .center, spacing: 10) {
196 Image(systemName: systemImage)
197 .font(.title3.weight(.semibold))
198 .foregroundStyle(tint)
199 .frame(width: 24, alignment: .leading)
200 .padding(.trailing, 4)
201 Text(displayCount(count))
202 .font(.system(size: 28, weight: .semibold, design: .rounded))
203 .monospacedDigit()
204 .lineLimit(1)
205 .minimumScaleFactor(0.8)
206 .foregroundStyle(tint)
207 }
208 Text(label)
209 .font(.caption)
210 .foregroundStyle(.secondary)
211 .lineLimit(1)
212 .minimumScaleFactor(0.85)
213 .frame(height: 16, alignment: .topLeading)
214 }
215 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
216 }
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
285 private func displayCount(_ count: Int?) -> String {
286 guard let count else { return "—" }
287 return "\(count)"
288 }
289
290 private func unreadTint(for count: Int?) -> Color {
291 guard let count, count > 0 else { return .primary }
292 return .blue
293 }
294
295 private func failedTint(for count: Int?) -> Color {
296 guard let count else { return .primary }
297 return count > 0 ? .red : .green
298 }
299}
300
301@main
302struct HutchWidgets: WidgetBundle {
303 var body: some Widget {
304 NeedsAttentionWidget()
305 }
306}