krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.4: 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}