krz/hutch

an ios client for sourcehut

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

v3.9.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    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}