summaryrefslogtreecommitdiff
path: root/Hutch/Views/Projects/ProjectsListView.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Projects/ProjectsListView.swift')
-rw-r--r--Hutch/Views/Projects/ProjectsListView.swift146
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)
+ }
+}