krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.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
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.userFacingMessage
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.userFacingMessage
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 "Couldn’t create the tracker. \(error.userFacingMessage)"
169 }
170}