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}