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