krz/hutch

an ios client for sourcehut

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

v3.1.11: 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                .themedRow()
 98            }
 99        }
100    }
101
102    private func workSection(_ viewModel: HomeViewModel) -> some View {
103        Section("Work") {
104            NavigationLink(value: HomeRoute.work) {
105                HomeSummaryRow(
106                    title: workTitle(viewModel),
107                    summary: workSummary(viewModel),
108                    systemImage: "tray.full",
109                    tint: workCount(viewModel) > 0 ? .blue : .secondary,
110                    emphasis: .action
111                )
112            }
113            .themedRow()
114        }
115    }
116
117    @ViewBuilder
118    private var recentSection: some View {
119        if !recentItems.isEmpty {
120            Section("Recent") {
121                ForEach(recentItems.prefix(3)) { item in
122                    Button {
123                        openRecentItem(item)
124                    } label: {
125                        HomeRecentRow(item: item)
126                    }
127                    .buttonStyle(.plain)
128                    .disabled(isOpeningRecentItem)
129                    .listRowSeparator(.hidden)
130                }
131                .themedRow()
132            }
133        }
134    }
135
136    private func buildsSection(_ viewModel: HomeViewModel) -> some View {
137        Section("Builds") {
138            NavigationLink {
139                BuildListView()
140            } label: {
141                HomeSummaryRow(
142                    title: buildsTitle(viewModel),
143                    summary: buildsSummary(viewModel),
144                    systemImage: "hammer",
145                    tint: viewModel.failedBuildCount > 0 ? .orange : .secondary,
146                    emphasis: .monitoring
147                )
148            }
149            .themedRow()
150        }
151    }
152
153    private func pinnedSection(_ viewModel: HomeViewModel) -> some View {
154        let items = pinnedItems(viewModel)
155
156        return Section("Pinned") {
157            if items.isEmpty {
158                NavigationLink {
159                    ProjectsListView()
160                } label: {
161                    HomeCompactMessageRow(text: "Pin projects for quick access", systemImage: "pin")
162                }
163                .themedRow()
164            } else {
165                LazyVGrid(
166                    columns: [
167                        GridItem(.flexible(), spacing: 10),
168                        GridItem(.flexible(), spacing: 10),
169                    ],
170                    spacing: 10
171                ) {
172                    ForEach(items) { item in
173                        Button {
174                            openPinnedItem(item)
175                        } label: {
176                            HomePinnedCard(item: item)
177                        }
178                        .buttonStyle(.plain)
179                    }
180                }
181                .padding(.vertical, 2)
182                .themedRow()
183            }
184        }
185    }
186
187    private func workCount(_ viewModel: HomeViewModel) -> Int {
188        unreadCount(viewModel) + viewModel.assignedTickets.count
189    }
190
191    private func unreadCount(_ viewModel: HomeViewModel) -> Int {
192        viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count
193    }
194
195    private func workTitle(_ viewModel: HomeViewModel) -> String {
196        let count = workCount(viewModel)
197        if count == 0 {
198            return "Queue clear"
199        }
200        return "\(count) item\(count == 1 ? "" : "s") need attention"
201    }
202
203    private func workSummary(_ viewModel: HomeViewModel) -> String {
204        let unread = unreadCount(viewModel)
205        let assigned = viewModel.assignedTickets.count
206        return "\(unread) unread • \(assigned) assigned"
207    }
208
209    private func buildsTitle(_ viewModel: HomeViewModel) -> String {
210        let failed = viewModel.failedBuildCount
211        let running = viewModel.activeBuildCount
212
213        if failed == 0 && running == 0 {
214            return "Build monitoring clear"
215        }
216        if failed > 0 {
217            return "\(failed) failed build\(failed == 1 ? "" : "s")"
218        }
219        return "\(running) running build\(running == 1 ? "" : "s")"
220    }
221
222    private func buildsSummary(_ viewModel: HomeViewModel) -> String {
223        let failed = viewModel.failedBuildCount
224        let running = viewModel.activeBuildCount
225        if failed == 0 && running == 0 {
226            return "No failures • \(buildTimeframeLabel(viewModel))"
227        }
228        if failed > 0 && running > 0 {
229            return "\(failed) failed • \(running) running • \(buildTimeframeLabel(viewModel))"
230        }
231        if failed > 0 {
232            return "\(failed) failed • \(buildTimeframeLabel(viewModel))"
233        }
234        return "\(running) running • \(buildTimeframeLabel(viewModel))"
235    }
236
237    private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] {
238        let currentUserKey = appState.currentUser?.canonicalName ?? ""
239        let pins = HomePinStore.loadPins(for: currentUserKey, defaults: appState.accountDefaults)
240        let projectsByID = Dictionary(uniqueKeysWithValues: viewModel.projects.map { ($0.id, $0) })
241
242        return pins.compactMap { pin in
243            switch pin.kind {
244            case .project:
245                guard let project = projectsByID[pin.value] else { return nil }
246                return HomePinnedItem(pin: pin, project: project)
247            case .repository, .tracker, .mailingList, .user:
248                return HomePinnedItem(pin: pin, project: nil)
249            }
250        }
251    }
252
253    private func buildTimeframeLabel(_ viewModel: HomeViewModel) -> String {
254        let calendar = Calendar.current
255        let buildDates = viewModel.recentBuilds.map(\.job.updated)
256
257        guard !buildDates.isEmpty else {
258            return "today"
259        }
260
261        return buildDates.allSatisfy(calendar.isDateInToday) ? "today" : "this week"
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        .padding(.vertical, verticalPadding)
467    }
468
469    private var iconColor: Color {
470        switch emphasis {
471        case .action:
472            return tint
473        case .monitoring:
474            return tint.opacity(0.9)
475        }
476    }
477
478    private var verticalPadding: CGFloat {
479        switch emphasis {
480        case .action:
481            return 3
482        case .monitoring:
483            return 2
484        }
485    }
486}
487
488private struct HomeRecentRow: View {
489    let item: RecentActivityEntry
490
491    var body: some View {
492        HStack(spacing: 10) {
493            Image(systemName: iconName)
494                .font(.caption.weight(.semibold))
495                .foregroundStyle(.secondary)
496                .frame(width: 16)
497
498            VStack(alignment: .leading, spacing: 1) {
499                Text(item.title)
500                    .font(.subheadline.weight(.medium))
501                    .lineLimit(1)
502                Text(item.detailText)
503                    .font(.caption)
504                    .foregroundStyle(.secondary)
505                    .lineLimit(1)
506            }
507
508            Spacer(minLength: 8)
509        }
510        .padding(.vertical, 1)
511    }
512
513    private var iconName: String {
514        switch item.kind {
515        case .repository:
516            return "book.closed"
517        case .ticket:
518            return "number"
519        case .build:
520            return "hammer"
521        }
522    }
523}
524
525private struct HomePinnedCard: View {
526    let item: HomePinnedItem
527
528    var body: some View {
529        VStack(alignment: .leading, spacing: 6) {
530            HStack(spacing: 6) {
531                Image(systemName: "square.stack.3d.up")
532                    .font(.caption.weight(.semibold))
533                    .foregroundStyle(.secondary)
534                Text(item.detail)
535                    .font(.caption2.weight(.semibold))
536                    .foregroundStyle(.secondary)
537            }
538
539            Text(item.title)
540                .font(.subheadline.weight(.semibold))
541                .lineLimit(2)
542
543            Spacer(minLength: 0)
544        }
545        .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
546        .padding(10)
547        .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
548    }
549}
550
551private struct HomeCompactMessageRow: View {
552    let text: String
553    let systemImage: String
554
555    var body: some View {
556        Label(text, systemImage: systemImage)
557            .font(.caption)
558            .foregroundStyle(.secondary)
559            .padding(.vertical, 2)
560    }
561}