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