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