krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2: Hutch/Views/Home/HomeView.swift · raw
1import SwiftUI
2
3struct HomeView: View {
4 @Environment(AppState.self) private var appState
5 @State private var viewModel: HomeViewModel?
6 private let previewLimit = 4
7 private let projectPreviewLimit = 3
8
9 var body: some View {
10 Group {
11 if let viewModel {
12 content(viewModel)
13 } else {
14 SRHTLoadingStateView(message: "Loading Home…")
15 }
16 }
17 .navigationTitle("Home")
18 .toolbar {
19 ToolbarItem(placement: .topBarTrailing) {
20 NavigationLink {
21 InboxView()
22 } label: {
23 HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true)
24 }
25 }
26 }
27 .task {
28 if viewModel == nil, let currentUser = appState.currentUser {
29 let vm = HomeViewModel(currentUser: currentUser, client: appState.client)
30 viewModel = vm
31 await vm.loadDashboard()
32 }
33 }
34 }
35
36 @ViewBuilder
37 private func content(_ viewModel: HomeViewModel) -> some View {
38 List {
39 projectsSection(viewModel)
40 assignedTicketsSection(viewModel)
41 recentBuildsSection(viewModel)
42 }
43 .listStyle(.insetGrouped)
44 .overlay {
45 if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
46 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
47 SRHTLoadingStateView(message: "Loading Home…")
48 } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
49 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
50 viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
51 ContentUnavailableView(
52 "All Clear",
53 systemImage: "checkmark.circle",
54 description: Text("There are no assigned tickets or recent builds right now.")
55 )
56 }
57 }
58 .refreshable {
59 await viewModel.loadDashboard()
60 }
61 }
62
63 @ViewBuilder
64 private func projectsSection(_ viewModel: HomeViewModel) -> some View {
65 if !viewModel.projects.isEmpty {
66 Section {
67 ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in
68 NavigationLink {
69 ProjectDetailView(project: project)
70 } label: {
71 HomeProjectRow(project: project)
72 }
73 }
74 } header: {
75 HomeSectionHeader("Projects") {
76 HomeProjectsListView(viewModel: viewModel)
77 }
78 }
79 }
80 }
81
82 @ViewBuilder
83 private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
84 Section {
85 if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
86 HomeSectionLoadingRow(label: "Loading assigned tickets")
87 } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty {
88 HomeSectionMessageRow(
89 text: "Couldn’t load assigned tickets.",
90 systemImage: "exclamationmark.triangle",
91 emphasized: true,
92 accessibilityHint: error
93 )
94 } else if viewModel.assignedTickets.isEmpty {
95 HomeSectionMessageRow(
96 text: "No open tickets assigned to you.",
97 systemImage: "person.crop.circle.badge.checkmark"
98 )
99 } else {
100 ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in
101 NavigationLink {
102 TicketDetailView(
103 ownerUsername: ticket.ownerUsername,
104 trackerName: ticket.trackerName,
105 trackerId: ticket.trackerId,
106 trackerRid: ticket.trackerRid,
107 ticketId: ticket.ticket.id
108 )
109 } label: {
110 HomeAssignedTicketRow(ticket: ticket)
111 }
112 }
113 }
114 } header: {
115 HomeSectionHeader("Assigned Tickets") {
116 HomeAssignedTicketsListView(viewModel: viewModel)
117 }
118 }
119 }
120
121 @ViewBuilder
122 private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
123 Section {
124 if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty {
125 HomeSectionLoadingRow(label: "Loading recent builds")
126 } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty {
127 HomeSectionMessageRow(
128 text: "Couldn’t load recent builds.",
129 systemImage: "exclamationmark.triangle",
130 emphasized: true,
131 accessibilityHint: error
132 )
133 } else if viewModel.recentBuilds.isEmpty {
134 HomeSectionMessageRow(
135 text: "No recent builds.",
136 systemImage: "clock"
137 )
138 } else {
139 ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in
140 NavigationLink {
141 BuildDetailView(jobId: build.job.id)
142 } label: {
143 HomeBuildRow(build: build)
144 }
145 }
146 }
147 } header: {
148 HomeSectionActionHeader("Recent Builds") {
149 appState.selectedTab = .builds
150 }
151 }
152 }
153
154}
155
156private struct HomeInboxToolbarIcon: View {
157 let hasUnreadThreads: Bool
158
159 var body: some View {
160 ZStack(alignment: .topTrailing) {
161 Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
162
163 if hasUnreadThreads {
164 Circle()
165 .fill(.blue)
166 .frame(width: 9, height: 9)
167 .offset(x: 4, y: -2)
168 }
169 }
170 .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
171 }
172}
173
174private struct HomeProjectRow: View {
175 let project: Project
176
177 var body: some View {
178 VStack(alignment: .leading, spacing: 4) {
179 Text(project.name)
180 .font(.subheadline.weight(.medium))
181 .lineLimit(1)
182
183 if let description = project.description, !description.isEmpty {
184 Text(description)
185 .font(.caption)
186 .foregroundStyle(.secondary)
187 .lineLimit(1)
188 }
189
190 if let summary = project.resourceSummary {
191 Text(summary)
192 .font(.caption)
193 .foregroundStyle(.tertiary)
194 .lineLimit(1)
195 }
196 }
197 .padding(.vertical, 2)
198 }
199}
200
201private struct HomeProjectsListView: View {
202 let viewModel: HomeViewModel
203
204 var body: some View {
205 List {
206 ForEach(viewModel.projects) { project in
207 NavigationLink {
208 ProjectDetailView(project: project)
209 } label: {
210 HomeProjectRow(project: project)
211 }
212 }
213 }
214 .navigationTitle("Projects")
215 .navigationBarTitleDisplayMode(.inline)
216 .refreshable {
217 await viewModel.loadDashboard()
218 }
219 .overlay {
220 if viewModel.isLoadingProjects && viewModel.projects.isEmpty {
221 SRHTLoadingStateView(message: "Loading projects…")
222 }
223 }
224 }
225}
226
227private struct HomeBuildRow: View {
228 let build: HomeBuildItem
229
230 var body: some View {
231 HStack(spacing: 12) {
232 JobStatusIcon(status: build.job.status)
233 .frame(width: 20)
234
235 VStack(alignment: .leading, spacing: 4) {
236 Text(primaryTitle)
237 .font(.subheadline.weight(.medium))
238 .lineLimit(1)
239
240 HStack(spacing: 8) {
241 Text("Job #\(build.job.id)")
242 .font(.caption)
243 .foregroundStyle(.secondary)
244
245 Text("•")
246 .font(.caption)
247 .foregroundStyle(.tertiary)
248
249 Text(build.job.status.rawValue.capitalized)
250 .font(.caption)
251 .foregroundStyle(.secondary)
252
253 Text("•")
254 .font(.caption)
255 .foregroundStyle(.tertiary)
256
257 Text(build.job.created.relativeDescription)
258 .font(.caption)
259 .foregroundStyle(.tertiary)
260
261 Spacer()
262 }
263 }
264 }
265 .padding(.vertical, 2)
266 }
267
268 private var primaryTitle: String {
269 if let repositoryDisplayName = build.repositoryDisplayName {
270 return repositoryDisplayName
271 }
272 return build.job.displayLabel
273 }
274}
275
276private struct HomeAssignedTicketRow: View {
277 let ticket: HomeAssignedTicket
278
279 var body: some View {
280 HStack(alignment: .top, spacing: 12) {
281 TicketStatusIcon(status: ticket.ticket.status)
282 .frame(width: 20)
283
284 VStack(alignment: .leading, spacing: 4) {
285 Text(ticket.ticket.title)
286 .font(.subheadline.weight(.medium))
287 .lineLimit(2)
288
289 Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)")
290 .font(.caption)
291 .foregroundStyle(.secondary)
292 .lineLimit(1)
293 .truncationMode(.tail)
294 }
295
296 Spacer(minLength: 8)
297
298 Text(ticket.ticket.status.displayName)
299 .font(.caption2.weight(.medium))
300 .foregroundStyle(.secondary)
301 .lineLimit(1)
302 .fixedSize()
303 }
304 .padding(.vertical, 2)
305 }
306}
307
308private struct HomeSectionLoadingRow: View {
309 let label: String
310
311 var body: some View {
312 HStack(spacing: 10) {
313 ProgressView()
314 .controlSize(.small)
315 Text(label)
316 .foregroundStyle(.secondary)
317 }
318 .frame(maxWidth: .infinity, alignment: .leading)
319 }
320}
321
322private struct HomeSectionHeader<Destination: View>: View {
323 let title: String
324 let destination: Destination
325
326 init(_ title: String, @ViewBuilder destination: () -> Destination) {
327 self.title = title
328 self.destination = destination()
329 }
330
331 var body: some View {
332 HStack {
333 Text(title)
334 Spacer()
335 NavigationLink {
336 destination
337 } label: {
338 Text("See All")
339 .font(.caption.weight(.medium))
340 }
341 .buttonStyle(.plain)
342 }
343 .textCase(nil)
344 }
345}
346
347private struct HomeSectionActionHeader: View {
348 let title: String
349 let action: () -> Void
350
351 init(_ title: String, action: @escaping () -> Void) {
352 self.title = title
353 self.action = action
354 }
355
356 var body: some View {
357 HStack {
358 Text(title)
359 Spacer()
360 Button("See All", action: action)
361 .font(.caption.weight(.medium))
362 .buttonStyle(.plain)
363 }
364 .textCase(nil)
365 }
366}
367
368private struct HomeAssignedTicketsListView: View {
369 let viewModel: HomeViewModel
370
371 var body: some View {
372 List {
373 ForEach(viewModel.assignedTickets) { ticket in
374 NavigationLink {
375 TicketDetailView(
376 ownerUsername: ticket.ownerUsername,
377 trackerName: ticket.trackerName,
378 trackerId: ticket.trackerId,
379 trackerRid: ticket.trackerRid,
380 ticketId: ticket.ticket.id
381 )
382 } label: {
383 HomeAssignedTicketRow(ticket: ticket)
384 }
385 }
386
387 if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
388 HomeSectionMessageRow(
389 text: "No open tickets assigned to you.",
390 systemImage: "person.crop.circle.badge.checkmark"
391 )
392 }
393 }
394 .navigationTitle("Assigned Tickets")
395 .navigationBarTitleDisplayMode(.inline)
396 .refreshable {
397 await viewModel.loadDashboard()
398 }
399 .overlay {
400 if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
401 SRHTLoadingStateView(message: "Loading assigned tickets…")
402 }
403 }
404 }
405}
406
407private struct HomeSectionMessageRow: View {
408 let text: String
409 let systemImage: String
410 var emphasized = false
411 var accessibilityHint: String? = nil
412
413 var body: some View {
414 Label(text, systemImage: systemImage)
415 .font(.subheadline)
416 .foregroundStyle(emphasized ? .secondary : .tertiary)
417 .accessibilityHint(accessibilityHint ?? "")
418 }
419}