krz/hutch

an ios client for sourcehut

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

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