krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.5.0: Hutch/Views/Work/WorkView.swift · raw
1import SwiftUI
2
3extension HutchWorkQueueScope: Identifiable {
4 var id: String { rawValue }
5
6 var displayName: String {
7 switch self {
8 case .all: "All"
9 case .unread: "Unread"
10 case .assigned: "Assigned"
11 }
12 }
13}
14
15struct WorkView: View {
16 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
17 @Environment(AppState.self) private var appState
18 @Environment(\.isAMOLEDTheme) private var isAMOLED
19 @Environment(\.scenePhase) private var scenePhase
20 @State private var viewModel: HomeViewModel?
21 @State private var scope: HutchWorkQueueScope
22
23 init(initialScope: HutchWorkQueueScope = .all) {
24 _scope = State(initialValue: initialScope)
25 }
26
27 var body: some View {
28 Group {
29 if let viewModel {
30 content(viewModel)
31 } else {
32 SRHTLoadingStateView(message: "Loading Work…")
33 }
34 }
35 .navigationTitle("Work")
36 .navigationBarTitleDisplayMode(.inline)
37 .toolbar {
38 ToolbarItem(placement: .topBarTrailing) {
39 Button("Mark All Read") {
40 viewModel?.markAllInboxThreadsRead()
41 }
42 .disabled(viewModel.map { unreadCount($0) } ?? 0 == 0)
43 }
44 }
45 .task {
46 guard let currentUser = appState.currentUser else { return }
47 await ensureViewModel(currentUser: currentUser).loadDashboard()
48 }
49 .onChange(of: scenePhase) { _, newPhase in
50 guard newPhase == .active, let viewModel, viewModel.needsRefresh() else { return }
51 Task {
52 await viewModel.loadDashboard()
53 }
54 }
55 }
56
57 @ViewBuilder
58 private func content(_ viewModel: HomeViewModel) -> some View {
59 List {
60 headerSection(viewModel)
61 scopeSection
62
63 switch scope {
64 case .all:
65 allScopeContent(viewModel)
66 case .unread:
67 unreadSection(viewModel, compactWhenEmpty: true)
68 case .assigned:
69 assignedSection(viewModel)
70 }
71 }
72 .themedList()
73 .listStyle(.insetGrouped)
74 .refreshable {
75 await viewModel.loadDashboard()
76 }
77 .connectivityOverlay(hasContent: hasWorkContent(viewModel)) {
78 await viewModel.loadDashboard()
79 }
80 }
81
82 private func headerSection(_ viewModel: HomeViewModel) -> some View {
83 Section {
84 VStack(alignment: .leading, spacing: 6) {
85 Text(title(viewModel))
86 .font(.headline)
87 if workCount(viewModel) > 0 {
88 Text(summary(viewModel))
89 .font(.subheadline)
90 .foregroundStyle(.secondary)
91 }
92 }
93 .padding(.vertical, 2)
94 .themedRow()
95 }
96 }
97
98 @ViewBuilder
99 private func allScopeContent(_ viewModel: HomeViewModel) -> some View {
100 if unreadCount(viewModel) > 0 {
101 unreadSection(viewModel, compactWhenEmpty: false)
102 }
103
104 assignedSection(viewModel)
105
106 if workCount(viewModel) == 0 {
107 Section {
108 WorkCompactMessageRow(text: "Nothing to do", systemImage: "checkmark.circle")
109 .themedRow()
110 }
111 }
112 }
113
114 private var scopeSection: some View {
115 Section {
116 Picker("Scope", selection: $scope) {
117 ForEach(HutchWorkQueueScope.allCases) { scope in
118 Text(scope.displayName).tag(scope)
119 }
120 }
121 .pickerStyle(.segmented)
122 .listRowBackground(isAMOLED ? Color.black : Color.clear)
123 .listRowInsets(EdgeInsets())
124 }
125 }
126
127 @ViewBuilder
128 private func unreadSection(_ viewModel: HomeViewModel, compactWhenEmpty: Bool) -> some View {
129 Section {
130 if isLoadingUnread(viewModel) {
131 WorkLoadingRow(label: "Loading unread threads")
132 .themedRow()
133 } else if viewModel.unreadInboxThreads.isEmpty {
134 if compactWhenEmpty {
135 WorkCompactMessageRow(text: "No unread threads", systemImage: "tray")
136 .themedRow()
137 }
138 } else {
139 ForEach(viewModel.unreadInboxThreads) { thread in
140 NavigationLink {
141 ThreadDetailView(
142 thread: thread,
143 onViewed: { viewModel.markInboxThreadRead(thread) },
144 onMarkRead: { viewModel.markInboxThreadRead(thread) },
145 onMarkUnread: { viewModel.markInboxThreadUnread(thread) }
146 )
147 } label: {
148 WorkThreadRow(thread: thread)
149 }
150 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
151 if swipeActionsEnabled {
152 Button {
153 viewModel.markInboxThreadRead(thread)
154 } label: {
155 Label("Mark Read", systemImage: "envelope.open")
156 }
157 .tint(.blue)
158 }
159 }
160 }
161 .themedRow()
162 }
163 } header: {
164 Text("Unread Threads")
165 } footer: {
166 NavigationLink {
167 MailingListListView()
168 } label: {
169 Label("Open mailing list workspace", systemImage: "list.bullet")
170 .font(.subheadline.weight(.medium))
171 }
172 }
173 }
174
175 @ViewBuilder
176 private func assignedSection(_ viewModel: HomeViewModel) -> some View {
177 Section {
178 if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
179 WorkLoadingRow(label: "Loading assigned tickets")
180 .themedRow()
181 } else if viewModel.assignedTickets.isEmpty {
182 WorkCompactMessageRow(text: "No assigned tickets", systemImage: "person.crop.circle.badge.checkmark")
183 .themedRow()
184 } else {
185 ForEach(viewModel.assignedTickets) { ticket in
186 NavigationLink {
187 TicketDetailView(
188 ownerUsername: ticket.ownerUsername,
189 trackerName: ticket.trackerName,
190 trackerId: ticket.trackerId,
191 trackerRid: ticket.trackerRid,
192 ticketId: ticket.ticket.id
193 )
194 } label: {
195 WorkAssignedTicketRow(ticket: ticket)
196 }
197 .swipeActions(edge: .leading, allowsFullSwipe: true) {
198 if swipeActionsEnabled {
199 if ticket.ticket.status.isOpen {
200 Button {
201 Task { await viewModel.resolveTicket(ticket) }
202 } label: {
203 Label("Resolve", systemImage: "checkmark.circle")
204 }
205 .tint(.green)
206 } else {
207 Button {
208 Task { await viewModel.reopenTicket(ticket) }
209 } label: {
210 Label("Reopen", systemImage: "arrow.uturn.backward")
211 }
212 .tint(.blue)
213 }
214 }
215 }
216 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
217 if swipeActionsEnabled {
218 Button {
219 Task { await viewModel.unassignFromMe(ticket) }
220 } label: {
221 Label("Unassign", systemImage: "person.badge.minus")
222 }
223 .tint(.orange)
224 }
225 }
226 }
227 .themedRow()
228 }
229 } header: {
230 Text("Assigned Tickets")
231 } footer: {
232 NavigationLink {
233 TrackerListView()
234 } label: {
235 Label("Open tracker workspace", systemImage: "checklist")
236 .font(.subheadline.weight(.medium))
237 }
238 }
239 }
240
241 private func title(_ viewModel: HomeViewModel) -> String {
242 let count = workCount(viewModel)
243 if count == 0 {
244 return "Queue clear"
245 }
246 return "\(count) item\(count == 1 ? "" : "s") need attention"
247 }
248
249 private func summary(_ viewModel: HomeViewModel) -> String {
250 "\(unreadCount(viewModel)) unread • \(viewModel.assignedTickets.count) assigned"
251 }
252
253 private func workCount(_ viewModel: HomeViewModel) -> Int {
254 unreadCount(viewModel) + viewModel.assignedTickets.count
255 }
256
257 private func unreadCount(_ viewModel: HomeViewModel) -> Int {
258 viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count
259 }
260
261 private func isLoadingUnread(_ viewModel: HomeViewModel) -> Bool {
262 viewModel.unreadInboxThreadCount == nil && viewModel.unreadInboxThreads.isEmpty
263 }
264
265 private func hasWorkContent(_ viewModel: HomeViewModel) -> Bool {
266 workCount(viewModel) > 0
267 }
268
269 @MainActor
270 private func ensureViewModel(currentUser: User) -> HomeViewModel {
271 if let viewModel {
272 return viewModel
273 }
274
275 let newViewModel = HomeViewModel(
276 currentUser: currentUser,
277 client: appState.client,
278 systemStatusRepository: appState.systemStatusRepository,
279 defaults: appState.accountDefaults,
280 accountID: appState.activeAccountID
281 )
282 viewModel = newViewModel
283 return newViewModel
284 }
285}
286
287private struct WorkThreadRow: View {
288 let thread: InboxThreadSummary
289
290 var body: some View {
291 VStack(alignment: .leading, spacing: 6) {
292 HStack(alignment: .top, spacing: 8) {
293 Circle()
294 .fill(thread.isUnread ? .blue : .clear)
295 .frame(width: 8, height: 8)
296 .padding(.top, 5)
297
298 Text(thread.displaySubject)
299 .font(.subheadline.weight(.semibold))
300 .lineLimit(2)
301 }
302
303 Text(thread.listDisplayName)
304 .font(.caption)
305 .foregroundStyle(.secondary)
306 .lineLimit(1)
307
308 Text(thread.metadataLine)
309 .font(.caption)
310 .foregroundStyle(.tertiary)
311 .lineLimit(1)
312 }
313 .padding(.vertical, 2)
314 }
315}
316
317private struct WorkAssignedTicketRow: View {
318 let ticket: HomeAssignedTicket
319
320 var body: some View {
321 VStack(alignment: .leading, spacing: 6) {
322 HStack(alignment: .firstTextBaseline, spacing: 8) {
323 Text("#\(ticket.ticket.id)")
324 .font(.caption.monospacedDigit())
325 .foregroundStyle(.secondary)
326
327 Text(ticket.ticket.title)
328 .font(.subheadline.weight(.semibold))
329 .lineLimit(2)
330
331 Spacer(minLength: 8)
332
333 Text(ticket.ticket.status.displayName)
334 .font(.caption2.weight(.semibold))
335 .foregroundStyle(ticket.ticket.status.isOpen ? .orange : .secondary)
336 }
337
338 Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName)")
339 .font(.caption)
340 .foregroundStyle(.secondary)
341 .lineLimit(1)
342
343 Text(ticket.ticket.created.relativeDescription)
344 .font(.caption)
345 .foregroundStyle(.tertiary)
346 }
347 .padding(.vertical, 2)
348 }
349}
350
351private struct WorkCompactMessageRow: View {
352 let text: String
353 let systemImage: String
354
355 var body: some View {
356 Label(text, systemImage: systemImage)
357 .font(.caption)
358 .foregroundStyle(.secondary)
359 .padding(.vertical, 2)
360 }
361}
362
363private struct WorkLoadingRow: View {
364 let label: String
365
366 var body: some View {
367 HStack(spacing: 10) {
368 ProgressView()
369 .controlSize(.small)
370 Text(label)
371 .font(.subheadline)
372 .foregroundStyle(.secondary)
373 }
374 .padding(.vertical, 4)
375 }
376}