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}