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