krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.10.0: Hutch/Views/Tickets/TrackerListView.swift · raw
1import SwiftUI
2
3struct TrackerListView: View {
4 @Environment(AppState.self) private var appState
5 @State private var viewModel: TrackerListViewModel?
6 @State private var showCreateTrackerSheet = false
7 @State private var createdTracker: TrackerSummary?
8 @State private var editingTracker: TrackerSummary?
9 @State private var pendingDeletion: TrackerSummary?
10
11 var body: some View {
12 Group {
13 if let viewModel {
14 listContent(viewModel)
15 } else {
16 SRHTLoadingStateView(message: "Loading trackers…")
17 }
18 }
19 .navigationTitle("Trackers")
20 .toolbar {
21 if viewModel != nil {
22 ToolbarItem(placement: .topBarTrailing) {
23 Button {
24 showCreateTrackerSheet = true
25 } label: {
26 Image(systemName: "plus")
27 }
28 }
29 }
30 }
31 .sheet(isPresented: $showCreateTrackerSheet) {
32 if let viewModel {
33 TrackerEditorSheet(
34 title: "New Tracker",
35 confirmationTitle: "Create Tracker",
36 isSaving: viewModel.isCreatingTracker,
37 error: viewModel.error,
38 initialName: "",
39 initialDescription: "",
40 initialVisibility: .publicVisibility
41 ) { name, description, visibility in
42 if let tracker = await viewModel.createTracker(
43 name: name,
44 description: description,
45 visibility: visibility
46 ) {
47 createdTracker = tracker
48 showCreateTrackerSheet = false
49 return true
50 }
51 return false
52 }
53 }
54 }
55 .sheet(item: $editingTracker) { tracker in
56 if let viewModel {
57 TrackerEditorSheet(
58 title: "Update Tracker",
59 confirmationTitle: "Save",
60 isSaving: viewModel.isCreatingTracker,
61 error: viewModel.error,
62 initialName: tracker.name,
63 initialDescription: tracker.description ?? "",
64 initialVisibility: tracker.visibility
65 ) { name, description, visibility in
66 if let updatedTracker = await viewModel.updateTracker(
67 tracker,
68 name: name,
69 description: description,
70 visibility: visibility
71 ) {
72 createdTracker = createdTracker?.id == tracker.id ? updatedTracker : createdTracker
73 editingTracker = nil
74 return true
75 }
76 return false
77 }
78 }
79 }
80 .navigationDestination(isPresented: Binding(
81 get: { createdTracker != nil },
82 set: { isPresented in
83 if !isPresented {
84 createdTracker = nil
85 }
86 }
87 )) {
88 if let createdTracker {
89 TicketListView(tracker: createdTracker) { updatedTracker in
90 viewModel?.applyTrackerUpdate(updatedTracker)
91 self.createdTracker = updatedTracker
92 } onTrackerDeleted: { deletedTracker in
93 viewModel?.applyTrackerDeletion(deletedTracker)
94 if let viewModel {
95 Task { await viewModel.loadTrackers() }
96 }
97 self.createdTracker = nil
98 }
99 }
100 }
101 .alert("Delete Tracker?", isPresented: Binding(
102 get: { pendingDeletion != nil },
103 set: { isPresented in
104 if !isPresented {
105 pendingDeletion = nil
106 }
107 }
108 )) {
109 Button("Cancel", role: .cancel) {
110 // no-op: .cancel role handles alert dismissal
111 }
112 Button("Delete", role: .destructive) {
113 guard let pendingDeletion, let viewModel else { return }
114 Task {
115 let didDelete = await viewModel.deleteTracker(pendingDeletion)
116 if didDelete {
117 if createdTracker?.id == pendingDeletion.id {
118 createdTracker = nil
119 }
120 self.pendingDeletion = nil
121 }
122 }
123 }
124 } message: {
125 if let pendingDeletion {
126 Text("“\(pendingDeletion.name)” will be permanently deleted.")
127 }
128 }
129 .task {
130 if viewModel == nil {
131 let vm = TrackerListViewModel(client: appState.client)
132 viewModel = vm
133 await vm.loadTrackers()
134 }
135 }
136 }
137
138 @ViewBuilder
139 private func listContent(_ viewModel: TrackerListViewModel) -> some View {
140 @Bindable var vm = viewModel
141
142 List {
143 ForEach(viewModel.filteredTrackers) { tracker in
144 NavigationLink(value: tracker) {
145 TrackerRowView(tracker: tracker)
146 }
147 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
148 Button {
149 pendingDeletion = tracker
150 } label: {
151 Label("Delete", systemImage: "trash")
152 }
153 .tint(.red)
154
155 Button {
156 editingTracker = tracker
157 } label: {
158 Label("Edit", systemImage: "pencil")
159 }
160 .tint(.blue)
161 }
162 .task {
163 await viewModel.loadMoreIfNeeded(currentItem: tracker)
164 }
165 }
166 .themedRow()
167
168 if viewModel.isLoadingMore {
169 HStack {
170 Spacer()
171 ProgressView()
172 Spacer()
173 }
174 .listRowSeparator(.hidden)
175 .themedRow()
176 }
177 }
178 .themedList()
179 .listStyle(.plain)
180 .searchable(
181 text: $vm.searchText,
182 placement: .navigationBarDrawer(displayMode: .always),
183 prompt: "Search trackers"
184 )
185 .overlay {
186 if viewModel.isLoading, viewModel.trackers.isEmpty {
187 SRHTLoadingStateView(message: "Loading trackers…")
188 } else if let error = viewModel.error, viewModel.trackers.isEmpty {
189 SRHTErrorStateView(
190 title: "Couldn't Load Trackers",
191 message: error,
192 retryAction: { await viewModel.loadTrackers() }
193 )
194 } else if !viewModel.trackers.isEmpty, viewModel.filteredTrackers.isEmpty {
195 ContentUnavailableView.search(text: viewModel.searchText)
196 } else if viewModel.trackers.isEmpty, viewModel.error == nil {
197 ContentUnavailableView(
198 "No Trackers",
199 systemImage: "checklist",
200 description: Text("Your bug trackers will appear here.")
201 )
202 }
203 }
204 .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
205 await viewModel.loadTrackers()
206 }
207 .srhtErrorBanner(error: $vm.error)
208 .refreshable {
209 await viewModel.loadTrackers()
210 }
211 .navigationDestination(for: TrackerSummary.self) { tracker in
212 TicketListView(tracker: tracker) { updatedTracker in
213 viewModel.applyTrackerUpdate(updatedTracker)
214 if createdTracker?.id == updatedTracker.id {
215 createdTracker = updatedTracker
216 }
217 } onTrackerDeleted: { deletedTracker in
218 viewModel.applyTrackerDeletion(deletedTracker)
219 Task { await viewModel.loadTrackers() }
220 if createdTracker?.id == deletedTracker.id {
221 createdTracker = nil
222 }
223 }
224 }
225 }
226}
227
228// MARK: - Tracker Row
229
230private struct TrackerRowView: View {
231 @Environment(AppState.self) private var appState
232 @Environment(\.openURL) private var openURL
233
234 let tracker: TrackerSummary
235
236 var body: some View {
237 VStack(alignment: .leading, spacing: 4) {
238 HStack {
239 Text(tracker.name)
240 .font(.subheadline.weight(.medium))
241
242 Spacer()
243
244 VisibilityBadge(visibility: tracker.visibility)
245 }
246
247 if let owner = tracker.owner.canonicalName.split(separator: "~").last {
248 Text("~\(owner)")
249 .font(.caption)
250 .foregroundStyle(.secondary)
251 }
252
253 if let description = tracker.description, !description.isEmpty {
254 Text(description)
255 .font(.caption)
256 .foregroundStyle(.secondary)
257 .lineLimit(2)
258 }
259
260 Text(tracker.updated.relativeDescription)
261 .font(.caption2)
262 .foregroundStyle(.tertiary)
263 }
264 .padding(.vertical, 2)
265 .contextMenu {
266 if let url = SRHTWebURL.tracker(tracker) {
267 Button {
268 openURL(url)
269 } label: {
270 Label("Open in Browser", systemImage: "safari")
271 }
272
273 Button {
274 appState.copyToPasteboard(url.absoluteString, label: "tracker URL")
275 } label: {
276 Label("Copy URL", systemImage: "doc.on.doc")
277 }
278 }
279
280 Button {
281 appState.copyToPasteboard(String(tracker.id), label: "tracker ID")
282 } label: {
283 Label("Copy Tracker ID", systemImage: "number")
284 }
285
286 Button {
287 appState.copyToPasteboard(tracker.rid, label: "tracker RID")
288 } label: {
289 Label("Copy RID", systemImage: "number")
290 }
291 }
292 }
293}