diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 00:34:54 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 00:34:54 -0500 |
| commit | 5f6d545eb3455a9e4da5a3cb4460811e3a48d939 (patch) | |
| tree | 7b7591146da4e02bd617ddb9f26e47b1c4474ef6 /Hutch/Views/Home/HomeView.swift | |
| parent | cb1ea5ea4f163d87053285d3fb7999b12a3558b9 (diff) | |
| download | hutch-2.14.0.tar.gz hutch-2.14.0.tar.bz2 hutch-2.14.0.zip | |
improve home attention flows and cross-linkingv2.14.0
Diffstat (limited to 'Hutch/Views/Home/HomeView.swift')
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 378 |
1 files changed, 332 insertions, 46 deletions
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index 632e804..3efef7a 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -55,7 +55,9 @@ struct HomeView: View { @ViewBuilder private func content(_ viewModel: HomeViewModel) -> some View { List { + attentionSection(viewModel) systemStatusBannerSection(viewModel) + inboxSection(viewModel) projectsSection(viewModel) assignedTicketsSection(viewModel) recentBuildsSection(viewModel) @@ -63,15 +65,17 @@ struct HomeView: View { .listStyle(.insetGrouped) .overlay { if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && - viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { + viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && + viewModel.unreadInboxThreads.isEmpty { SRHTLoadingStateView(message: "Loading Home…") } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && + viewModel.unreadInboxThreads.isEmpty && viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { ContentUnavailableView( "All Clear", systemImage: "checkmark.circle", - description: Text("There are no assigned tickets or recent builds right now.") + description: Text("There are no unread threads, assigned tickets, or urgent builds right now.") ) } } @@ -84,6 +88,41 @@ struct HomeView: View { } @ViewBuilder + private func attentionSection(_ viewModel: HomeViewModel) -> some View { + Section("Needs Attention") { + HomeAttentionSummaryRow( + title: viewModel.needsAttentionCount == 0 ? "All clear" : "\(viewModel.needsAttentionCount) things need attention", + summary: viewModel.attentionSummaryText + ) + + HomeAttentionLinkRow( + title: "Inbox", + summary: viewModel.inboxSummaryText, + countText: viewModel.unreadInboxThreadCount.map(String.init) ?? "?" + ) { + InboxView() + } + + HomeAttentionLinkRow( + title: "Assigned Tickets", + summary: viewModel.ticketsSummaryText, + countText: String(viewModel.assignedTickets.count) + ) { + HomeAssignedTicketsListView(viewModel: viewModel) + } + + HomeAttentionLinkRow( + title: "Builds", + summary: viewModel.buildsSummaryText, + countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount), + action: { + appState.selectedTab = .builds + } + ) + } + } + + @ViewBuilder private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View { Section { NavigationLink { @@ -101,6 +140,40 @@ struct HomeView: View { } @ViewBuilder + private func inboxSection(_ viewModel: HomeViewModel) -> some View { + Section { + if let unreadCount = viewModel.unreadInboxThreadCount, unreadCount == 0 { + HomeSectionMessageRow( + text: "No unread inbox threads.", + systemImage: "tray" + ) + } else if viewModel.unreadInboxThreads.isEmpty { + HomeSectionMessageRow( + text: viewModel.inboxSummaryText, + systemImage: "tray" + ) + } else { + ForEach(viewModel.unreadInboxThreads.prefix(previewLimit)) { thread in + NavigationLink { + ThreadDetailView( + thread: thread, + onViewed: { viewModel.markInboxThreadRead(thread) }, + onMarkRead: { viewModel.markInboxThreadRead(thread) }, + onMarkUnread: { viewModel.markInboxThreadUnread(thread) } + ) + } label: { + HomeInboxThreadRow(thread: thread) + } + } + } + } header: { + HomeSectionHeader("Inbox") { + InboxView() + } + } + } + + @ViewBuilder private func projectsSection(_ viewModel: HomeViewModel) -> some View { if !viewModel.projects.isEmpty { Section { @@ -312,41 +385,55 @@ private struct HomeProjectsListView: View { } private struct HomeBuildRow: View { + @Environment(AppState.self) private var appState let build: HomeBuildItem var body: some View { - HStack(spacing: 12) { - JobStatusIcon(status: build.job.status) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 4) { - Text(primaryTitle) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - - HStack(spacing: 8) { - Text("Job #\(build.job.id)") - .font(.caption) - .foregroundStyle(.secondary) - - Text("•") - .font(.caption) - .foregroundStyle(.tertiary) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 12) { + JobStatusIcon(status: build.job.status) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 4) { + Text(primaryTitle) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + HStack(spacing: 8) { + Text("Job #\(build.job.id)") + .font(.caption) + .foregroundStyle(.secondary) + + Text("•") + .font(.caption) + .foregroundStyle(.tertiary) + + Text(build.job.status.rawValue.capitalized) + .font(.caption) + .foregroundStyle(.secondary) + + Text("•") + .font(.caption) + .foregroundStyle(.tertiary) + + Text(build.job.created.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + + Spacer() + } + } + } - Text(build.job.status.rawValue.capitalized) + if let repositoryDisplayName = build.repositoryDisplayName { + Button { + openRepository() + } label: { + Label(repositoryDisplayName, systemImage: "book.closed") .font(.caption) .foregroundStyle(.secondary) - - Text("•") - .font(.caption) - .foregroundStyle(.tertiary) - - Text(build.job.created.relativeDescription) - .font(.caption) - .foregroundStyle(.tertiary) - - Spacer() } + .buttonStyle(.plain) } } .padding(.vertical, 2) @@ -358,38 +445,148 @@ private struct HomeBuildRow: View { } return build.job.displayLabel } + + private func openRepository() { + guard let repositoryName = build.repositoryName, + let repositoryOwner = build.repositoryOwner else { return } + Task { + do { + let repository = try await appState.resolveRepository( + owner: repositoryOwner.hasPrefix("~") ? String(repositoryOwner.dropFirst()) : repositoryOwner, + name: repositoryName + ) + appState.navigateToRepository(repository) + } catch { + appState.presentRepositoryDeepLinkError() + } + } + } } private struct HomeAssignedTicketRow: View { + @Environment(AppState.self) private var appState let ticket: HomeAssignedTicket var body: some View { - HStack(alignment: .top, spacing: 12) { - TicketStatusIcon(status: ticket.ticket.status) - .frame(width: 20) + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 12) { + TicketStatusIcon(status: ticket.ticket.status) + .frame(width: 20) - VStack(alignment: .leading, spacing: 4) { - Text(ticket.ticket.title) - .font(.subheadline.weight(.medium)) - .lineLimit(2) + VStack(alignment: .leading, spacing: 4) { + Text(ticket.ticket.title) + .font(.subheadline.weight(.medium)) + .lineLimit(2) - Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") - .font(.caption) + Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 8) + + Text(ticket.ticket.status.displayName) + .font(.caption2.weight(.medium)) .foregroundStyle(.secondary) .lineLimit(1) - .truncationMode(.tail) + .fixedSize() } - Spacer(minLength: 8) + Button { + openTracker() + } label: { + Label("\(ticket.ownerCanonicalName)/\(ticket.trackerName)", systemImage: "checklist") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.vertical, 2) + } - Text(ticket.ticket.status.displayName) - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - .fixedSize() + private func openTracker() { + Task { + do { + let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName) + appState.navigateToTracker(tracker) + } catch { + appState.presentTicketDeepLinkError() + } + } + } +} + +private struct HomeInboxThreadRow: View { + @Environment(AppState.self) private var appState + let thread: InboxThreadSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(.blue) + .frame(width: 8, height: 8) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 4) { + Text(thread.displaySubject) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + + Text(thread.metadataLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + HStack(spacing: 10) { + Button { + appState.navigateToMailingList( + InboxMailingListReference( + id: thread.listID, + rid: thread.listRID, + name: thread.listName, + owner: thread.listOwner + ) + ) + } label: { + Label(thread.listName, systemImage: "list.bullet") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + if let repo = thread.repo { + Button { + openRepository(named: repo) + } label: { + Label(repo, systemImage: "book.closed") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } } .padding(.vertical, 2) } + + private func openRepository(named repositoryName: String) { + Task { + do { + let ownerUsername = thread.listOwner.canonicalName.hasPrefix("~") + ? String(thread.listOwner.canonicalName.dropFirst()) + : thread.listOwner.canonicalName + let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName) + appState.navigateToRepository(repository) + } catch { + appState.presentRepositoryDeepLinkError() + } + } + } } private struct HomeSectionLoadingRow: View { @@ -431,6 +628,95 @@ private struct HomeSectionHeader<Destination: View>: View { } } +private struct HomeAttentionSummaryRow: View { + let title: String + let summary: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.semibold)) + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 2) + } +} + +private struct HomeAttentionLinkRow<Destination: View>: View { + let title: String + let summary: String + let countText: String + let destination: Destination? + let action: (() -> Void)? + + init( + title: String, + summary: String, + countText: String, + @ViewBuilder destination: () -> Destination + ) { + self.title = title + self.summary = summary + self.countText = countText + self.destination = destination() + self.action = nil + } + + init( + title: String, + summary: String, + countText: String, + action: @escaping () -> Void + ) where Destination == EmptyView { + self.title = title + self.summary = summary + self.countText = countText + self.destination = nil + self.action = action + } + + var body: some View { + Group { + if let destination { + NavigationLink { + destination + } label: { + content + } + } else if let action { + Button(action: action) { + content + } + .buttonStyle(.plain) + } + } + } + + private var content: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.medium)) + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Text(countText) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.secondarySystemFill), in: Capsule()) + } + .padding(.vertical, 2) + } +} + private struct HomeSectionActionHeader: View { let title: String let action: () -> Void |
