krz/hutch

an ios client for sourcehut

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

v3.1.11: 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 updateRepository(_ repository: RepositorySummary) {
 65        guard let index = repositories.firstIndex(where: { $0.id == repository.id }) else { return }
 66        repositories[index] = repository
 67        repositories.sort { lhs, rhs in
 68            if lhs.updated == rhs.updated {
 69                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
 70            }
 71            return lhs.updated > rhs.updated
 72        }
 73    }
 74
 75    func loadTrackers() async {
 76        isLoadingTrackers = true
 77        trackersError = nil
 78        defer { isLoadingTrackers = false }
 79
 80        do {
 81            let result = try await client.execute(
 82                service: .todo,
 83                query: Self.trackersQuery,
 84                variables: ["owner": ownerUsername],
 85                responseType: UserTrackersResponse.self
 86            )
 87            trackers = result.user.trackers.results
 88        } catch {
 89            trackersError = error.userFacingMessage
 90        }
 91    }
 92
 93    func loadContributions(endingOn endDate: Date? = nil) async {
 94        isLoadingContributions = true
 95        contributionsError = nil
 96        defer { isLoadingContributions = false }
 97
 98        let resolvedEndDate = Calendar.contributionCalendar.startOfDay(for: endDate ?? Date())
 99        do {
100            async let contributionCalendar = statsService.fetchContributionCalendar(actor: actor, endingOn: resolvedEndDate)
101            async let contributionStats = statsService.fetchContributionStats(actor: actor, endingOn: resolvedEndDate)
102
103            self.contributionCalendar = try await contributionCalendar
104            self.contributionStats = try await contributionStats
105            if contributionDisplayState != .unavailable {
106                contributionsError = nil
107            }
108        } catch {
109            contributionsError = error.userFacingMessage
110        }
111    }
112
113    private static let repositoriesQuery = """
114    query userRepositories($owner: String!) {
115        user(username: $owner) {
116            repositories {
117                results {
118                    id
119                    rid
120                    name
121                    description
122                    visibility
123                    updated
124                    owner { canonicalName }
125                    HEAD { name target }
126                }
127                cursor
128            }
129        }
130    }
131    """
132
133    private static let trackersQuery = """
134    query userTrackers($owner: String!) {
135        user(username: $owner) {
136            trackers {
137                results {
138                    id
139                    rid
140                    name
141                    description
142                    visibility
143                    updated
144                    owner { canonicalName }
145                }
146                cursor
147            }
148        }
149    }
150    """
151
152    private struct UserRepositoriesResponse: Decodable, Sendable {
153        let user: UserRepositoriesContainer
154    }
155
156    private struct UserRepositoriesContainer: Decodable, Sendable {
157        let repositories: RepositoriesPage
158    }
159
160    private struct RepositoriesPage: Decodable, Sendable {
161        let results: [RepositoryPayload]
162        let cursor: String?
163    }
164
165    private struct RepositoryPayload: Decodable, Sendable {
166        let id: Int
167        let rid: String
168        let name: String
169        let description: String?
170        let visibility: Visibility
171        let updated: Date
172        let owner: Entity
173        let head: Reference?
174
175        enum CodingKeys: String, CodingKey {
176            case id, rid, name, description, visibility, updated, owner
177            case head = "HEAD"
178        }
179
180        func repositorySummary(service: SRHTService) -> RepositorySummary {
181            RepositorySummary(
182                fields: .init(
183                    id: id,
184                    rid: rid,
185                    service: service,
186                    name: name,
187                    description: description,
188                    visibility: visibility,
189                    updated: updated,
190                    owner: owner,
191                    head: head
192                )
193            )
194        }
195    }
196
197    private struct UserTrackersResponse: Decodable, Sendable {
198        let user: UserTrackersContainer
199    }
200
201    private struct UserTrackersContainer: Decodable, Sendable {
202        let trackers: TrackersPage
203    }
204
205    private struct TrackersPage: Decodable, Sendable {
206        let results: [TrackerSummary]
207        let cursor: String?
208    }
209
210    private enum ContributionDisplayState {
211        case populated
212        case indexing
213        case empty
214        case unavailable
215    }
216
217    private var contributionDisplayState: ContributionDisplayState {
218        if let contributionStats, contributionStats.totalEvents > 0 {
219            return .populated
220        }
221
222        if let contributionCalendar, !contributionCalendar.isEmpty {
223            return .populated
224        }
225
226        let indexingState = contributionStats?.indexingState ?? contributionCalendar?.indexingState
227        switch indexingState {
228        case .pending:
229            return .indexing
230        case .error:
231            return .unavailable
232        case .indexed, nil:
233            return .empty
234        }
235    }
236
237}