krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.9.0: 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}