krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.9.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: .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 Button("Delete", role: .destructive) {
111 guard let pendingDeletion, let viewModel else { return }
112 Task {
113 let didDelete = await viewModel.deleteTracker(pendingDeletion)
114 if didDelete {
115 if createdTracker?.id == pendingDeletion.id {
116 createdTracker = nil
117 }
118 self.pendingDeletion = nil
119 }
120 }
121 }
122 } message: {
123 if let pendingDeletion {
124 Text("“\(pendingDeletion.name)” will be permanently deleted.")
125 }
126 }
127 .task {
128 if viewModel == nil {
129 let vm = TrackerListViewModel(client: appState.client)
130 viewModel = vm
131 await vm.loadTrackers()
132 }
133 }
134 }
135
136 @ViewBuilder
137 private func listContent(_ viewModel: TrackerListViewModel) -> some View {
138 @Bindable var vm = viewModel
139
140 List {
141 ForEach(viewModel.filteredTrackers) { tracker in
142 NavigationLink(value: tracker) {
143 TrackerRowView(tracker: tracker)
144 }
145 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
146 Button(role: .destructive) {
147 pendingDeletion = tracker
148 } label: {
149 Label("Delete", systemImage: "trash")
150 }
151
152 Button {
153 editingTracker = tracker
154 } label: {
155 Label("Edit", systemImage: "pencil")
156 }
157 .tint(.blue)
158 }
159 .task {
160 await viewModel.loadMoreIfNeeded(currentItem: tracker)
161 }
162 }
163
164 if viewModel.isLoadingMore {
165 HStack {
166 Spacer()
167 ProgressView()
168 Spacer()
169 }
170 .listRowSeparator(.hidden)
171 }
172 }
173 .listStyle(.plain)
174 .searchable(
175 text: $vm.searchText,
176 placement: .navigationBarDrawer(displayMode: .always),
177 prompt: "Search trackers"
178 )
179 .overlay {
180 if viewModel.isLoading, viewModel.trackers.isEmpty {
181 SRHTLoadingStateView(message: "Loading trackers…")
182 } else if let error = viewModel.error, viewModel.trackers.isEmpty {
183 SRHTErrorStateView(
184 title: "Couldn't Load Trackers",
185 message: error,
186 retryAction: { await viewModel.loadTrackers() }
187 )
188 } else if !viewModel.trackers.isEmpty, viewModel.filteredTrackers.isEmpty {
189 ContentUnavailableView.search(text: viewModel.searchText)
190 } else if viewModel.trackers.isEmpty, viewModel.error == nil {
191 ContentUnavailableView(
192 "No Trackers",
193 systemImage: "checklist",
194 description: Text("Your bug trackers will appear here.")
195 )
196 }
197 }
198 .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
199 await viewModel.loadTrackers()
200 }
201 .srhtErrorBanner(error: $vm.error)
202 .refreshable {
203 await viewModel.loadTrackers()
204 }
205 .navigationDestination(for: TrackerSummary.self) { tracker in
206 TicketListView(tracker: tracker) { updatedTracker in
207 viewModel.applyTrackerUpdate(updatedTracker)
208 if createdTracker?.id == updatedTracker.id {
209 createdTracker = updatedTracker
210 }
211 } onTrackerDeleted: { deletedTracker in
212 viewModel.applyTrackerDeletion(deletedTracker)
213 Task { await viewModel.loadTrackers() }
214 if createdTracker?.id == deletedTracker.id {
215 createdTracker = nil
216 }
217 }
218 }
219 }
220}
221
222// MARK: - Tracker Row
223
224private struct TrackerRowView: View {
225 let tracker: TrackerSummary
226
227 var body: some View {
228 VStack(alignment: .leading, spacing: 4) {
229 HStack {
230 Text(tracker.name)
231 .font(.subheadline.weight(.medium))
232
233 Spacer()
234
235 VisibilityBadge(visibility: tracker.visibility)
236 }
237
238 if let owner = tracker.owner.canonicalName.split(separator: "~").last {
239 Text("~\(owner)")
240 .font(.caption)
241 .foregroundStyle(.secondary)
242 }
243
244 if let description = tracker.description, !description.isEmpty {
245 Text(description)
246 .font(.caption)
247 .foregroundStyle(.secondary)
248 .lineLimit(2)
249 }
250
251 Text(tracker.updated.relativeDescription)
252 .font(.caption2)
253 .foregroundStyle(.tertiary)
254 }
255 .padding(.vertical, 2)
256 }
257}