krz/hutch

an ios client for sourcehut

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

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