summaryrefslogtreecommitdiff
path: root/Hutch/Views/Projects
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 19:58:20 -0500
committerChristian Cleberg <[email protected]>2026-04-12 19:58:20 -0500
commit9cd99c3e1c7e2620c34bfd9e78931e3b2b63aa8a (patch)
treebf7a249bec3b9aba18176e6139d690c9bf384ab5 /Hutch/Views/Projects
parent1354f2464180ece6d13296a543a964ddf6d2d393 (diff)
downloadhutch-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')
-rw-r--r--Hutch/Views/Projects/ProjectDetailView.swift165
-rw-r--r--Hutch/Views/Projects/ProjectPinStore.swift50
-rw-r--r--Hutch/Views/Projects/ProjectsListView.swift146
3 files changed, 347 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 {
diff --git a/Hutch/Views/Projects/ProjectPinStore.swift b/Hutch/Views/Projects/ProjectPinStore.swift
new file mode 100644
index 0000000..cd3d961
--- /dev/null
+++ b/Hutch/Views/Projects/ProjectPinStore.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+enum ProjectPinStore {
+ static func loadPinnedProjectIDs(
+ for userKey: String,
+ defaults: UserDefaults = .standard
+ ) -> [String] {
+ let pinnedProjects = loadAll(defaults: defaults)
+ return pinnedProjects[userKey] ?? []
+ }
+
+ static func isPinned(
+ projectID: String,
+ for userKey: String,
+ defaults: UserDefaults = .standard
+ ) -> Bool {
+ loadPinnedProjectIDs(for: userKey, defaults: defaults).contains(projectID)
+ }
+
+ static func togglePin(
+ projectID: String,
+ for userKey: String,
+ defaults: UserDefaults = .standard
+ ) {
+ var pinnedProjects = loadAll(defaults: defaults)
+ var projectIDs = pinnedProjects[userKey] ?? []
+
+ if let index = projectIDs.firstIndex(of: projectID) {
+ projectIDs.remove(at: index)
+ } else {
+ projectIDs.append(projectID)
+ }
+
+ pinnedProjects[userKey] = projectIDs
+ save(pinnedProjects, defaults: defaults)
+ }
+
+ private static func loadAll(defaults: UserDefaults) -> [String: [String]] {
+ guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeProjects) else {
+ return [:]
+ }
+
+ return (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:]
+ }
+
+ private static func save(_ pinnedProjects: [String: [String]], defaults: UserDefaults) {
+ guard let data = try? JSONEncoder().encode(pinnedProjects) else { return }
+ defaults.set(data, forKey: AppStorageKeys.pinnedHomeProjects)
+ }
+}
diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift
new file mode 100644
index 0000000..0ffe183
--- /dev/null
+++ b/Hutch/Views/Projects/ProjectsListView.swift
@@ -0,0 +1,146 @@
+import SwiftUI
+
+@Observable
+@MainActor
+final class ProjectsListViewModel {
+ private(set) var projects: [Project] = []
+ private(set) var isLoading = false
+ var error: String?
+ var searchText = ""
+
+ private let service: ProjectService
+
+ init(service: ProjectService) {
+ self.service = service
+ }
+
+ var filteredProjects: [Project] {
+ let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !query.isEmpty else { return projects }
+
+ return projects.filter {
+ $0.name.lowercased().contains(query) ||
+ ($0.description?.lowercased().contains(query) ?? false) ||
+ $0.tags.contains(where: { $0.lowercased().contains(query) })
+ }
+ }
+
+ func loadProjects() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ do {
+ projects = try await service.fetchProjects()
+ } catch {
+ self.error = "Failed to load projects"
+ }
+ }
+}
+
+struct ProjectsListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: ProjectsListViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading projects…")
+ }
+ }
+ .navigationTitle("Projects")
+ .task {
+ if viewModel == nil {
+ let vm = ProjectsListViewModel(service: ProjectService(client: appState.client))
+ viewModel = vm
+ await vm.loadProjects()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: ProjectsListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.filteredProjects) { project in
+ NavigationLink {
+ ProjectDetailView(project: project)
+ } label: {
+ ProjectListRow(project: project)
+ }
+ }
+ }
+ .themedList()
+ .listStyle(.plain)
+ .searchable(
+ text: $vm.searchText,
+ placement: .navigationBarDrawer(displayMode: .always),
+ prompt: "Search projects"
+ )
+ .overlay {
+ if viewModel.isLoading, viewModel.projects.isEmpty {
+ SRHTLoadingStateView(message: "Loading projects…")
+ } else if let error = viewModel.error, viewModel.projects.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Projects",
+ message: error,
+ retryAction: { await viewModel.loadProjects() }
+ )
+ } else if !viewModel.projects.isEmpty, viewModel.filteredProjects.isEmpty {
+ ContentUnavailableView.search(text: viewModel.searchText)
+ } else if viewModel.projects.isEmpty {
+ ContentUnavailableView(
+ "No Projects",
+ systemImage: "square.stack.3d.up",
+ description: Text("Your SourceHut projects will appear here.")
+ )
+ }
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadProjects()
+ }
+ .connectivityOverlay(hasContent: !viewModel.projects.isEmpty) {
+ await viewModel.loadProjects()
+ }
+ }
+}
+
+private struct ProjectListRow: View {
+ let project: Project
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(project.name)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(1)
+
+ Spacer()
+
+ Text(project.updated.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+
+ if let description = project.description, !description.isEmpty {
+ Text(description)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ if let summary = project.resourceSummary {
+ Text(summary)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}