krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.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 @Bindable var vm = viewModel
71
72 List {
73 ForEach(viewModel.filteredProjects) { project in
74 NavigationLink {
75 ProjectDetailView(project: project)
76 } label: {
77 ProjectListRow(project: project)
78 }
79 .buttonStyle(.plain)
80 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
81 }
82 }
83 .themedList()
84 .listStyle(.plain)
85 .searchable(
86 text: $vm.searchText,
87 placement: .navigationBarDrawer(displayMode: .always),
88 prompt: "Search projects"
89 )
90 .overlay {
91 if viewModel.isLoading, viewModel.projects.isEmpty {
92 SRHTLoadingStateView(message: "Loading projects…")
93 } else if let error = viewModel.error, viewModel.projects.isEmpty {
94 SRHTErrorStateView(
95 title: "Couldn't Load Projects",
96 message: error,
97 retryAction: { await viewModel.loadProjects() }
98 )
99 } else if !viewModel.projects.isEmpty, viewModel.filteredProjects.isEmpty {
100 ContentUnavailableView.search(text: viewModel.searchText)
101 } else if viewModel.projects.isEmpty {
102 ContentUnavailableView(
103 "No Projects",
104 systemImage: "square.stack.3d.up",
105 description: Text("Projects from your SourceHut account will appear here when available.")
106 )
107 }
108 }
109 .srhtErrorBanner(error: $vm.error)
110 .refreshable {
111 await viewModel.loadProjects()
112 }
113 .connectivityOverlay(hasContent: !viewModel.projects.isEmpty) {
114 await viewModel.loadProjects()
115 }
116 }
117}
118
119private struct ProjectListRow: View {
120 let project: Project
121
122 var body: some View {
123 VStack(alignment: .leading, spacing: 6) {
124 HStack(alignment: .top, spacing: 10) {
125 VStack(alignment: .leading, spacing: 4) {
126 Text(project.displayName)
127 .font(.subheadline.weight(.medium))
128 .foregroundStyle(.primary)
129 .lineLimit(1)
130
131 if let description = project.displayDescription {
132 Text(description)
133 .font(.caption)
134 .foregroundStyle(.secondary)
135 .lineLimit(2)
136 }
137 }
138
139 Spacer(minLength: 8)
140
141 VisibilityBadge(visibility: project.visibility)
142 }
143
144 Text(project.metadataLine)
145 .font(.caption)
146 .foregroundStyle(.secondary)
147 .lineLimit(1)
148
149 if !project.displayTags.isEmpty {
150 ScrollView(.horizontal, showsIndicators: false) {
151 HStack(spacing: 6) {
152 ForEach(project.displayTags.prefix(4), id: \.self) { tag in
153 Text(tag)
154 .font(.caption2.weight(.medium))
155 .foregroundStyle(.secondary)
156 .padding(.horizontal, 8)
157 .padding(.vertical, 3)
158 .background(.quaternary, in: Capsule())
159 }
160 }
161 }
162 .scrollDisabled(true)
163 }
164 }
165 .contentShape(Rectangle())
166 .padding(.vertical, 4)
167 }
168}