krz/hutch

an ios client for sourcehut

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

v3.1.11: 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}