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}