krz/hutch

an ios client for sourcehut

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

v2.4.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
 14// MARK: - View Model
 15
 16@Observable
 17@MainActor
 18final class TrackerListViewModel {
 19
 20    private(set) var trackers: [TrackerSummary] = []
 21    private(set) var isLoading = false
 22    private(set) var isLoadingMore = false
 23    private(set) var isCreatingTracker = false
 24    var error: String?
 25    var searchText = ""
 26
 27    private var cursor: String?
 28    private var hasMore = true
 29    private let client: SRHTClient
 30
 31    init(client: SRHTClient) {
 32        self.client = client
 33    }
 34
 35    var filteredTrackers: [TrackerSummary] {
 36        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
 37        guard !q.isEmpty else { return trackers }
 38        return trackers.filter {
 39            $0.name.lowercased().contains(q) ||
 40            ($0.description?.lowercased().contains(q) == true) ||
 41            $0.owner.canonicalName.lowercased().contains(q)
 42        }
 43    }
 44
 45    // MARK: - Query
 46
 47    private static let query = """
 48    query trackers($cursor: Cursor) {
 49        trackers(cursor: $cursor) {
 50            results {
 51                id
 52                rid
 53                name
 54                description
 55                visibility
 56                updated
 57                owner { canonicalName }
 58            }
 59            cursor
 60        }
 61    }
 62    """
 63
 64    private static let createTrackerMutation = """
 65    mutation createTracker($name: String!, $visibility: Visibility!, $description: String) {
 66        createTracker(name: $name, visibility: $visibility, description: $description) {
 67            id
 68            rid
 69            name
 70            description
 71            visibility
 72            updated
 73            owner { canonicalName }
 74        }
 75    }
 76    """
 77
 78    // MARK: - Public API
 79
 80    func loadTrackers() async {
 81        isLoading = true
 82        error = nil
 83        cursor = nil
 84        hasMore = true
 85
 86        do {
 87            let page = try await fetchPage(cursor: nil)
 88            trackers = page.results
 89            cursor = page.cursor
 90            hasMore = page.cursor != nil
 91        } catch {
 92            self.error = error.userFacingMessage
 93        }
 94
 95        isLoading = false
 96    }
 97
 98    func loadMoreIfNeeded(currentItem: TrackerSummary) async {
 99        guard let last = trackers.last,
100              last.id == currentItem.id,
101              hasMore,
102              !isLoadingMore else {
103            return
104        }
105
106        isLoadingMore = true
107
108        do {
109            let page = try await fetchPage(cursor: cursor)
110            trackers.append(contentsOf: page.results)
111            cursor = page.cursor
112            hasMore = page.cursor != nil
113        } catch {
114            self.error = error.userFacingMessage
115        }
116
117        isLoadingMore = false
118    }
119
120    func createTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? {
121        guard !isCreatingTracker else { return nil }
122
123        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
124        guard !trimmedName.isEmpty else {
125            error = "Enter a tracker name."
126            return nil
127        }
128
129        isCreatingTracker = true
130        error = nil
131        defer { isCreatingTracker = false }
132
133        var variables: [String: any Sendable] = [
134            "name": trimmedName,
135            "visibility": visibility.rawValue
136        ]
137        let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
138        if !trimmedDescription.isEmpty {
139            variables["description"] = trimmedDescription
140        }
141
142        do {
143            let result = try await client.execute(
144                service: .todo,
145                query: Self.createTrackerMutation,
146                variables: variables,
147                responseType: CreateTrackerResponse.self
148            )
149            let tracker = result.createTracker
150            trackers.insert(tracker, at: 0)
151            return tracker
152        } catch {
153            self.error = trackerCreationErrorMessage(for: error)
154            return nil
155        }
156    }
157
158    // MARK: - Private
159
160    private func fetchPage(cursor: String?) async throws -> TrackersPage {
161        var variables: [String: any Sendable] = [:]
162        if let cursor {
163            variables["cursor"] = cursor
164        }
165        let result = try await client.execute(
166            service: .todo,
167            query: Self.query,
168            variables: variables.isEmpty ? nil : variables,
169            responseType: TrackersResponse.self
170        )
171        return result.trackers
172    }
173
174    private struct CreateTrackerResponse: Decodable, Sendable {
175        let createTracker: TrackerSummary
176    }
177
178    private func trackerCreationErrorMessage(for error: Error) -> String {
179        "Couldn’t create the tracker. \(error.userFacingMessage)"
180    }
181}