krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.6: 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 @State private var viewModel: HomeViewModel?
7 @State private var recentItems: [RecentActivityEntry] = []
8 @State private var isOpeningRecentItem = false
9 @State private var selectedPinnedProject: Project?
10 @State private var selectedPinnedUser: User?
11
12 var body: some View {
13 Group {
14 if let viewModel {
15 content(viewModel)
16 } else {
17 SRHTLoadingStateView(message: "Loading Home…")
18 }
19 }
20 .navigationTitle("Home")
21 .navigationDestination(isPresented: Binding(
22 get: { selectedPinnedProject != nil },
23 set: { isPresented in
24 if !isPresented {
25 selectedPinnedProject = nil
26 }
27 }
28 )) {
29 if let selectedPinnedProject {
30 ProjectDetailView(project: selectedPinnedProject)
31 }
32 }
33 .navigationDestination(isPresented: Binding(
34 get: { selectedPinnedUser != nil },
35 set: { isPresented in
36 if !isPresented {
37 selectedPinnedUser = nil
38 }
39 }
40 )) {
41 if let selectedPinnedUser {
42 UserProfileView(user: selectedPinnedUser)
43 }
44 }
45 .task {
46 guard let currentUser = appState.currentUser else { return }
47 await ensureViewModel(currentUser: currentUser).loadDashboard()
48 loadRecentActivity()
49 }
50 .onChange(of: scenePhase) { _, newPhase in
51 guard newPhase == .active, let viewModel, viewModel.needsRefresh() else { return }
52 Task {
53 await viewModel.loadDashboard()
54 loadRecentActivity()
55 }
56 }
57 }
58
59 @ViewBuilder
60 private func content(_ viewModel: HomeViewModel) -> some View {
61 List {
62 systemStatusSection(viewModel)
63 workSection(viewModel)
64 recentSection
65 buildsSection(viewModel)
66 pinnedSection(viewModel)
67 }
68 .themedList()
69 .listStyle(.insetGrouped)
70 .listSectionSpacing(.compact)
71 .refreshable {
72 await viewModel.loadDashboard()
73 }
74 .connectivityOverlay(hasContent: hasHomeContent(viewModel)) {
75 await viewModel.loadDashboard()
76 }
77 .onAppear {
78 loadRecentActivity()
79 }
80 }
81
82 @ViewBuilder
83 private func systemStatusSection(_ viewModel: HomeViewModel) -> some View {
84 if viewModel.systemStatusSnapshot?.hasDisruption == true {
85 Section {
86 NavigationLink {
87 SystemStatusView()
88 } label: {
89 SystemStatusSummaryRow(
90 snapshot: viewModel.systemStatusSnapshot,
91 isLoading: viewModel.isLoadingSystemStatus,
92 errorMessage: viewModel.systemStatusErrorMessage,
93 isShowingStaleData: viewModel.isShowingStaleSystemStatus
94 )
95 }
96 .buttonStyle(.plain)
97 .themedRow()
98 }
99 }
100 }
101
102 private func workSection(_ viewModel: HomeViewModel) -> some View {
103 Section("Work") {
104 NavigationLink(value: HomeRoute.work) {
105 HomeSummaryRow(
106 title: workTitle(viewModel),
107 summary: workSummary(viewModel),
108 systemImage: "tray.full",
109 tint: workCount(viewModel) > 0 ? .blue : .secondary,
110 emphasis: .action
111 )
112 }
113 .themedRow()
114 }
115 }
116
117 @ViewBuilder
118 private var recentSection: some View {
119 if !recentItems.isEmpty {
120 Section("Recent") {
121 ForEach(recentItems.prefix(3)) { item in
122 Button {
123 openRecentItem(item)
124 } label: {
125 HomeRecentRow(item: item)
126 }
127 .buttonStyle(.plain)
128 .disabled(isOpeningRecentItem)
129 .listRowSeparator(.hidden)
130 }
131 .themedRow()
132 }
133 }
134 }
135
136 private func buildsSection(_ viewModel: HomeViewModel) -> some View {
137 Section("Builds") {
138 NavigationLink {
139 BuildListView()
140 } label: {
141 HomeSummaryRow(
142 title: buildsTitle(viewModel),
143 summary: buildsSummary(viewModel),
144 systemImage: "hammer",
145 tint: viewModel.failedBuildCount > 0 ? .orange : .secondary,
146 emphasis: .monitoring
147 )
148 }
149 .themedRow()
150 }
151 }
152
153 private func pinnedSection(_ viewModel: HomeViewModel) -> some View {
154 let items = pinnedItems(viewModel)
155
156 return Section("Pinned") {
157 if items.isEmpty {
158 NavigationLink {
159 ProjectsListView()
160 } label: {
161 HomeCompactMessageRow(text: "Pin projects for quick access", systemImage: "pin")
162 }
163 .themedRow()
164 } else {
165 LazyVGrid(
166 columns: [
167 GridItem(.flexible(), spacing: 10),
168 GridItem(.flexible(), spacing: 10),
169 ],
170 spacing: 10
171 ) {
172 ForEach(items) { item in
173 Button {
174 openPinnedItem(item)
175 } label: {
176 HomePinnedCard(item: item)
177 }
178 .buttonStyle(.plain)
179 }
180 }
181 .padding(.vertical, 2)
182 .themedRow()
183 }
184 }
185 }
186
187 private func workCount(_ viewModel: HomeViewModel) -> Int {
188 unreadCount(viewModel) + viewModel.assignedTickets.count
189 }
190
191 private func unreadCount(_ viewModel: HomeViewModel) -> Int {
192 viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count
193 }
194
195 private func workTitle(_ viewModel: HomeViewModel) -> String {
196 let count = workCount(viewModel)
197 if count == 0 {
198 return "Queue clear"
199 }
200 return "\(count) item\(count == 1 ? "" : "s") need attention"
201 }
202
203 private func workSummary(_ viewModel: HomeViewModel) -> String {
204 let unread = unreadCount(viewModel)
205 let assigned = viewModel.assignedTickets.count
206 return "\(unread) unread • \(assigned) assigned"
207 }
208
209 private func buildsTitle(_ viewModel: HomeViewModel) -> String {
210 let failed = viewModel.failedBuildCount
211 let running = viewModel.activeBuildCount
212
213 if failed == 0 && running == 0 {
214 return "Build monitoring clear"
215 }
216 if failed > 0 {
217 return "\(failed) failed build\(failed == 1 ? "" : "s")"
218 }
219 return "\(running) running build\(running == 1 ? "" : "s")"
220 }
221
222 private func buildsSummary(_ viewModel: HomeViewModel) -> String {
223 let failed = viewModel.failedBuildCount
224 let running = viewModel.activeBuildCount
225 if failed == 0 && running == 0 {
226 return "No failures • \(buildTimeframeLabel(viewModel))"
227 }
228 if failed > 0 && running > 0 {
229 return "\(failed) failed • \(running) running • \(buildTimeframeLabel(viewModel))"
230 }
231 if failed > 0 {
232 return "\(failed) failed • \(buildTimeframeLabel(viewModel))"
233 }
234 return "\(running) running • \(buildTimeframeLabel(viewModel))"
235 }
236
237 private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] {
238 let currentUserKey = appState.currentUser?.canonicalName ?? ""
239 let pins = HomePinStore.loadPins(for: currentUserKey, defaults: appState.accountDefaults)
240 let projectsByID = Dictionary(uniqueKeysWithValues: viewModel.projects.map { ($0.id, $0) })
241
242 return pins.compactMap { pin in
243 switch pin.kind {
244 case .project:
245 guard let project = projectsByID[pin.value] else { return nil }
246 return HomePinnedItem(pin: pin, project: project)
247 case .repository, .tracker, .mailingList, .user:
248 return HomePinnedItem(pin: pin, project: nil)
249 }
250 }
251 }
252
253 private func buildTimeframeLabel(_ viewModel: HomeViewModel) -> String {
254 let calendar = Calendar.current
255 let buildDates = viewModel.recentBuilds.map(\.job.updated)
256
257 guard !buildDates.isEmpty else {
258 return "today"
259 }
260
261 return buildDates.allSatisfy(calendar.isDateInToday) ? "today" : "this week"
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 .padding(.vertical, verticalPadding)
467 }
468
469 private var iconColor: Color {
470 switch emphasis {
471 case .action:
472 return tint
473 case .monitoring:
474 return tint.opacity(0.9)
475 }
476 }
477
478 private var verticalPadding: CGFloat {
479 switch emphasis {
480 case .action:
481 return 3
482 case .monitoring:
483 return 2
484 }
485 }
486}
487
488private struct HomeRecentRow: View {
489 let item: RecentActivityEntry
490
491 var body: some View {
492 HStack(spacing: 10) {
493 Image(systemName: iconName)
494 .font(.caption.weight(.semibold))
495 .foregroundStyle(.secondary)
496 .frame(width: 16)
497
498 VStack(alignment: .leading, spacing: 1) {
499 Text(item.title)
500 .font(.subheadline.weight(.medium))
501 .lineLimit(1)
502 Text(item.detailText)
503 .font(.caption)
504 .foregroundStyle(.secondary)
505 .lineLimit(1)
506 }
507
508 Spacer(minLength: 8)
509 }
510 .padding(.vertical, 1)
511 }
512
513 private var iconName: String {
514 switch item.kind {
515 case .repository:
516 return "book.closed"
517 case .ticket:
518 return "number"
519 case .build:
520 return "hammer"
521 }
522 }
523}
524
525private struct HomePinnedCard: View {
526 let item: HomePinnedItem
527
528 var body: some View {
529 VStack(alignment: .leading, spacing: 6) {
530 HStack(spacing: 6) {
531 Image(systemName: "square.stack.3d.up")
532 .font(.caption.weight(.semibold))
533 .foregroundStyle(.secondary)
534 Text(item.detail)
535 .font(.caption2.weight(.semibold))
536 .foregroundStyle(.secondary)
537 }
538
539 Text(item.title)
540 .font(.subheadline.weight(.semibold))
541 .lineLimit(2)
542
543 Spacer(minLength: 0)
544 }
545 .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
546 .padding(10)
547 .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
548 }
549}
550
551private struct HomeCompactMessageRow: View {
552 let text: String
553 let systemImage: String
554
555 var body: some View {
556 Label(text, systemImage: systemImage)
557 .font(.caption)
558 .foregroundStyle(.secondary)
559 .padding(.vertical, 2)
560 }
561}