krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.9.0: Hutch/Views/Projects/ProjectDetailView.swift · raw
1import SwiftUI
2
3struct ProjectDetailView: View {
4 let project: Project
5 var canManage: Bool = false
6
7 @Environment(AppState.self) private var appState
8 @Environment(\.dismiss) private var dismiss
9 @Environment(\.openURL) private var openURL
10 @State private var detailProject: Project?
11 @State private var isLoading = false
12 @State private var error: String?
13 @State private var pinChangeCount = 0
14 @State private var isPresentingEdit = false
15 @State private var isPresentingManage = false
16 @State private var isSavingEdit = false
17 @State private var editError: String?
18
19 private var projectService: ProjectService {
20 ProjectService(client: appState.client)
21 }
22
23 private var displayedProject: Project {
24 detailProject ?? project
25 }
26
27 private var currentUserKey: String? {
28 appState.currentUser?.canonicalName
29 }
30
31 private var isPinnedToHome: Bool {
32 _ = pinChangeCount
33 guard let currentUserKey else { return false }
34 return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
35 }
36
37 var body: some View {
38 List {
39 headerSection
40 repositoriesSection
41 trackersSection
42 mailingListsSection
43 linksSection
44 emptyResourcesSection
45 }
46 .themedList()
47 .navigationTitle(displayedProject.displayName)
48 .navigationBarTitleDisplayMode(.inline)
49 .overlay {
50 if isLoading, detailProject == nil, !project.isFullyLoaded {
51 SRHTLoadingStateView(message: "Loading project…")
52 } else if let error, detailProject == nil, !project.isFullyLoaded {
53 SRHTErrorStateView(
54 title: "Couldn't Load Project",
55 message: error,
56 retryAction: { await loadProjectIfNeeded(forceRefresh: true) }
57 )
58 }
59 }
60 .toolbar {
61 if currentUserKey != nil {
62 ToolbarItem(placement: .topBarTrailing) {
63 Button {
64 togglePinnedState()
65 } label: {
66 Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
67 }
68 .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
69 }
70 }
71 if canManage {
72 ToolbarItem(placement: .topBarTrailing) {
73 Menu {
74 Button {
75 editError = nil
76 isPresentingEdit = true
77 } label: {
78 Label("Edit Project", systemImage: "pencil")
79 }
80 Button {
81 isPresentingManage = true
82 } label: {
83 Label("Manage Resources", systemImage: "link")
84 }
85 } label: {
86 Image(systemName: "ellipsis.circle")
87 }
88 .accessibilityLabel("Manage project")
89 }
90 }
91 }
92 .sheet(isPresented: $isPresentingEdit) {
93 ProjectFormSheet(
94 title: "Edit Project",
95 confirmationTitle: "Save",
96 isSaving: isSavingEdit,
97 error: editError,
98 includeWebsite: true,
99 initialName: displayedProject.name,
100 initialDescription: displayedProject.description ?? "",
101 initialWebsite: displayedProject.website ?? "",
102 initialTags: displayedProject.tags,
103 initialVisibility: displayedProject.visibility,
104 onSave: { await saveEdits($0) }
105 )
106 }
107 .sheet(isPresented: $isPresentingManage) {
108 ManageProjectResourcesView(project: displayedProject) {
109 await loadProjectIfNeeded(forceRefresh: true)
110 }
111 }
112 .task {
113 await loadProjectIfNeeded()
114 }
115 .refreshable {
116 await loadProjectIfNeeded(forceRefresh: true)
117 }
118 .srhtErrorBanner(error: $error)
119 }
120
121 private func saveEdits(_ values: ProjectFormValues) async -> Bool {
122 guard !isSavingEdit else { return false }
123 isSavingEdit = true
124 editError = nil
125 defer { isSavingEdit = false }
126
127 do {
128 let updated = try await projectService.updateProject(
129 rid: displayedProject.id,
130 name: values.name,
131 description: values.description,
132 website: values.website,
133 visibility: values.visibility,
134 tags: values.tags
135 )
136 // Preserve already-loaded linked resources; the mutation returns metadata only.
137 detailProject = Project(
138 metadata: .init(
139 id: updated.id,
140 name: updated.name,
141 description: updated.description,
142 website: updated.website,
143 visibility: updated.visibility,
144 tags: updated.tags,
145 updated: updated.updated
146 ),
147 resources: .init(
148 mailingLists: displayedProject.mailingLists,
149 sources: displayedProject.sources,
150 trackers: displayedProject.trackers,
151 isFullyLoaded: displayedProject.isFullyLoaded
152 )
153 )
154 await loadProjectIfNeeded(forceRefresh: true)
155 return true
156 } catch {
157 editError = error.userFacingMessage
158 return false
159 }
160 }
161
162 @ViewBuilder
163 private var headerSection: some View {
164 Section {
165 VStack(alignment: .leading, spacing: 10) {
166 Text(displayedProject.displayName)
167 .font(.headline)
168
169 if let description = displayedProject.displayDescription {
170 Text(description)
171 .font(.subheadline)
172 .foregroundStyle(.secondary)
173 }
174
175 if !displayedProject.displayTags.isEmpty {
176 ScrollView(.horizontal, showsIndicators: false) {
177 HStack(spacing: 8) {
178 ForEach(displayedProject.displayTags, id: \.self) { tag in
179 Text(tag)
180 .font(.caption.weight(.medium))
181 .padding(.horizontal, 10)
182 .padding(.vertical, 4)
183 .background(.quaternary, in: Capsule())
184 }
185 }
186 }
187 }
188
189 LabeledContent("Project", value: displayedProject.visibility.displayName)
190 LabeledContent("Updated", value: displayedProject.updated.relativeDescription)
191 if let summary = displayedProject.resourceSummary {
192 LabeledContent("Linked", value: summary)
193 }
194 }
195 .padding(.vertical, 4)
196 .themedRow()
197 }
198 }
199
200 @ViewBuilder
201 private var linksSection: some View {
202 let links = projectLinks(for: displayedProject)
203 if !links.isEmpty {
204 Section("Links") {
205 ForEach(links) { link in
206 Button {
207 openURL(link.url)
208 } label: {
209 HStack(spacing: 12) {
210 Label(link.title, systemImage: link.systemImage)
211 .font(.subheadline)
212 .foregroundStyle(.primary)
213 Spacer()
214 Image(systemName: "arrow.up.right")
215 .font(.caption.weight(.semibold))
216 .foregroundStyle(.tertiary)
217 }
218 }
219 .buttonStyle(.plain)
220 }
221 .themedRow()
222 }
223 }
224 }
225
226 @ViewBuilder
227 private var repositoriesSection: some View {
228 if !displayedProject.sources.isEmpty {
229 Section("Repositories") {
230 ForEach(displayedProject.sources) { source in
231 Button {
232 Task {
233 do {
234 try await appState.openProjectSource(source)
235 dismiss()
236 } catch {
237 self.error = "Couldn’t open repository. \(error.userFacingMessage)"
238 }
239 }
240 } label: {
241 ProjectResourceRow(
242 title: source.displayName,
243 subtitle: source.ownerDisplayName,
244 detail: source.displayDescription,
245 systemImage: "book.closed"
246 )
247 }
248 .buttonStyle(.plain)
249 }
250 .themedRow()
251 }
252 }
253 }
254
255 @ViewBuilder
256 private var trackersSection: some View {
257 if !displayedProject.trackers.isEmpty {
258 Section("Trackers") {
259 ForEach(displayedProject.trackers) { tracker in
260 Button {
261 Task {
262 do {
263 try await appState.openProjectTracker(tracker)
264 dismiss()
265 } catch {
266 self.error = "Couldn’t open tracker. \(error.userFacingMessage)"
267 }
268 }
269 } label: {
270 ProjectResourceRow(
271 title: tracker.displayName,
272 subtitle: tracker.ownerDisplayName,
273 detail: tracker.displayDescription,
274 systemImage: "checklist"
275 )
276 }
277 .buttonStyle(.plain)
278 }
279 .themedRow()
280 }
281 }
282 }
283
284 @ViewBuilder
285 private var mailingListsSection: some View {
286 if !displayedProject.mailingLists.isEmpty {
287 Section("Mailing Lists") {
288 ForEach(displayedProject.mailingLists) { mailingList in
289 // Pushed here rather than routed through AppState. Projects
290 // already lives in the More tab, so asking for a tab
291 // navigation made the path rebuild itself while dismiss()
292 // popped this view out from under it, leaving a blank screen.
293 // Sources and trackers still route, because they genuinely
294 // land in other tabs.
295 NavigationLink {
296 MailingListDetailView(mailingList: mailingList.inboxReference)
297 } label: {
298 ProjectResourceRow(
299 title: mailingList.displayName,
300 subtitle: mailingList.ownerDisplayName,
301 detail: mailingList.displayDescription,
302 systemImage: "list.bullet"
303 )
304 }
305 .buttonStyle(.plain)
306 }
307 .themedRow()
308 }
309 }
310 }
311
312 @ViewBuilder
313 private var emptyResourcesSection: some View {
314 if !displayedProject.hasLinkedResources, displayedProject.websiteURL == nil {
315 Section {
316 ContentUnavailableView(
317 "No Linked Resources",
318 systemImage: "square.stack.3d.up.slash",
319 description: Text("This project doesn’t currently expose repositories, trackers, mailing lists, or external links.")
320 )
321 .themedRow()
322 }
323 }
324 }
325
326 private func loadProjectIfNeeded(forceRefresh: Bool = false) async {
327 guard forceRefresh || !project.isFullyLoaded else {
328 detailProject = project
329 return
330 }
331 guard !isLoading else { return }
332
333 isLoading = true
334 error = nil
335 defer { isLoading = false }
336
337 do {
338 let service = ProjectService(client: appState.client)
339 detailProject = try await service.fetchProjectDetail(rid: project.id)
340 } catch {
341 self.error = "Couldn’t load project. \(error.userFacingMessage)"
342 }
343 }
344
345 private func togglePinnedState() {
346 guard let currentUserKey else { return }
347 ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
348 pinChangeCount += 1
349 }
350
351 private func projectLinks(for project: Project) -> [ProjectLink] {
352 var links: [ProjectLink] = []
353
354 if let url = project.websiteURL {
355 links.append(ProjectLink(id: "website", title: project.website ?? url.absoluteString, systemImage: "globe", url: url))
356 }
357
358 if let source = project.sources.first,
359 let url = source.webURL {
360 links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.displayName)", systemImage: "book.closed", url: url))
361 }
362
363 if let tracker = project.trackers.first,
364 let url = tracker.webURL {
365 links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.displayName)", systemImage: "checklist", url: url))
366 }
367
368 if let mailingList = project.mailingLists.first,
369 let url = SRHTWebURL.mailingList(ownerUsername: mailingList.ownerUsername, listName: mailingList.name) {
370 links.append(ProjectLink(id: "primary-list", title: "\(mailingList.ownerUsername)/\(mailingList.displayName)", systemImage: "list.bullet", url: url))
371 }
372
373 return links
374 }
375}
376
377private struct ProjectLink: Identifiable {
378 let id: String
379 let title: String
380 let systemImage: String
381 let url: URL
382}
383
384private struct ProjectResourceRow: View {
385 let title: String
386 let subtitle: String
387 let detail: String?
388 let systemImage: String
389
390 var body: some View {
391 HStack(alignment: .top, spacing: 12) {
392 Image(systemName: systemImage)
393 .frame(width: 18, alignment: .leading)
394 .foregroundStyle(.secondary)
395
396 VStack(alignment: .leading, spacing: 4) {
397 Text(title)
398 .font(.subheadline.weight(.medium))
399 .foregroundStyle(.primary)
400
401 Text(subtitle)
402 .font(.caption)
403 .foregroundStyle(.secondary)
404 .lineLimit(1)
405
406 if let detail, !detail.isEmpty {
407 Text(detail)
408 .font(.caption)
409 .foregroundStyle(.tertiary)
410 .lineLimit(2)
411 }
412 }
413
414 Spacer(minLength: 8)
415
416 Image(systemName: "chevron.right")
417 .font(.caption.weight(.semibold))
418 .foregroundStyle(.tertiary)
419 }
420 .contentShape(Rectangle())
421 .padding(.vertical, 4)
422 }
423}