krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.11.0: 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 debugLog("profile contributions start actor=\(actor) endDate=\(resolvedEndDate.formatted(date: .abbreviated, time: .omitted))")
89
90 do {
91 async let contributionCalendar = statsService.fetchContributionCalendar(actor: actor, endingOn: resolvedEndDate)
92 async let contributionStats = statsService.fetchContributionStats(actor: actor, endingOn: resolvedEndDate)
93
94 self.contributionCalendar = try await contributionCalendar
95 self.contributionStats = try await contributionStats
96 if contributionDisplayState != .unavailable {
97 contributionsError = nil
98 }
99 debugLog(
100 "profile contributions complete actor=\(actor) endDate=\(resolvedEndDate.formatted(date: .abbreviated, time: .omitted)) " +
101 "state=\(String(describing: contributionDisplayState)) days=\(self.contributionCalendar?.days.count ?? 0) " +
102 "totalEvents=\(self.contributionStats?.totalEvents ?? 0) error=\(contributionsError ?? "none")"
103 )
104 } catch {
105 contributionsError = error.userFacingMessage
106 debugLog(
107 "profile contributions failed actor=\(actor) endDate=\(resolvedEndDate.formatted(date: .abbreviated, time: .omitted)) " +
108 "error=\(error.localizedDescription) userFacing=\(contributionsError ?? "none")"
109 )
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 private func debugLog(_ message: String) {
236#if DEBUG
237 print("[UserProfileViewModel] \(message)")
238#endif
239 }
240}