krz/hutch

an ios client for sourcehut

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

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