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