krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: 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 private(set) var isSaving = false
9 var error: String?
10 var saveError: String?
11 var searchText = ""
12
13 let service: ProjectService
14
15 init(service: ProjectService) {
16 self.service = service
17 }
18
19 func createProject(_ values: ProjectFormValues) async -> Bool {
20 guard !isSaving else { return false }
21 isSaving = true
22 saveError = nil
23 defer { isSaving = false }
24
25 do {
26 _ = try await service.createProject(
27 name: values.name,
28 visibility: values.visibility,
29 description: values.description.isEmpty ? nil : values.description,
30 tags: values.tags
31 )
32 await loadProjects(forceRefresh: true)
33 return true
34 } catch {
35 saveError = error.userFacingMessage
36 return false
37 }
38 }
39
40 var filteredProjects: [Project] {
41 let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
42 guard !query.isEmpty else { return projects }
43
44 return projects.filter {
45 $0.name.lowercased().contains(query) ||
46 ($0.description?.lowercased().contains(query) ?? false) ||
47 $0.tags.contains(where: { $0.lowercased().contains(query) })
48 }
49 }
50
51 func loadProjects(forceRefresh: Bool = false) async {
52 guard !isLoading else { return }
53 isLoading = true
54 error = nil
55 defer { isLoading = false }
56
57 do {
58 projects = try await service.fetchProjects(forceRefresh: forceRefresh)
59 } catch {
60 if projects.isEmpty {
61 self.error = error.userFacingMessage
62 } else {
63 self.error = "Couldn’t refresh projects. \(error.userFacingMessage)"
64 }
65 }
66 }
67}
68
69struct ProjectsListView: View {
70 @Environment(AppState.self) private var appState
71 @State private var viewModel: ProjectsListViewModel?
72 @State private var isPresentingCreate = false
73
74 var body: some View {
75 Group {
76 if let viewModel {
77 content(viewModel)
78 .toolbar {
79 ToolbarItem(placement: .topBarTrailing) {
80 NavigationLink {
81 DiscoverProjectsView()
82 } label: {
83 Image(systemName: "sparkle.magnifyingglass")
84 }
85 .accessibilityLabel("Discover public projects")
86 }
87 if appState.currentUser != nil {
88 ToolbarItem(placement: .topBarTrailing) {
89 Button {
90 isPresentingCreate = true
91 } label: {
92 Image(systemName: "plus")
93 }
94 .accessibilityLabel("Create project")
95 }
96 }
97 }
98 .sheet(isPresented: $isPresentingCreate) {
99 ProjectFormSheet(
100 title: "New Project",
101 confirmationTitle: "Create",
102 isSaving: viewModel.isSaving,
103 error: viewModel.saveError,
104 includeWebsite: false,
105 onSave: { await viewModel.createProject($0) }
106 )
107 }
108 } else {
109 SRHTLoadingStateView(message: "Loading projects…")
110 }
111 }
112 .navigationTitle("Projects")
113 .task {
114 if viewModel == nil {
115 let vm = ProjectsListViewModel(service: ProjectService(client: appState.client))
116 viewModel = vm
117 await vm.loadProjects()
118 }
119 }
120 }
121
122 @ViewBuilder
123 private func content(_ viewModel: ProjectsListViewModel) -> some View {
124 List {
125 ForEach(viewModel.filteredProjects) { project in
126 NavigationLink {
127 ProjectDetailView(project: project, canManage: true)
128 } label: {
129 ProjectListRow(project: project)
130 }
131 .buttonStyle(.plain)
132 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
133 }
134 .themedRow()
135 }
136 .themedList()
137 .listStyle(.plain)
138 .searchable(
139 text: Binding(
140 get: { viewModel.searchText },
141 set: { viewModel.searchText = $0 }
142 ),
143 placement: .navigationBarDrawer(displayMode: .always),
144 prompt: "Search projects"
145 )
146 .overlay {
147 if viewModel.isLoading, viewModel.projects.isEmpty {
148 SRHTLoadingStateView(message: "Loading projects…")
149 } else if let error = viewModel.error, viewModel.projects.isEmpty {
150 SRHTErrorStateView(
151 title: "Couldn't Load Projects",
152 message: error,
153 retryAction: { await viewModel.loadProjects() }
154 )
155 } else if !viewModel.projects.isEmpty, viewModel.filteredProjects.isEmpty {
156 ContentUnavailableView.search(text: viewModel.searchText)
157 } else if viewModel.projects.isEmpty {
158 ContentUnavailableView(
159 "No Projects",
160 systemImage: "square.stack.3d.up",
161 description: Text("Projects from your SourceHut account will appear here when available.")
162 )
163 }
164 }
165 .srhtErrorBanner(
166 error: Binding(
167 get: { viewModel.error },
168 set: { viewModel.error = $0 }
169 )
170 )
171 .refreshable {
172 await viewModel.loadProjects(forceRefresh: true)
173 }
174 .connectivityOverlay(hasContent: !viewModel.projects.isEmpty) {
175 await viewModel.loadProjects()
176 }
177 }
178}
179
180private struct ProjectListRow: View {
181 let project: Project
182
183 var body: some View {
184 VStack(alignment: .leading, spacing: 6) {
185 HStack(alignment: .top, spacing: 10) {
186 VStack(alignment: .leading, spacing: 4) {
187 Text(project.displayName)
188 .font(.subheadline.weight(.medium))
189 .foregroundStyle(.primary)
190 .lineLimit(1)
191
192 if let description = project.displayDescription {
193 Text(description)
194 .font(.caption)
195 .foregroundStyle(.secondary)
196 .lineLimit(2)
197 }
198 }
199
200 Spacer(minLength: 8)
201
202 VisibilityBadge(visibility: project.visibility)
203 }
204
205 Text(project.metadataLine)
206 .font(.caption)
207 .foregroundStyle(.secondary)
208 .lineLimit(1)
209
210 if !project.displayTags.isEmpty {
211 ScrollView(.horizontal, showsIndicators: false) {
212 HStack(spacing: 6) {
213 ForEach(project.displayTags.prefix(4), id: \.self) { tag in
214 Text(tag)
215 .font(.caption2.weight(.medium))
216 .foregroundStyle(.secondary)
217 .padding(.horizontal, 8)
218 .padding(.vertical, 3)
219 .background(.quaternary, in: Capsule())
220 }
221 }
222 }
223 .scrollDisabled(true)
224 }
225 }
226 .contentShape(Rectangle())
227 .padding(.vertical, 4)
228 }
229}