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}