krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.16.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 do {
89 async let contributionCalendar = statsService.fetchContributionCalendar(actor: actor, endingOn: resolvedEndDate)
90 async let contributionStats = statsService.fetchContributionStats(actor: actor, endingOn: resolvedEndDate)
91
92 self.contributionCalendar = try await contributionCalendar
93 self.contributionStats = try await contributionStats
94 if contributionDisplayState != .unavailable {
95 contributionsError = nil
96 }
97 } catch {
98 contributionsError = error.userFacingMessage
99 }
100 }
101
102 private static let repositoriesQuery = """
103 query userRepositories($owner: String!) {
104 user(username: $owner) {
105 repositories {
106 results {
107 id
108 rid
109 name
110 description
111 visibility
112 updated
113 owner { canonicalName }
114 HEAD { name target }
115 }
116 cursor
117 }
118 }
119 }
120 """
121
122 private static let trackersQuery = """
123 query userTrackers($owner: String!) {
124 user(username: $owner) {
125 trackers {
126 results {
127 id
128 rid
129 name
130 description
131 visibility
132 updated
133 owner { canonicalName }
134 }
135 cursor
136 }
137 }
138 }
139 """
140
141 private struct UserRepositoriesResponse: Decodable, Sendable {
142 let user: UserRepositoriesContainer
143 }
144
145 private struct UserRepositoriesContainer: Decodable, Sendable {
146 let repositories: RepositoriesPage
147 }
148
149 private struct RepositoriesPage: Decodable, Sendable {
150 let results: [RepositoryPayload]
151 let cursor: String?
152 }
153
154 private struct RepositoryPayload: Decodable, Sendable {
155 let id: Int
156 let rid: String
157 let name: String
158 let description: String?
159 let visibility: Visibility
160 let updated: Date
161 let owner: Entity
162 let head: Reference?
163
164 enum CodingKeys: String, CodingKey {
165 case id, rid, name, description, visibility, updated, owner
166 case head = "HEAD"
167 }
168
169 func repositorySummary(service: SRHTService) -> RepositorySummary {
170 RepositorySummary(
171 id: id,
172 rid: rid,
173 service: service,
174 name: name,
175 description: description,
176 visibility: visibility,
177 updated: updated,
178 owner: owner,
179 head: head
180 )
181 }
182 }
183
184 private struct UserTrackersResponse: Decodable, Sendable {
185 let user: UserTrackersContainer
186 }
187
188 private struct UserTrackersContainer: Decodable, Sendable {
189 let trackers: TrackersPage
190 }
191
192 private struct TrackersPage: Decodable, Sendable {
193 let results: [TrackerSummary]
194 let cursor: String?
195 }
196
197 private enum ContributionDisplayState {
198 case populated
199 case indexing
200 case empty
201 case unavailable
202 }
203
204 private var contributionDisplayState: ContributionDisplayState {
205 if let contributionStats, contributionStats.totalEvents > 0 {
206 return .populated
207 }
208
209 if let contributionCalendar, !contributionCalendar.isEmpty {
210 return .populated
211 }
212
213 let indexingState = contributionStats?.indexingState ?? contributionCalendar?.indexingState
214 switch indexingState {
215 case .pending:
216 return .indexing
217 case .error:
218 return .unavailable
219 case .indexed, nil:
220 return .empty
221 }
222 }
223
224}