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