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