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}