krz/hutch

an ios client for sourcehut

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

v3.2.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}