krz/hutch

an ios client for sourcehut

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

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