krz/hutch

an ios client for sourcehut

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

v3.0.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    @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 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}