diff options
24 files changed, 1399 insertions, 1064 deletions
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift index 4be8004..94e1310 100644 --- a/Hutch/App/AppStorageKeys.swift +++ b/Hutch/App/AppStorageKeys.swift @@ -5,12 +5,14 @@ enum AppStorageKeys { static let activeAccountID = "activeAccountID" static let wrapRepositoryFileLines = "wrapRepositoryFileLines" static let lookupHistory = "lookupHistory" + static let recentActivity = "recentActivity" static let scopedSearchHistory = "scopedSearchHistory" static let hutchStatsBaseURL = "hutchStatsBaseURL" static let systemStatusSnapshotCache = "systemStatusSnapshotCache" static let systemStatusIncidentCache = "systemStatusIncidentCache" static let homeProjectsExpanded = "homeProjectsExpanded" static let pinnedHomeProjects = "pinnedHomeProjects" + static let pinnedHomeItems = "pinnedHomeItems" static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" static let homeBuildsExpanded = "homeBuildsExpanded" static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift index bbf8885..786891d 100644 --- a/Hutch/App/DeepLink.swift +++ b/Hutch/App/DeepLink.swift @@ -3,6 +3,7 @@ import Foundation /// Represents a parsed `hutch://` deep link. enum DeepLink: Equatable { case home + case work /// hutch://git/<owner>/<repo> case repository(owner: String, repo: String) /// hutch://todo/<owner>/<tracker>/<ticketId> @@ -33,6 +34,9 @@ enum DeepLink: Equatable { case "home", nil: self = .home + case "work", "inbox": + self = .work + case "git" where components.count >= 3: let owner = components[1] let repo = components[2] diff --git a/Hutch/App/HutchApp.swift b/Hutch/App/HutchApp.swift index 853b1c2..0fb1875 100644 --- a/Hutch/App/HutchApp.swift +++ b/Hutch/App/HutchApp.swift @@ -26,7 +26,7 @@ struct HutchApp: App { let link: DeepLink switch destination { case .home: link = .home - case .inbox: link = .home + case .work: link = .work case .builds: link = .buildsTab case .repositories: link = .repositoriesTab case .trackers: link = .trackersTab diff --git a/Hutch/App/HutchIntents.swift b/Hutch/App/HutchIntents.swift index 652c07b..a7a33b3 100644 --- a/Hutch/App/HutchIntents.swift +++ b/Hutch/App/HutchIntents.swift @@ -5,7 +5,7 @@ import Foundation enum HutchDestination: String, AppEnum { case home - case inbox + case work case builds case repositories case trackers @@ -16,7 +16,7 @@ enum HutchDestination: String, AppEnum { static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [ .home: "Home", - .inbox: "Inbox", + .work: "Work", .builds: "Builds", .repositories: "Repositories", .trackers: "Trackers", diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index f65bced..b4cf3bd 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -69,6 +69,12 @@ struct RootView: View { return TabView(selection: $appState.selectedTab) { NavigationStack(path: $homePath) { HomeView() + .navigationDestination(for: HomeRoute.self) { route in + switch route { + case .work: + WorkView() + } + } } .tag(AppState.Tab.home) .tabItem { @@ -196,6 +202,14 @@ struct RootView: View { case .ticket(let owner, let tracker, let ticketId): resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) + case .work: + homePath = NavigationPath() + appState.selectedTab = .home + Task { + await settleNavigationTransition() + homePath.append(HomeRoute.work) + } + case .buildsTab: buildsPath = NavigationPath() appState.selectedTab = .builds diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift index f8ab90d..5895f48 100644 --- a/Hutch/Views/Builds/BuildDetailView.swift +++ b/Hutch/Views/Builds/BuildDetailView.swift @@ -331,6 +331,13 @@ struct BuildDetailView: View { } } } + .task(id: job.id) { + RecentActivityStore.recordBuild( + jobId: job.id, + title: recentActivityTitle(for: job), + defaults: appState.accountDefaults + ) + } .refreshable { await reloadDetail(viewModel) } @@ -367,6 +374,16 @@ struct BuildDetailView: View { await viewModel.loadJob() } } + + private func recentActivityTitle(for job: JobDetail) -> String { + if let note = job.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { + return note + } + if !job.tags.isEmpty { + return job.tags.joined(separator: ", ") + } + return "Job #\(job.id)" + } } private struct BuildArtifactRow: View { diff --git a/Hutch/Views/Home/HomePinStore.swift b/Hutch/Views/Home/HomePinStore.swift new file mode 100644 index 0000000..e9bffd1 --- /dev/null +++ b/Hutch/Views/Home/HomePinStore.swift @@ -0,0 +1,207 @@ +import Foundation + +enum HomePinKind: String, Codable, Sendable { + case project + case repository + case tracker + case mailingList + case user +} + +struct HomePinRecord: Codable, Hashable, Identifiable, Sendable { + let kind: HomePinKind + let value: String + let title: String + let subtitle: String + let ownerUsername: String? + let service: SRHTService? + + var id: String { + switch kind { + case .project: + return "\(kind.rawValue):\(value)" + case .repository: + return "\(kind.rawValue):\(service?.rawValue ?? "git"):\(ownerUsername ?? "")/\(value)" + case .tracker, .mailingList: + return "\(kind.rawValue):\(ownerUsername ?? "")/\(value)" + case .user: + return "\(kind.rawValue):\(ownerUsername ?? value)" + } + } + + static func project(_ project: Project) -> HomePinRecord { + HomePinRecord( + kind: .project, + value: project.id, + title: project.displayName, + subtitle: "Project", + ownerUsername: nil, + service: nil + ) + } + + static func repository(_ repository: RepositorySummary) -> HomePinRecord { + HomePinRecord( + kind: .repository, + value: repository.name, + title: repository.name, + subtitle: repository.service == .hg ? "Mercurial Repo" : "Git Repo", + ownerUsername: repository.owner.canonicalName.srhtUsername, + service: repository.service + ) + } + + static func tracker(_ tracker: TrackerSummary) -> HomePinRecord { + HomePinRecord( + kind: .tracker, + value: tracker.name, + title: tracker.name, + subtitle: "Tracker", + ownerUsername: tracker.owner.canonicalName.srhtUsername, + service: nil + ) + } + + static func mailingList(_ mailingList: InboxMailingListReference) -> HomePinRecord { + HomePinRecord( + kind: .mailingList, + value: mailingList.rid, + title: mailingList.name, + subtitle: "Mailing List", + ownerUsername: mailingList.owner.canonicalName.srhtUsername, + service: nil + ) + } + + static func user(_ user: User) -> HomePinRecord { + HomePinRecord( + kind: .user, + value: user.username, + title: user.canonicalName, + subtitle: "User", + ownerUsername: user.username, + service: nil + ) + } +} + +enum HomePinStore { + static func loadPins( + for userKey: String, + defaults: UserDefaults = .standard + ) -> [HomePinRecord] { + let normalizedUserKey = normalizedUserKey(userKey) + guard !normalizedUserKey.isEmpty else { return [] } + + let storedPins = loadAll(defaults: defaults) + if let storedPinsForUser = storedPins[normalizedUserKey] { + return normalizedPins(storedPinsForUser) + } + + let legacyProjectIDs = ProjectPinStore.loadLegacyPinnedProjectIDs(for: normalizedUserKey, defaults: defaults) + guard !legacyProjectIDs.isEmpty else { return [] } + + let migratedPins = legacyProjectIDs.map { + HomePinRecord( + kind: .project, + value: $0, + title: "Pinned Project", + subtitle: "Project", + ownerUsername: nil, + service: nil + ) + } + savePins(migratedPins, for: normalizedUserKey, defaults: defaults) + return migratedPins + } + + static func isPinned( + _ pin: HomePinRecord, + for userKey: String, + defaults: UserDefaults = .standard + ) -> Bool { + loadPins(for: userKey, defaults: defaults).contains(pin) + } + + static func togglePin( + _ pin: HomePinRecord, + for userKey: String, + defaults: UserDefaults = .standard + ) { + let normalizedUserKey = normalizedUserKey(userKey) + guard !normalizedUserKey.isEmpty else { return } + + var pinsByUser = loadAll(defaults: defaults) + var pins = normalizedPins(pinsByUser[normalizedUserKey] ?? loadPins(for: normalizedUserKey, defaults: defaults)) + + if let index = pins.firstIndex(of: pin) { + pins.remove(at: index) + } else { + pins.append(pin) + } + + pinsByUser[normalizedUserKey] = pins + saveAll(pinsByUser, defaults: defaults) + } + + static func pinnedProjectIDs( + for userKey: String, + defaults: UserDefaults = .standard + ) -> [String] { + loadPins(for: userKey, defaults: defaults) + .filter { $0.kind == .project } + .map(\.value) + } + + private static func loadAll(defaults: UserDefaults) -> [String: [HomePinRecord]] { + guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeItems) else { + return [:] + } + + let decoded = (try? JSONDecoder().decode([String: [HomePinRecord]].self, from: data)) ?? [:] + return decoded.reduce(into: [String: [HomePinRecord]]()) { result, entry in + let key = normalizedUserKey(entry.key) + guard !key.isEmpty else { return } + let pins = normalizedPins(entry.value) + if !pins.isEmpty { + result[key] = pins + } + } + } + + private static func savePins(_ pins: [HomePinRecord], for userKey: String, defaults: UserDefaults) { + var allPins = loadAll(defaults: defaults) + allPins[userKey] = normalizedPins(pins) + saveAll(allPins, defaults: defaults) + } + + private static func saveAll(_ pinsByUser: [String: [HomePinRecord]], defaults: UserDefaults) { + guard let data = try? JSONEncoder().encode(pinsByUser) else { return } + defaults.set(data, forKey: AppStorageKeys.pinnedHomeItems) + } + + private static func normalizedPins(_ pins: [HomePinRecord]) -> [HomePinRecord] { + var seen = Set<String>() + return pins.compactMap { pin in + let title = pin.title.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitle = pin.subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + let value = pin.value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty, !subtitle.isEmpty, !value.isEmpty else { return nil } + + let normalized = HomePinRecord( + kind: pin.kind, + value: value, + title: title, + subtitle: subtitle, + ownerUsername: pin.ownerUsername?.trimmingCharacters(in: .whitespacesAndNewlines), + service: pin.service + ) + guard seen.insert(normalized.id).inserted else { return nil } + return normalized + } + } + + private static func normalizedUserKey(_ userKey: String) -> String { + userKey.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Hutch/Views/Home/HomePrototypeView.swift b/Hutch/Views/Home/HomePrototypeView.swift new file mode 100644 index 0000000..77c360a --- /dev/null +++ b/Hutch/Views/Home/HomePrototypeView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct HomePrototypeView: View { + var body: some View { + HomeView() + } +} 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) } } diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index a28f731..f0c14dd 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -393,7 +393,7 @@ final class HomeViewModel { let inboxUnreadSnapshot = await inboxUnreadTask unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount - unreadInboxThreads = Array(inboxUnreadSnapshot?.threads.prefix(4) ?? []) + unreadInboxThreads = inboxUnreadSnapshot?.threads ?? [] hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 let systemStatusResult = await systemStatusTask switch systemStatusResult { @@ -414,7 +414,7 @@ final class HomeViewModel { } var pinnedProjects: [Project] { - let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults) + let pinnedIDs = HomePinStore.pinnedProjectIDs(for: currentUserKey, defaults: defaults) guard !pinnedIDs.isEmpty else { return [] } let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) }) @@ -422,7 +422,7 @@ final class HomeViewModel { } var hasPinnedProjects: Bool { - !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults).isEmpty + !HomePinStore.loadPins(for: currentUserKey, defaults: defaults).isEmpty } var failedBuildCount: Int { diff --git a/Hutch/Views/Home/RecentActivityStore.swift b/Hutch/Views/Home/RecentActivityStore.swift new file mode 100644 index 0000000..464a2c1 --- /dev/null +++ b/Hutch/Views/Home/RecentActivityStore.swift @@ -0,0 +1,148 @@ +import Foundation + +enum RecentActivityKind: String, Codable, Sendable { + case repository + case ticket + case build +} + +struct RecentActivityEntry: Codable, Hashable, Identifiable, Sendable { + let kind: RecentActivityKind + let title: String + let viewedAt: Date + let repositoryOwner: String? + let repositoryName: String? + let repositoryService: SRHTService? + let ticketOwnerUsername: String? + let ticketTrackerName: String? + let ticketId: Int? + let buildJobId: Int? + + var id: String { + switch kind { + case .repository: + let service = repositoryService?.rawValue ?? SRHTService.git.rawValue + return "repo:\(service):\(repositoryOwner ?? "")/\(repositoryName ?? "")" + case .ticket: + return "ticket:\(ticketOwnerUsername ?? "")/\(ticketTrackerName ?? "")#\(ticketId ?? 0)" + case .build: + return "build:\(buildJobId ?? 0)" + } + } + + var detailText: String { + switch kind { + case .repository: + let serviceName = repositoryService?.displayName ?? "Repository" + return "\(serviceName) • Viewed \(viewedAt.relativeDescription)" + case .ticket: + return "Ticket • Viewed \(viewedAt.relativeDescription)" + case .build: + return "Build • Viewed \(viewedAt.relativeDescription)" + } + } +} + +enum RecentActivityStore { + private static let maximumEntries = 5 + + static func load(defaults: UserDefaults) -> [RecentActivityEntry] { + guard let data = defaults.data(forKey: AppStorageKeys.recentActivity) else { + return [] + } + + do { + return try JSONDecoder().decode([RecentActivityEntry].self, from: data) + } catch { + defaults.removeObject(forKey: AppStorageKeys.recentActivity) + return [] + } + } + + static func recordRepository( + _ repository: RepositorySummary, + defaults: UserDefaults, + now: Date = .now + ) { + record( + RecentActivityEntry( + kind: .repository, + title: "\(repository.owner.canonicalName)/\(repository.name)", + viewedAt: now, + repositoryOwner: repository.owner.canonicalName.srhtUsername, + repositoryName: repository.name, + repositoryService: repository.service, + ticketOwnerUsername: nil, + ticketTrackerName: nil, + ticketId: nil, + buildJobId: nil + ), + defaults: defaults + ) + } + + static func recordTicket( + ownerUsername: String, + trackerName: String, + ticketId: Int, + title: String, + defaults: UserDefaults, + now: Date = .now + ) { + record( + RecentActivityEntry( + kind: .ticket, + title: "#\(ticketId) \(title)", + viewedAt: now, + repositoryOwner: nil, + repositoryName: nil, + repositoryService: nil, + ticketOwnerUsername: ownerUsername, + ticketTrackerName: trackerName, + ticketId: ticketId, + buildJobId: nil + ), + defaults: defaults + ) + } + + static func recordBuild( + jobId: Int, + title: String, + defaults: UserDefaults, + now: Date = .now + ) { + record( + RecentActivityEntry( + kind: .build, + title: title, + viewedAt: now, + repositoryOwner: nil, + repositoryName: nil, + repositoryService: nil, + ticketOwnerUsername: nil, + ticketTrackerName: nil, + ticketId: nil, + buildJobId: jobId + ), + defaults: defaults + ) + } + + private static func record(_ entry: RecentActivityEntry, defaults: UserDefaults) { + var entries = load(defaults: defaults) + entries.removeAll { $0.id == entry.id } + entries.insert(entry, at: 0) + + if entries.count > maximumEntries { + entries = Array(entries.prefix(maximumEntries)) + } + + save(entries, defaults: defaults) + } + + private static func save(_ entries: [RecentActivityEntry], defaults: UserDefaults) { + guard let data = try? JSONEncoder().encode(entries) else { return } + defaults.set(data, forKey: AppStorageKeys.recentActivity) + } +} diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift index c5299ec..a4870db 100644 --- a/Hutch/Views/Inbox/InboxView.swift +++ b/Hutch/Views/Inbox/InboxView.swift @@ -1,196 +1,10 @@ import SwiftUI +// Legacy wrapper kept temporarily so stale references continue to compile while +// the app transitions from Inbox to Work. struct InboxView: View { - @Environment(AppState.self) private var appState - @Environment(\.scenePhase) private var scenePhase - @State private var viewModel: InboxViewModel? - @State private var selectedThreadID: InboxThreadSummary.ID? - @State private var selectedThreadSnapshot: InboxThreadSummary? - @State private var isShowingThreadDetail = false - var body: some View { - Group { - if let viewModel { - listContent(viewModel) - } else { - SRHTLoadingStateView(message: "Loading inbox…") - } - } - .navigationTitle("Inbox") - .task { - let vm: InboxViewModel - if let viewModel { - vm = viewModel - } else { - let newViewModel = InboxViewModel( - client: appState.client, - defaults: appState.accountDefaults, - accountID: appState.activeAccountID - ) - viewModel = newViewModel - vm = newViewModel - } - - await vm.loadThreads() - } - .onChange(of: scenePhase) { _, newPhase in - guard newPhase == .active, let viewModel, !isShowingThreadDetail else { return } - Task { - await viewModel.loadThreads() - } - } - } - - @ViewBuilder - private func listContent(_ viewModel: InboxViewModel) -> some View { - @Bindable var vm = viewModel - - List { - Section { - Picker("Filter", selection: $vm.filter) { - ForEach(InboxThreadFilter.allCases, id: \.self) { filter in - Text(filter.rawValue).tag(filter) - } - } - .pickerStyle(.segmented) - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) - } - - ForEach(viewModel.filteredThreads) { thread in - Button { - selectThread(thread) - } label: { - InboxThreadRow(thread: thread) - } - .buttonStyle(.plain) - .swipeActions(edge: .leading, allowsFullSwipe: true) { - readStateAction(for: thread, in: viewModel) - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - readStateAction(for: thread, in: viewModel) - } - } - } - .themedList() - .searchable( - text: $vm.searchText, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "Search inbox" - ) - .listStyle(.plain) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if viewModel.hasUnreadThreads { - Button("Mark All Read") { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.markAllThreadsRead() - } - } - } - } - } - .overlay { - if viewModel.isLoading, viewModel.threads.isEmpty { - SRHTLoadingStateView(message: "Loading inbox…") - } else if let error = viewModel.error, viewModel.threads.isEmpty { - SRHTErrorStateView( - title: "Failed to load inbox", - message: error, - retryAction: { await viewModel.loadThreads() } - ) - } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty { - ContentUnavailableView.search(text: viewModel.searchText) - } else if viewModel.threads.isEmpty, viewModel.error == nil { - ContentUnavailableView( - "Inbox Zero", - systemImage: "tray", - description: Text("You're up to date.") - ) - } - } - .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) { - await viewModel.loadThreads() - } - .srhtErrorBanner(error: $vm.error) - .refreshable { - await viewModel.loadThreads() - } - .onChange(of: viewModel.threads) { _, threads in - syncSelectedThreadSnapshot(with: threads) - } - .navigationDestination(isPresented: Binding( - get: { isShowingThreadDetail && selectedThread(for: viewModel) != nil }, - set: { isPresented in - if !isPresented { - clearSelection() - } - isShowingThreadDetail = isPresented - } - )) { - if let thread = selectedThread(for: viewModel) { - ThreadDetailView(thread: thread) { - viewModel.markThreadRead(thread) - } - .onAppear { - cacheSelectedThread(thread) - } - .onDisappear { - handleThreadDetailDisappear(for: thread.id) - } - } else { - ContentUnavailableView( - "Thread Unavailable", - systemImage: "tray", - description: Text("This thread could not be restored.") - ) - } - } - } - - @ViewBuilder - private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.markThreadRead(thread) - } - } label: { - Label("Mark as Read", systemImage: "envelope.open") - } - .tint(.blue) - } - - private func selectThread(_ thread: InboxThreadSummary) { - cacheSelectedThread(thread) - isShowingThreadDetail = true - } - - private func cacheSelectedThread(_ thread: InboxThreadSummary) { - selectedThreadID = thread.id - selectedThreadSnapshot = thread - } - - private func selectedThread(for viewModel: InboxViewModel) -> InboxThreadSummary? { - guard let selectedThreadID else { return selectedThreadSnapshot } - return viewModel.thread(withID: selectedThreadID) ?? (selectedThreadSnapshot?.id == selectedThreadID ? selectedThreadSnapshot : nil) - } - - private func handleThreadDetailDisappear(for threadID: String) { - let isActiveSelection = selectedThreadID == threadID - guard isActiveSelection else { return } - clearSelection() - } - - private func syncSelectedThreadSnapshot(with threads: [InboxThreadSummary]) { - guard let selectedThreadID else { return } - guard let updatedThread = threads.first(where: { $0.id == selectedThreadID }) else { return } - selectedThreadSnapshot = updatedThread - } - - private func clearSelection() { - selectedThreadID = nil - selectedThreadSnapshot = nil - isShowingThreadDetail = false + WorkView() } } diff --git a/Hutch/Views/Lookup/UserProfileView.swift b/Hutch/Views/Lookup/UserProfileView.swift index 84375de..4fbb5a0 100644 --- a/Hutch/Views/Lookup/UserProfileView.swift +++ b/Hutch/Views/Lookup/UserProfileView.swift @@ -6,6 +6,7 @@ struct UserProfileView: View { let user: User @State private var profileViewModel: UserProfileViewModel? + @State private var pinChangeCount = 0 private static let iso8601Formatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() @@ -14,6 +15,16 @@ struct UserProfileView: View { return formatter }() + private var currentUserKey: String? { + appState.currentUser?.canonicalName + } + + private var isPinnedToHome: Bool { + _ = pinChangeCount + guard let currentUserKey else { return false } + return HomePinStore.isPinned(.user(user), for: currentUserKey, defaults: appState.accountDefaults) + } + var body: some View { List { if let avatarURL = user.avatar.flatMap(URL.init(string:)) { @@ -164,6 +175,18 @@ struct UserProfileView: View { .listStyle(.insetGrouped) .navigationTitle(user.canonicalName) .navigationBarTitleDisplayMode(.inline) + .toolbar { + if currentUserKey != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + togglePinnedState() + } label: { + Image(systemName: isPinnedToHome ? "pin.fill" : "pin") + } + .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") + } + } + } .task(id: user.canonicalName) { let owner = user.canonicalName.hasPrefix("~") ? String(user.canonicalName.dropFirst()) @@ -200,6 +223,12 @@ struct UserProfileView: View { } } + private func togglePinnedState() { + guard let currentUserKey else { return } + HomePinStore.togglePin(.user(user), for: currentUserKey, defaults: appState.accountDefaults) + pinChangeCount += 1 + } + private func formattedTimestamp(_ value: String) -> String { guard let date = Self.iso8601Formatter.date(from: value) else { return value diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift index 478e471..4ff93e9 100644 --- a/Hutch/Views/Projects/ProjectMailingListView.swift +++ b/Hutch/Views/Projects/ProjectMailingListView.swift @@ -229,6 +229,17 @@ struct MailingListDetailView: View { @Environment(AppState.self) private var appState @State private var viewModel: MailingListDetailViewModel? + @State private var pinChangeCount = 0 + + private var currentUserKey: String? { + appState.currentUser?.canonicalName + } + + private var isPinnedToHome: Bool { + _ = pinChangeCount + guard let currentUserKey else { return false } + return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults) + } var body: some View { Group { @@ -240,6 +251,18 @@ struct MailingListDetailView: View { } .navigationTitle(mailingList.name) .navigationBarTitleDisplayMode(.inline) + .toolbar { + if currentUserKey != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + togglePinnedState() + } label: { + Image(systemName: isPinnedToHome ? "pin.fill" : "pin") + } + .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") + } + } + } .task { if viewModel == nil { let viewModel = MailingListDetailViewModel( @@ -260,6 +283,12 @@ struct MailingListDetailView: View { } } + private func togglePinnedState() { + guard let currentUserKey else { return } + HomePinStore.togglePin(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults) + pinChangeCount += 1 + } + @ViewBuilder private func content(_ viewModel: MailingListDetailViewModel) -> some View { @Bindable var vm = viewModel diff --git a/Hutch/Views/Projects/ProjectPinStore.swift b/Hutch/Views/Projects/ProjectPinStore.swift index 77a4172..ed7e386 100644 --- a/Hutch/Views/Projects/ProjectPinStore.swift +++ b/Hutch/Views/Projects/ProjectPinStore.swift @@ -5,6 +5,11 @@ enum ProjectPinStore { for userKey: String, defaults: UserDefaults = .standard ) -> [String] { + let generalizedPins = HomePinStore.pinnedProjectIDs(for: userKey, defaults: defaults) + if !generalizedPins.isEmpty { + return generalizedPins + } + let pinnedProjects = loadAll(defaults: defaults) return normalizedProjectIDs(pinnedProjects[userKey] ?? []) } @@ -22,6 +27,22 @@ enum ProjectPinStore { for userKey: String, defaults: UserDefaults = .standard ) { + let trimmed = projectID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + HomePinStore.togglePin( + HomePinRecord( + kind: .project, + value: trimmed, + title: "Pinned Project", + subtitle: "Project", + ownerUsername: nil, + service: nil + ), + for: userKey, + defaults: defaults + ) + var pinnedProjects = loadAll(defaults: defaults) var projectIDs = normalizedProjectIDs(pinnedProjects[userKey] ?? []) @@ -37,6 +58,14 @@ enum ProjectPinStore { save(pinnedProjects, defaults: defaults) } + static func loadLegacyPinnedProjectIDs( + for userKey: String, + defaults: UserDefaults = .standard + ) -> [String] { + let pinnedProjects = loadAll(defaults: defaults) + return normalizedProjectIDs(pinnedProjects[userKey] ?? []) + } + private static func loadAll(defaults: UserDefaults) -> [String: [String]] { guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeProjects) else { return [:] diff --git a/Hutch/Views/Repositories/HgRepositoryDetailView.swift b/Hutch/Views/Repositories/HgRepositoryDetailView.swift index 4a61c1d..ac7c04f 100644 --- a/Hutch/Views/Repositories/HgRepositoryDetailView.swift +++ b/Hutch/Views/Repositories/HgRepositoryDetailView.swift @@ -19,12 +19,23 @@ struct HgRepositoryDetailView: View { @State private var showShareUnavailableAlert = false @State private var didCopyFileContents = false @State private var copyResetTask: Task<Void, Never>? + @State private var pinChangeCount = 0 private var canManageRepository: Bool { guard let currentUser = appState.currentUser else { return false } return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName) } + private var currentUserKey: String? { + appState.currentUser?.canonicalName + } + + private var isPinnedToHome: Bool { + _ = pinChangeCount + guard let currentUserKey else { return false } + return HomePinStore.isPinned(.repository(repository), for: currentUserKey, defaults: appState.accountDefaults) + } + private var shareURL: URL? { guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil } return SRHTWebURL.file( @@ -58,17 +69,7 @@ struct HgRepositoryDetailView: View { } } - SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) { - Image(systemName: "square.and.arrow.up") - } - - if canManageRepository { - Button { - showSettings = true - } label: { - Image(systemName: "gear") - } - } + repositoryActionsMenu } } .sheet(isPresented: $showSettings) { @@ -106,6 +107,46 @@ struct HgRepositoryDetailView: View { return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed } + private func togglePinnedState() { + guard let currentUserKey else { return } + HomePinStore.togglePin(.repository(repository), for: currentUserKey, defaults: appState.accountDefaults) + pinChangeCount += 1 + } + + private var repositoryActionsMenu: some View { + Menu { + if currentUserKey != nil { + Button { + togglePinnedState() + } label: { + Label( + isPinnedToHome ? "Unpin from Home" : "Pin to Home", + systemImage: isPinnedToHome ? "pin.slash" : "pin" + ) + } + } + + if let shareURL = SRHTWebURL.repository(repository) { + ShareLink(item: shareURL) { + Label("Share", systemImage: "square.and.arrow.up") + } + } + + if canManageRepository { + Divider() + + Button { + showSettings = true + } label: { + Label("Repository Settings", systemImage: "gear") + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("Repository actions") + } + @ViewBuilder private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View { VStack(spacing: 0) { diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift index 2715134..6e7343f 100644 --- a/Hutch/Views/Repositories/RepositoryDetailView.swift +++ b/Hutch/Views/Repositories/RepositoryDetailView.swift @@ -13,12 +13,23 @@ struct RepositoryDetailView: View { @State private var showSettings = false @State private var showACLs = false @State private var currentRepository: RepositorySummary + @State private var pinChangeCount = 0 private var canManageRepository: Bool { guard let currentUser = appState.currentUser else { return false } return normalizedUsername(currentUser.username) == normalizedUsername(currentRepository.owner.canonicalName) } + private var currentUserKey: String? { + appState.currentUser?.canonicalName + } + + private var isPinnedToHome: Bool { + _ = pinChangeCount + guard let currentUserKey else { return false } + return HomePinStore.isPinned(.repository(currentRepository), for: currentUserKey, defaults: appState.accountDefaults) + } + init( repository: RepositorySummary, onRepositoryUpdated: ((RepositorySummary) -> Void)? = nil, @@ -43,12 +54,8 @@ struct RepositoryDetailView: View { .navigationTitle(currentRepository.name) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { repositoryActionsMenu - - SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) { - Image(systemName: "square.and.arrow.up") - } } } .sheet(isPresented: $showSettings) { @@ -82,6 +89,7 @@ struct RepositoryDetailView: View { client: appState.client ) } + RecentActivityStore.recordRepository(currentRepository, defaults: appState.accountDefaults) } } } @@ -130,6 +138,25 @@ struct RepositoryDetailView: View { private var repositoryActionsMenu: some View { Menu { + if currentUserKey != nil { + Button { + togglePinnedState() + } label: { + Label( + isPinnedToHome ? "Unpin from Home" : "Pin to Home", + systemImage: isPinnedToHome ? "pin.slash" : "pin" + ) + } + } + + if let shareURL = SRHTWebURL.repository(currentRepository) { + ShareLink(item: shareURL) { + Label("Share", systemImage: "square.and.arrow.up") + } + } + + Divider() + if let repositoryURL = SRHTWebURL.repository(currentRepository) { Button { openURL(repositoryURL) @@ -184,4 +211,10 @@ struct RepositoryDetailView: View { } .accessibilityLabel("Repository actions") } + + private func togglePinnedState() { + guard let currentUserKey else { return } + HomePinStore.togglePin(.repository(currentRepository), for: currentUserKey, defaults: appState.accountDefaults) + pinChangeCount += 1 + } } diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift index f8cc0bd..44b3ba5 100644 --- a/Hutch/Views/Settings/SettingsView.swift +++ b/Hutch/Views/Settings/SettingsView.swift @@ -264,10 +264,22 @@ private struct AboutView: View { get: { appState.isDebugModeEnabled }, set: { appState.isDebugModeEnabled = $0 } )) + + NavigationLink { + HomePrototypeView() + } label: { + SwiftUI.Label("Home Prototype", systemImage: "house") + } + + NavigationLink { + WorkPrototypeView() + } label: { + SwiftUI.Label("Work Prototype", systemImage: "tray.full") + } } header: { Text("Developer") } footer: { - Text("Shows raw API payloads and diagnostic details on builds and tickets screens. This stays hidden until explicitly enabled.") + Text("Shows raw API payloads and diagnostic details on builds and tickets screens. Home Prototype explores the dashboard structure, while Work Prototype evaluates the personal queue surface. This stays hidden until explicitly enabled.") } } } diff --git a/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift b/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift index 5d94dad..e25cb26 100644 --- a/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift +++ b/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift @@ -63,7 +63,8 @@ struct SystemStatusSummaryRow: View { private var primaryMessage: String { if let snapshot { - return snapshot.hasDisruption ? snapshot.bannerSummary : snapshot.overallStatusText + let summary = snapshot.hasDisruption ? snapshot.bannerSummary : snapshot.overallStatusText + return "\(summary) • Updated \(snapshot.lastUpdated.relativeDescription)" } if let errorMessage, !errorMessage.isEmpty { return errorMessage @@ -75,11 +76,8 @@ struct SystemStatusSummaryRow: View { } private var metadataMessage: String? { - if let snapshot { - if isShowingStaleData { - return "Updated \(snapshot.lastUpdated.relativeDescription) • Showing saved data" - } - return "Updated \(snapshot.lastUpdated.relativeDescription)" + if snapshot != nil, isShowingStaleData { + return "Showing saved data" } if errorMessage != nil { return "Open System Status to retry." diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index f85a0c1..32ef89c 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -220,6 +220,15 @@ struct TicketDetailView: View { commentInput(viewModel) } } + .task(id: ticket.id) { + RecentActivityStore.recordTicket( + ownerUsername: ownerUsername, + trackerName: trackerName, + ticketId: ticket.id, + title: ticket.title, + defaults: appState.accountDefaults + ) + } .srhtErrorBanner(error: $vm.error) .refreshable { await reloadDetail(viewModel) diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index f7529fb..8f7d348 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -23,12 +23,29 @@ struct TicketListView: View { @State private var showBulkCloseSheet = false @State private var showBulkAssignSheet = false @State private var bulkActionResult: TicketBulkActionResult? + @State private var pinChangeCount = 0 private var isOwnedByCurrentUser: Bool { guard let currentUser = appState.currentUser else { return false } return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName) } + private var currentUserKey: String? { + appState.currentUser?.canonicalName + } + + private var isPinnedToHome: Bool { + _ = pinChangeCount + guard let currentUserKey else { return false } + return HomePinStore.isPinned(.tracker(tracker), for: currentUserKey, defaults: appState.accountDefaults) + } + + private var navigationTitle: String { + guard let viewModel, viewModel.isSelectionMode else { return tracker.name } + let count = viewModel.selectedTicketCount + return count == 0 ? "Select Tickets" : "\(count) Selected" + } + init( tracker: TrackerSummary, onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in /* no-op: default for callers that don't handle this event */ }, @@ -47,7 +64,7 @@ struct TicketListView: View { SRHTLoadingStateView(message: "Loading tickets…") } } - .navigationTitle(tracker.name) + .navigationTitle(navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -66,16 +83,6 @@ struct TicketListView: View { } .disabled(viewModel.filteredTickets.isEmpty || viewModel.isPerformingAction) } else { - SRHTShareButton( - url: SRHTWebURL.tracker( - ownerUsername: String(tracker.owner.canonicalName.dropFirst()), - trackerName: tracker.name - ), - target: .tracker - ) { - Image(systemName: "square.and.arrow.up") - } - Button { showCreateTicketSheet = true } label: { @@ -83,10 +90,6 @@ struct TicketListView: View { } .accessibilityLabel("Create ticket") - Button("Select") { - viewModel?.setSelectionMode(true) - } - trackerActionsMenu } } @@ -242,6 +245,12 @@ struct TicketListView: View { } } + private func togglePinnedState() { + guard let currentUserKey else { return } + HomePinStore.togglePin(.tracker(tracker), for: currentUserKey, defaults: appState.accountDefaults) + pinChangeCount += 1 + } + @ViewBuilder private func listContent(_ viewModel: TicketListViewModel) -> some View { @Bindable var vm = viewModel @@ -429,6 +438,31 @@ struct TicketListView: View { private var trackerActionsMenu: some View { Menu { + Button { + viewModel?.setSelectionMode(true) + } label: { + Label("Select", systemImage: "checkmark.circle") + } + + if currentUserKey != nil { + Button { + togglePinnedState() + } label: { + Label( + isPinnedToHome ? "Unpin from Home" : "Pin to Home", + systemImage: isPinnedToHome ? "pin.slash" : "pin" + ) + } + } + + if let shareURL = SRHTWebURL.tracker(tracker) { + ShareLink(item: shareURL) { + Label("Share", systemImage: "square.and.arrow.up") + } + } + + Divider() + if let trackerURL = SRHTWebURL.tracker(tracker) { Button { openURL(trackerURL) diff --git a/Hutch/Views/Work/WorkPrototypeView.swift b/Hutch/Views/Work/WorkPrototypeView.swift new file mode 100644 index 0000000..fb5c977 --- /dev/null +++ b/Hutch/Views/Work/WorkPrototypeView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct WorkPrototypeView: View { + var body: some View { + WorkView() + } +} diff --git a/Hutch/Views/Work/WorkView.swift b/Hutch/Views/Work/WorkView.swift new file mode 100644 index 0000000..1e684d4 --- /dev/null +++ b/Hutch/Views/Work/WorkView.swift @@ -0,0 +1,351 @@ +import SwiftUI + +struct WorkView: View { + private enum Scope: String, CaseIterable, Identifiable { + case all = "All" + case unread = "Unread" + case assigned = "Assigned" + + var id: String { rawValue } + } + + @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true + @Environment(AppState.self) private var appState + @Environment(\.scenePhase) private var scenePhase + @State private var viewModel: HomeViewModel? + @State private var scope: Scope = .all + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading Work…") + } + } + .navigationTitle("Work") + .navigationBarTitleDisplayMode(.inline) + .task { + guard let currentUser = appState.currentUser else { return } + await ensureViewModel(currentUser: currentUser).loadDashboard() + } + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active, let viewModel else { return } + Task { + await viewModel.loadDashboard() + } + } + } + + @ViewBuilder + private func content(_ viewModel: HomeViewModel) -> some View { + List { + headerSection(viewModel) + scopeSection + + switch scope { + case .all: + allScopeContent(viewModel) + case .unread: + unreadSection(viewModel, compactWhenEmpty: true) + case .assigned: + assignedSection(viewModel) + } + } + .themedList() + .listStyle(.insetGrouped) + .refreshable { + await viewModel.loadDashboard() + } + .connectivityOverlay(hasContent: hasWorkContent(viewModel)) { + await viewModel.loadDashboard() + } + } + + private func headerSection(_ viewModel: HomeViewModel) -> some View { + Section { + VStack(alignment: .leading, spacing: 6) { + Text(title(viewModel)) + .font(.headline) + if workCount(viewModel) > 0 { + Text(summary(viewModel)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } + } + + @ViewBuilder + private func allScopeContent(_ viewModel: HomeViewModel) -> some View { + if unreadCount(viewModel) > 0 { + unreadSection(viewModel, compactWhenEmpty: false) + } + + assignedSection(viewModel) + + if workCount(viewModel) == 0 { + Section { + WorkCompactMessageRow(text: "Nothing to do", systemImage: "checkmark.circle") + } + } + } + + private var scopeSection: some View { + Section { + Picker("Scope", selection: $scope) { + ForEach(Scope.allCases) { scope in + Text(scope.rawValue).tag(scope) + } + } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + } + } + + @ViewBuilder + private func unreadSection(_ viewModel: HomeViewModel, compactWhenEmpty: Bool) -> some View { + Section { + if isLoadingUnread(viewModel) { + WorkLoadingRow(label: "Loading unread threads") + } else if viewModel.unreadInboxThreads.isEmpty { + if compactWhenEmpty { + WorkCompactMessageRow(text: "No unread threads", systemImage: "tray") + } + } else { + ForEach(viewModel.unreadInboxThreads) { thread in + NavigationLink { + ThreadDetailView( + thread: thread, + onViewed: { viewModel.markInboxThreadRead(thread) }, + onMarkRead: { viewModel.markInboxThreadRead(thread) }, + onMarkUnread: { viewModel.markInboxThreadUnread(thread) } + ) + } label: { + WorkThreadRow(thread: thread) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + if swipeActionsEnabled { + Button { + viewModel.markInboxThreadRead(thread) + } label: { + Label("Mark Read", systemImage: "envelope.open") + } + .tint(.blue) + } + } + } + } + } header: { + Text("Unread Threads") + } footer: { + NavigationLink { + MailingListListView() + } label: { + Label("Open mailing list workspace", systemImage: "list.bullet") + .font(.subheadline.weight(.medium)) + } + } + } + + @ViewBuilder + private func assignedSection(_ viewModel: HomeViewModel) -> some View { + Section { + if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { + WorkLoadingRow(label: "Loading assigned tickets") + } else if viewModel.assignedTickets.isEmpty { + WorkCompactMessageRow(text: "No assigned tickets", systemImage: "person.crop.circle.badge.checkmark") + } else { + ForEach(viewModel.assignedTickets) { ticket in + NavigationLink { + TicketDetailView( + ownerUsername: ticket.ownerUsername, + trackerName: ticket.trackerName, + trackerId: ticket.trackerId, + trackerRid: ticket.trackerRid, + ticketId: ticket.ticket.id + ) + } label: { + WorkAssignedTicketRow(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", systemImage: "person.badge.minus") + } + .tint(.orange) + } + } + } + } + } header: { + Text("Assigned Tickets") + } footer: { + NavigationLink { + TrackerListView() + } label: { + Label("Open tracker workspace", systemImage: "checklist") + .font(.subheadline.weight(.medium)) + } + } + } + + private func title(_ viewModel: HomeViewModel) -> String { + let count = workCount(viewModel) + if count == 0 { + return "Queue clear" + } + return "\(count) item\(count == 1 ? "" : "s") need attention" + } + + private func summary(_ viewModel: HomeViewModel) -> String { + "\(unreadCount(viewModel)) unread • \(viewModel.assignedTickets.count) assigned" + } + + private func workCount(_ viewModel: HomeViewModel) -> Int { + unreadCount(viewModel) + viewModel.assignedTickets.count + } + + private func unreadCount(_ viewModel: HomeViewModel) -> Int { + viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count + } + + private func isLoadingUnread(_ viewModel: HomeViewModel) -> Bool { + viewModel.unreadInboxThreadCount == nil && viewModel.unreadInboxThreads.isEmpty + } + + private func hasWorkContent(_ viewModel: HomeViewModel) -> Bool { + workCount(viewModel) > 0 + } + + @MainActor + private func ensureViewModel(currentUser: User) -> HomeViewModel { + if let viewModel { + return viewModel + } + + let newViewModel = HomeViewModel( + currentUser: currentUser, + client: appState.client, + systemStatusRepository: appState.systemStatusRepository, + defaults: appState.accountDefaults, + accountID: appState.activeAccountID + ) + viewModel = newViewModel + return newViewModel + } +} + +private struct WorkThreadRow: View { + let thread: InboxThreadSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(thread.isUnread ? .blue : .clear) + .frame(width: 8, height: 8) + .padding(.top, 5) + + Text(thread.displaySubject) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + } + + Text(thread.listDisplayName) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + Text(thread.metadataLine) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + .padding(.vertical, 2) + } +} + +private struct WorkAssignedTicketRow: View { + let ticket: HomeAssignedTicket + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("#\(ticket.ticket.id)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + + Text(ticket.ticket.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + + Spacer(minLength: 8) + + Text(ticket.ticket.status.displayName) + .font(.caption2.weight(.semibold)) + .foregroundStyle(ticket.ticket.status.isOpen ? .orange : .secondary) + } + + Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + Text(ticket.ticket.created.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 2) + } +} + +private struct WorkCompactMessageRow: View { + let text: String + let systemImage: String + + var body: some View { + Label(text, systemImage: systemImage) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 2) + } +} + +private struct WorkLoadingRow: View { + let label: String + + var body: some View { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(label) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } +} diff --git a/labels.sh b/labels.sh deleted file mode 100755 index 746e0c8..0000000 --- a/labels.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TRACKER="hutch" -ERR_FILE="$(mktemp)" -trap 'rm -f "$ERR_FILE"' EXIT - -label_ticket() { - local ticket_id="$1" - shift - - for label in "$@"; do - echo "Applying label '$label' to ticket #$ticket_id" - if ! hut todo ticket label "$ticket_id" -t "$TRACKER" -l "$label" 2>"$ERR_FILE"; then - if grep -q "already assigned to this ticket" "$ERR_FILE"; then - echo " already present, skipping" - else - cat "$ERR_FILE" >&2 - exit 1 - fi - fi - done -} - -# v2.16.x -label_ticket 14 enhancement ui ux # Home: Collapsible Sections -label_ticket 15 enhancement ui # Home: See All Navigation -label_ticket 16 enhancement ui ux # Home: Repo-Based Grouping - -label_ticket 17 enhancement builds # Builds: Auto-Refresh Toggle -label_ticket 18 enhancement builds # Builds: Per-Repo Filter -label_ticket 19 enhancement builds ux # Builds: Retry/Cancel Polish - -label_ticket 20 enhancement tickets ui # Tickets: Swipe Actions -label_ticket 21 enhancement tickets ui # Tickets: Status and Label Visibility - -label_ticket 22 bug inbox ui # Inbox: Patch Rendering Fixes -label_ticket 23 enhancement inbox ux # Inbox: Reply Flow Polish - -label_ticket 24 new-feature theming ui # Theming: AMOLED Mode -label_ticket 25 new-feature theming ui # Theming: High-Density Mode - -# v2.17.x -label_ticket 26 new-feature projects # Projects: Project List View -label_ticket 27 new-feature projects # Projects: Project Detail View -label_ticket 28 enhancement projects ui # Projects: Pin to Home - -label_ticket 29 enhancement builds # Builds: Log Search -label_ticket 30 enhancement builds # Builds: Jump to Error -label_ticket 31 enhancement builds # Builds: Artifact List - -label_ticket 32 enhancement tickets # Tickets: Saved Filters -label_ticket 33 enhancement tickets # Tickets: Label Filtering - -label_ticket 34 enhancement inbox # Inbox: Basic Threading -label_ticket 35 enhancement inbox # Inbox: Collapse Long Diffs - -# v2.18.x -label_ticket 36 new-feature projects # Projects: Create Project -label_ticket 37 enhancement projects # Projects: Edit Project -label_ticket 38 enhancement projects # Projects: Manage Linked Resources - -label_ticket 39 new-feature acl repo # ACL: View Permissions -label_ticket 40 enhancement acl repo # ACL: Add and Remove Users -label_ticket 41 enhancement acl repo # ACL: Edit Permissions - -label_ticket 42 enhancement repo # Repo Settings: Edit Metadata -label_ticket 43 enhancement repo # Repo Settings: Set Default Branch -label_ticket 44 enhancement repo # Repo Settings: Visibility Changes - -label_ticket 45 new-feature tickets # Tickets: Label CRUD -label_ticket 46 enhancement tickets # Tickets: Bulk Actions - -# v2.19.x -label_ticket 47 new-feature search # Search: Per-Type Search -label_ticket 48 enhancement search # Search: Recent Searches - -label_ticket 49 new-feature accounts # Accounts: Multi-Account Support -label_ticket 50 enhancement accounts ui # Accounts: Account Switcher UI -label_ticket 51 enhancement accounts performance # Accounts: Isolated Caching - -label_ticket 52 enhancement power # Power: Open in Browser -label_ticket 53 enhancement power # Power: Copy Actions -label_ticket 54 enhancement power # Power: Debug View Toggle - -# v2.20.x -label_ticket 55 enhancement navigation ux # Navigation: Merge Inbox and Tickets Evaluation - -label_ticket 56 enhancement performance # Performance: Caching Improvements -label_ticket 57 enhancement performance # Performance: List Virtualization - -label_ticket 58 enhancement repo # Consistency: Git and Mercurial Parity - -label_ticket 59 bug api # Errors: Normalize GraphQL Handling - -echo "Done." |
