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