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