krz/hutch

an ios client for sourcehut

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

v2.1: Hutch/Views/Tickets/TicketListViewModel.swift · raw

  1import Foundation
  2
  3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
  4
  5private struct TrackerTicketsResponse: Decodable, Sendable {
  6    let user: UserTrackerWrapper
  7}
  8
  9private struct UserTrackerWrapper: Decodable, Sendable {
 10    let tracker: TrackerTicketsWrapper
 11}
 12
 13private struct TrackerTicketsWrapper: Decodable, Sendable {
 14    let tickets: TicketsPage
 15}
 16
 17private struct TicketsPage: Decodable, Sendable {
 18    let results: [TicketSummary]
 19    let cursor: String?
 20}
 21
 22// MARK: - Filter
 23
 24enum TicketFilter: String, CaseIterable, Sendable {
 25    case open = "Open"
 26    case resolved = "Resolved"
 27    case all = "All"
 28}
 29
 30// MARK: - View Model
 31
 32@Observable
 33@MainActor
 34final class TicketListViewModel {
 35    let ownerUsername: String
 36    let trackerName: String
 37    let trackerId: Int
 38
 39    private(set) var tickets: [TicketSummary] = []
 40    private(set) var isLoading = false
 41    private(set) var isLoadingMore = false
 42    private(set) var isCreatingTicket = false
 43    var error: String?
 44    var filter: TicketFilter = .open
 45
 46    private var cursor: String?
 47    private var hasMore = true
 48    private let client: SRHTClient
 49
 50    init(ownerUsername: String, trackerName: String, trackerId: Int, client: SRHTClient) {
 51        self.ownerUsername = ownerUsername
 52        self.trackerName = trackerName
 53        self.trackerId = trackerId
 54        self.client = client
 55    }
 56
 57    // MARK: - Query
 58
 59    private static let query = """
 60    query tickets($owner: String!, $tracker: String!, $cursor: Cursor) {
 61        user(username: $owner) {
 62            tracker(name: $tracker) {
 63                tickets(cursor: $cursor) {
 64                    results {
 65                        id
 66                        title: subject
 67                        status
 68                        resolution
 69                        created
 70                        submitter { canonicalName }
 71                        labels { id name backgroundColor foregroundColor }
 72                        assignees { canonicalName }
 73                    }
 74                    cursor
 75                }
 76            }
 77        }
 78    }
 79    """
 80
 81    private static let submitTicketMutation = """
 82    mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
 83        submitTicket(trackerId: $trackerId, input: $input) {
 84            id
 85            title: subject
 86            status
 87            resolution
 88            created
 89            submitter { canonicalName }
 90            labels { id name backgroundColor foregroundColor }
 91            assignees { canonicalName }
 92        }
 93    }
 94    """
 95
 96    // MARK: - Computed
 97
 98    /// Tickets filtered by the selected status filter.
 99    var filteredTickets: [TicketSummary] {
100        switch filter {
101        case .open:
102            tickets.filter { $0.status.isOpen }
103        case .resolved:
104            tickets.filter { !$0.status.isOpen }
105        case .all:
106            tickets
107        }
108    }
109
110    // MARK: - Public API
111
112    func loadTickets() async {
113        isLoading = true
114        error = nil
115        cursor = nil
116        hasMore = true
117
118        do {
119            let page = try await fetchPage(cursor: nil)
120            tickets = page.results
121            cursor = page.cursor
122            hasMore = page.cursor != nil
123        } catch {
124            self.error = error.userFacingMessage
125        }
126
127        isLoading = false
128    }
129
130    func loadMoreIfNeeded(currentItem: TicketSummary) async {
131        guard let last = tickets.last,
132              last.id == currentItem.id,
133              hasMore,
134              !isLoadingMore else {
135            return
136        }
137
138        isLoadingMore = true
139
140        do {
141            let page = try await fetchPage(cursor: cursor)
142            tickets.append(contentsOf: page.results)
143            cursor = page.cursor
144            hasMore = page.cursor != nil
145        } catch {
146            self.error = error.userFacingMessage
147        }
148
149        isLoadingMore = false
150    }
151
152    func createTicket(subject: String, body: String) async -> TicketSummary? {
153        guard !isCreatingTicket else { return nil }
154
155        let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
156        guard !trimmedSubject.isEmpty else {
157            error = "Enter a ticket title."
158            return nil
159        }
160
161        isCreatingTicket = true
162        error = nil
163        defer { isCreatingTicket = false }
164
165        var input: [String: any Sendable] = [
166            "subject": trimmedSubject
167        ]
168        let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
169        if !trimmedBody.isEmpty {
170            input["body"] = trimmedBody
171        }
172        let variables: [String: any Sendable] = [
173            "trackerId": trackerId,
174            "input": input
175        ]
176
177        do {
178            let result = try await client.execute(
179                service: .todo,
180                query: Self.submitTicketMutation,
181                variables: variables,
182                responseType: SubmitTicketResponse.self
183            )
184            let ticket = result.submitTicket
185            tickets.insert(ticket, at: 0)
186            return ticket
187        } catch {
188            self.error = "Couldn’t create the ticket. \(error.userFacingMessage)"
189            return nil
190        }
191    }
192
193    // MARK: - Private
194
195    private func fetchPage(cursor: String?) async throws -> TicketsPage {
196        var variables: [String: any Sendable] = [
197            "owner": ownerUsername,
198            "tracker": trackerName
199        ]
200        if let cursor {
201            variables["cursor"] = cursor
202        }
203        let result = try await client.execute(
204            service: .todo,
205            query: Self.query,
206            variables: variables,
207            responseType: TrackerTicketsResponse.self
208        )
209        return result.user.tracker.tickets
210    }
211
212    private struct SubmitTicketResponse: Decodable, Sendable {
213        let submitTicket: TicketSummary
214    }
215}