krz/hutch

an ios client for sourcehut

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

v3.1.11: 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        List {
 71            ForEach(viewModel.filteredProjects) { project in
 72                NavigationLink {
 73                    ProjectDetailView(project: project)
 74                } label: {
 75                    ProjectListRow(project: project)
 76                }
 77                .buttonStyle(.plain)
 78                .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
 79            }
 80            .themedRow()
 81        }
 82        .themedList()
 83        .listStyle(.plain)
 84        .searchable(
 85            text: Binding(
 86                get: { viewModel.searchText },
 87                set: { viewModel.searchText = $0 }
 88            ),
 89            placement: .navigationBarDrawer(displayMode: .always),
 90            prompt: "Search projects"
 91        )
 92        .overlay {
 93            if viewModel.isLoading, viewModel.projects.isEmpty {
 94                SRHTLoadingStateView(message: "Loading projects…")
 95            } else if let error = viewModel.error, viewModel.projects.isEmpty {
 96                SRHTErrorStateView(
 97                    title: "Couldn't Load Projects",
 98                    message: error,
 99                    retryAction: { await viewModel.loadProjects() }
100                )
101            } else if !viewModel.projects.isEmpty, viewModel.filteredProjects.isEmpty {
102                ContentUnavailableView.search(text: viewModel.searchText)
103            } else if viewModel.projects.isEmpty {
104                ContentUnavailableView(
105                    "No Projects",
106                    systemImage: "square.stack.3d.up",
107                    description: Text("Projects from your SourceHut account will appear here when available.")
108                )
109            }
110        }
111        .srhtErrorBanner(
112            error: Binding(
113                get: { viewModel.error },
114                set: { viewModel.error = $0 }
115            )
116        )
117        .refreshable {
118            await viewModel.loadProjects()
119        }
120        .connectivityOverlay(hasContent: !viewModel.projects.isEmpty) {
121            await viewModel.loadProjects()
122        }
123    }
124}
125
126private struct ProjectListRow: View {
127    let project: Project
128
129    var body: some View {
130        VStack(alignment: .leading, spacing: 6) {
131            HStack(alignment: .top, spacing: 10) {
132                VStack(alignment: .leading, spacing: 4) {
133                    Text(project.displayName)
134                        .font(.subheadline.weight(.medium))
135                        .foregroundStyle(.primary)
136                        .lineLimit(1)
137
138                    if let description = project.displayDescription {
139                        Text(description)
140                            .font(.caption)
141                            .foregroundStyle(.secondary)
142                            .lineLimit(2)
143                    }
144                }
145
146                Spacer(minLength: 8)
147
148                VisibilityBadge(visibility: project.visibility)
149            }
150
151            Text(project.metadataLine)
152                .font(.caption)
153                .foregroundStyle(.secondary)
154                .lineLimit(1)
155
156            if !project.displayTags.isEmpty {
157                ScrollView(.horizontal, showsIndicators: false) {
158                    HStack(spacing: 6) {
159                        ForEach(project.displayTags.prefix(4), id: \.self) { tag in
160                            Text(tag)
161                                .font(.caption2.weight(.medium))
162                                .foregroundStyle(.secondary)
163                                .padding(.horizontal, 8)
164                                .padding(.vertical, 3)
165                                .background(.quaternary, in: Capsule())
166                        }
167                    }
168                }
169                .scrollDisabled(true)
170            }
171        }
172        .contentShape(Rectangle())
173        .padding(.vertical, 4)
174    }
175}