krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.10.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(forceRefresh: true)
 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}