krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.6: 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(role: .destructive) {
149 pendingDeletion = tracker
150 } label: {
151 Label("Delete", systemImage: "trash")
152 }
153
154 Button {
155 editingTracker = tracker
156 } label: {
157 Label("Edit", systemImage: "pencil")
158 }
159 .tint(.blue)
160 }
161 .task {
162 await viewModel.loadMoreIfNeeded(currentItem: tracker)
163 }
164 }
165 .themedRow()
166
167 if viewModel.isLoadingMore {
168 HStack {
169 Spacer()
170 ProgressView()
171 Spacer()
172 }
173 .listRowSeparator(.hidden)
174 .themedRow()
175 }
176 }
177 .themedList()
178 .listStyle(.plain)
179 .searchable(
180 text: $vm.searchText,
181 placement: .navigationBarDrawer(displayMode: .always),
182 prompt: "Search trackers"
183 )
184 .overlay {
185 if viewModel.isLoading, viewModel.trackers.isEmpty {
186 SRHTLoadingStateView(message: "Loading trackers…")
187 } else if let error = viewModel.error, viewModel.trackers.isEmpty {
188 SRHTErrorStateView(
189 title: "Couldn't Load Trackers",
190 message: error,
191 retryAction: { await viewModel.loadTrackers() }
192 )
193 } else if !viewModel.trackers.isEmpty, viewModel.filteredTrackers.isEmpty {
194 ContentUnavailableView.search(text: viewModel.searchText)
195 } else if viewModel.trackers.isEmpty, viewModel.error == nil {
196 ContentUnavailableView(
197 "No Trackers",
198 systemImage: "checklist",
199 description: Text("Your bug trackers will appear here.")
200 )
201 }
202 }
203 .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
204 await viewModel.loadTrackers()
205 }
206 .srhtErrorBanner(error: $vm.error)
207 .refreshable {
208 await viewModel.loadTrackers()
209 }
210 .navigationDestination(for: TrackerSummary.self) { tracker in
211 TicketListView(tracker: tracker) { updatedTracker in
212 viewModel.applyTrackerUpdate(updatedTracker)
213 if createdTracker?.id == updatedTracker.id {
214 createdTracker = updatedTracker
215 }
216 } onTrackerDeleted: { deletedTracker in
217 viewModel.applyTrackerDeletion(deletedTracker)
218 Task { await viewModel.loadTrackers() }
219 if createdTracker?.id == deletedTracker.id {
220 createdTracker = nil
221 }
222 }
223 }
224 }
225}
226
227// MARK: - Tracker Row
228
229private struct TrackerRowView: View {
230 @Environment(AppState.self) private var appState
231 @Environment(\.openURL) private var openURL
232
233 let tracker: TrackerSummary
234
235 var body: some View {
236 VStack(alignment: .leading, spacing: 4) {
237 HStack {
238 Text(tracker.name)
239 .font(.subheadline.weight(.medium))
240
241 Spacer()
242
243 VisibilityBadge(visibility: tracker.visibility)
244 }
245
246 if let owner = tracker.owner.canonicalName.split(separator: "~").last {
247 Text("~\(owner)")
248 .font(.caption)
249 .foregroundStyle(.secondary)
250 }
251
252 if let description = tracker.description, !description.isEmpty {
253 Text(description)
254 .font(.caption)
255 .foregroundStyle(.secondary)
256 .lineLimit(2)
257 }
258
259 Text(tracker.updated.relativeDescription)
260 .font(.caption2)
261 .foregroundStyle(.tertiary)
262 }
263 .padding(.vertical, 2)
264 .contextMenu {
265 if let url = SRHTWebURL.tracker(tracker) {
266 Button {
267 openURL(url)
268 } label: {
269 Label("Open in Browser", systemImage: "safari")
270 }
271
272 Button {
273 appState.copyToPasteboard(url.absoluteString, label: "tracker URL")
274 } label: {
275 Label("Copy URL", systemImage: "doc.on.doc")
276 }
277 }
278
279 Button {
280 appState.copyToPasteboard(String(tracker.id), label: "tracker ID")
281 } label: {
282 Label("Copy Tracker ID", systemImage: "number")
283 }
284
285 Button {
286 appState.copyToPasteboard(tracker.rid, label: "tracker RID")
287 } label: {
288 Label("Copy RID", systemImage: "number")
289 }
290 }
291 }
292}