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}