krz/hutch

an ios client for sourcehut

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

v2.13.1: Hutch/Views/Home/HomeView.swift · raw

  1import SwiftUI
  2
  3struct HomeView: View {
  4    @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
  5    @Environment(AppState.self) private var appState
  6    @Environment(\.scenePhase) private var scenePhase
  7    @State private var viewModel: HomeViewModel?
  8    private let previewLimit = 4
  9    private let projectPreviewLimit = 3
 10
 11    var body: some View {
 12        Group {
 13            if let viewModel {
 14                content(viewModel)
 15            } else {
 16                SRHTLoadingStateView(message: "Loading Home…")
 17            }
 18        }
 19        .navigationTitle("Home")
 20        .toolbar {
 21            ToolbarItem(placement: .topBarTrailing) {
 22                NavigationLink {
 23                    InboxView()
 24                } label: {
 25                    HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true)
 26                }
 27            }
 28        }
 29        .task {
 30            guard let currentUser = appState.currentUser else { return }
 31
 32            let vm: HomeViewModel
 33            if let viewModel {
 34                vm = viewModel
 35            } else {
 36                let newViewModel = HomeViewModel(
 37                    currentUser: currentUser,
 38                    client: appState.client,
 39                    systemStatusRepository: appState.systemStatusRepository
 40                )
 41                viewModel = newViewModel
 42                vm = newViewModel
 43            }
 44
 45            await vm.loadDashboard()
 46        }
 47        .onChange(of: scenePhase) { _, newPhase in
 48            guard newPhase == .active, let viewModel else { return }
 49            Task {
 50                await viewModel.loadDashboard()
 51            }
 52        }
 53    }
 54
 55    @ViewBuilder
 56    private func content(_ viewModel: HomeViewModel) -> some View {
 57        List {
 58            systemStatusBannerSection(viewModel)
 59            projectsSection(viewModel)
 60            assignedTicketsSection(viewModel)
 61            recentBuildsSection(viewModel)
 62        }
 63        .listStyle(.insetGrouped)
 64        .overlay {
 65            if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
 66                viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
 67                SRHTLoadingStateView(message: "Loading Home…")
 68            } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
 69                        viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
 70                        viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
 71                ContentUnavailableView(
 72                    "All Clear",
 73                    systemImage: "checkmark.circle",
 74                    description: Text("There are no assigned tickets or recent builds right now.")
 75                )
 76            }
 77        }
 78        .refreshable {
 79            await viewModel.loadDashboard()
 80        }
 81        .connectivityOverlay(hasContent: viewModel.hasDashboardContent) {
 82            await viewModel.loadDashboard()
 83        }
 84    }
 85
 86    @ViewBuilder
 87    private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View {
 88        Section {
 89            NavigationLink {
 90                SystemStatusView()
 91            } label: {
 92                SystemStatusSummaryRow(
 93                    snapshot: viewModel.systemStatusSnapshot,
 94                    isLoading: viewModel.isLoadingSystemStatus,
 95                    errorMessage: viewModel.systemStatusErrorMessage,
 96                    isShowingStaleData: viewModel.isShowingStaleSystemStatus
 97                )
 98            }
 99            .buttonStyle(.plain)
100        }
101    }
102
103    @ViewBuilder
104    private func projectsSection(_ viewModel: HomeViewModel) -> some View {
105        if !viewModel.projects.isEmpty {
106            Section {
107                ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in
108                    NavigationLink {
109                        ProjectDetailView(project: project)
110                    } label: {
111                        HomeProjectRow(project: project)
112                    }
113                }
114            } header: {
115                HomeSectionHeader("Projects") {
116                    HomeProjectsListView(viewModel: viewModel)
117                }
118            }
119        }
120    }
121
122    @ViewBuilder
123    private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
124        Section {
125            if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
126                HomeSectionLoadingRow(label: "Loading assigned tickets")
127            } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty {
128                HomeSectionMessageRow(
129                    text: "Couldn’t load assigned tickets.",
130                    systemImage: "exclamationmark.triangle",
131                    emphasized: true,
132                    accessibilityHint: error
133                )
134            } else if viewModel.assignedTickets.isEmpty {
135                HomeSectionMessageRow(
136                    text: "No open tickets assigned to you.",
137                    systemImage: "person.crop.circle.badge.checkmark"
138                )
139            } else {
140                ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in
141                    NavigationLink {
142                        TicketDetailView(
143                            ownerUsername: ticket.ownerUsername,
144                            trackerName: ticket.trackerName,
145                            trackerId: ticket.trackerId,
146                            trackerRid: ticket.trackerRid,
147                            ticketId: ticket.ticket.id
148                        )
149                    } label: {
150                        HomeAssignedTicketRow(ticket: ticket)
151                    }
152                    .swipeActions(edge: .leading, allowsFullSwipe: true) {
153                        if swipeActionsEnabled {
154                            ticketLeadingSwipeAction(ticket, viewModel: viewModel)
155                        }
156                    }
157                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
158                        if swipeActionsEnabled {
159                            Button {
160                                Task {
161                                    await viewModel.unassignFromMe(ticket)
162                                }
163                            } label: {
164                                Label("Unassign Me", systemImage: "person.badge.minus")
165                            }
166                            .tint(.orange)
167                        }
168                    }
169                }
170            }
171        } header: {
172            HomeSectionHeader("Assigned Tickets") {
173                HomeAssignedTicketsListView(viewModel: viewModel)
174            }
175        }
176    }
177
178    @ViewBuilder
179    private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
180        Section {
181            if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty {
182                HomeSectionLoadingRow(label: "Loading recent builds")
183            } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty {
184                HomeSectionMessageRow(
185                    text: "Couldn’t load recent builds.",
186                    systemImage: "exclamationmark.triangle",
187                    emphasized: true,
188                    accessibilityHint: error
189                )
190            } else if viewModel.recentBuilds.isEmpty {
191                HomeSectionMessageRow(
192                    text: "No recent builds.",
193                    systemImage: "clock"
194                )
195            } else {
196                ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in
197                    NavigationLink {
198                        BuildDetailView(jobId: build.job.id)
199                    } label: {
200                        HomeBuildRow(build: build)
201                    }
202                    .swipeActions(edge: .leading, allowsFullSwipe: true) {
203                        if swipeActionsEnabled, build.job.status.isCancellable {
204                            Button {
205                                Task {
206                                    await viewModel.cancelBuild(build)
207                                }
208                            }
209                            label: {
210                                Label("Cancel", systemImage: "xmark.circle")
211                            }
212                            .tint(.red)
213                        }
214                    }
215                }
216            }
217        } header: {
218            HomeSectionActionHeader("Recent Builds") {
219                appState.selectedTab = .builds
220            }
221        }
222    }
223
224    @ViewBuilder
225    private func ticketLeadingSwipeAction(
226        _ ticket: HomeAssignedTicket,
227        viewModel: HomeViewModel
228    ) -> some View {
229        if ticket.ticket.status.isOpen {
230            Button {
231                Task {
232                    await viewModel.resolveTicket(ticket)
233                }
234            } label: {
235                Label("Resolve", systemImage: "checkmark.circle")
236            }
237            .tint(.green)
238        } else {
239            Button {
240                Task {
241                    await viewModel.reopenTicket(ticket)
242                }
243            } label: {
244                Label("Reopen", systemImage: "arrow.uturn.backward")
245            }
246            .tint(.blue)
247        }
248    }
249
250}
251
252private struct HomeInboxToolbarIcon: View {
253    let hasUnreadThreads: Bool
254
255    var body: some View {
256        Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
257            .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
258    }
259}
260
261private struct HomeProjectRow: View {
262    let project: Project
263
264    var body: some View {
265        VStack(alignment: .leading, spacing: 4) {
266            Text(project.name)
267                .font(.subheadline.weight(.medium))
268                .lineLimit(1)
269
270            if let description = project.description, !description.isEmpty {
271                Text(description)
272                    .font(.caption)
273                    .foregroundStyle(.secondary)
274                    .lineLimit(1)
275            }
276
277            if let summary = project.resourceSummary {
278                Text(summary)
279                    .font(.caption)
280                    .foregroundStyle(.tertiary)
281                    .lineLimit(1)
282            }
283        }
284        .padding(.vertical, 2)
285    }
286}
287
288private struct HomeProjectsListView: View {
289    let viewModel: HomeViewModel
290
291    var body: some View {
292        List {
293            ForEach(viewModel.projects) { project in
294                NavigationLink {
295                    ProjectDetailView(project: project)
296                } label: {
297                    HomeProjectRow(project: project)
298                }
299            }
300        }
301        .navigationTitle("Projects")
302        .navigationBarTitleDisplayMode(.inline)
303        .refreshable {
304            await viewModel.loadDashboard()
305        }
306        .overlay {
307            if viewModel.isLoadingProjects && viewModel.projects.isEmpty {
308                SRHTLoadingStateView(message: "Loading projects…")
309            }
310        }
311    }
312}
313
314private struct HomeBuildRow: View {
315    let build: HomeBuildItem
316
317    var body: some View {
318        HStack(spacing: 12) {
319            JobStatusIcon(status: build.job.status)
320                .frame(width: 20)
321
322            VStack(alignment: .leading, spacing: 4) {
323                Text(primaryTitle)
324                    .font(.subheadline.weight(.medium))
325                    .lineLimit(1)
326
327                HStack(spacing: 8) {
328                    Text("Job #\(build.job.id)")
329                        .font(.caption)
330                        .foregroundStyle(.secondary)
331
332                    Text("")
333                        .font(.caption)
334                        .foregroundStyle(.tertiary)
335
336                    Text(build.job.status.rawValue.capitalized)
337                        .font(.caption)
338                        .foregroundStyle(.secondary)
339
340                    Text("")
341                        .font(.caption)
342                        .foregroundStyle(.tertiary)
343
344                    Text(build.job.created.relativeDescription)
345                        .font(.caption)
346                        .foregroundStyle(.tertiary)
347
348                    Spacer()
349                }
350            }
351        }
352        .padding(.vertical, 2)
353    }
354
355    private var primaryTitle: String {
356        if let repositoryDisplayName = build.repositoryDisplayName {
357            return repositoryDisplayName
358        }
359        return build.job.displayLabel
360    }
361}
362
363private struct HomeAssignedTicketRow: View {
364    let ticket: HomeAssignedTicket
365
366    var body: some View {
367        HStack(alignment: .top, spacing: 12) {
368            TicketStatusIcon(status: ticket.ticket.status)
369                .frame(width: 20)
370
371            VStack(alignment: .leading, spacing: 4) {
372                Text(ticket.ticket.title)
373                    .font(.subheadline.weight(.medium))
374                    .lineLimit(2)
375
376                Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id)\(ticket.ticket.created.relativeDescription)")
377                    .font(.caption)
378                    .foregroundStyle(.secondary)
379                    .lineLimit(1)
380                    .truncationMode(.tail)
381            }
382
383            Spacer(minLength: 8)
384
385            Text(ticket.ticket.status.displayName)
386                .font(.caption2.weight(.medium))
387                .foregroundStyle(.secondary)
388                .lineLimit(1)
389                .fixedSize()
390        }
391        .padding(.vertical, 2)
392    }
393}
394
395private struct HomeSectionLoadingRow: View {
396    let label: String
397
398    var body: some View {
399        HStack(spacing: 10) {
400            ProgressView()
401                .controlSize(.small)
402            Text(label)
403                .foregroundStyle(.secondary)
404        }
405        .frame(maxWidth: .infinity, alignment: .leading)
406    }
407}
408
409private struct HomeSectionHeader<Destination: View>: View {
410    let title: String
411    let destination: Destination
412
413    init(_ title: String, @ViewBuilder destination: () -> Destination) {
414        self.title = title
415        self.destination = destination()
416    }
417
418    var body: some View {
419        HStack {
420            Text(title)
421            Spacer()
422            NavigationLink {
423                destination
424            } label: {
425                Text("See All")
426                    .font(.caption.weight(.medium))
427            }
428            .buttonStyle(.plain)
429        }
430        .textCase(nil)
431    }
432}
433
434private struct HomeSectionActionHeader: View {
435    let title: String
436    let action: () -> Void
437
438    init(_ title: String, action: @escaping () -> Void) {
439        self.title = title
440        self.action = action
441    }
442
443    var body: some View {
444        HStack {
445            Text(title)
446            Spacer()
447            Button("See All", action: action)
448                .font(.caption.weight(.medium))
449                .buttonStyle(.plain)
450        }
451        .textCase(nil)
452    }
453}
454
455private struct HomeAssignedTicketsListView: View {
456    let viewModel: HomeViewModel
457    @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
458
459    var body: some View {
460        List {
461            ForEach(viewModel.assignedTickets) { ticket in
462                NavigationLink {
463                    TicketDetailView(
464                        ownerUsername: ticket.ownerUsername,
465                        trackerName: ticket.trackerName,
466                        trackerId: ticket.trackerId,
467                        trackerRid: ticket.trackerRid,
468                        ticketId: ticket.ticket.id
469                    )
470                } label: {
471                    HomeAssignedTicketRow(ticket: ticket)
472                }
473                .swipeActions(edge: .leading, allowsFullSwipe: true) {
474                    if swipeActionsEnabled {
475                        if ticket.ticket.status.isOpen {
476                            Button {
477                                Task { await viewModel.resolveTicket(ticket) }
478                            } label: {
479                                Label("Resolve", systemImage: "checkmark.circle")
480                            }
481                            .tint(.green)
482                        } else {
483                            Button {
484                                Task { await viewModel.reopenTicket(ticket) }
485                            } label: {
486                                Label("Reopen", systemImage: "arrow.uturn.backward")
487                            }
488                            .tint(.blue)
489                        }
490                    }
491                }
492                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
493                    if swipeActionsEnabled {
494                        Button {
495                            Task { await viewModel.unassignFromMe(ticket) }
496                        } label: {
497                            Label("Unassign Me", systemImage: "person.badge.minus")
498                        }
499                        .tint(.orange)
500                    }
501                }
502            }
503
504            if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
505                HomeSectionMessageRow(
506                    text: "No open tickets assigned to you.",
507                    systemImage: "person.crop.circle.badge.checkmark"
508                )
509            }
510        }
511        .navigationTitle("Assigned Tickets")
512        .navigationBarTitleDisplayMode(.inline)
513        .refreshable {
514            await viewModel.loadDashboard()
515        }
516        .overlay {
517            if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
518                SRHTLoadingStateView(message: "Loading assigned tickets…")
519            }
520        }
521    }
522}
523
524private struct HomeSectionMessageRow: View {
525    let text: String
526    let systemImage: String
527    var emphasized = false
528    var accessibilityHint: String? = nil
529
530    var body: some View {
531        Label(text, systemImage: systemImage)
532            .font(.subheadline)
533            .foregroundStyle(emphasized ? .secondary : .tertiary)
534            .accessibilityHint(accessibilityHint ?? "")
535    }
536}