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