summaryrefslogtreecommitdiff
path: root/Hutch/Views/Home/HomeView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 13:09:57 -0500
committerChristian Cleberg <[email protected]>2026-04-13 13:09:57 -0500
commitf5f5757d0e1b78470429ae7cb9f742c95662849b (patch)
tree758f67965a985f4f1f52a456d9b672f8256851de /Hutch/Views/Home/HomeView.swift
parent0e461a382257979037e8927e5f6b468635b0a7fe (diff)
downloadhutch-3.0.0.tar.gz
hutch-3.0.0.tar.bz2
hutch-3.0.0.zip
release: v3.0.0 — navigation overhaul, Work view, and multi-pin supportv3.0.0
Reworks the app's core navigation and home screen for v3.0.0: - Replace Inbox with Work view: unified dashboard showing unread threads and assigned tickets, with All/Unread/Assigned scope picker - Overhaul Home screen: redesigned with pinned items grid (trackers, repos, mailing lists, users), recent activity section, and system status banner; backed by HomePinStore supporting all resource types - Simplify navigation bars across list screens: default toolbar shows only the primary action (+) and an overflow menu (…); pin, share, and select moved into the overflow menu; selection mode gets its own nav bar with Cancel / "N Selected" / All - Consolidate repo detail toolbars: pin and share moved into the existing actions menu for both Git and Mercurial detail views - Add recent activity tracking via RecentActivityStore - Add work and inbox deep link aliases (hutch://work, hutch://inbox) Implements: https://todo.sr.ht/~ccleberg/hutch/55
Diffstat (limited to 'Hutch/Views/Home/HomeView.swift')
-rw-r--r--Hutch/Views/Home/HomeView.swift1116
1 files changed, 381 insertions, 735 deletions
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 69e47da..1a68498 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -1,15 +1,13 @@
import SwiftUI
struct HomeView: View {
- @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
- @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true
- @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true
- @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true
@Environment(AppState.self) private var appState
@Environment(\.scenePhase) private var scenePhase
@State private var viewModel: HomeViewModel?
- private let previewLimit = 4
- private let projectPreviewLimit = 3
+ @State private var recentItems: [RecentActivityEntry] = []
+ @State private var isOpeningRecentItem = false
+ @State private var selectedPinnedProject: Project?
+ @State private var selectedPinnedUser: User?
var body: some View {
Group {
@@ -20,39 +18,40 @@ struct HomeView: View {
}
}
.navigationTitle("Home")
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- NavigationLink {
- InboxView()
- } label: {
- HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true)
+ .navigationDestination(isPresented: Binding(
+ get: { selectedPinnedProject != nil },
+ set: { isPresented in
+ if !isPresented {
+ selectedPinnedProject = nil
}
}
+ )) {
+ if let selectedPinnedProject {
+ ProjectDetailView(project: selectedPinnedProject)
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { selectedPinnedUser != nil },
+ set: { isPresented in
+ if !isPresented {
+ selectedPinnedUser = nil
+ }
+ }
+ )) {
+ if let selectedPinnedUser {
+ UserProfileView(user: selectedPinnedUser)
+ }
}
.task {
guard let currentUser = appState.currentUser else { return }
-
- let vm: HomeViewModel
- if let viewModel {
- vm = viewModel
- } else {
- let newViewModel = HomeViewModel(
- currentUser: currentUser,
- client: appState.client,
- systemStatusRepository: appState.systemStatusRepository,
- defaults: appState.accountDefaults,
- accountID: appState.activeAccountID
- )
- viewModel = newViewModel
- vm = newViewModel
- }
-
- await vm.loadDashboard()
+ await ensureViewModel(currentUser: currentUser).loadDashboard()
+ loadRecentActivity()
}
.onChange(of: scenePhase) { _, newPhase in
guard newPhase == .active, let viewModel else { return }
Task {
await viewModel.loadDashboard()
+ loadRecentActivity()
}
}
}
@@ -60,850 +59,497 @@ struct HomeView: View {
@ViewBuilder
private func content(_ viewModel: HomeViewModel) -> some View {
List {
- attentionSection(viewModel)
- systemStatusBannerSection(viewModel)
- inboxSection(viewModel)
- projectsSection(viewModel)
- assignedTicketsSection(viewModel)
- recentBuildsSection(viewModel)
+ systemStatusSection(viewModel)
+ workSection(viewModel)
+ recentSection
+ buildsSection(viewModel)
+ pinnedSection(viewModel)
}
.themedList()
.listStyle(.insetGrouped)
- .overlay {
- if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
- viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
- viewModel.unreadInboxThreads.isEmpty {
- SRHTLoadingStateView(message: "Loading Home…")
- }
- }
+ .listSectionSpacing(.compact)
.refreshable {
await viewModel.loadDashboard()
}
- .connectivityOverlay(hasContent: viewModel.hasDashboardContent) {
+ .connectivityOverlay(hasContent: hasHomeContent(viewModel)) {
await viewModel.loadDashboard()
}
- }
-
- @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.navigateToBuildsList()
- }
- )
+ .onAppear {
+ loadRecentActivity()
}
}
@ViewBuilder
- private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View {
- Section {
- NavigationLink {
- SystemStatusView()
- } label: {
- SystemStatusSummaryRow(
- snapshot: viewModel.systemStatusSnapshot,
- isLoading: viewModel.isLoadingSystemStatus,
- errorMessage: viewModel.systemStatusErrorMessage,
- isShowingStaleData: viewModel.isShowingStaleSystemStatus
- )
+ private func systemStatusSection(_ viewModel: HomeViewModel) -> some View {
+ if viewModel.systemStatusSnapshot?.hasDisruption == true {
+ Section {
+ NavigationLink {
+ SystemStatusView()
+ } label: {
+ SystemStatusSummaryRow(
+ snapshot: viewModel.systemStatusSnapshot,
+ isLoading: viewModel.isLoadingSystemStatus,
+ errorMessage: viewModel.systemStatusErrorMessage,
+ isShowingStaleData: viewModel.isShowingStaleSystemStatus
+ )
+ }
+ .buttonStyle(.plain)
}
- .buttonStyle(.plain)
}
}
- @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"
+ private func workSection(_ viewModel: HomeViewModel) -> some View {
+ Section("Work") {
+ NavigationLink(value: HomeRoute.work) {
+ HomeSummaryRow(
+ title: workTitle(viewModel),
+ summary: workSummary(viewModel),
+ systemImage: "tray.full",
+ tint: workCount(viewModel) > 0 ? .blue : .secondary,
+ emphasis: .action
)
- } 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.hasPinnedProjects {
- HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) {
- NavigationLink {
- ProjectsListView()
- } label: {
- Text("See All")
- .font(.caption.weight(.medium))
- }
- .buttonStyle(.plain)
- } content: {
- if viewModel.isLoadingProjects && viewModel.pinnedProjects.isEmpty {
- HomeSectionLoadingRow(label: "Loading pinned projects")
- } else if let error = viewModel.projectsError, viewModel.pinnedProjects.isEmpty {
- HomeSectionMessageRow(
- text: "Couldn’t load pinned projects.",
- systemImage: "exclamationmark.triangle",
- emphasized: true,
- accessibilityHint: error
- )
- } else if viewModel.pinnedProjects.isEmpty {
- HomeSectionMessageRow(
- text: "Pinned projects will appear here when they’re available.",
- systemImage: "pin"
- )
- } else {
- ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
- NavigationLink {
- ProjectDetailView(project: project)
- } label: {
- HomeProjectRow(project: project)
- }
+ private var recentSection: some View {
+ if !recentItems.isEmpty {
+ Section("Recent") {
+ ForEach(recentItems) { item in
+ Button {
+ openRecentItem(item)
+ } label: {
+ HomeRecentRow(item: item)
}
+ .buttonStyle(.plain)
+ .disabled(isOpeningRecentItem)
+ .listRowSeparator(.hidden)
}
}
}
}
- @ViewBuilder
- private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
- HomeSectionView("Tickets", isExpanded: $assignedTicketsExpanded) {
+ private func buildsSection(_ viewModel: HomeViewModel) -> some View {
+ Section("Builds") {
NavigationLink {
- HomeAssignedTicketsListView(viewModel: viewModel)
+ BuildListView()
} label: {
- Text("See All")
- .font(.caption.weight(.medium))
- }
- .buttonStyle(.plain)
- } content: {
- if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
- HomeSectionLoadingRow(label: "Loading assigned tickets")
- } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty {
- HomeSectionMessageRow(
- text: "Couldn’t load assigned tickets.",
- systemImage: "exclamationmark.triangle",
- emphasized: true,
- accessibilityHint: error
- )
- } else if viewModel.assignedTickets.isEmpty {
- HomeSectionMessageRow(
- text: "No open tickets assigned to you.",
- systemImage: "person.crop.circle.badge.checkmark"
+ HomeSummaryRow(
+ title: buildsTitle(viewModel),
+ summary: buildsSummary(viewModel),
+ systemImage: "hammer",
+ tint: viewModel.failedBuildCount > 0 ? .orange : .secondary,
+ emphasis: .monitoring
)
- } else {
- ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in
- NavigationLink {
- TicketDetailView(
- ownerUsername: ticket.ownerUsername,
- trackerName: ticket.trackerName,
- trackerId: ticket.trackerId,
- trackerRid: ticket.trackerRid,
- ticketId: ticket.ticket.id
- )
- } label: {
- HomeAssignedTicketRow(ticket: ticket)
- }
- .swipeActions(edge: .leading, allowsFullSwipe: true) {
- if swipeActionsEnabled {
- ticketLeadingSwipeAction(ticket, viewModel: viewModel)
- }
- }
- .swipeActions(edge: .trailing, allowsFullSwipe: false) {
- if swipeActionsEnabled {
- Button {
- Task {
- await viewModel.unassignFromMe(ticket)
- }
- } label: {
- Label("Unassign Me", systemImage: "person.badge.minus")
- }
- .tint(.orange)
- }
- }
- }
}
}
}
- @ViewBuilder
- private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
- HomeSectionView("Builds", isExpanded: $buildsExpanded) {
- Button("See All") {
- appState.navigateToBuildsList()
- }
- .font(.caption.weight(.medium))
- .buttonStyle(.plain)
- } content: {
- if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty {
- HomeSectionLoadingRow(label: "Loading recent builds")
- } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty {
- HomeSectionMessageRow(
- text: "Couldn’t load recent builds.",
- systemImage: "exclamationmark.triangle",
- emphasized: true,
- accessibilityHint: error
- )
- } else if viewModel.recentBuilds.isEmpty {
- HomeSectionMessageRow(
- text: "No recent builds.",
- systemImage: "clock"
- )
- } else {
- ForEach(buildGroups(for: viewModel.recentBuilds)) { group in
- if let repositoryDisplayName = group.repositoryDisplayName {
- HomeBuildGroupHeader(
- repositoryDisplayName: repositoryDisplayName,
- buildCount: group.builds.count,
- latestStatus: group.latestStatus
- )
- }
+ private func pinnedSection(_ viewModel: HomeViewModel) -> some View {
+ let items = pinnedItems(viewModel)
- ForEach(group.builds) { build in
- NavigationLink {
- BuildDetailView(jobId: build.job.id)
+ return Section("Pinned") {
+ if items.isEmpty {
+ NavigationLink {
+ ProjectsListView()
+ } label: {
+ HomeCompactMessageRow(text: "Pin projects for quick access", systemImage: "pin")
+ }
+ } else {
+ LazyVGrid(
+ columns: [
+ GridItem(.flexible(), spacing: 10),
+ GridItem(.flexible(), spacing: 10),
+ ],
+ spacing: 10
+ ) {
+ ForEach(items) { item in
+ Button {
+ openPinnedItem(item, viewModel: viewModel)
} label: {
- HomeBuildRow(
- build: build,
- showsRepositoryLink: group.repositoryDisplayName == nil
- )
- }
- .swipeActions(edge: .leading, allowsFullSwipe: true) {
- if swipeActionsEnabled, build.job.status.isCancellable {
- Button {
- Task {
- await viewModel.cancelBuild(build)
- }
- }
- label: {
- Label("Cancel", systemImage: "xmark.circle")
- }
- .tint(.red)
- }
+ HomePinnedCard(item: item)
}
+ .buttonStyle(.plain)
}
}
+ .padding(.vertical, 2)
}
}
}
- private func buildGroups(for builds: [HomeBuildItem]) -> [HomeBuildGroup] {
- let previewBuilds = Array(builds.prefix(previewLimit))
- guard let firstBuild = previewBuilds.first else { return [] }
-
- var groups: [HomeBuildGroup] = []
- var currentIdentity = HomeBuildGroup.Identity(build: firstBuild)
- var currentBuilds: [HomeBuildItem] = []
-
- for build in previewBuilds {
- let identity = HomeBuildGroup.Identity(build: build)
- if identity == currentIdentity {
- currentBuilds.append(build)
- } else {
- groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds))
- currentIdentity = identity
- currentBuilds = [build]
- }
- }
-
- if !currentBuilds.isEmpty {
- groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds))
- }
+ private func workCount(_ viewModel: HomeViewModel) -> Int {
+ unreadCount(viewModel) + viewModel.assignedTickets.count
+ }
- return groups
+ private func unreadCount(_ viewModel: HomeViewModel) -> Int {
+ viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count
}
- @ViewBuilder
- private func ticketLeadingSwipeAction(
- _ ticket: HomeAssignedTicket,
- viewModel: HomeViewModel
- ) -> some View {
- if ticket.ticket.status.isOpen {
- Button {
- Task {
- await viewModel.resolveTicket(ticket)
- }
- } label: {
- Label("Resolve", systemImage: "checkmark.circle")
- }
- .tint(.green)
- } else {
- Button {
- Task {
- await viewModel.reopenTicket(ticket)
- }
- } label: {
- Label("Reopen", systemImage: "arrow.uturn.backward")
- }
- .tint(.blue)
+ private func workTitle(_ viewModel: HomeViewModel) -> String {
+ let count = workCount(viewModel)
+ if count == 0 {
+ return "Queue clear"
}
+ return "\(count) item\(count == 1 ? "" : "s") need attention"
}
-}
-
-private struct HomeInboxToolbarIcon: View {
- let hasUnreadThreads: Bool
-
- var body: some View {
- Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
- .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
+ private func workSummary(_ viewModel: HomeViewModel) -> String {
+ let unread = unreadCount(viewModel)
+ let assigned = viewModel.assignedTickets.count
+ return "\(unread) unread • \(assigned) assigned"
}
-}
-
-private struct HomeProjectRow: View {
- let project: Project
-
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- HStack(alignment: .top, spacing: 10) {
- VStack(alignment: .leading, spacing: 4) {
- Text(project.displayName)
- .font(.subheadline.weight(.medium))
- .foregroundStyle(.primary)
- .lineLimit(1)
-
- if let description = project.displayDescription {
- Text(description)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(2)
- }
- }
- Spacer(minLength: 8)
+ private func buildsTitle(_ viewModel: HomeViewModel) -> String {
+ let failed = viewModel.failedBuildCount
+ let running = viewModel.activeBuildCount
- VisibilityBadge(visibility: project.visibility)
- }
-
- Text(project.metadataLine)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
+ if failed == 0 && running == 0 {
+ return "Build monitoring clear"
}
- .contentShape(Rectangle())
- .padding(.vertical, 4)
- }
-}
-
-private struct HomeBuildRow: View {
- @Environment(AppState.self) private var appState
- let build: HomeBuildItem
- var showsRepositoryLink = true
-
- var body: some View {
- VStack(alignment: .leading, spacing: 6) {
- HStack(spacing: 12) {
- JobStatusIcon(status: build.job.status)
- .frame(width: 20)
-
- VStack(alignment: .leading, spacing: 4) {
- Text(build.job.displayLabel)
- .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.displayTitle)
- .font(.caption)
- .foregroundStyle(.secondary)
-
- Text("•")
- .font(.caption)
- .foregroundStyle(.tertiary)
-
- Text(build.job.created.relativeDescription)
- .font(.caption)
- .foregroundStyle(.tertiary)
-
- Spacer()
- }
- }
- }
-
- if showsRepositoryLink, let repositoryDisplayName = build.repositoryDisplayName {
- Button {
- openRepository()
- } label: {
- Label(repositoryDisplayName, systemImage: "book.closed")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
- }
+ if failed > 0 {
+ return "\(failed) failed build\(failed == 1 ? "" : "s")"
}
- .padding(.vertical, 2)
+ return "\(running) running build\(running == 1 ? "" : "s")"
}
- 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 func buildsSummary(_ viewModel: HomeViewModel) -> String {
+ let failed = viewModel.failedBuildCount
+ let running = viewModel.activeBuildCount
+ if failed == 0 && running == 0 {
+ return "No failures • \(buildTimeframeLabel(viewModel))"
+ }
+ if failed > 0 && running > 0 {
+ return "\(failed) failed • \(running) running • \(buildTimeframeLabel(viewModel))"
}
+ if failed > 0 {
+ return "\(failed) failed • \(buildTimeframeLabel(viewModel))"
+ }
+ return "\(running) running • \(buildTimeframeLabel(viewModel))"
}
-}
-private struct HomeBuildGroup: Identifiable {
- enum Identity: Hashable {
- case repository(owner: String?, name: String)
- case standalone(Int)
+ private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] {
+ let currentUserKey = appState.currentUser?.canonicalName ?? ""
+ let pins = HomePinStore.loadPins(for: currentUserKey, defaults: appState.accountDefaults)
+ let projectsByID = Dictionary(uniqueKeysWithValues: viewModel.projects.map { ($0.id, $0) })
- init(build: HomeBuildItem) {
- if let repositoryName = build.repositoryName {
- self = .repository(owner: build.repositoryOwner, name: repositoryName)
- } else {
- self = .standalone(build.id)
+ return pins.compactMap { pin in
+ switch pin.kind {
+ case .project:
+ guard let project = projectsByID[pin.value] else { return nil }
+ return HomePinnedItem(pin: pin, project: project)
+ case .repository, .tracker, .mailingList, .user:
+ return HomePinnedItem(pin: pin, project: nil)
}
}
}
- let identity: Identity
- let builds: [HomeBuildItem]
+ private func buildTimeframeLabel(_ viewModel: HomeViewModel) -> String {
+ let calendar = Calendar.current
+ let buildDates = viewModel.recentBuilds.map(\.job.updated)
- var id: String {
- switch identity {
- case .repository(let owner, let name):
- return "\(owner ?? "_")/\(name)#\(builds.first?.id ?? 0)"
- case .standalone(let jobId):
- return "job-\(jobId)"
+ guard !buildDates.isEmpty else {
+ return "today"
}
- }
- var repositoryDisplayName: String? {
- builds.first?.repositoryDisplayName
+ return buildDates.allSatisfy(calendar.isDateInToday) ? "today" : "this week"
}
- var latestStatus: JobStatus {
- builds.first?.job.status ?? .pending
+ private func hasHomeContent(_ viewModel: HomeViewModel) -> Bool {
+ viewModel.systemStatusSnapshot?.hasDisruption == true ||
+ workCount(viewModel) > 0 ||
+ !recentItems.isEmpty ||
+ !pinnedItems(viewModel).isEmpty
}
-}
-
-private struct HomeBuildGroupHeader: View {
- let repositoryDisplayName: String
- let buildCount: Int
- let latestStatus: JobStatus
- var body: some View {
- HStack(spacing: 12) {
- Label(repositoryDisplayName, systemImage: "book.closed")
- .font(.caption.weight(.medium))
- .foregroundStyle(.secondary)
- .lineLimit(1)
-
- Spacer(minLength: 8)
-
- Text("\(buildCount) \(buildCount == 1 ? "build" : "builds")")
- .font(.caption2.weight(.medium))
- .foregroundStyle(.tertiary)
-
- JobStatusBadge(status: latestStatus)
- }
- .padding(.top, 4)
- .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 0, trailing: 20))
- .listRowSeparator(.hidden)
- .accessibilityElement(children: .combine)
+ private func loadRecentActivity() {
+ recentItems = RecentActivityStore.load(defaults: appState.accountDefaults)
}
-}
-private struct HomeAssignedTicketRow: View {
- @Environment(AppState.self) private var appState
- let ticket: HomeAssignedTicket
+ private func openRecentItem(_ item: RecentActivityEntry) {
+ guard !isOpeningRecentItem else { return }
- var body: some View {
- 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)
-
- 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)
- .fixedSize()
+ switch item.kind {
+ case .build:
+ guard let jobId = item.buildJobId else { return }
+ appState.navigateToBuild(jobId: jobId)
+ case .ticket:
+ guard
+ let ownerUsername = item.ticketOwnerUsername,
+ let trackerName = item.ticketTrackerName,
+ let ticketId = item.ticketId
+ else {
+ return
}
-
- Button {
- openTracker()
- } label: {
- Label("\(ticket.ownerCanonicalName)/\(ticket.trackerName)", systemImage: "checklist")
- .font(.caption)
- .foregroundStyle(.secondary)
+ appState.navigateToTicket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId)
+ case .repository:
+ guard
+ let owner = item.repositoryOwner,
+ let name = item.repositoryName
+ else {
+ return
}
- .buttonStyle(.plain)
- }
- .padding(.vertical, 2)
- }
- private func openTracker() {
- Task {
- do {
- let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName)
- appState.navigateToTracker(tracker)
- } catch {
- appState.presentTicketDeepLinkError()
+ isOpeningRecentItem = true
+ Task {
+ defer { isOpeningRecentItem = false }
+ do {
+ let repository = try await appState.resolveRepository(
+ owner: owner,
+ name: name,
+ service: item.repositoryService ?? .git
+ )
+ appState.navigateToRepository(repository)
+ } catch {
+ appState.presentRepositoryDeepLinkError()
+ }
}
}
}
-}
-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)
+ private func openPinnedItem(_ item: HomePinnedItem, viewModel: HomeViewModel) {
+ switch item.pin.kind {
+ case .project:
+ guard let project = item.project else { return }
+ selectedPinnedProject = project
+ case .repository:
+ guard
+ let owner = item.pin.ownerUsername,
+ let service = item.pin.service
+ else {
+ return
+ }
+ isOpeningRecentItem = true
+ Task {
+ defer { isOpeningRecentItem = false }
+ do {
+ let repository = try await appState.resolveRepository(owner: owner, name: item.pin.value, service: service)
+ appState.navigateToRepository(repository)
+ } catch {
+ appState.presentRepositoryDeepLinkError()
}
}
-
- 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)
+ case .tracker:
+ guard let owner = item.pin.ownerUsername else { return }
+ isOpeningRecentItem = true
+ Task {
+ defer { isOpeningRecentItem = false }
+ do {
+ let tracker = try await appState.resolveTracker(owner: owner, name: item.pin.value)
+ appState.navigateToTracker(tracker)
+ } catch {
+ appState.presentTicketDeepLinkError()
}
- .buttonStyle(.plain)
-
- if let repo = thread.repo {
- Button {
- openRepository(named: repo)
- } label: {
- Label(repo, systemImage: "book.closed")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
+ }
+ case .mailingList:
+ guard let ownerUsername = item.pin.ownerUsername else { return }
+ appState.openMailingList(
+ InboxMailingListReference(
+ id: 0,
+ rid: item.pin.value,
+ name: item.pin.title,
+ owner: Entity(canonicalName: "~\(ownerUsername)")
+ )
+ )
+ case .user:
+ guard let ownerUsername = item.pin.ownerUsername else { return }
+ isOpeningRecentItem = true
+ Task {
+ defer { isOpeningRecentItem = false }
+ if let user = try? await resolvePinnedUser(username: ownerUsername) {
+ selectedPinnedUser = user
}
}
}
- .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 func resolvePinnedUser(username: String) async throws -> User {
+ struct Response: Decodable, Sendable {
+ let user: User
+ }
+
+ let query = """
+ query userLookup($username: String!) {
+ user: userByName(username: $username) {
+ id
+ created
+ updated
+ canonicalName
+ username
+ email
+ url
+ location
+ bio
+ avatar
+ pronouns
+ userType
}
}
- }
-}
+ """
-private struct HomeSectionLoadingRow: View {
- let label: String
+ let result = try await appState.client.execute(
+ service: .meta,
+ query: query,
+ variables: ["username": username],
+ responseType: Response.self
+ )
+ return result.user
+ }
- var body: some View {
- HStack(spacing: 10) {
- ProgressView()
- .controlSize(.small)
- Text(label)
- .foregroundStyle(.secondary)
+ @MainActor
+ private func ensureViewModel(currentUser: User) -> HomeViewModel {
+ if let viewModel {
+ return viewModel
}
- .frame(maxWidth: .infinity, alignment: .leading)
+
+ let newViewModel = HomeViewModel(
+ currentUser: currentUser,
+ client: appState.client,
+ systemStatusRepository: appState.systemStatusRepository,
+ defaults: appState.accountDefaults,
+ accountID: appState.activeAccountID
+ )
+ viewModel = newViewModel
+ return newViewModel
}
}
-private struct HomeSectionHeader<Destination: View>: View {
- let title: String
- let destination: Destination
+enum HomeRoute: Hashable {
+ case work
+}
- init(_ title: String, @ViewBuilder destination: () -> Destination) {
- self.title = title
- self.destination = destination()
- }
+private enum HomeSummaryEmphasis {
+ case action
+ case monitoring
+}
- var body: some View {
- HStack {
- Text(title)
- Spacer()
- NavigationLink {
- destination
- } label: {
- Text("See All")
- .font(.caption.weight(.medium))
- }
- .buttonStyle(.plain)
- }
- .textCase(nil)
- }
+private struct HomePinnedItem: Identifiable {
+ let pin: HomePinRecord
+ let project: Project?
+
+ var id: String { pin.id }
+ var title: String { project?.displayName ?? pin.title }
+ var detail: String { pin.subtitle }
}
-private struct HomeAttentionSummaryRow: View {
+private struct HomeSummaryRow: View {
let title: String
let summary: String
+ let systemImage: String
+ let tint: Color
+ let emphasis: HomeSummaryEmphasis
var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(title)
+ HStack(spacing: 10) {
+ Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
- Text(summary)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(2)
+ .foregroundStyle(iconColor)
+ .frame(width: 18)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.subheadline.weight(.semibold))
+ Text(summary)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ Spacer(minLength: 8)
}
- .padding(.vertical, 2)
+ .padding(.vertical, verticalPadding)
}
-}
-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
+ private var iconColor: Color {
+ switch emphasis {
+ case .action:
+ return tint
+ case .monitoring:
+ return tint.opacity(0.9)
+ }
}
- 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
+ private var verticalPadding: CGFloat {
+ switch emphasis {
+ case .action:
+ return 3
+ case .monitoring:
+ return 2
+ }
}
+}
+
+private struct HomeRecentRow: View {
+ let item: RecentActivityEntry
var body: some View {
- Group {
- if let destination {
- NavigationLink {
- destination
- } label: {
- content
- }
- } else if let action {
- Button(action: action) {
- content
- }
- .buttonStyle(.plain)
- }
- }
- }
+ HStack(spacing: 10) {
+ Image(systemName: iconName)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 16)
- private var content: some View {
- HStack(spacing: 12) {
- VStack(alignment: .leading, spacing: 4) {
- Text(title)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(item.title)
.font(.subheadline.weight(.medium))
- Text(summary)
+ .lineLimit(1)
+ Text(item.detailText)
.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())
- if action != nil {
- Image(systemName: "chevron.right")
- .font(.caption.weight(.semibold))
- .foregroundStyle(.tertiary)
- }
+ Spacer(minLength: 8)
+ }
+ .padding(.vertical, 1)
+ }
+
+ private var iconName: String {
+ switch item.kind {
+ case .repository:
+ return "book.closed"
+ case .ticket:
+ return "number"
+ case .build:
+ return "hammer"
}
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- .padding(.vertical, 2)
}
}
-private struct HomeAssignedTicketsListView: View {
- let viewModel: HomeViewModel
- @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
+private struct HomePinnedCard: View {
+ let item: HomePinnedItem
var body: some View {
- List {
- ForEach(viewModel.assignedTickets) { ticket in
- NavigationLink {
- TicketDetailView(
- ownerUsername: ticket.ownerUsername,
- trackerName: ticket.trackerName,
- trackerId: ticket.trackerId,
- trackerRid: ticket.trackerRid,
- ticketId: ticket.ticket.id
- )
- } label: {
- HomeAssignedTicketRow(ticket: ticket)
- }
- .swipeActions(edge: .leading, allowsFullSwipe: true) {
- if swipeActionsEnabled {
- if ticket.ticket.status.isOpen {
- Button {
- Task { await viewModel.resolveTicket(ticket) }
- } label: {
- Label("Resolve", systemImage: "checkmark.circle")
- }
- .tint(.green)
- } else {
- Button {
- Task { await viewModel.reopenTicket(ticket) }
- } label: {
- Label("Reopen", systemImage: "arrow.uturn.backward")
- }
- .tint(.blue)
- }
- }
- }
- .swipeActions(edge: .trailing, allowsFullSwipe: false) {
- if swipeActionsEnabled {
- Button {
- Task { await viewModel.unassignFromMe(ticket) }
- } label: {
- Label("Unassign Me", systemImage: "person.badge.minus")
- }
- .tint(.orange)
- }
- }
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 6) {
+ Image(systemName: "square.stack.3d.up")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Text(item.detail)
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(.secondary)
}
- if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
- HomeSectionMessageRow(
- text: "No open tickets assigned to you.",
- systemImage: "person.crop.circle.badge.checkmark"
- )
- }
- }
- .navigationTitle("Assigned Tickets")
- .navigationBarTitleDisplayMode(.inline)
- .refreshable {
- await viewModel.loadDashboard()
- }
- .overlay {
- if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
- SRHTLoadingStateView(message: "Loading assigned tickets…")
- }
+ Text(item.title)
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(2)
+
+ Spacer(minLength: 0)
}
+ .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
+ .padding(10)
+ .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
}
}
-private struct HomeSectionMessageRow: View {
+private struct HomeCompactMessageRow: View {
let text: String
let systemImage: String
- var emphasized = false
- var accessibilityHint: String? = nil
var body: some View {
Label(text, systemImage: systemImage)
- .font(.subheadline)
- .foregroundStyle(emphasized ? .secondary : .tertiary)
- .accessibilityHint(accessibilityHint ?? "")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.vertical, 2)
}
}