krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.3: 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 id: id,
183 rid: rid,
184 service: service,
185 name: name,
186 description: description,
187 visibility: visibility,
188 updated: updated,
189 owner: owner,
190 head: head
191 )
192 }
193 }
194
195 private struct UserTrackersResponse: Decodable, Sendable {
196 let user: UserTrackersContainer
197 }
198
199 private struct UserTrackersContainer: Decodable, Sendable {
200 let trackers: TrackersPage
201 }
202
203 private struct TrackersPage: Decodable, Sendable {
204 let results: [TrackerSummary]
205 let cursor: String?
206 }
207
208 private enum ContributionDisplayState {
209 case populated
210 case indexing
211 case empty
212 case unavailable
213 }
214
215 private var contributionDisplayState: ContributionDisplayState {
216 if let contributionStats, contributionStats.totalEvents > 0 {
217 return .populated
218 }
219
220 if let contributionCalendar, !contributionCalendar.isEmpty {
221 return .populated
222 }
223
224 let indexingState = contributionStats?.indexingState ?? contributionCalendar?.indexingState
225 switch indexingState {
226 case .pending:
227 return .indexing
228 case .error:
229 return .unavailable
230 case .indexed, nil:
231 return .empty
232 }
233 }
234
235}