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