krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.8.1: 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(forceRefresh: true)
 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}