krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.15.1: 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}