krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.14.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 attentionSection(viewModel)
59 systemStatusBannerSection(viewModel)
60 inboxSection(viewModel)
61 projectsSection(viewModel)
62 assignedTicketsSection(viewModel)
63 recentBuildsSection(viewModel)
64 }
65 .listStyle(.insetGrouped)
66 .overlay {
67 if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
68 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
69 viewModel.unreadInboxThreads.isEmpty {
70 SRHTLoadingStateView(message: "Loading Home…")
71 } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
72 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
73 viewModel.unreadInboxThreads.isEmpty &&
74 viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
75 ContentUnavailableView(
76 "All Clear",
77 systemImage: "checkmark.circle",
78 description: Text("There are no unread threads, assigned tickets, or urgent builds right now.")
79 )
80 }
81 }
82 .refreshable {
83 await viewModel.loadDashboard()
84 }
85 .connectivityOverlay(hasContent: viewModel.hasDashboardContent) {
86 await viewModel.loadDashboard()
87 }
88 }
89
90 @ViewBuilder
91 private func attentionSection(_ viewModel: HomeViewModel) -> some View {
92 Section("Needs Attention") {
93 HomeAttentionSummaryRow(
94 title: viewModel.needsAttentionCount == 0 ? "All clear" : "\(viewModel.needsAttentionCount) things need attention",
95 summary: viewModel.attentionSummaryText
96 )
97
98 HomeAttentionLinkRow(
99 title: "Inbox",
100 summary: viewModel.inboxSummaryText,
101 countText: viewModel.unreadInboxThreadCount.map(String.init) ?? "?"
102 ) {
103 InboxView()
104 }
105
106 HomeAttentionLinkRow(
107 title: "Assigned Tickets",
108 summary: viewModel.ticketsSummaryText,
109 countText: String(viewModel.assignedTickets.count)
110 ) {
111 HomeAssignedTicketsListView(viewModel: viewModel)
112 }
113
114 HomeAttentionLinkRow(
115 title: "Builds",
116 summary: viewModel.buildsSummaryText,
117 countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount),
118 action: {
119 appState.selectedTab = .builds
120 }
121 )
122 }
123 }
124
125 @ViewBuilder
126 private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View {
127 Section {
128 NavigationLink {
129 SystemStatusView()
130 } label: {
131 SystemStatusSummaryRow(
132 snapshot: viewModel.systemStatusSnapshot,
133 isLoading: viewModel.isLoadingSystemStatus,
134 errorMessage: viewModel.systemStatusErrorMessage,
135 isShowingStaleData: viewModel.isShowingStaleSystemStatus
136 )
137 }
138 .buttonStyle(.plain)
139 }
140 }
141
142 @ViewBuilder
143 private func inboxSection(_ viewModel: HomeViewModel) -> some View {
144 Section {
145 if let unreadCount = viewModel.unreadInboxThreadCount, unreadCount == 0 {
146 HomeSectionMessageRow(
147 text: "No unread inbox threads.",
148 systemImage: "tray"
149 )
150 } else if viewModel.unreadInboxThreads.isEmpty {
151 HomeSectionMessageRow(
152 text: viewModel.inboxSummaryText,
153 systemImage: "tray"
154 )
155 } else {
156 ForEach(viewModel.unreadInboxThreads.prefix(previewLimit)) { thread in
157 NavigationLink {
158 ThreadDetailView(
159 thread: thread,
160 onViewed: { viewModel.markInboxThreadRead(thread) },
161 onMarkRead: { viewModel.markInboxThreadRead(thread) },
162 onMarkUnread: { viewModel.markInboxThreadUnread(thread) }
163 )
164 } label: {
165 HomeInboxThreadRow(thread: thread)
166 }
167 }
168 }
169 } header: {
170 HomeSectionHeader("Inbox") {
171 InboxView()
172 }
173 }
174 }
175
176 @ViewBuilder
177 private func projectsSection(_ viewModel: HomeViewModel) -> some View {
178 if !viewModel.projects.isEmpty {
179 Section {
180 ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in
181 NavigationLink {
182 ProjectDetailView(project: project)
183 } label: {
184 HomeProjectRow(project: project)
185 }
186 }
187 } header: {
188 HomeSectionHeader("Projects") {
189 HomeProjectsListView(viewModel: viewModel)
190 }
191 }
192 }
193 }
194
195 @ViewBuilder
196 private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
197 Section {
198 if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
199 HomeSectionLoadingRow(label: "Loading assigned tickets")
200 } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty {
201 HomeSectionMessageRow(
202 text: "Couldn’t load assigned tickets.",
203 systemImage: "exclamationmark.triangle",
204 emphasized: true,
205 accessibilityHint: error
206 )
207 } else if viewModel.assignedTickets.isEmpty {
208 HomeSectionMessageRow(
209 text: "No open tickets assigned to you.",
210 systemImage: "person.crop.circle.badge.checkmark"
211 )
212 } else {
213 ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in
214 NavigationLink {
215 TicketDetailView(
216 ownerUsername: ticket.ownerUsername,
217 trackerName: ticket.trackerName,
218 trackerId: ticket.trackerId,
219 trackerRid: ticket.trackerRid,
220 ticketId: ticket.ticket.id
221 )
222 } label: {
223 HomeAssignedTicketRow(ticket: ticket)
224 }
225 .swipeActions(edge: .leading, allowsFullSwipe: true) {
226 if swipeActionsEnabled {
227 ticketLeadingSwipeAction(ticket, viewModel: viewModel)
228 }
229 }
230 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
231 if swipeActionsEnabled {
232 Button {
233 Task {
234 await viewModel.unassignFromMe(ticket)
235 }
236 } label: {
237 Label("Unassign Me", systemImage: "person.badge.minus")
238 }
239 .tint(.orange)
240 }
241 }
242 }
243 }
244 } header: {
245 HomeSectionHeader("Assigned Tickets") {
246 HomeAssignedTicketsListView(viewModel: viewModel)
247 }
248 }
249 }
250
251 @ViewBuilder
252 private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
253 Section {
254 if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty {
255 HomeSectionLoadingRow(label: "Loading recent builds")
256 } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty {
257 HomeSectionMessageRow(
258 text: "Couldn’t load recent builds.",
259 systemImage: "exclamationmark.triangle",
260 emphasized: true,
261 accessibilityHint: error
262 )
263 } else if viewModel.recentBuilds.isEmpty {
264 HomeSectionMessageRow(
265 text: "No recent builds.",
266 systemImage: "clock"
267 )
268 } else {
269 ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in
270 NavigationLink {
271 BuildDetailView(jobId: build.job.id)
272 } label: {
273 HomeBuildRow(build: build)
274 }
275 .swipeActions(edge: .leading, allowsFullSwipe: true) {
276 if swipeActionsEnabled, build.job.status.isCancellable {
277 Button {
278 Task {
279 await viewModel.cancelBuild(build)
280 }
281 }
282 label: {
283 Label("Cancel", systemImage: "xmark.circle")
284 }
285 .tint(.red)
286 }
287 }
288 }
289 }
290 } header: {
291 HomeSectionActionHeader("Recent Builds") {
292 appState.selectedTab = .builds
293 }
294 }
295 }
296
297 @ViewBuilder
298 private func ticketLeadingSwipeAction(
299 _ ticket: HomeAssignedTicket,
300 viewModel: HomeViewModel
301 ) -> some View {
302 if ticket.ticket.status.isOpen {
303 Button {
304 Task {
305 await viewModel.resolveTicket(ticket)
306 }
307 } label: {
308 Label("Resolve", systemImage: "checkmark.circle")
309 }
310 .tint(.green)
311 } else {
312 Button {
313 Task {
314 await viewModel.reopenTicket(ticket)
315 }
316 } label: {
317 Label("Reopen", systemImage: "arrow.uturn.backward")
318 }
319 .tint(.blue)
320 }
321 }
322
323}
324
325private struct HomeInboxToolbarIcon: View {
326 let hasUnreadThreads: Bool
327
328 var body: some View {
329 Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
330 .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
331 }
332}
333
334private struct HomeProjectRow: View {
335 let project: Project
336
337 var body: some View {
338 VStack(alignment: .leading, spacing: 4) {
339 Text(project.name)
340 .font(.subheadline.weight(.medium))
341 .lineLimit(1)
342
343 if let description = project.description, !description.isEmpty {
344 Text(description)
345 .font(.caption)
346 .foregroundStyle(.secondary)
347 .lineLimit(1)
348 }
349
350 if let summary = project.resourceSummary {
351 Text(summary)
352 .font(.caption)
353 .foregroundStyle(.tertiary)
354 .lineLimit(1)
355 }
356 }
357 .padding(.vertical, 2)
358 }
359}
360
361private struct HomeProjectsListView: View {
362 let viewModel: HomeViewModel
363
364 var body: some View {
365 List {
366 ForEach(viewModel.projects) { project in
367 NavigationLink {
368 ProjectDetailView(project: project)
369 } label: {
370 HomeProjectRow(project: project)
371 }
372 }
373 }
374 .navigationTitle("Projects")
375 .navigationBarTitleDisplayMode(.inline)
376 .refreshable {
377 await viewModel.loadDashboard()
378 }
379 .overlay {
380 if viewModel.isLoadingProjects && viewModel.projects.isEmpty {
381 SRHTLoadingStateView(message: "Loading projects…")
382 }
383 }
384 }
385}
386
387private struct HomeBuildRow: View {
388 @Environment(AppState.self) private var appState
389 let build: HomeBuildItem
390
391 var body: some View {
392 VStack(alignment: .leading, spacing: 6) {
393 HStack(spacing: 12) {
394 JobStatusIcon(status: build.job.status)
395 .frame(width: 20)
396
397 VStack(alignment: .leading, spacing: 4) {
398 Text(primaryTitle)
399 .font(.subheadline.weight(.medium))
400 .lineLimit(1)
401
402 HStack(spacing: 8) {
403 Text("Job #\(build.job.id)")
404 .font(.caption)
405 .foregroundStyle(.secondary)
406
407 Text("•")
408 .font(.caption)
409 .foregroundStyle(.tertiary)
410
411 Text(build.job.status.rawValue.capitalized)
412 .font(.caption)
413 .foregroundStyle(.secondary)
414
415 Text("•")
416 .font(.caption)
417 .foregroundStyle(.tertiary)
418
419 Text(build.job.created.relativeDescription)
420 .font(.caption)
421 .foregroundStyle(.tertiary)
422
423 Spacer()
424 }
425 }
426 }
427
428 if let repositoryDisplayName = build.repositoryDisplayName {
429 Button {
430 openRepository()
431 } label: {
432 Label(repositoryDisplayName, systemImage: "book.closed")
433 .font(.caption)
434 .foregroundStyle(.secondary)
435 }
436 .buttonStyle(.plain)
437 }
438 }
439 .padding(.vertical, 2)
440 }
441
442 private var primaryTitle: String {
443 if let repositoryDisplayName = build.repositoryDisplayName {
444 return repositoryDisplayName
445 }
446 return build.job.displayLabel
447 }
448
449 private func openRepository() {
450 guard let repositoryName = build.repositoryName,
451 let repositoryOwner = build.repositoryOwner else { return }
452 Task {
453 do {
454 let repository = try await appState.resolveRepository(
455 owner: repositoryOwner.hasPrefix("~") ? String(repositoryOwner.dropFirst()) : repositoryOwner,
456 name: repositoryName
457 )
458 appState.navigateToRepository(repository)
459 } catch {
460 appState.presentRepositoryDeepLinkError()
461 }
462 }
463 }
464}
465
466private struct HomeAssignedTicketRow: View {
467 @Environment(AppState.self) private var appState
468 let ticket: HomeAssignedTicket
469
470 var body: some View {
471 VStack(alignment: .leading, spacing: 6) {
472 HStack(alignment: .top, spacing: 12) {
473 TicketStatusIcon(status: ticket.ticket.status)
474 .frame(width: 20)
475
476 VStack(alignment: .leading, spacing: 4) {
477 Text(ticket.ticket.title)
478 .font(.subheadline.weight(.medium))
479 .lineLimit(2)
480
481 Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)")
482 .font(.caption)
483 .foregroundStyle(.secondary)
484 .lineLimit(1)
485 .truncationMode(.tail)
486 }
487
488 Spacer(minLength: 8)
489
490 Text(ticket.ticket.status.displayName)
491 .font(.caption2.weight(.medium))
492 .foregroundStyle(.secondary)
493 .lineLimit(1)
494 .fixedSize()
495 }
496
497 Button {
498 openTracker()
499 } label: {
500 Label("\(ticket.ownerCanonicalName)/\(ticket.trackerName)", systemImage: "checklist")
501 .font(.caption)
502 .foregroundStyle(.secondary)
503 }
504 .buttonStyle(.plain)
505 }
506 .padding(.vertical, 2)
507 }
508
509 private func openTracker() {
510 Task {
511 do {
512 let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName)
513 appState.navigateToTracker(tracker)
514 } catch {
515 appState.presentTicketDeepLinkError()
516 }
517 }
518 }
519}
520
521private struct HomeInboxThreadRow: View {
522 @Environment(AppState.self) private var appState
523 let thread: InboxThreadSummary
524
525 var body: some View {
526 VStack(alignment: .leading, spacing: 6) {
527 HStack(alignment: .top, spacing: 10) {
528 Circle()
529 .fill(.blue)
530 .frame(width: 8, height: 8)
531 .padding(.top, 6)
532
533 VStack(alignment: .leading, spacing: 4) {
534 Text(thread.displaySubject)
535 .font(.subheadline.weight(.medium))
536 .lineLimit(2)
537
538 Text(thread.metadataLine)
539 .font(.caption)
540 .foregroundStyle(.secondary)
541 .lineLimit(1)
542 }
543 }
544
545 HStack(spacing: 10) {
546 Button {
547 appState.navigateToMailingList(
548 InboxMailingListReference(
549 id: thread.listID,
550 rid: thread.listRID,
551 name: thread.listName,
552 owner: thread.listOwner
553 )
554 )
555 } label: {
556 Label(thread.listName, systemImage: "list.bullet")
557 .font(.caption)
558 .foregroundStyle(.secondary)
559 }
560 .buttonStyle(.plain)
561
562 if let repo = thread.repo {
563 Button {
564 openRepository(named: repo)
565 } label: {
566 Label(repo, systemImage: "book.closed")
567 .font(.caption)
568 .foregroundStyle(.secondary)
569 }
570 .buttonStyle(.plain)
571 }
572 }
573 }
574 .padding(.vertical, 2)
575 }
576
577 private func openRepository(named repositoryName: String) {
578 Task {
579 do {
580 let ownerUsername = thread.listOwner.canonicalName.hasPrefix("~")
581 ? String(thread.listOwner.canonicalName.dropFirst())
582 : thread.listOwner.canonicalName
583 let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName)
584 appState.navigateToRepository(repository)
585 } catch {
586 appState.presentRepositoryDeepLinkError()
587 }
588 }
589 }
590}
591
592private struct HomeSectionLoadingRow: View {
593 let label: String
594
595 var body: some View {
596 HStack(spacing: 10) {
597 ProgressView()
598 .controlSize(.small)
599 Text(label)
600 .foregroundStyle(.secondary)
601 }
602 .frame(maxWidth: .infinity, alignment: .leading)
603 }
604}
605
606private struct HomeSectionHeader<Destination: View>: View {
607 let title: String
608 let destination: Destination
609
610 init(_ title: String, @ViewBuilder destination: () -> Destination) {
611 self.title = title
612 self.destination = destination()
613 }
614
615 var body: some View {
616 HStack {
617 Text(title)
618 Spacer()
619 NavigationLink {
620 destination
621 } label: {
622 Text("See All")
623 .font(.caption.weight(.medium))
624 }
625 .buttonStyle(.plain)
626 }
627 .textCase(nil)
628 }
629}
630
631private struct HomeAttentionSummaryRow: View {
632 let title: String
633 let summary: String
634
635 var body: some View {
636 VStack(alignment: .leading, spacing: 4) {
637 Text(title)
638 .font(.subheadline.weight(.semibold))
639 Text(summary)
640 .font(.caption)
641 .foregroundStyle(.secondary)
642 .lineLimit(2)
643 }
644 .padding(.vertical, 2)
645 }
646}
647
648private struct HomeAttentionLinkRow<Destination: View>: View {
649 let title: String
650 let summary: String
651 let countText: String
652 let destination: Destination?
653 let action: (() -> Void)?
654
655 init(
656 title: String,
657 summary: String,
658 countText: String,
659 @ViewBuilder destination: () -> Destination
660 ) {
661 self.title = title
662 self.summary = summary
663 self.countText = countText
664 self.destination = destination()
665 self.action = nil
666 }
667
668 init(
669 title: String,
670 summary: String,
671 countText: String,
672 action: @escaping () -> Void
673 ) where Destination == EmptyView {
674 self.title = title
675 self.summary = summary
676 self.countText = countText
677 self.destination = nil
678 self.action = action
679 }
680
681 var body: some View {
682 Group {
683 if let destination {
684 NavigationLink {
685 destination
686 } label: {
687 content
688 }
689 } else if let action {
690 Button(action: action) {
691 content
692 }
693 .buttonStyle(.plain)
694 }
695 }
696 }
697
698 private var content: some View {
699 HStack(spacing: 12) {
700 VStack(alignment: .leading, spacing: 4) {
701 Text(title)
702 .font(.subheadline.weight(.medium))
703 Text(summary)
704 .font(.caption)
705 .foregroundStyle(.secondary)
706 .lineLimit(1)
707 }
708 Spacer()
709 Text(countText)
710 .font(.caption.weight(.semibold))
711 .foregroundStyle(.secondary)
712 .padding(.horizontal, 8)
713 .padding(.vertical, 4)
714 .background(Color(.secondarySystemFill), in: Capsule())
715 }
716 .padding(.vertical, 2)
717 }
718}
719
720private struct HomeSectionActionHeader: View {
721 let title: String
722 let action: () -> Void
723
724 init(_ title: String, action: @escaping () -> Void) {
725 self.title = title
726 self.action = action
727 }
728
729 var body: some View {
730 HStack {
731 Text(title)
732 Spacer()
733 Button("See All", action: action)
734 .font(.caption.weight(.medium))
735 .buttonStyle(.plain)
736 }
737 .textCase(nil)
738 }
739}
740
741private struct HomeAssignedTicketsListView: View {
742 let viewModel: HomeViewModel
743 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
744
745 var body: some View {
746 List {
747 ForEach(viewModel.assignedTickets) { ticket in
748 NavigationLink {
749 TicketDetailView(
750 ownerUsername: ticket.ownerUsername,
751 trackerName: ticket.trackerName,
752 trackerId: ticket.trackerId,
753 trackerRid: ticket.trackerRid,
754 ticketId: ticket.ticket.id
755 )
756 } label: {
757 HomeAssignedTicketRow(ticket: ticket)
758 }
759 .swipeActions(edge: .leading, allowsFullSwipe: true) {
760 if swipeActionsEnabled {
761 if ticket.ticket.status.isOpen {
762 Button {
763 Task { await viewModel.resolveTicket(ticket) }
764 } label: {
765 Label("Resolve", systemImage: "checkmark.circle")
766 }
767 .tint(.green)
768 } else {
769 Button {
770 Task { await viewModel.reopenTicket(ticket) }
771 } label: {
772 Label("Reopen", systemImage: "arrow.uturn.backward")
773 }
774 .tint(.blue)
775 }
776 }
777 }
778 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
779 if swipeActionsEnabled {
780 Button {
781 Task { await viewModel.unassignFromMe(ticket) }
782 } label: {
783 Label("Unassign Me", systemImage: "person.badge.minus")
784 }
785 .tint(.orange)
786 }
787 }
788 }
789
790 if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
791 HomeSectionMessageRow(
792 text: "No open tickets assigned to you.",
793 systemImage: "person.crop.circle.badge.checkmark"
794 )
795 }
796 }
797 .navigationTitle("Assigned Tickets")
798 .navigationBarTitleDisplayMode(.inline)
799 .refreshable {
800 await viewModel.loadDashboard()
801 }
802 .overlay {
803 if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
804 SRHTLoadingStateView(message: "Loading assigned tickets…")
805 }
806 }
807 }
808}
809
810private struct HomeSectionMessageRow: View {
811 let text: String
812 let systemImage: String
813 var emphasized = false
814 var accessibilityHint: String? = nil
815
816 var body: some View {
817 Label(text, systemImage: systemImage)
818 .font(.subheadline)
819 .foregroundStyle(emphasized ? .secondary : .tertiary)
820 .accessibilityHint(accessibilityHint ?? "")
821 }
822}