krz/hutch

an ios client for sourcehut

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

v3.8.0: 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                    // Pushed here rather than routed through AppState. Projects
200                    // already lives in the More tab, so asking for a tab
201                    // navigation made the path rebuild itself while dismiss()
202                    // popped this view out from under it, leaving a blank screen.
203                    // Sources and trackers still route, because they genuinely
204                    // land in other tabs.
205                    NavigationLink {
206                        MailingListDetailView(mailingList: mailingList.inboxReference)
207                    } label: {
208                        ProjectResourceRow(
209                            title: mailingList.displayName,
210                            subtitle: mailingList.ownerDisplayName,
211                            detail: mailingList.displayDescription,
212                            systemImage: "list.bullet"
213                        )
214                    }
215                    .buttonStyle(.plain)
216                }
217                .themedRow()
218            }
219        }
220    }
221
222    @ViewBuilder
223    private var emptyResourcesSection: some View {
224        if !displayedProject.hasLinkedResources, displayedProject.websiteURL == nil {
225            Section {
226                ContentUnavailableView(
227                    "No Linked Resources",
228                    systemImage: "square.stack.3d.up.slash",
229                    description: Text("This project doesn’t currently expose repositories, trackers, mailing lists, or external links.")
230                )
231                .themedRow()
232            }
233        }
234    }
235
236    private func loadProjectIfNeeded(forceRefresh: Bool = false) async {
237        guard forceRefresh || !project.isFullyLoaded else {
238            detailProject = project
239            return
240        }
241        guard !isLoading else { return }
242
243        isLoading = true
244        error = nil
245        defer { isLoading = false }
246
247        do {
248            let service = ProjectService(client: appState.client)
249            detailProject = try await service.fetchProjectDetail(rid: project.id)
250        } catch {
251            self.error = "Couldn’t load project. \(error.userFacingMessage)"
252        }
253    }
254
255    private func togglePinnedState() {
256        guard let currentUserKey else { return }
257        ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
258        pinChangeCount += 1
259    }
260
261    private func projectLinks(for project: Project) -> [ProjectLink] {
262        var links: [ProjectLink] = []
263
264        if let url = project.websiteURL {
265            links.append(ProjectLink(id: "website", title: project.website ?? url.absoluteString, systemImage: "globe", url: url))
266        }
267
268        if let source = project.sources.first,
269           let url = source.webURL {
270            links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.displayName)", systemImage: "book.closed", url: url))
271        }
272
273        if let tracker = project.trackers.first,
274           let url = tracker.webURL {
275            links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.displayName)", systemImage: "checklist", url: url))
276        }
277
278        if let mailingList = project.mailingLists.first,
279           let url = SRHTWebURL.mailingList(ownerUsername: mailingList.ownerUsername, listName: mailingList.name) {
280            links.append(ProjectLink(id: "primary-list", title: "\(mailingList.ownerUsername)/\(mailingList.displayName)", systemImage: "list.bullet", url: url))
281        }
282
283        return links
284    }
285}
286
287private struct ProjectLink: Identifiable {
288    let id: String
289    let title: String
290    let systemImage: String
291    let url: URL
292}
293
294private struct ProjectResourceRow: View {
295    let title: String
296    let subtitle: String
297    let detail: String?
298    let systemImage: String
299
300    var body: some View {
301        HStack(alignment: .top, spacing: 12) {
302            Image(systemName: systemImage)
303                .frame(width: 18, alignment: .leading)
304                .foregroundStyle(.secondary)
305
306            VStack(alignment: .leading, spacing: 4) {
307                Text(title)
308                    .font(.subheadline.weight(.medium))
309                    .foregroundStyle(.primary)
310
311                Text(subtitle)
312                    .font(.caption)
313                    .foregroundStyle(.secondary)
314                    .lineLimit(1)
315
316                if let detail, !detail.isEmpty {
317                    Text(detail)
318                        .font(.caption)
319                        .foregroundStyle(.tertiary)
320                        .lineLimit(2)
321                }
322            }
323
324            Spacer(minLength: 8)
325
326            Image(systemName: "chevron.right")
327                .font(.caption.weight(.semibold))
328                .foregroundStyle(.tertiary)
329        }
330        .contentShape(Rectangle())
331        .padding(.vertical, 4)
332    }
333}