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