krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.0.0: Hutch/Views/Projects/ProjectsListView.swift · raw

  1import SwiftUI
  2
  3@Observable
  4@MainActor
  5final class ProjectsListViewModel {
  6    private(set) var projects: [Project] = []
  7    private(set) var isLoading = false
  8    var error: String?
  9    var searchText = ""
 10
 11    private let service: ProjectService
 12
 13    init(service: ProjectService) {
 14        self.service = service
 15    }
 16
 17    var filteredProjects: [Project] {
 18        let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
 19        guard !query.isEmpty else { return projects }
 20
 21        return projects.filter {
 22            $0.name.lowercased().contains(query) ||
 23            ($0.description?.lowercased().contains(query) ?? false) ||
 24            $0.tags.contains(where: { $0.lowercased().contains(query) })
 25        }
 26    }
 27
 28    func loadProjects() async {
 29        guard !isLoading else { return }
 30        isLoading = true
 31        error = nil
 32        defer { isLoading = false }
 33
 34        do {
 35            projects = try await service.fetchProjects()
 36        } catch {
 37            if projects.isEmpty {
 38                self.error = error.userFacingMessage
 39            } else {
 40                self.error = "Couldn’t refresh projects. \(error.userFacingMessage)"
 41            }
 42        }
 43    }
 44}
 45
 46struct ProjectsListView: View {
 47    @Environment(AppState.self) private var appState
 48    @State private var viewModel: ProjectsListViewModel?
 49
 50    var body: some View {
 51        Group {
 52            if let viewModel {
 53                content(viewModel)
 54            } else {
 55                SRHTLoadingStateView(message: "Loading projects…")
 56            }
 57        }
 58        .navigationTitle("Projects")
 59        .task {
 60            if viewModel == nil {
 61                let vm = ProjectsListViewModel(service: ProjectService(client: appState.client))
 62                viewModel = vm
 63                await vm.loadProjects()
 64            }
 65        }
 66    }
 67
 68    @ViewBuilder
 69    private func content(_ viewModel: ProjectsListViewModel) -> some View {
 70        @Bindable var vm = viewModel
 71
 72        List {
 73            ForEach(viewModel.filteredProjects) { project in
 74                NavigationLink {
 75                    ProjectDetailView(project: project)
 76                } label: {
 77                    ProjectListRow(project: project)
 78                }
 79                .buttonStyle(.plain)
 80                .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
 81            }
 82        }
 83        .themedList()
 84        .listStyle(.plain)
 85        .searchable(
 86            text: $vm.searchText,
 87            placement: .navigationBarDrawer(displayMode: .always),
 88            prompt: "Search projects"
 89        )
 90        .overlay {
 91            if viewModel.isLoading, viewModel.projects.isEmpty {
 92                SRHTLoadingStateView(message: "Loading projects…")
 93            } else if let error = viewModel.error, viewModel.projects.isEmpty {
 94                SRHTErrorStateView(
 95                    title: "Couldn't Load Projects",
 96                    message: error,
 97                    retryAction: { await viewModel.loadProjects() }
 98                )
 99            } else if !viewModel.projects.isEmpty, viewModel.filteredProjects.isEmpty {
100                ContentUnavailableView.search(text: viewModel.searchText)
101            } else if viewModel.projects.isEmpty {
102                ContentUnavailableView(
103                    "No Projects",
104                    systemImage: "square.stack.3d.up",
105                    description: Text("Projects from your SourceHut account will appear here when available.")
106                )
107            }
108        }
109        .srhtErrorBanner(error: $vm.error)
110        .refreshable {
111            await viewModel.loadProjects()
112        }
113        .connectivityOverlay(hasContent: !viewModel.projects.isEmpty) {
114            await viewModel.loadProjects()
115        }
116    }
117}
118
119private struct ProjectListRow: View {
120    let project: Project
121
122    var body: some View {
123        VStack(alignment: .leading, spacing: 6) {
124            HStack(alignment: .top, spacing: 10) {
125                VStack(alignment: .leading, spacing: 4) {
126                    Text(project.displayName)
127                        .font(.subheadline.weight(.medium))
128                        .foregroundStyle(.primary)
129                        .lineLimit(1)
130
131                    if let description = project.displayDescription {
132                        Text(description)
133                            .font(.caption)
134                            .foregroundStyle(.secondary)
135                            .lineLimit(2)
136                    }
137                }
138
139                Spacer(minLength: 8)
140
141                VisibilityBadge(visibility: project.visibility)
142            }
143
144            Text(project.metadataLine)
145                .font(.caption)
146                .foregroundStyle(.secondary)
147                .lineLimit(1)
148
149            if !project.displayTags.isEmpty {
150                ScrollView(.horizontal, showsIndicators: false) {
151                    HStack(spacing: 6) {
152                        ForEach(project.displayTags.prefix(4), id: \.self) { tag in
153                            Text(tag)
154                                .font(.caption2.weight(.medium))
155                                .foregroundStyle(.secondary)
156                                .padding(.horizontal, 8)
157                                .padding(.vertical, 3)
158                                .background(.quaternary, in: Capsule())
159                        }
160                    }
161                }
162                .scrollDisabled(true)
163            }
164        }
165        .contentShape(Rectangle())
166        .padding(.vertical, 4)
167    }
168}