krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2: 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.localizedDescription
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.localizedDescription
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.localizedDescription)"
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}