krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.15.1: 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: .public
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
166 if viewModel.isLoadingMore {
167 HStack {
168 Spacer()
169 ProgressView()
170 Spacer()
171 }
172 .listRowSeparator(.hidden)
173 }
174 }
175 .listStyle(.plain)
176 .searchable(
177 text: $vm.searchText,
178 placement: .navigationBarDrawer(displayMode: .always),
179 prompt: "Search trackers"
180 )
181 .overlay {
182 if viewModel.isLoading, viewModel.trackers.isEmpty {
183 SRHTLoadingStateView(message: "Loading trackers…")
184 } else if let error = viewModel.error, viewModel.trackers.isEmpty {
185 SRHTErrorStateView(
186 title: "Couldn't Load Trackers",
187 message: error,
188 retryAction: { await viewModel.loadTrackers() }
189 )
190 } else if !viewModel.trackers.isEmpty, viewModel.filteredTrackers.isEmpty {
191 ContentUnavailableView.search(text: viewModel.searchText)
192 } else if viewModel.trackers.isEmpty, viewModel.error == nil {
193 ContentUnavailableView(
194 "No Trackers",
195 systemImage: "checklist",
196 description: Text("Your bug trackers will appear here.")
197 )
198 }
199 }
200 .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
201 await viewModel.loadTrackers()
202 }
203 .srhtErrorBanner(error: $vm.error)
204 .refreshable {
205 await viewModel.loadTrackers()
206 }
207 .navigationDestination(for: TrackerSummary.self) { tracker in
208 TicketListView(tracker: tracker) { updatedTracker in
209 viewModel.applyTrackerUpdate(updatedTracker)
210 if createdTracker?.id == updatedTracker.id {
211 createdTracker = updatedTracker
212 }
213 } onTrackerDeleted: { deletedTracker in
214 viewModel.applyTrackerDeletion(deletedTracker)
215 Task { await viewModel.loadTrackers() }
216 if createdTracker?.id == deletedTracker.id {
217 createdTracker = nil
218 }
219 }
220 }
221 }
222}
223
224// MARK: - Tracker Row
225
226private struct TrackerRowView: View {
227 let tracker: TrackerSummary
228
229 var body: some View {
230 VStack(alignment: .leading, spacing: 4) {
231 HStack {
232 Text(tracker.name)
233 .font(.subheadline.weight(.medium))
234
235 Spacer()
236
237 VisibilityBadge(visibility: tracker.visibility)
238 }
239
240 if let owner = tracker.owner.canonicalName.split(separator: "~").last {
241 Text("~\(owner)")
242 .font(.caption)
243 .foregroundStyle(.secondary)
244 }
245
246 if let description = tracker.description, !description.isEmpty {
247 Text(description)
248 .font(.caption)
249 .foregroundStyle(.secondary)
250 .lineLimit(2)
251 }
252
253 Text(tracker.updated.relativeDescription)
254 .font(.caption2)
255 .foregroundStyle(.tertiary)
256 }
257 .padding(.vertical, 2)
258 }
259}