krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

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