krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.11: Hutch/Views/Projects/ProjectDetailView.swift · raw
1import SwiftUI
2
3struct ProjectDetailView: View {
4 let project: Project
5
6 @Environment(AppState.self) private var appState
7 @Environment(\.dismiss) private var dismiss
8 @Environment(\.openURL) private var openURL
9 @State private var detailProject: Project?
10 @State private var isLoading = false
11 @State private var error: String?
12 @State private var pinChangeCount = 0
13
14 private var displayedProject: Project {
15 detailProject ?? project
16 }
17
18 private var currentUserKey: String? {
19 appState.currentUser?.canonicalName
20 }
21
22 private var isPinnedToHome: Bool {
23 _ = pinChangeCount
24 guard let currentUserKey else { return false }
25 return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
26 }
27
28 var body: some View {
29 List {
30 headerSection
31 repositoriesSection
32 trackersSection
33 mailingListsSection
34 linksSection
35 emptyResourcesSection
36 }
37 .themedList()
38 .navigationTitle(displayedProject.displayName)
39 .navigationBarTitleDisplayMode(.inline)
40 .overlay {
41 if isLoading, detailProject == nil, !project.isFullyLoaded {
42 SRHTLoadingStateView(message: "Loading project…")
43 } else if let error, detailProject == nil, !project.isFullyLoaded {
44 SRHTErrorStateView(
45 title: "Couldn't Load Project",
46 message: error,
47 retryAction: { await loadProjectIfNeeded(forceRefresh: true) }
48 )
49 }
50 }
51 .toolbar {
52 if currentUserKey != nil {
53 ToolbarItem(placement: .topBarTrailing) {
54 Button {
55 togglePinnedState()
56 } label: {
57 Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
58 }
59 .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
60 }
61 }
62 }
63 .task {
64 await loadProjectIfNeeded()
65 }
66 .refreshable {
67 await loadProjectIfNeeded(forceRefresh: true)
68 }
69 .srhtErrorBanner(error: $error)
70 }
71
72 @ViewBuilder
73 private var headerSection: some View {
74 Section {
75 VStack(alignment: .leading, spacing: 10) {
76 Text(displayedProject.displayName)
77 .font(.headline)
78
79 if let description = displayedProject.displayDescription {
80 Text(description)
81 .font(.subheadline)
82 .foregroundStyle(.secondary)
83 }
84
85 if !displayedProject.displayTags.isEmpty {
86 ScrollView(.horizontal, showsIndicators: false) {
87 HStack(spacing: 8) {
88 ForEach(displayedProject.displayTags, id: \.self) { tag in
89 Text(tag)
90 .font(.caption.weight(.medium))
91 .padding(.horizontal, 10)
92 .padding(.vertical, 4)
93 .background(.quaternary, in: Capsule())
94 }
95 }
96 }
97 }
98
99 LabeledContent("Project", value: displayedProject.visibility.displayName)
100 LabeledContent("Updated", value: displayedProject.updated.relativeDescription)
101 if let summary = displayedProject.resourceSummary {
102 LabeledContent("Linked", value: summary)
103 }
104 }
105 .padding(.vertical, 4)
106 .themedRow()
107 }
108 }
109
110 @ViewBuilder
111 private var linksSection: some View {
112 let links = projectLinks(for: displayedProject)
113 if !links.isEmpty {
114 Section("Links") {
115 ForEach(links) { link in
116 Button {
117 openURL(link.url)
118 } label: {
119 HStack(spacing: 12) {
120 Label(link.title, systemImage: link.systemImage)
121 .font(.subheadline)
122 .foregroundStyle(.primary)
123 Spacer()
124 Image(systemName: "arrow.up.right")
125 .font(.caption.weight(.semibold))
126 .foregroundStyle(.tertiary)
127 }
128 }
129 .buttonStyle(.plain)
130 }
131 .themedRow()
132 }
133 }
134 }
135
136 @ViewBuilder
137 private var repositoriesSection: some View {
138 if !displayedProject.sources.isEmpty {
139 Section("Repositories") {
140 ForEach(displayedProject.sources) { source in
141 Button {
142 Task {
143 do {
144 try await appState.openProjectSource(source)
145 dismiss()
146 } catch {
147 self.error = "Couldn’t open repository. \(error.userFacingMessage)"
148 }
149 }
150 } label: {
151 ProjectResourceRow(
152 title: source.displayName,
153 subtitle: source.ownerDisplayName,
154 detail: source.displayDescription,
155 systemImage: "book.closed"
156 )
157 }
158 .buttonStyle(.plain)
159 }
160 .themedRow()
161 }
162 }
163 }
164
165 @ViewBuilder
166 private var trackersSection: some View {
167 if !displayedProject.trackers.isEmpty {
168 Section("Trackers") {
169 ForEach(displayedProject.trackers) { tracker in
170 Button {
171 Task {
172 do {
173 try await appState.openProjectTracker(tracker)
174 dismiss()
175 } catch {
176 self.error = "Couldn’t open tracker. \(error.userFacingMessage)"
177 }
178 }
179 } label: {
180 ProjectResourceRow(
181 title: tracker.displayName,
182 subtitle: tracker.ownerDisplayName,
183 detail: tracker.displayDescription,
184 systemImage: "checklist"
185 )
186 }
187 .buttonStyle(.plain)
188 }
189 .themedRow()
190 }
191 }
192 }
193
194 @ViewBuilder
195 private var mailingListsSection: some View {
196 if !displayedProject.mailingLists.isEmpty {
197 Section("Mailing Lists") {
198 ForEach(displayedProject.mailingLists) { mailingList in
199 Button {
200 appState.openMailingList(mailingList.inboxReference)
201 dismiss()
202 } label: {
203 ProjectResourceRow(
204 title: mailingList.displayName,
205 subtitle: mailingList.ownerDisplayName,
206 detail: mailingList.displayDescription,
207 systemImage: "list.bullet"
208 )
209 }
210 .buttonStyle(.plain)
211 }
212 .themedRow()
213 }
214 }
215 }
216
217 @ViewBuilder
218 private var emptyResourcesSection: some View {
219 if !displayedProject.hasLinkedResources, displayedProject.websiteURL == nil {
220 Section {
221 ContentUnavailableView(
222 "No Linked Resources",
223 systemImage: "square.stack.3d.up.slash",
224 description: Text("This project doesn’t currently expose repositories, trackers, mailing lists, or external links.")
225 )
226 .themedRow()
227 }
228 }
229 }
230
231 private func loadProjectIfNeeded(forceRefresh: Bool = false) async {
232 guard forceRefresh || !project.isFullyLoaded else {
233 detailProject = project
234 return
235 }
236 guard !isLoading else { return }
237
238 isLoading = true
239 error = nil
240 defer { isLoading = false }
241
242 do {
243 let service = ProjectService(client: appState.client)
244 detailProject = try await service.fetchProjectDetail(rid: project.id)
245 } catch {
246 self.error = "Couldn’t load project. \(error.userFacingMessage)"
247 }
248 }
249
250 private func togglePinnedState() {
251 guard let currentUserKey else { return }
252 ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
253 pinChangeCount += 1
254 }
255
256 private func projectLinks(for project: Project) -> [ProjectLink] {
257 var links: [ProjectLink] = []
258
259 if let url = project.websiteURL {
260 links.append(ProjectLink(id: "website", title: project.website ?? url.absoluteString, systemImage: "globe", url: url))
261 }
262
263 if let source = project.sources.first,
264 let url = source.webURL {
265 links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.displayName)", systemImage: "book.closed", url: url))
266 }
267
268 if let tracker = project.trackers.first,
269 let url = tracker.webURL {
270 links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.displayName)", systemImage: "checklist", url: url))
271 }
272
273 if let mailingList = project.mailingLists.first,
274 let url = SRHTWebURL.mailingList(ownerUsername: mailingList.ownerUsername, listName: mailingList.name) {
275 links.append(ProjectLink(id: "primary-list", title: "\(mailingList.ownerUsername)/\(mailingList.displayName)", systemImage: "list.bullet", url: url))
276 }
277
278 return links
279 }
280}
281
282private struct ProjectLink: Identifiable {
283 let id: String
284 let title: String
285 let systemImage: String
286 let url: URL
287}
288
289private struct ProjectResourceRow: View {
290 let title: String
291 let subtitle: String
292 let detail: String?
293 let systemImage: String
294
295 var body: some View {
296 HStack(alignment: .top, spacing: 12) {
297 Image(systemName: systemImage)
298 .frame(width: 18, alignment: .leading)
299 .foregroundStyle(.secondary)
300
301 VStack(alignment: .leading, spacing: 4) {
302 Text(title)
303 .font(.subheadline.weight(.medium))
304 .foregroundStyle(.primary)
305
306 Text(subtitle)
307 .font(.caption)
308 .foregroundStyle(.secondary)
309 .lineLimit(1)
310
311 if let detail, !detail.isEmpty {
312 Text(detail)
313 .font(.caption)
314 .foregroundStyle(.tertiary)
315 .lineLimit(2)
316 }
317 }
318
319 Spacer(minLength: 8)
320
321 Image(systemName: "chevron.right")
322 .font(.caption.weight(.semibold))
323 .foregroundStyle(.tertiary)
324 }
325 .contentShape(Rectangle())
326 .padding(.vertical, 4)
327 }
328}