krz/hutch

an ios client for sourcehut

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

v2.15.1: Hutch/Views/Lookup/UserProfileViewModel.swift · raw

  1import Foundation
  2
  3@Observable
  4@MainActor
  5final class UserProfileViewModel {
  6    private(set) var repositories: [RepositorySummary] = []
  7    private(set) var trackers: [TrackerSummary] = []
  8    private(set) var contributionCalendar: ContributionCalendarResponse?
  9    private(set) var contributionStats: ContributionStatsResponse?
 10    private(set) var isLoadingRepositories = false
 11    private(set) var isLoadingTrackers = false
 12    private(set) var isLoadingContributions = false
 13    var repositoriesError: String?
 14    var trackersError: String?
 15    var contributionsError: String?
 16
 17    private let client: SRHTClient
 18    private let statsService: HutchStatsService
 19    let ownerUsername: String
 20    let actor: String
 21
 22    var isContributionActivityIndexedButEmpty: Bool {
 23        contributionDisplayState != .populated && contributionDisplayState != .unavailable
 24    }
 25
 26    var contributionStatusText: String? {
 27        switch contributionDisplayState {
 28        case .indexing:
 29            return "Activity is being indexed."
 30        case .empty:
 31            return "No contribution activity found."
 32        case .unavailable:
 33            return "Contribution activity is unavailable."
 34        case .populated:
 35            return nil
 36        }
 37    }
 38
 39    init(ownerUsername: String, actor: String, client: SRHTClient, statsService: HutchStatsService) {
 40        self.ownerUsername = ownerUsername
 41        self.actor = actor
 42        self.client = client
 43        self.statsService = statsService
 44    }
 45
 46    func loadRepositories() async {
 47        isLoadingRepositories = true
 48        repositoriesError = nil
 49        defer { isLoadingRepositories = false }
 50
 51        do {
 52            let result = try await client.execute(
 53                service: .git,
 54                query: Self.repositoriesQuery,
 55                variables: ["owner": ownerUsername],
 56                responseType: UserRepositoriesResponse.self
 57            )
 58            repositories = result.user.repositories.results.map { $0.repositorySummary(service: .git) }
 59        } catch {
 60            repositoriesError = error.userFacingMessage
 61        }
 62    }
 63
 64    func loadTrackers() async {
 65        isLoadingTrackers = true
 66        trackersError = nil
 67        defer { isLoadingTrackers = false }
 68
 69        do {
 70            let result = try await client.execute(
 71                service: .todo,
 72                query: Self.trackersQuery,
 73                variables: ["owner": ownerUsername],
 74                responseType: UserTrackersResponse.self
 75            )
 76            trackers = result.user.trackers.results
 77        } catch {
 78            trackersError = error.userFacingMessage
 79        }
 80    }
 81
 82    func loadContributions(endingOn endDate: Date? = nil) async {
 83        isLoadingContributions = true
 84        contributionsError = nil
 85        defer { isLoadingContributions = false }
 86
 87        let resolvedEndDate = Calendar.contributionCalendar.startOfDay(for: endDate ?? Date())
 88        do {
 89            async let contributionCalendar = statsService.fetchContributionCalendar(actor: actor, endingOn: resolvedEndDate)
 90            async let contributionStats = statsService.fetchContributionStats(actor: actor, endingOn: resolvedEndDate)
 91
 92            self.contributionCalendar = try await contributionCalendar
 93            self.contributionStats = try await contributionStats
 94            if contributionDisplayState != .unavailable {
 95                contributionsError = nil
 96            }
 97        } catch {
 98            contributionsError = error.userFacingMessage
 99        }
100    }
101
102    private static let repositoriesQuery = """
103    query userRepositories($owner: String!) {
104        user(username: $owner) {
105            repositories {
106                results {
107                    id
108                    rid
109                    name
110                    description
111                    visibility
112                    updated
113                    owner { canonicalName }
114                    HEAD { name target }
115                }
116                cursor
117            }
118        }
119    }
120    """
121
122    private static let trackersQuery = """
123    query userTrackers($owner: String!) {
124        user(username: $owner) {
125            trackers {
126                results {
127                    id
128                    rid
129                    name
130                    description
131                    visibility
132                    updated
133                    owner { canonicalName }
134                }
135                cursor
136            }
137        }
138    }
139    """
140
141    private struct UserRepositoriesResponse: Decodable, Sendable {
142        let user: UserRepositoriesContainer
143    }
144
145    private struct UserRepositoriesContainer: Decodable, Sendable {
146        let repositories: RepositoriesPage
147    }
148
149    private struct RepositoriesPage: Decodable, Sendable {
150        let results: [RepositoryPayload]
151        let cursor: String?
152    }
153
154    private struct RepositoryPayload: Decodable, Sendable {
155        let id: Int
156        let rid: String
157        let name: String
158        let description: String?
159        let visibility: Visibility
160        let updated: Date
161        let owner: Entity
162        let head: Reference?
163
164        enum CodingKeys: String, CodingKey {
165            case id, rid, name, description, visibility, updated, owner
166            case head = "HEAD"
167        }
168
169        func repositorySummary(service: SRHTService) -> RepositorySummary {
170            RepositorySummary(
171                id: id,
172                rid: rid,
173                service: service,
174                name: name,
175                description: description,
176                visibility: visibility,
177                updated: updated,
178                owner: owner,
179                head: head
180            )
181        }
182    }
183
184    private struct UserTrackersResponse: Decodable, Sendable {
185        let user: UserTrackersContainer
186    }
187
188    private struct UserTrackersContainer: Decodable, Sendable {
189        let trackers: TrackersPage
190    }
191
192    private struct TrackersPage: Decodable, Sendable {
193        let results: [TrackerSummary]
194        let cursor: String?
195    }
196
197    private enum ContributionDisplayState {
198        case populated
199        case indexing
200        case empty
201        case unavailable
202    }
203
204    private var contributionDisplayState: ContributionDisplayState {
205        if let contributionStats, contributionStats.totalEvents > 0 {
206            return .populated
207        }
208
209        if let contributionCalendar, !contributionCalendar.isEmpty {
210            return .populated
211        }
212
213        let indexingState = contributionStats?.indexingState ?? contributionCalendar?.indexingState
214        switch indexingState {
215        case .pending:
216            return .indexing
217        case .error:
218            return .unavailable
219        case .indexed, nil:
220            return .empty
221        }
222    }
223
224}