krz/hutch

an ios client for sourcehut

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

v2.13.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(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        ContributionGraphWidget()
306    }
307}