krz/hutch

an ios client for sourcehut

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

v2.3.1: 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
  9    var body: some View {
 10        Group {
 11            if let viewModel {
 12                listContent(viewModel)
 13            } else {
 14                SRHTLoadingStateView(message: "Loading trackers…")
 15            }
 16        }
 17        .navigationTitle("Trackers")
 18        .toolbar {
 19            if viewModel != nil {
 20                ToolbarItem(placement: .topBarTrailing) {
 21                    Button {
 22                        showCreateTrackerSheet = true
 23                    } label: {
 24                        Image(systemName: "plus")
 25                    }
 26                }
 27            }
 28        }
 29        .sheet(isPresented: $showCreateTrackerSheet) {
 30            if let viewModel {
 31                CreateTrackerSheet(viewModel: viewModel) { tracker in
 32                    showCreateTrackerSheet = false
 33                    createdTracker = tracker
 34                }
 35            }
 36        }
 37        .navigationDestination(isPresented: Binding(
 38            get: { createdTracker != nil },
 39            set: { isPresented in
 40                if !isPresented {
 41                    createdTracker = nil
 42                }
 43            }
 44        )) {
 45            if let createdTracker {
 46                TicketListView(
 47                    ownerUsername: String(createdTracker.owner.canonicalName.dropFirst()),
 48                    trackerName: createdTracker.name,
 49                    trackerId: createdTracker.id,
 50                    trackerRid: createdTracker.rid
 51                )
 52            }
 53        }
 54        .task {
 55            if viewModel == nil {
 56                let vm = TrackerListViewModel(client: appState.client)
 57                viewModel = vm
 58                await vm.loadTrackers()
 59            }
 60        }
 61    }
 62
 63    @ViewBuilder
 64    private func listContent(_ viewModel: TrackerListViewModel) -> some View {
 65        @Bindable var vm = viewModel
 66
 67        List {
 68            ForEach(viewModel.filteredTrackers) { tracker in
 69                NavigationLink(value: tracker) {
 70                    TrackerRowView(tracker: tracker)
 71                }
 72                .task {
 73                    await viewModel.loadMoreIfNeeded(currentItem: tracker)
 74                }
 75            }
 76
 77            if viewModel.isLoadingMore {
 78                HStack {
 79                    Spacer()
 80                    ProgressView()
 81                    Spacer()
 82                }
 83                .listRowSeparator(.hidden)
 84            }
 85        }
 86        .listStyle(.plain)
 87        .searchable(
 88            text: $vm.searchText,
 89            placement: .navigationBarDrawer(displayMode: .always),
 90            prompt: "Search trackers"
 91        )
 92        .overlay {
 93            if viewModel.isLoading, viewModel.trackers.isEmpty {
 94                SRHTLoadingStateView(message: "Loading trackers…")
 95            } else if let error = viewModel.error, viewModel.trackers.isEmpty {
 96                SRHTErrorStateView(
 97                    title: "Couldn't Load Trackers",
 98                    message: error,
 99                    retryAction: { await viewModel.loadTrackers() }
100                )
101            } else if !viewModel.trackers.isEmpty, viewModel.filteredTrackers.isEmpty {
102                ContentUnavailableView.search(text: viewModel.searchText)
103            } else if viewModel.trackers.isEmpty, viewModel.error == nil {
104                ContentUnavailableView(
105                    "No Trackers",
106                    systemImage: "checklist",
107                    description: Text("Your bug trackers will appear here.")
108                )
109            }
110        }
111        .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
112            await viewModel.loadTrackers()
113        }
114        .srhtErrorBanner(error: $vm.error)
115        .refreshable {
116            await viewModel.loadTrackers()
117        }
118        .navigationDestination(for: TrackerSummary.self) { tracker in
119            TicketListView(
120                ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
121                trackerName: tracker.name,
122                trackerId: tracker.id,
123                trackerRid: tracker.rid
124            )
125        }
126    }
127}
128
129private struct CreateTrackerSheet: View {
130    let viewModel: TrackerListViewModel
131    let onCreated: (TrackerSummary) -> Void
132
133    @Environment(\.dismiss) private var dismiss
134    @Bindable var viewModelBindable: TrackerListViewModel
135    @State private var name = ""
136    @State private var description = ""
137    @State private var visibility: Visibility = .public
138
139    init(viewModel: TrackerListViewModel, onCreated: @escaping (TrackerSummary) -> Void) {
140        self.viewModel = viewModel
141        self._viewModelBindable = Bindable(viewModel)
142        self.onCreated = onCreated
143    }
144
145    var body: some View {
146        NavigationStack {
147            Form {
148                Section("Tracker Details") {
149                    TextField("Tracker name", text: $name)
150                        .textInputAutocapitalization(.never)
151                        .autocorrectionDisabled()
152                    TextField("Short description (optional)", text: $description, axis: .vertical)
153                        .lineLimit(2...4)
154                    Picker("Visibility", selection: $visibility) {
155                        Text("Public").tag(Visibility.public)
156                        Text("Unlisted").tag(Visibility.unlisted)
157                        Text("Private").tag(Visibility.private)
158                    }
159                }
160
161                if let error = viewModel.error {
162                    Section {
163                        Label {
164                            Text(error)
165                        } icon: {
166                            Image(systemName: "exclamationmark.triangle.fill")
167                                .foregroundStyle(.red)
168                        }
169                        .foregroundStyle(.red)
170                    }
171                }
172            }
173            .navigationTitle("New Tracker")
174            .navigationBarTitleDisplayMode(.inline)
175            .onDisappear {
176                viewModelBindable.error = nil
177            }
178            .toolbar {
179                ToolbarItem(placement: .cancellationAction) {
180                    Button("Cancel") {
181                        viewModelBindable.error = nil
182                        dismiss()
183                    }
184                }
185                ToolbarItem(placement: .confirmationAction) {
186                    Button {
187                        Task {
188                            if let tracker = await viewModel.createTracker(
189                                name: name,
190                                description: description,
191                                visibility: visibility
192                            ) {
193                                onCreated(tracker)
194                            }
195                        }
196                    } label: {
197                        if viewModel.isCreatingTracker {
198                            ProgressView()
199                                .controlSize(.small)
200                        } else {
201                            Text("Create Tracker")
202                        }
203                    }
204                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker)
205                }
206            }
207        }
208    }
209}
210
211// MARK: - Tracker Row
212
213private struct TrackerRowView: View {
214    let tracker: TrackerSummary
215
216    var body: some View {
217        VStack(alignment: .leading, spacing: 4) {
218            HStack {
219                Text(tracker.name)
220                    .font(.subheadline.weight(.medium))
221
222                Spacer()
223
224                VisibilityBadge(visibility: tracker.visibility)
225            }
226
227            if let owner = tracker.owner.canonicalName.split(separator: "~").last {
228                Text("~\(owner)")
229                    .font(.caption)
230                    .foregroundStyle(.secondary)
231            }
232
233            if let description = tracker.description, !description.isEmpty {
234                Text(description)
235                    .font(.caption)
236                    .foregroundStyle(.secondary)
237                    .lineLimit(2)
238            }
239
240            Text(tracker.updated.relativeDescription)
241                .font(.caption2)
242                .foregroundStyle(.tertiary)
243        }
244        .padding(.vertical, 2)
245    }
246}