krz/hutch

an ios client for sourcehut

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

main: Hutch/Views/Projects/DiscoverProjectsView.swift · raw

  1import SwiftUI
  2
  3@Observable
  4@MainActor
  5final class DiscoverProjectsViewModel {
  6    private(set) var projects: [DiscoveredProject] = []
  7    private(set) var isLoading = false
  8    private(set) var isLoadingMore = false
  9    var error: String?
 10
 11    private var cursor: String?
 12    private var canLoadMore = true
 13    private let service: ProjectService
 14
 15    init(service: ProjectService) {
 16        self.service = service
 17    }
 18
 19    func loadInitial() async {
 20        guard projects.isEmpty, !isLoading else { return }
 21        isLoading = true
 22        error = nil
 23        defer { isLoading = false }
 24
 25        do {
 26            let page = try await service.fetchPublicProjects(cursor: nil)
 27            projects = page.projects
 28            cursor = page.cursor
 29            canLoadMore = page.cursor != nil
 30        } catch {
 31            self.error = error.userFacingMessage
 32        }
 33    }
 34
 35    func reload() async {
 36        cursor = nil
 37        canLoadMore = true
 38        projects = []
 39        await loadInitial()
 40    }
 41
 42    func loadMoreIfNeeded(current item: DiscoveredProject) async {
 43        guard canLoadMore, !isLoadingMore, !isLoading else { return }
 44        guard let index = projects.firstIndex(of: item), index >= projects.count - 3 else { return }
 45
 46        isLoadingMore = true
 47        defer { isLoadingMore = false }
 48
 49        do {
 50            let page = try await service.fetchPublicProjects(cursor: cursor)
 51            let existing = Set(projects.map(\.id))
 52            projects.append(contentsOf: page.projects.filter { !existing.contains($0.id) })
 53            cursor = page.cursor
 54            canLoadMore = page.cursor != nil
 55        } catch {
 56            self.error = error.userFacingMessage
 57        }
 58    }
 59}
 60
 61struct DiscoverProjectsView: View {
 62    @Environment(AppState.self) private var appState
 63    @State private var viewModel: DiscoverProjectsViewModel?
 64
 65    var body: some View {
 66        Group {
 67            if let viewModel {
 68                content(viewModel)
 69            } else {
 70                SRHTLoadingStateView(message: "Loading projects…")
 71            }
 72        }
 73        .navigationTitle("Discover")
 74        .navigationBarTitleDisplayMode(.inline)
 75        .task {
 76            if viewModel == nil {
 77                let vm = DiscoverProjectsViewModel(service: ProjectService(client: appState.client))
 78                viewModel = vm
 79                await vm.loadInitial()
 80            }
 81        }
 82    }
 83
 84    @ViewBuilder
 85    private func content(_ viewModel: DiscoverProjectsViewModel) -> some View {
 86        List {
 87            ForEach(viewModel.projects) { discovered in
 88                NavigationLink {
 89                    ProjectDetailView(
 90                        project: discovered.project,
 91                        canManage: discovered.ownerCanonicalName == appState.currentUser?.canonicalName
 92                    )
 93                } label: {
 94                    DiscoveredProjectRow(discovered: discovered)
 95                }
 96                .buttonStyle(.plain)
 97                .task {
 98                    await viewModel.loadMoreIfNeeded(current: discovered)
 99                }
100            }
101            .themedRow()
102
103            if viewModel.isLoadingMore {
104                HStack {
105                    Spacer()
106                    ProgressView()
107                    Spacer()
108                }
109                .listRowSeparator(.hidden)
110                .themedRow()
111            }
112        }
113        .themedList()
114        .listStyle(.plain)
115        .overlay {
116            if viewModel.isLoading, viewModel.projects.isEmpty {
117                SRHTLoadingStateView(message: "Loading projects…")
118            } else if let error = viewModel.error, viewModel.projects.isEmpty {
119                SRHTErrorStateView(
120                    title: "Couldn't Load Projects",
121                    message: error,
122                    retryAction: { await viewModel.reload() }
123                )
124            } else if viewModel.projects.isEmpty {
125                ContentUnavailableView(
126                    "No Public Projects",
127                    systemImage: "sparkle.magnifyingglass",
128                    description: Text("Public projects on SourceHut will appear here.")
129                )
130            }
131        }
132        .srhtErrorBanner(
133            error: Binding(
134                get: { viewModel.error },
135                set: { viewModel.error = $0 }
136            )
137        )
138        .refreshable {
139            await viewModel.reload()
140        }
141    }
142}
143
144private struct DiscoveredProjectRow: View {
145    let discovered: DiscoveredProject
146
147    private var project: Project { discovered.project }
148
149    var body: some View {
150        VStack(alignment: .leading, spacing: 4) {
151            Text(project.displayName)
152                .font(.headline)
153                .lineLimit(1)
154            Text(discovered.ownerCanonicalName)
155                .font(.caption)
156                .foregroundStyle(.secondary)
157            if let description = project.displayDescription {
158                Text(description)
159                    .font(.subheadline)
160                    .foregroundStyle(.secondary)
161                    .lineLimit(2)
162            }
163        }
164        .padding(.vertical, 2)
165    }
166}