krz/hutch

an ios client for sourcehut

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

v2.4.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])
 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                    default:
 65                        smallView(snapshot: snapshot)
 66                    }
 67                }
 68            } else {
 69                fallbackState
 70            }
 71        }
 72        .widgetURL(URL(string: "hutch://home"))
 73        .containerBackground(for: .widget) {
 74            Color(.systemBackground)
 75        }
 76    }
 77
 78    private var header: some View {
 79        VStack(alignment: .leading, spacing: 1) {
 80            Text("Hutch")
 81                .font(.caption.weight(.semibold))
 82                .foregroundStyle(.secondary)
 83            Text("Needs Attention")
 84                .font(.headline)
 85                .lineLimit(1)
 86        }
 87    }
 88
 89    private func smallView(snapshot: NeedsAttentionSnapshot) -> some View {
 90        VStack(alignment: .leading, spacing: 12) {
 91            Spacer(minLength: 0)
 92            VStack(alignment: .leading, spacing: 8) {
 93                compactMetric(
 94                    count: snapshot.unreadInboxThreads,
 95                    label: "Unread",
 96                    tint: unreadTint(for: snapshot.unreadInboxThreads)
 97                )
 98                compactMetric(count: snapshot.assignedOpenTickets, label: "Assigned")
 99                compactMetric(
100                    count: snapshot.failedBuilds,
101                    label: "Failed",
102                    tint: failedTint(for: snapshot.failedBuilds)
103                )
104            }
105            Spacer(minLength: 0)
106        }
107        .padding()
108    }
109
110    private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
111        VStack(alignment: .leading, spacing: 18) {
112            header
113                .padding(.top, 6)
114            HStack(alignment: .top, spacing: 12) {
115                metricColumn(
116                    count: snapshot.unreadInboxThreads,
117                    label: "Unread",
118                    systemImage: "tray",
119                    tint: unreadTint(for: snapshot.unreadInboxThreads)
120                )
121                metricColumn(
122                    count: snapshot.assignedOpenTickets,
123                    label: "Assigned",
124                    systemImage: "ticket"
125                )
126                metricColumn(
127                    count: snapshot.failedBuilds,
128                    label: "Failed",
129                    systemImage: "hammer",
130                    tint: failedTint(for: snapshot.failedBuilds)
131                )
132            }
133            Spacer(minLength: 0)
134        }
135        .padding(.horizontal, 16)
136        .padding(.top, 18)
137        .padding(.bottom, 16)
138    }
139
140    private var clearState: some View {
141        VStack(alignment: .leading, spacing: 12) {
142            header
143            Spacer(minLength: 0)
144            Text("All clear")
145                .font(.title3.weight(.semibold))
146            Text("No unread threads, assigned tickets, or failed builds.")
147                .font(.caption)
148                .foregroundStyle(.secondary)
149                .fixedSize(horizontal: false, vertical: true)
150        }
151        .padding()
152    }
153
154    private var fallbackState: some View {
155        VStack(alignment: .leading, spacing: 12) {
156            header
157            Spacer(minLength: 0)
158            Text("Open Hutch")
159                .font(.title3.weight(.semibold))
160            Text("Unable to load recent counts.")
161                .font(.caption)
162                .foregroundStyle(.secondary)
163        }
164        .padding()
165    }
166
167    private func compactMetric(count: Int?, label: String, tint: Color = .primary) -> some View {
168        HStack(alignment: .center, spacing: 6) {
169            Text(displayCount(count))
170                .font(.title2.weight(.semibold))
171                .monospacedDigit()
172                .foregroundStyle(tint)
173            Text(label)
174                .font(.subheadline)
175                .foregroundStyle(.secondary)
176            Spacer(minLength: 0)
177        }
178    }
179
180    private func metricColumn(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View {
181        VStack(alignment: .leading, spacing: 8) {
182            HStack(alignment: .center, spacing: 10) {
183                Image(systemName: systemImage)
184                    .font(.title3.weight(.semibold))
185                    .foregroundStyle(tint)
186                    .frame(width: 24, alignment: .leading)
187                    .padding(.trailing, 4)
188                Text(displayCount(count))
189                    .font(.system(size: 28, weight: .semibold, design: .rounded))
190                    .monospacedDigit()
191                    .lineLimit(1)
192                    .minimumScaleFactor(0.8)
193                    .foregroundStyle(tint)
194            }
195            Text(label)
196                .font(.caption)
197                .foregroundStyle(.secondary)
198                .lineLimit(1)
199                .minimumScaleFactor(0.85)
200                .frame(height: 16, alignment: .topLeading)
201        }
202        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
203    }
204
205    private func displayCount(_ count: Int?) -> String {
206        guard let count else { return "" }
207        return "\(count)"
208    }
209
210    private func unreadTint(for count: Int?) -> Color {
211        guard let count, count > 0 else { return .primary }
212        return .blue
213    }
214
215    private func failedTint(for count: Int?) -> Color {
216        guard let count else { return .primary }
217        return count > 0 ? .red : .green
218    }
219}
220
221@main
222struct HutchWidgets: WidgetBundle {
223    var body: some Widget {
224        NeedsAttentionWidget()
225    }
226}