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