krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.5.0: Hutch/Views/Tickets/TrackerListViewModel.swift · raw
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct TrackersResponse: Decodable, Sendable {
6 let trackers: TrackersPage
7}
8
9private struct TrackersPage: Decodable, Sendable {
10 let results: [TrackerSummary]
11 let cursor: String?
12}
13
14private struct UpdateTrackerResponse: Decodable, Sendable {
15 let updateTracker: TrackerSummary
16}
17
18private struct DeleteTrackerResponse: Decodable, Sendable {
19 let deleteTracker: DeletedTracker
20}
21
22private struct DeletedTracker: Decodable, Sendable {
23 let id: Int
24}
25
26// MARK: - View Model
27
28@Observable
29@MainActor
30final class TrackerListViewModel {
31
32 private(set) var trackers: [TrackerSummary] = []
33 private(set) var isLoading = false
34 private(set) var isLoadingMore = false
35 private(set) var isCreatingTracker = false
36 var error: String?
37 var searchText = ""
38
39 private var cursor: String?
40 private var hasMore = true
41 private let client: SRHTClient
42
43 init(client: SRHTClient) {
44 self.client = client
45 }
46
47 var filteredTrackers: [TrackerSummary] {
48 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
49 guard !q.isEmpty else { return trackers }
50 return trackers.filter {
51 $0.name.lowercased().contains(q) ||
52 ($0.description?.lowercased().contains(q) == true) ||
53 $0.owner.canonicalName.lowercased().contains(q)
54 }
55 }
56
57 // MARK: - Query
58
59 private static let query = """
60 query trackers($cursor: Cursor) {
61 trackers(cursor: $cursor) {
62 results {
63 id
64 rid
65 name
66 description
67 visibility
68 updated
69 owner { canonicalName }
70 }
71 cursor
72 }
73 }
74 """
75
76 private static let createTrackerMutation = """
77 mutation createTracker($name: String!, $visibility: Visibility!, $description: String) {
78 createTracker(name: $name, visibility: $visibility, description: $description) {
79 id
80 rid
81 name
82 description
83 visibility
84 updated
85 owner { canonicalName }
86 }
87 }
88 """
89
90 private static let updateTrackerMutation = """
91 mutation updateTracker($id: Int!, $input: TrackerInput!) {
92 updateTracker(id: $id, input: $input) {
93 id
94 rid
95 name
96 description
97 visibility
98 updated
99 owner { canonicalName }
100 }
101 }
102 """
103
104 private static let deleteTrackerMutation = """
105 mutation deleteTracker($id: Int!) {
106 deleteTracker(id: $id) {
107 id
108 }
109 }
110 """
111
112 // MARK: - Public API
113
114 func loadTrackers() async {
115 isLoading = true
116 error = nil
117 cursor = nil
118 hasMore = true
119
120 do {
121 if trackers.isEmpty, let cached = try? await fetchPage(cursor: nil, policy: .cacheOnly) {
122 trackers = cached.results
123 cursor = cached.cursor
124 hasMore = cached.cursor != nil
125 isLoading = false
126 }
127 let page = try await fetchPage(cursor: nil)
128 trackers = page.results
129 cursor = page.cursor
130 hasMore = page.cursor != nil
131 } catch {
132 self.error = error.userFacingMessage
133 }
134
135 isLoading = false
136 }
137
138 func loadMoreIfNeeded(currentItem: TrackerSummary) async {
139 guard let last = trackers.last,
140 last.id == currentItem.id,
141 hasMore,
142 !isLoadingMore else {
143 return
144 }
145
146 isLoadingMore = true
147
148 do {
149 let page = try await fetchPage(cursor: cursor)
150 trackers.append(contentsOf: page.results)
151 cursor = page.cursor
152 hasMore = page.cursor != nil
153 } catch {
154 self.error = error.userFacingMessage
155 }
156
157 isLoadingMore = false
158 }
159
160 func createTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? {
161 guard !isCreatingTracker else { return nil }
162
163 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
164 guard !trimmedName.isEmpty else {
165 error = "Enter a tracker name."
166 return nil
167 }
168
169 isCreatingTracker = true
170 error = nil
171 defer { isCreatingTracker = false }
172
173 var variables: [String: any Sendable] = [
174 "name": trimmedName,
175 "visibility": visibility.rawValue
176 ]
177 let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
178 if !trimmedDescription.isEmpty {
179 variables["description"] = trimmedDescription
180 }
181
182 do {
183 let result = try await client.execute(
184 service: .todo,
185 query: Self.createTrackerMutation,
186 variables: variables,
187 responseType: CreateTrackerResponse.self
188 )
189 let tracker = result.createTracker
190 await invalidateTrackerCaches()
191 trackers.insert(tracker, at: 0)
192 return tracker
193 } catch {
194 self.error = trackerCreationErrorMessage(for: error)
195 return nil
196 }
197 }
198
199 func updateTracker(
200 _ tracker: TrackerSummary,
201 name: String,
202 description: String,
203 visibility: Visibility
204 ) async -> TrackerSummary? {
205 guard !isCreatingTracker else { return nil }
206
207 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
208 guard !trimmedName.isEmpty else {
209 error = "Enter a tracker name."
210 return nil
211 }
212
213 isCreatingTracker = true
214 error = nil
215 defer { isCreatingTracker = false }
216
217 let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
218 let input: [String: any Sendable] = [
219 "name": trimmedName,
220 "description": trimmedDescription.isEmpty ? "" : trimmedDescription,
221 "visibility": visibility.rawValue
222 ]
223
224 do {
225 let result = try await client.execute(
226 service: .todo,
227 query: Self.updateTrackerMutation,
228 variables: [
229 "id": tracker.id,
230 "input": input
231 ],
232 responseType: UpdateTrackerResponse.self
233 )
234 await invalidateTrackerCaches()
235 applyTrackerUpdate(result.updateTracker)
236 return result.updateTracker
237 } catch {
238 self.error = "Couldn’t update the tracker. \(error.userFacingMessage)"
239 return nil
240 }
241 }
242
243 func deleteTracker(_ tracker: TrackerSummary) async -> Bool {
244 guard !isCreatingTracker else { return false }
245
246 isCreatingTracker = true
247 error = nil
248 defer { isCreatingTracker = false }
249
250 do {
251 _ = try await client.execute(
252 service: .todo,
253 query: Self.deleteTrackerMutation,
254 variables: ["id": tracker.id],
255 responseType: DeleteTrackerResponse.self
256 )
257 await invalidateTrackerCaches()
258 trackers.removeAll { $0.id == tracker.id }
259 await loadTrackers()
260 return true
261 } catch {
262 self.error = "Couldn’t delete the tracker. \(error.userFacingMessage)"
263 return false
264 }
265 }
266
267 func applyTrackerUpdate(_ tracker: TrackerSummary) {
268 if let index = trackers.firstIndex(where: { $0.id == tracker.id }) {
269 trackers[index] = tracker
270 } else {
271 trackers.insert(tracker, at: 0)
272 }
273 }
274
275 func applyTrackerDeletion(_ tracker: TrackerSummary) {
276 trackers.removeAll { $0.id == tracker.id }
277 }
278
279 // MARK: - Private
280
281 private func fetchPage(cursor: String?, policy: CachePolicy = .cacheFirstThenRefresh) async throws -> TrackersPage {
282 var variables: [String: any Sendable] = [:]
283 if let cursor {
284 variables["cursor"] = cursor
285 }
286 let cached = try await client.executeCached(
287 service: .todo,
288 query: Self.query,
289 variables: variables.isEmpty ? nil : variables,
290 responseType: TrackersResponse.self,
291 cacheKey: APICacheKeys.trackers(cursor: cursor),
292 resourceType: .ticketList,
293 ttl: APICacheTTLs.ticketList,
294 policy: policy
295 )
296 return cached.value.trackers
297 }
298
299 private struct CreateTrackerResponse: Decodable, Sendable {
300 let createTracker: TrackerSummary
301 }
302
303 private func trackerCreationErrorMessage(for error: Error) -> String {
304 "Couldn’t create the tracker. \(error.userFacingMessage)"
305 }
306
307 private func invalidateTrackerCaches() async {
308 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "trackers"))
309 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
310 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
311 }
312}