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