krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.4.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: 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: 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: 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}