krz/hutch

an ios client for sourcehut

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

main: 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                    .accessibilityLabel("Create tracker")
 29                }
 30            }
 31        }
 32        .sheet(isPresented: $showCreateTrackerSheet) {
 33            if let viewModel {
 34                TrackerEditorSheet(
 35                    title: "New Tracker",
 36                    confirmationTitle: "Create Tracker",
 37                    isSaving: viewModel.isCreatingTracker,
 38                    error: viewModel.error,
 39                    initialName: "",
 40                    initialDescription: "",
 41                    initialVisibility: .publicVisibility
 42                ) { name, description, visibility in
 43                    if let tracker = await viewModel.createTracker(
 44                        name: name,
 45                        description: description,
 46                        visibility: visibility
 47                    ) {
 48                        createdTracker = tracker
 49                        showCreateTrackerSheet = false
 50                        return true
 51                    }
 52                    return false
 53                }
 54            }
 55        }
 56        .sheet(item: $editingTracker) { tracker in
 57            if let viewModel {
 58                TrackerEditorSheet(
 59                    title: "Update Tracker",
 60                    confirmationTitle: "Save",
 61                    isSaving: viewModel.isCreatingTracker,
 62                    error: viewModel.error,
 63                    initialName: tracker.name,
 64                    initialDescription: tracker.description ?? "",
 65                    initialVisibility: tracker.visibility
 66                ) { name, description, visibility in
 67                    if let updatedTracker = await viewModel.updateTracker(
 68                        tracker,
 69                        name: name,
 70                        description: description,
 71                        visibility: visibility
 72                    ) {
 73                        createdTracker = createdTracker?.id == tracker.id ? updatedTracker : createdTracker
 74                        editingTracker = nil
 75                        return true
 76                    }
 77                    return false
 78                }
 79            }
 80        }
 81        .navigationDestination(isPresented: Binding(
 82            get: { createdTracker != nil },
 83            set: { isPresented in
 84                if !isPresented {
 85                    createdTracker = nil
 86                }
 87            }
 88        )) {
 89            if let createdTracker {
 90                TicketListView(tracker: createdTracker) { updatedTracker in
 91                    viewModel?.applyTrackerUpdate(updatedTracker)
 92                    self.createdTracker = updatedTracker
 93                } onTrackerDeleted: { deletedTracker in
 94                    viewModel?.applyTrackerDeletion(deletedTracker)
 95                    if let viewModel {
 96                        Task { await viewModel.loadTrackers() }
 97                    }
 98                    self.createdTracker = nil
 99                }
100            }
101        }
102        .alert("Delete Tracker?", isPresented: Binding(
103            get: { pendingDeletion != nil },
104            set: { isPresented in
105                if !isPresented {
106                    pendingDeletion = nil
107                }
108            }
109        )) {
110            Button("Cancel", role: .cancel) {
111                // no-op: .cancel role handles alert dismissal
112            }
113            Button("Delete", role: .destructive) {
114                guard let pendingDeletion, let viewModel else { return }
115                Task {
116                    let didDelete = await viewModel.deleteTracker(pendingDeletion)
117                    if didDelete {
118                        if createdTracker?.id == pendingDeletion.id {
119                            createdTracker = nil
120                        }
121                        self.pendingDeletion = nil
122                    }
123                }
124            }
125        } message: {
126            if let pendingDeletion {
127                Text("\(pendingDeletion.name)” will be permanently deleted.")
128            }
129        }
130        .task {
131            if viewModel == nil {
132                let vm = TrackerListViewModel(client: appState.client)
133                viewModel = vm
134                await vm.loadTrackers()
135            }
136        }
137    }
138
139    @ViewBuilder
140    private func listContent(_ viewModel: TrackerListViewModel) -> some View {
141        @Bindable var vm = viewModel
142
143        List {
144            ForEach(viewModel.filteredTrackers) { tracker in
145                NavigationLink(value: tracker) {
146                    TrackerRowView(tracker: tracker)
147                }
148                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
149                    Button {
150                        pendingDeletion = tracker
151                    } label: {
152                        Label("Delete", systemImage: "trash")
153                    }
154                    .tint(.red)
155
156                    Button {
157                        editingTracker = tracker
158                    } label: {
159                        Label("Edit", systemImage: "pencil")
160                    }
161                    .tint(.blue)
162                }
163                .task {
164                    await viewModel.loadMoreIfNeeded(currentItem: tracker)
165                }
166            }
167            .themedRow()
168
169            if viewModel.isLoadingMore {
170                HStack {
171                    Spacer()
172                    ProgressView()
173                    Spacer()
174                }
175                .listRowSeparator(.hidden)
176                .themedRow()
177            }
178        }
179        .themedList()
180        .listStyle(.plain)
181        .searchable(
182            text: $vm.searchText,
183            placement: .navigationBarDrawer(displayMode: .always),
184            prompt: "Search trackers"
185        )
186        .overlay {
187            if viewModel.isLoading, viewModel.trackers.isEmpty {
188                SRHTLoadingStateView(message: "Loading trackers…")
189            } else if let error = viewModel.error, viewModel.trackers.isEmpty {
190                SRHTErrorStateView(
191                    title: "Couldn't Load Trackers",
192                    message: error,
193                    retryAction: { await viewModel.loadTrackers() }
194                )
195            } else if !viewModel.trackers.isEmpty, viewModel.filteredTrackers.isEmpty {
196                ContentUnavailableView.search(text: viewModel.searchText)
197            } else if viewModel.trackers.isEmpty, viewModel.error == nil {
198                ContentUnavailableView(
199                    "No Trackers",
200                    systemImage: "checklist",
201                    description: Text("Your bug trackers will appear here.")
202                )
203            }
204        }
205        .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
206            await viewModel.loadTrackers()
207        }
208        .srhtErrorBanner(error: $vm.error)
209        .refreshable {
210            await viewModel.loadTrackers()
211        }
212        .navigationDestination(for: TrackerSummary.self) { tracker in
213            TicketListView(tracker: tracker) { updatedTracker in
214                viewModel.applyTrackerUpdate(updatedTracker)
215                if createdTracker?.id == updatedTracker.id {
216                    createdTracker = updatedTracker
217                }
218            } onTrackerDeleted: { deletedTracker in
219                viewModel.applyTrackerDeletion(deletedTracker)
220                Task { await viewModel.loadTrackers() }
221                if createdTracker?.id == deletedTracker.id {
222                    createdTracker = nil
223                }
224            }
225        }
226    }
227}
228
229// MARK: - Tracker Row
230
231private struct TrackerRowView: View {
232    @Environment(AppState.self) private var appState
233    @Environment(\.openURL) private var openURL
234
235    let tracker: TrackerSummary
236
237    var body: some View {
238        VStack(alignment: .leading, spacing: 4) {
239            HStack {
240                Text(tracker.name)
241                    .font(.subheadline.weight(.medium))
242
243                Spacer()
244
245                VisibilityBadge(visibility: tracker.visibility)
246            }
247
248            if let owner = tracker.owner.canonicalName.split(separator: "~").last {
249                Text("~\(owner)")
250                    .font(.caption)
251                    .foregroundStyle(.secondary)
252            }
253
254            if let description = tracker.description, !description.isEmpty {
255                Text(description)
256                    .font(.caption)
257                    .foregroundStyle(.secondary)
258                    .lineLimit(2)
259            }
260
261            Text(tracker.updated.relativeDescription)
262                .font(.caption2)
263                .foregroundStyle(.tertiary)
264        }
265        .padding(.vertical, 2)
266        .contextMenu {
267            if let url = SRHTWebURL.tracker(tracker) {
268                Button {
269                    openURL(url)
270                } label: {
271                    Label("Open in Browser", systemImage: "safari")
272                }
273
274                Button {
275                    appState.copyToPasteboard(url.absoluteString, label: "tracker URL")
276                } label: {
277                    Label("Copy URL", systemImage: "doc.on.doc")
278                }
279            }
280
281            Button {
282                appState.copyToPasteboard(String(tracker.id), label: "tracker ID")
283            } label: {
284                Label("Copy Tracker ID", systemImage: "number")
285            }
286
287            Button {
288                appState.copyToPasteboard(tracker.rid, label: "tracker RID")
289            } label: {
290                Label("Copy RID", systemImage: "number")
291            }
292        }
293    }
294}