krz/hutch

an ios client for sourcehut

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

v3.8.1: 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}