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