krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.9.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            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}