krz/hutch

an ios client for sourcehut

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

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