krz/hutch

an ios client for sourcehut

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

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