diff options
Diffstat (limited to 'Hutch/Views')
| -rw-r--r-- | Hutch/Views/Builds/BuildDetailView.swift | 17 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomePinStore.swift | 207 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomePrototypeView.swift | 7 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 1116 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 6 | ||||
| -rw-r--r-- | Hutch/Views/Home/RecentActivityStore.swift | 148 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/InboxView.swift | 192 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileView.swift | 29 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectMailingListView.swift | 29 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectPinStore.swift | 29 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/HgRepositoryDetailView.swift | 63 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailView.swift | 43 | ||||
| -rw-r--r-- | Hutch/Views/Settings/SettingsView.swift | 14 | ||||
| -rw-r--r-- | Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift | 10 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailView.swift | 9 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 64 | ||||
| -rw-r--r-- | Hutch/Views/Work/WorkPrototypeView.swift | 7 | ||||
| -rw-r--r-- | Hutch/Views/Work/WorkView.swift | 351 |
18 files changed, 1376 insertions, 965 deletions
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) + } +} |
