krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.6: 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 let page = try await fetchPage(cursor: nil)
122 trackers = page.results
123 cursor = page.cursor
124 hasMore = page.cursor != nil
125 } catch {
126 self.error = error.userFacingMessage
127 }
128
129 isLoading = false
130 }
131
132 func loadMoreIfNeeded(currentItem: TrackerSummary) async {
133 guard let last = trackers.last,
134 last.id == currentItem.id,
135 hasMore,
136 !isLoadingMore else {
137 return
138 }
139
140 isLoadingMore = true
141
142 do {
143 let page = try await fetchPage(cursor: cursor)
144 trackers.append(contentsOf: page.results)
145 cursor = page.cursor
146 hasMore = page.cursor != nil
147 } catch {
148 self.error = error.userFacingMessage
149 }
150
151 isLoadingMore = false
152 }
153
154 func createTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? {
155 guard !isCreatingTracker else { return nil }
156
157 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
158 guard !trimmedName.isEmpty else {
159 error = "Enter a tracker name."
160 return nil
161 }
162
163 isCreatingTracker = true
164 error = nil
165 defer { isCreatingTracker = false }
166
167 var variables: [String: any Sendable] = [
168 "name": trimmedName,
169 "visibility": visibility.rawValue
170 ]
171 let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
172 if !trimmedDescription.isEmpty {
173 variables["description"] = trimmedDescription
174 }
175
176 do {
177 let result = try await client.execute(
178 service: .todo,
179 query: Self.createTrackerMutation,
180 variables: variables,
181 responseType: CreateTrackerResponse.self
182 )
183 let tracker = result.createTracker
184 trackers.insert(tracker, at: 0)
185 return tracker
186 } catch {
187 self.error = trackerCreationErrorMessage(for: error)
188 return nil
189 }
190 }
191
192 func updateTracker(
193 _ tracker: TrackerSummary,
194 name: String,
195 description: String,
196 visibility: Visibility
197 ) async -> TrackerSummary? {
198 guard !isCreatingTracker else { return nil }
199
200 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
201 guard !trimmedName.isEmpty else {
202 error = "Enter a tracker name."
203 return nil
204 }
205
206 isCreatingTracker = true
207 error = nil
208 defer { isCreatingTracker = false }
209
210 let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
211 let input: [String: any Sendable] = [
212 "name": trimmedName,
213 "description": trimmedDescription.isEmpty ? "" : trimmedDescription,
214 "visibility": visibility.rawValue
215 ]
216
217 do {
218 let result = try await client.execute(
219 service: .todo,
220 query: Self.updateTrackerMutation,
221 variables: [
222 "id": tracker.id,
223 "input": input
224 ],
225 responseType: UpdateTrackerResponse.self
226 )
227 applyTrackerUpdate(result.updateTracker)
228 return result.updateTracker
229 } catch {
230 self.error = "Couldn’t update the tracker. \(error.userFacingMessage)"
231 return nil
232 }
233 }
234
235 func deleteTracker(_ tracker: TrackerSummary) async -> Bool {
236 guard !isCreatingTracker else { return false }
237
238 isCreatingTracker = true
239 error = nil
240 defer { isCreatingTracker = false }
241
242 do {
243 _ = try await client.execute(
244 service: .todo,
245 query: Self.deleteTrackerMutation,
246 variables: ["id": tracker.id],
247 responseType: DeleteTrackerResponse.self
248 )
249 trackers.removeAll { $0.id == tracker.id }
250 await loadTrackers()
251 return true
252 } catch {
253 self.error = "Couldn’t delete the tracker. \(error.userFacingMessage)"
254 return false
255 }
256 }
257
258 func applyTrackerUpdate(_ tracker: TrackerSummary) {
259 if let index = trackers.firstIndex(where: { $0.id == tracker.id }) {
260 trackers[index] = tracker
261 } else {
262 trackers.insert(tracker, at: 0)
263 }
264 }
265
266 func applyTrackerDeletion(_ tracker: TrackerSummary) {
267 trackers.removeAll { $0.id == tracker.id }
268 }
269
270 // MARK: - Private
271
272 private func fetchPage(cursor: String?) async throws -> TrackersPage {
273 var variables: [String: any Sendable] = [:]
274 if let cursor {
275 variables["cursor"] = cursor
276 }
277 let result = try await client.execute(
278 service: .todo,
279 query: Self.query,
280 variables: variables.isEmpty ? nil : variables,
281 responseType: TrackersResponse.self
282 )
283 return result.trackers
284 }
285
286 private struct CreateTrackerResponse: Decodable, Sendable {
287 let createTracker: TrackerSummary
288 }
289
290 private func trackerCreationErrorMessage(for error: Error) -> String {
291 "Couldn’t create the tracker. \(error.userFacingMessage)"
292 }
293}