krz/hutch

an ios client for sourcehut

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

v3.0.4: 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    @Environment(AppState.self) private var appState
228    @Environment(\.openURL) private var openURL
229
230    let tracker: TrackerSummary
231
232    var body: some View {
233        VStack(alignment: .leading, spacing: 4) {
234            HStack {
235                Text(tracker.name)
236                    .font(.subheadline.weight(.medium))
237
238                Spacer()
239
240                VisibilityBadge(visibility: tracker.visibility)
241            }
242
243            if let owner = tracker.owner.canonicalName.split(separator: "~").last {
244                Text("~\(owner)")
245                    .font(.caption)
246                    .foregroundStyle(.secondary)
247            }
248
249            if let description = tracker.description, !description.isEmpty {
250                Text(description)
251                    .font(.caption)
252                    .foregroundStyle(.secondary)
253                    .lineLimit(2)
254            }
255
256            Text(tracker.updated.relativeDescription)
257                .font(.caption2)
258                .foregroundStyle(.tertiary)
259        }
260        .padding(.vertical, 2)
261        .contextMenu {
262            if let url = SRHTWebURL.tracker(tracker) {
263                Button {
264                    openURL(url)
265                } label: {
266                    Label("Open in Browser", systemImage: "safari")
267                }
268
269                Button {
270                    appState.copyToPasteboard(url.absoluteString, label: "tracker URL")
271                } label: {
272                    Label("Copy URL", systemImage: "doc.on.doc")
273                }
274            }
275
276            Button {
277                appState.copyToPasteboard(String(tracker.id), label: "tracker ID")
278            } label: {
279                Label("Copy Tracker ID", systemImage: "number")
280            }
281
282            Button {
283                appState.copyToPasteboard(tracker.rid, label: "tracker RID")
284            } label: {
285                Label("Copy RID", systemImage: "number")
286            }
287        }
288    }
289}