krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: 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 .accessibilityHint("Opens in your browser")
221 }
222 .themedRow()
223 }
224 }
225 }
226
227 @ViewBuilder
228 private var repositoriesSection: some View {
229 if !displayedProject.sources.isEmpty {
230 Section("Repositories") {
231 ForEach(displayedProject.sources) { source in
232 Button {
233 Task {
234 do {
235 try await appState.openProjectSource(source)
236 dismiss()
237 } catch {
238 self.error = "Couldn’t open repository. \(error.userFacingMessage)"
239 }
240 }
241 } label: {
242 ProjectResourceRow(
243 title: source.displayName,
244 subtitle: source.ownerDisplayName,
245 detail: source.displayDescription,
246 systemImage: "book.closed"
247 )
248 }
249 .buttonStyle(.plain)
250 }
251 .themedRow()
252 }
253 }
254 }
255
256 @ViewBuilder
257 private var trackersSection: some View {
258 if !displayedProject.trackers.isEmpty {
259 Section("Trackers") {
260 ForEach(displayedProject.trackers) { tracker in
261 Button {
262 Task {
263 do {
264 try await appState.openProjectTracker(tracker)
265 dismiss()
266 } catch {
267 self.error = "Couldn’t open tracker. \(error.userFacingMessage)"
268 }
269 }
270 } label: {
271 ProjectResourceRow(
272 title: tracker.displayName,
273 subtitle: tracker.ownerDisplayName,
274 detail: tracker.displayDescription,
275 systemImage: "checklist"
276 )
277 }
278 .buttonStyle(.plain)
279 }
280 .themedRow()
281 }
282 }
283 }
284
285 @ViewBuilder
286 private var mailingListsSection: some View {
287 if !displayedProject.mailingLists.isEmpty {
288 Section("Mailing Lists") {
289 ForEach(displayedProject.mailingLists) { mailingList in
290 // Pushed here rather than routed through AppState. Projects
291 // already lives in the More tab, so asking for a tab
292 // navigation made the path rebuild itself while dismiss()
293 // popped this view out from under it, leaving a blank screen.
294 // Sources and trackers still route, because they genuinely
295 // land in other tabs.
296 NavigationLink {
297 MailingListDetailView(mailingList: mailingList.inboxReference)
298 } label: {
299 ProjectResourceRow(
300 title: mailingList.displayName,
301 subtitle: mailingList.ownerDisplayName,
302 detail: mailingList.displayDescription,
303 systemImage: "list.bullet"
304 )
305 }
306 .buttonStyle(.plain)
307 }
308 .themedRow()
309 }
310 }
311 }
312
313 @ViewBuilder
314 private var emptyResourcesSection: some View {
315 if !displayedProject.hasLinkedResources, displayedProject.websiteURL == nil {
316 Section {
317 ContentUnavailableView(
318 "No Linked Resources",
319 systemImage: "square.stack.3d.up.slash",
320 description: Text("This project doesn’t currently expose repositories, trackers, mailing lists, or external links.")
321 )
322 .themedRow()
323 }
324 }
325 }
326
327 private func loadProjectIfNeeded(forceRefresh: Bool = false) async {
328 guard forceRefresh || !project.isFullyLoaded else {
329 detailProject = project
330 return
331 }
332 guard !isLoading else { return }
333
334 isLoading = true
335 error = nil
336 defer { isLoading = false }
337
338 do {
339 let service = ProjectService(client: appState.client)
340 detailProject = try await service.fetchProjectDetail(rid: project.id)
341 } catch {
342 self.error = "Couldn’t load project. \(error.userFacingMessage)"
343 }
344 }
345
346 private func togglePinnedState() {
347 guard let currentUserKey else { return }
348 ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
349 pinChangeCount += 1
350 }
351
352 private func projectLinks(for project: Project) -> [ProjectLink] {
353 var links: [ProjectLink] = []
354
355 if let url = project.websiteURL {
356 links.append(ProjectLink(id: "website", title: project.website ?? url.absoluteString, systemImage: "globe", url: url))
357 }
358
359 if let source = project.sources.first,
360 let url = source.webURL {
361 links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.displayName)", systemImage: "book.closed", url: url))
362 }
363
364 if let tracker = project.trackers.first,
365 let url = tracker.webURL {
366 links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.displayName)", systemImage: "checklist", url: url))
367 }
368
369 if let mailingList = project.mailingLists.first,
370 let url = SRHTWebURL.mailingList(ownerUsername: mailingList.ownerUsername, listName: mailingList.name) {
371 links.append(ProjectLink(id: "primary-list", title: "\(mailingList.ownerUsername)/\(mailingList.displayName)", systemImage: "list.bullet", url: url))
372 }
373
374 return links
375 }
376}
377
378private struct ProjectLink: Identifiable {
379 let id: String
380 let title: String
381 let systemImage: String
382 let url: URL
383}
384
385private struct ProjectResourceRow: View {
386 let title: String
387 let subtitle: String
388 let detail: String?
389 let systemImage: String
390
391 var body: some View {
392 HStack(alignment: .top, spacing: 12) {
393 Image(systemName: systemImage)
394 .frame(width: 18, alignment: .leading)
395 .foregroundStyle(.secondary)
396
397 VStack(alignment: .leading, spacing: 4) {
398 Text(title)
399 .font(.subheadline.weight(.medium))
400 .foregroundStyle(.primary)
401
402 Text(subtitle)
403 .font(.caption)
404 .foregroundStyle(.secondary)
405 .lineLimit(1)
406
407 if let detail, !detail.isEmpty {
408 Text(detail)
409 .font(.caption)
410 .foregroundStyle(.tertiary)
411 .lineLimit(2)
412 }
413 }
414
415 Spacer(minLength: 8)
416
417 Image(systemName: "chevron.right")
418 .font(.caption.weight(.semibold))
419 .foregroundStyle(.tertiary)
420 }
421 .contentShape(Rectangle())
422 .padding(.vertical, 4)
423 }
424}