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}