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/ProjectsListView.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/ProjectsListView.swift')
| -rw-r--r-- | Hutch/Views/Projects/ProjectsListView.swift | 146 |
1 files changed, 146 insertions, 0 deletions
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) + } +} |
