krz/hutch

an ios client for sourcehut

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

v3.0.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        }
107    }
108
109    @ViewBuilder
110    private var linksSection: some View {
111        let links = projectLinks(for: displayedProject)
112        if !links.isEmpty {
113            Section("Links") {
114                ForEach(links) { link in
115                    Button {
116                        openURL(link.url)
117                    } label: {
118                        HStack(spacing: 12) {
119                            Label(link.title, systemImage: link.systemImage)
120                                .font(.subheadline)
121                                .foregroundStyle(.primary)
122                            Spacer()
123                            Image(systemName: "arrow.up.right")
124                                .font(.caption.weight(.semibold))
125                                .foregroundStyle(.tertiary)
126                        }
127                    }
128                    .buttonStyle(.plain)
129                }
130            }
131        }
132    }
133
134    @ViewBuilder
135    private var repositoriesSection: some View {
136        if !displayedProject.sources.isEmpty {
137            Section("Repositories") {
138                ForEach(displayedProject.sources) { source in
139                    Button {
140                        Task {
141                            do {
142                                try await appState.openProjectSource(source)
143                                dismiss()
144                            } catch {
145                                self.error = "Couldn’t open repository. \(error.userFacingMessage)"
146                            }
147                        }
148                    } label: {
149                        ProjectResourceRow(
150                            title: source.displayName,
151                            subtitle: source.ownerDisplayName,
152                            detail: source.displayDescription,
153                            systemImage: "book.closed"
154                        )
155                    }
156                    .buttonStyle(.plain)
157                }
158            }
159        }
160    }
161
162    @ViewBuilder
163    private var trackersSection: some View {
164        if !displayedProject.trackers.isEmpty {
165            Section("Trackers") {
166                ForEach(displayedProject.trackers) { tracker in
167                    Button {
168                        Task {
169                            do {
170                                try await appState.openProjectTracker(tracker)
171                                dismiss()
172                            } catch {
173                                self.error = "Couldn’t open tracker. \(error.userFacingMessage)"
174                            }
175                        }
176                    } label: {
177                        ProjectResourceRow(
178                            title: tracker.displayName,
179                            subtitle: tracker.ownerDisplayName,
180                            detail: tracker.displayDescription,
181                            systemImage: "checklist"
182                        )
183                    }
184                    .buttonStyle(.plain)
185                }
186            }
187        }
188    }
189
190    @ViewBuilder
191    private var mailingListsSection: some View {
192        if !displayedProject.mailingLists.isEmpty {
193            Section("Mailing Lists") {
194                ForEach(displayedProject.mailingLists) { mailingList in
195                    Button {
196                        appState.openMailingList(mailingList.inboxReference)
197                        dismiss()
198                    } label: {
199                        ProjectResourceRow(
200                            title: mailingList.displayName,
201                            subtitle: mailingList.ownerDisplayName,
202                            detail: mailingList.displayDescription,
203                            systemImage: "list.bullet"
204                        )
205                    }
206                    .buttonStyle(.plain)
207                }
208            }
209        }
210    }
211
212    @ViewBuilder
213    private var emptyResourcesSection: some View {
214        if !displayedProject.hasLinkedResources, displayedProject.websiteURL == nil {
215            Section {
216                ContentUnavailableView(
217                    "No Linked Resources",
218                    systemImage: "square.stack.3d.up.slash",
219                    description: Text("This project doesn’t currently expose repositories, trackers, mailing lists, or external links.")
220                )
221            }
222        }
223    }
224
225    private func loadProjectIfNeeded(forceRefresh: Bool = false) async {
226        guard forceRefresh || !project.isFullyLoaded else {
227            detailProject = project
228            return
229        }
230        guard !isLoading else { return }
231
232        isLoading = true
233        error = nil
234        defer { isLoading = false }
235
236        do {
237            let service = ProjectService(client: appState.client)
238            detailProject = try await service.fetchProjectDetail(rid: project.id)
239        } catch {
240            self.error = "Couldn’t load project. \(error.userFacingMessage)"
241        }
242    }
243
244    private func togglePinnedState() {
245        guard let currentUserKey else { return }
246        ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
247        pinChangeCount += 1
248    }
249
250    private func projectLinks(for project: Project) -> [ProjectLink] {
251        var links: [ProjectLink] = []
252
253        if let url = project.websiteURL {
254            links.append(ProjectLink(id: "website", title: project.website ?? url.absoluteString, systemImage: "globe", url: url))
255        }
256
257        if let source = project.sources.first,
258           let url = source.webURL {
259            links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.displayName)", systemImage: "book.closed", url: url))
260        }
261
262        if let tracker = project.trackers.first,
263           let url = tracker.webURL {
264            links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.displayName)", systemImage: "checklist", url: url))
265        }
266
267        if let mailingList = project.mailingLists.first,
268           let url = SRHTWebURL.mailingList(ownerUsername: mailingList.ownerUsername, listName: mailingList.name) {
269            links.append(ProjectLink(id: "primary-list", title: "\(mailingList.ownerUsername)/\(mailingList.displayName)", systemImage: "list.bullet", url: url))
270        }
271
272        return links
273    }
274}
275
276private struct ProjectLink: Identifiable {
277    let id: String
278    let title: String
279    let systemImage: String
280    let url: URL
281}
282
283private struct ProjectResourceRow: View {
284    let title: String
285    let subtitle: String
286    let detail: String?
287    let systemImage: String
288
289    var body: some View {
290        HStack(alignment: .top, spacing: 12) {
291            Image(systemName: systemImage)
292                .frame(width: 18, alignment: .leading)
293                .foregroundStyle(.secondary)
294
295            VStack(alignment: .leading, spacing: 4) {
296                Text(title)
297                    .font(.subheadline.weight(.medium))
298                    .foregroundStyle(.primary)
299
300                Text(subtitle)
301                    .font(.caption)
302                    .foregroundStyle(.secondary)
303                    .lineLimit(1)
304
305                if let detail, !detail.isEmpty {
306                    Text(detail)
307                        .font(.caption)
308                        .foregroundStyle(.tertiary)
309                        .lineLimit(2)
310                }
311            }
312
313            Spacer(minLength: 8)
314
315            Image(systemName: "chevron.right")
316                .font(.caption.weight(.semibold))
317                .foregroundStyle(.tertiary)
318        }
319        .contentShape(Rectangle())
320        .padding(.vertical, 4)
321    }
322}