krz/hutch

an ios client for sourcehut

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

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