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}