krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.11.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}