krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
remove-splash-highlighter: Hutch/Views/Home/HomeView.swift · raw
1import SwiftUI
2
3struct HomeView: View {
4 @Environment(AppState.self) private var appState
5 @Environment(\.scenePhase) private var scenePhase
6 @AppStorage(AppStorageKeys.homeFailedBuildLookbackDays, store: .standard)
7 private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
8 @State private var viewModel: HomeViewModel?
9 @State private var recentItems: [RecentActivityEntry] = []
10 @State private var isOpeningRecentItem = false
11 @State private var selectedPinnedProject: Project?
12 @State private var selectedPinnedUser: User?
13 @State private var isShowingSystemStatus = false
14
15 var body: some View {
16 Group {
17 if let viewModel {
18 content(viewModel)
19 } else {
20 SRHTLoadingStateView(message: "Loading Home…")
21 }
22 }
23 .navigationTitle("Home")
24 .toolbar {
25 ToolbarItem(placement: .topBarTrailing) {
26 Button {
27 isShowingSystemStatus = true
28 } label: {
29 HomeSystemStatusIndicator(
30 snapshot: viewModel?.systemStatusSnapshot,
31 isLoading: viewModel?.isLoadingSystemStatus ?? false
32 )
33 }
34 .accessibilityLabel("System status")
35 }
36 }
37 .navigationDestination(isPresented: Binding(
38 get: { selectedPinnedProject != nil },
39 set: { isPresented in
40 if !isPresented {
41 selectedPinnedProject = nil
42 }
43 }
44 )) {
45 if let selectedPinnedProject {
46 ProjectDetailView(project: selectedPinnedProject)
47 }
48 }
49 .navigationDestination(isPresented: Binding(
50 get: { selectedPinnedUser != nil },
51 set: { isPresented in
52 if !isPresented {
53 selectedPinnedUser = nil
54 }
55 }
56 )) {
57 if let selectedPinnedUser {
58 UserProfileView(user: selectedPinnedUser)
59 }
60 }
61 .navigationDestination(isPresented: $isShowingSystemStatus) {
62 SystemStatusView()
63 }
64 .task {
65 guard let currentUser = appState.currentUser else { return }
66 await ensureViewModel(currentUser: currentUser).loadDashboard()
67 loadRecentActivity()
68 }
69 .onChange(of: scenePhase) { _, newPhase in
70 guard newPhase == .active, let viewModel, viewModel.needsRefresh() else { return }
71 Task {
72 await viewModel.loadDashboard()
73 loadRecentActivity()
74 }
75 }
76 .onChange(of: failedBuildLookbackDays) { _, _ in
77 viewModel?.refreshNeedsAttentionSnapshot()
78 }
79 }
80
81 @ViewBuilder
82 private func content(_ viewModel: HomeViewModel) -> some View {
83 List {
84 workSection(viewModel)
85 recentSection
86 buildsSection(viewModel)
87 pinnedSection(viewModel)
88 }
89 .themedList()
90 .listStyle(.insetGrouped)
91 .listSectionSpacing(.compact)
92 .refreshable {
93 await viewModel.loadDashboard(forceRefresh: true)
94 }
95 .connectivityOverlay(hasContent: hasHomeContent(viewModel)) {
96 await viewModel.loadDashboard()
97 }
98 .onAppear {
99 loadRecentActivity()
100 }
101 }
102
103 private func workSection(_ viewModel: HomeViewModel) -> some View {
104 Section("Work") {
105 NavigationLink(value: HomeRoute.work(scope: .all)) {
106 HomeSummaryRow(
107 title: workTitle(viewModel),
108 summary: workSummary(viewModel),
109 systemImage: "tray.full",
110 tint: workCount(viewModel) > 0 ? .blue : .secondary,
111 emphasis: .action
112 )
113 }
114 .themedRow()
115 }
116 }
117
118 @ViewBuilder
119 private var recentSection: some View {
120 if !recentItems.isEmpty {
121 Section("Recent") {
122 ForEach(recentItems.prefix(3)) { item in
123 Button {
124 openRecentItem(item)
125 } label: {
126 HomeRecentRow(item: item)
127 }
128 .buttonStyle(.plain)
129 .disabled(isOpeningRecentItem)
130 .listRowSeparator(.hidden)
131 }
132 .themedRow()
133 }
134 }
135 }
136
137 private func buildsSection(_ viewModel: HomeViewModel) -> some View {
138 Section("Builds") {
139 Button {
140 appState.navigateToBuildsList()
141 } label: {
142 HomeSummaryRow(
143 title: buildsTitle(viewModel),
144 summary: buildsSummary(viewModel),
145 systemImage: "hammer",
146 tint: failedBuildCount(viewModel) > 0 ? .orange : .secondary,
147 emphasis: .monitoring
148 )
149 }
150 .buttonStyle(.plain)
151 .themedRow()
152 }
153 }
154
155 private func pinnedSection(_ viewModel: HomeViewModel) -> some View {
156 let items = pinnedItems(viewModel)
157
158 return Section("Pinned") {
159 if items.isEmpty {
160 NavigationLink {
161 ProjectsListView()
162 } label: {
163 HomeCompactMessageRow(text: "Pin projects for quick access", systemImage: "pin")
164 }
165 .themedRow()
166 } else {
167 LazyVGrid(
168 columns: [
169 GridItem(.flexible(), spacing: 10),
170 GridItem(.flexible(), spacing: 10),
171 ],
172 spacing: 10
173 ) {
174 ForEach(items) { item in
175 Button {
176 openPinnedItem(item)
177 } label: {
178 HomePinnedCard(item: item)
179 }
180 .buttonStyle(.plain)
181 }
182 }
183 .padding(.vertical, 2)
184 .themedRow()
185 }
186 }
187 }
188
189 private func workCount(_ viewModel: HomeViewModel) -> Int {
190 unreadCount(viewModel) + viewModel.assignedTickets.count
191 }
192
193 private func unreadCount(_ viewModel: HomeViewModel) -> Int {
194 viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count
195 }
196
197 private func workTitle(_ viewModel: HomeViewModel) -> String {
198 let count = workCount(viewModel)
199 if count == 0 {
200 return "Queue clear"
201 }
202 return "\(count) item\(count == 1 ? "" : "s") need attention"
203 }
204
205 private func workSummary(_ viewModel: HomeViewModel) -> String {
206 let unread = unreadCount(viewModel)
207 let assigned = viewModel.assignedTickets.count
208 return "\(unread) unread • \(assigned) assigned"
209 }
210
211 private func buildsTitle(_ viewModel: HomeViewModel) -> String {
212 let failed = failedBuildCount(viewModel)
213 let running = viewModel.activeBuildCount
214
215 if failed == 0 && running == 0 {
216 return "Build monitoring clear"
217 }
218 if failed > 0 {
219 return "\(failed) failed build\(failed == 1 ? "" : "s")"
220 }
221 return "\(running) running build\(running == 1 ? "" : "s")"
222 }
223
224 private func buildsSummary(_ viewModel: HomeViewModel) -> String {
225 let failed = failedBuildCount(viewModel)
226 let running = viewModel.activeBuildCount
227 if failed == 0 && running == 0 {
228 return "No failures • \(buildTimeframeLabel())"
229 }
230 if failed > 0 && running > 0 {
231 return "\(failed) failed • \(running) running • \(buildTimeframeLabel())"
232 }
233 if failed > 0 {
234 return "\(failed) failed • \(buildTimeframeLabel())"
235 }
236 return "\(running) running now"
237 }
238
239 private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] {
240 let currentUserKey = appState.currentUser?.canonicalName ?? ""
241 let pins = HomePinStore.loadPins(for: currentUserKey, defaults: appState.accountDefaults)
242 let projectsByID = Dictionary(uniqueKeysWithValues: viewModel.projects.map { ($0.id, $0) })
243
244 return pins.compactMap { pin in
245 switch pin.kind {
246 case .project:
247 guard let project = projectsByID[pin.value] else { return nil }
248 return HomePinnedItem(pin: pin, project: project)
249 case .repository, .tracker, .mailingList, .user:
250 return HomePinnedItem(pin: pin, project: nil)
251 }
252 }
253 }
254
255 private func buildTimeframeLabel() -> String {
256 HomeViewModel.failedBuildLookbackLabel(days: failedBuildLookbackDays)
257 }
258
259 private func failedBuildCount(_ viewModel: HomeViewModel) -> Int {
260 viewModel.recentFailedBuilds(lookbackDays: failedBuildLookbackDays).count
261 }
262
263 private func hasHomeContent(_ viewModel: HomeViewModel) -> Bool {
264 viewModel.systemStatusSnapshot?.hasDisruption == true ||
265 workCount(viewModel) > 0 ||
266 !recentItems.isEmpty ||
267 !pinnedItems(viewModel).isEmpty
268 }
269
270 private func loadRecentActivity() {
271 recentItems = RecentActivityStore.load(defaults: appState.accountDefaults)
272 }
273
274 private func openRecentItem(_ item: RecentActivityEntry) {
275 guard !isOpeningRecentItem else { return }
276
277 switch item.kind {
278 case .build:
279 guard let jobId = item.buildJobId else { return }
280 appState.navigateToBuild(jobId: jobId)
281 case .ticket:
282 guard
283 let ownerUsername = item.ticketOwnerUsername,
284 let trackerName = item.ticketTrackerName,
285 let ticketId = item.ticketId
286 else {
287 return
288 }
289 appState.navigateToTicket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId)
290 case .repository:
291 guard
292 let owner = item.repositoryOwner,
293 let name = item.repositoryName
294 else {
295 return
296 }
297
298 isOpeningRecentItem = true
299 Task {
300 defer { isOpeningRecentItem = false }
301 do {
302 let repository = try await appState.resolveRepository(
303 owner: owner,
304 name: name,
305 service: item.repositoryService ?? .git
306 )
307 appState.navigateToRepository(repository)
308 } catch {
309 appState.presentRepositoryDeepLinkError()
310 }
311 }
312 }
313 }
314
315 private func openPinnedItem(_ item: HomePinnedItem) {
316 switch item.pin.kind {
317 case .project:
318 guard let project = item.project else { return }
319 selectedPinnedProject = project
320 case .repository:
321 guard
322 let owner = item.pin.ownerUsername,
323 let service = item.pin.service
324 else {
325 return
326 }
327 isOpeningRecentItem = true
328 Task {
329 defer { isOpeningRecentItem = false }
330 do {
331 let repository = try await appState.resolveRepository(owner: owner, name: item.pin.value, service: service)
332 appState.navigateToRepository(repository)
333 } catch {
334 appState.presentRepositoryDeepLinkError()
335 }
336 }
337 case .tracker:
338 guard let owner = item.pin.ownerUsername else { return }
339 isOpeningRecentItem = true
340 Task {
341 defer { isOpeningRecentItem = false }
342 do {
343 let tracker = try await appState.resolveTracker(owner: owner, name: item.pin.value)
344 appState.navigateToTracker(tracker)
345 } catch {
346 appState.presentTicketDeepLinkError()
347 }
348 }
349 case .mailingList:
350 guard let ownerUsername = item.pin.ownerUsername else { return }
351 appState.openMailingList(
352 InboxMailingListReference(
353 id: 0,
354 rid: item.pin.value,
355 name: item.pin.title,
356 owner: Entity(canonicalName: "~\(ownerUsername)")
357 )
358 )
359 case .user:
360 guard let ownerUsername = item.pin.ownerUsername else { return }
361 isOpeningRecentItem = true
362 Task {
363 defer { isOpeningRecentItem = false }
364 if let user = try? await resolvePinnedUser(username: ownerUsername) {
365 selectedPinnedUser = user
366 }
367 }
368 }
369 }
370
371 private func resolvePinnedUser(username: String) async throws -> User {
372 struct Response: Decodable, Sendable {
373 let user: User
374 }
375
376 let query = """
377 query userLookup($username: String!) {
378 user: userByName(username: $username) {
379 id
380 created
381 updated
382 canonicalName
383 username
384 email
385 url
386 location
387 bio
388 avatar
389 pronouns
390 userType
391 }
392 }
393 """
394
395 let result = try await appState.client.execute(
396 service: .meta,
397 query: query,
398 variables: ["username": username],
399 responseType: Response.self
400 )
401 return result.user
402 }
403
404 @MainActor
405 private func ensureViewModel(currentUser: User) -> HomeViewModel {
406 if let viewModel {
407 return viewModel
408 }
409
410 let newViewModel = HomeViewModel(
411 currentUser: currentUser,
412 client: appState.client,
413 systemStatusRepository: appState.systemStatusRepository,
414 defaults: appState.accountDefaults,
415 accountID: appState.activeAccountID
416 )
417 viewModel = newViewModel
418 return newViewModel
419 }
420}
421
422enum HomeRoute: Hashable {
423 case work(scope: HutchWorkQueueScope)
424}
425
426private enum HomeSummaryEmphasis {
427 case action
428 case monitoring
429}
430
431private struct HomePinnedItem: Identifiable {
432 let pin: HomePinRecord
433 let project: Project?
434
435 var id: String { pin.id }
436 var title: String { project?.displayName ?? pin.title }
437 var detail: String { pin.subtitle }
438}
439
440private struct HomeSummaryRow: View {
441 let title: String
442 let summary: String
443 let systemImage: String
444 let tint: Color
445 let emphasis: HomeSummaryEmphasis
446
447 var body: some View {
448 HStack(spacing: 10) {
449 Image(systemName: systemImage)
450 .font(.subheadline.weight(.semibold))
451 .foregroundStyle(iconColor)
452 .frame(width: 18)
453
454 VStack(alignment: .leading, spacing: 2) {
455 Text(title)
456 .font(.subheadline.weight(.semibold))
457 Text(summary)
458 .font(.caption)
459 .foregroundStyle(.secondary)
460 .lineLimit(1)
461 }
462
463 Spacer(minLength: 8)
464 }
465 .frame(maxWidth: .infinity, alignment: .leading)
466 .contentShape(Rectangle())
467 .padding(.vertical, verticalPadding)
468 }
469
470 private var iconColor: Color {
471 switch emphasis {
472 case .action:
473 return tint
474 case .monitoring:
475 return tint.opacity(0.9)
476 }
477 }
478
479 private var verticalPadding: CGFloat {
480 switch emphasis {
481 case .action:
482 return 3
483 case .monitoring:
484 return 2
485 }
486 }
487}
488
489private struct HomeSystemStatusIndicator: View {
490 let snapshot: SystemStatusSnapshot?
491 let isLoading: Bool
492
493 var body: some View {
494 if isLoading && snapshot == nil {
495 ProgressView()
496 .controlSize(.small)
497 } else {
498 Circle()
499 .fill(color)
500 .frame(width: 22, height: 22)
501 .overlay {
502 Image(systemName: glyph)
503 .font(.system(size: 11, weight: .bold))
504 .foregroundStyle(.white)
505 }
506 }
507 }
508
509 private var color: Color {
510 if let snapshot {
511 return snapshot.hasDisruption ? .orange : .green
512 }
513 return .secondary
514 }
515
516 private var glyph: String {
517 if let snapshot {
518 return snapshot.hasDisruption ? "exclamationmark" : "checkmark"
519 }
520 return "questionmark"
521 }
522}
523
524private struct HomeRecentRow: View {
525 let item: RecentActivityEntry
526
527 var body: some View {
528 HStack(spacing: 10) {
529 Image(systemName: iconName)
530 .font(.caption.weight(.semibold))
531 .foregroundStyle(.secondary)
532 .frame(width: 16)
533
534 VStack(alignment: .leading, spacing: 1) {
535 Text(item.title)
536 .font(.subheadline.weight(.medium))
537 .lineLimit(1)
538 Text(item.detailText)
539 .font(.caption)
540 .foregroundStyle(.secondary)
541 .lineLimit(1)
542 }
543
544 Spacer(minLength: 8)
545 }
546 .frame(maxWidth: .infinity, alignment: .leading)
547 .contentShape(Rectangle())
548 .padding(.vertical, 1)
549 }
550
551 private var iconName: String {
552 switch item.kind {
553 case .repository:
554 return "book.closed"
555 case .ticket:
556 return "number"
557 case .build:
558 return "hammer"
559 }
560 }
561}
562
563private struct HomePinnedCard: View {
564 let item: HomePinnedItem
565
566 var body: some View {
567 VStack(alignment: .leading, spacing: 6) {
568 HStack(spacing: 6) {
569 Image(systemName: "square.stack.3d.up")
570 .font(.caption.weight(.semibold))
571 .foregroundStyle(.secondary)
572 Text(item.detail)
573 .font(.caption2.weight(.semibold))
574 .foregroundStyle(.secondary)
575 }
576
577 Text(item.title)
578 .font(.subheadline.weight(.semibold))
579 .lineLimit(2)
580
581 Spacer(minLength: 0)
582 }
583 .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
584 .padding(10)
585 .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
586 }
587}
588
589private struct HomeCompactMessageRow: View {
590 let text: String
591 let systemImage: String
592
593 var body: some View {
594 Label(text, systemImage: systemImage)
595 .font(.caption)
596 .foregroundStyle(.secondary)
597 .padding(.vertical, 2)
598 }
599}