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