diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 19:58:20 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 19:58:20 -0500 |
| commit | 9cd99c3e1c7e2620c34bfd9e78931e3b2b63aa8a (patch) | |
| tree | bf7a249bec3b9aba18176e6139d690c9bf384ab5 /Hutch/Views/Projects/ProjectDetailView.swift | |
| parent | 1354f2464180ece6d13296a543a964ddf6d2d393 (diff) | |
| download | hutch-9cd99c3e1c7e2620c34bfd9e78931e3b2b63aa8a.tar.gz hutch-9cd99c3e1c7e2620c34bfd9e78931e3b2b63aa8a.tar.bz2 hutch-9cd99c3e1c7e2620c34bfd9e78931e3b2b63aa8a.zip | |
feat: add projects list, detail view, and home pinning
Implements: https://todo.sr.ht/~ccleberg/hutch/26
Implements: https://todo.sr.ht/~ccleberg/hutch/27
Implements: https://todo.sr.ht/~ccleberg/hutch/28
Diffstat (limited to 'Hutch/Views/Projects/ProjectDetailView.swift')
| -rw-r--r-- | Hutch/Views/Projects/ProjectDetailView.swift | 165 |
1 files changed, 151 insertions, 14 deletions
diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift index 89601a9..a04c526 100644 --- a/Hutch/Views/Projects/ProjectDetailView.swift +++ b/Hutch/Views/Projects/ProjectDetailView.swift @@ -2,49 +2,124 @@ import SwiftUI struct ProjectDetailView: View { let project: Project + @Environment(AppState.self) private var appState @Environment(\.dismiss) private var dismiss + @State private var detailProject: Project? + @State private var isLoading = false + @State private var error: String? + @State private var pinChangeCount = 0 + + private var displayedProject: Project { + detailProject ?? project + } + + private var currentUserKey: String? { + appState.currentUser?.canonicalName + } + + private var isPinnedToHome: Bool { + _ = pinChangeCount + guard let currentUserKey else { return false } + return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey) + } var body: some View { List { headerSection + linksSection repositoriesSection trackersSection mailingListsSection } - .navigationTitle(project.name) + .themedList() + .navigationTitle(displayedProject.name) .navigationBarTitleDisplayMode(.inline) + .overlay { + if isLoading, detailProject == nil, !project.isFullyLoaded { + SRHTLoadingStateView(message: "Loading project…") + } else if let error, detailProject == nil, !project.isFullyLoaded { + SRHTErrorStateView( + title: "Couldn't Load Project", + message: error, + retryAction: { await loadProjectIfNeeded(forceRefresh: true) } + ) + } + } + .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 { + await loadProjectIfNeeded() + } + .refreshable { + await loadProjectIfNeeded(forceRefresh: true) + } + .srhtErrorBanner(error: $error) } @ViewBuilder private var headerSection: some View { Section { - VStack(alignment: .leading, spacing: 8) { - Text(project.name) + VStack(alignment: .leading, spacing: 10) { + Text(displayedProject.name) .font(.headline) - if let description = project.description, !description.isEmpty { + if let description = displayedProject.description, !description.isEmpty { Text(description) .font(.subheadline) .foregroundStyle(.secondary) } - if let website = project.website, let url = URL(string: website) { - Link(destination: url) { - Label(website, systemImage: "link") - .font(.subheadline) + if !displayedProject.tags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(displayedProject.tags, id: \.self) { tag in + Text(tag) + .font(.caption.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.quaternary, in: Capsule()) + } + } } } + + LabeledContent("Updated", value: displayedProject.updated.relativeDescription) } .padding(.vertical, 4) } } @ViewBuilder + private var linksSection: some View { + let links = projectLinks(for: displayedProject) + if !links.isEmpty { + Section("Links") { + ForEach(links) { link in + Link(destination: link.url) { + Label(link.title, systemImage: link.systemImage) + .font(.subheadline) + } + } + } + } + } + + @ViewBuilder private var repositoriesSection: some View { - if !project.sources.isEmpty { + if !displayedProject.sources.isEmpty { Section("Repositories") { - ForEach(project.sources) { source in + ForEach(displayedProject.sources) { source in Button { Task { try? await appState.openProjectSource(source) @@ -65,9 +140,9 @@ struct ProjectDetailView: View { @ViewBuilder private var trackersSection: some View { - if !project.trackers.isEmpty { + if !displayedProject.trackers.isEmpty { Section("Trackers") { - ForEach(project.trackers) { tracker in + ForEach(displayedProject.trackers) { tracker in Button { Task { try? await appState.openProjectTracker(tracker) @@ -88,9 +163,9 @@ struct ProjectDetailView: View { @ViewBuilder private var mailingListsSection: some View { - if !project.mailingLists.isEmpty { + if !displayedProject.mailingLists.isEmpty { Section("Mailing Lists") { - ForEach(project.mailingLists) { mailingList in + ForEach(displayedProject.mailingLists) { mailingList in Button { appState.openMailingList(mailingList.inboxReference) dismiss() @@ -106,6 +181,68 @@ struct ProjectDetailView: View { } } } + + private func loadProjectIfNeeded(forceRefresh: Bool = false) async { + guard forceRefresh || !project.isFullyLoaded else { + detailProject = project + return + } + guard !isLoading else { return } + + isLoading = true + error = nil + defer { isLoading = false } + + do { + let service = ProjectService(client: appState.client) + detailProject = try await service.fetchProjectDetail(rid: project.id) + } catch { + self.error = "Failed to load project" + } + } + + private func togglePinnedState() { + guard let currentUserKey else { return } + ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey) + pinChangeCount += 1 + } + + private func projectLinks(for project: Project) -> [ProjectLink] { + var links: [ProjectLink] = [] + + if let website = project.website?.trimmingCharacters(in: .whitespacesAndNewlines), + let url = URL(string: website), + !website.isEmpty { + links.append(ProjectLink(id: "website", title: website, systemImage: "globe", url: url)) + } + + if let source = project.sources.first, + let url = sourceURL(for: source) { + links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.name)", systemImage: "book.closed", url: url)) + } + + if let tracker = project.trackers.first, + let url = SRHTWebURL.tracker(ownerUsername: tracker.ownerUsername, trackerName: tracker.name) { + links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.name)", systemImage: "checklist", url: url)) + } + + return links + } + + private func sourceURL(for source: Project.SourceRepo) -> URL? { + var components = URLComponents() + components.scheme = "https" + components.host = "\(source.repoType.service.rawValue).sr.ht" + components.percentEncodedPath = "/~\(source.ownerUsername)/\(source.name)" + return components.url + } +} + +private struct ProjectLink: Identifiable { + let id: String + let title: String + let systemImage: String + let url: URL } private struct ProjectResourceRow: View { |
