krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.5: 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}