krz/hutch

an ios client for sourcehut

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

v2.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.trackers) { 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        .overlay {
 88            if viewModel.isLoading, viewModel.trackers.isEmpty {
 89                SRHTLoadingStateView(message: "Loading trackers…")
 90            } else if let error = viewModel.error, viewModel.trackers.isEmpty {
 91                SRHTErrorStateView(
 92                    title: "Couldn't Load Trackers",
 93                    message: error,
 94                    retryAction: { await viewModel.loadTrackers() }
 95                )
 96            } else if viewModel.trackers.isEmpty, viewModel.error == nil {
 97                ContentUnavailableView(
 98                    "No Trackers",
 99                    systemImage: "checklist",
100                    description: Text("Your bug trackers will appear here.")
101                )
102            }
103        }
104        .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
105            await viewModel.loadTrackers()
106        }
107        .srhtErrorBanner(error: $vm.error)
108        .refreshable {
109            await viewModel.loadTrackers()
110        }
111        .navigationDestination(for: TrackerSummary.self) { tracker in
112            TicketListView(
113                ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
114                trackerName: tracker.name,
115                trackerId: tracker.id,
116                trackerRid: tracker.rid
117            )
118        }
119    }
120}
121
122private struct CreateTrackerSheet: View {
123    let viewModel: TrackerListViewModel
124    let onCreated: (TrackerSummary) -> Void
125
126    @Environment(\.dismiss) private var dismiss
127    @Bindable var viewModelBindable: TrackerListViewModel
128    @State private var name = ""
129    @State private var description = ""
130    @State private var visibility: Visibility = .public
131
132    init(viewModel: TrackerListViewModel, onCreated: @escaping (TrackerSummary) -> Void) {
133        self.viewModel = viewModel
134        self._viewModelBindable = Bindable(viewModel)
135        self.onCreated = onCreated
136    }
137
138    var body: some View {
139        NavigationStack {
140            Form {
141                Section("Tracker Details") {
142                    TextField("Tracker name", text: $name)
143                        .textInputAutocapitalization(.never)
144                        .autocorrectionDisabled()
145                    TextField("Short description (optional)", text: $description, axis: .vertical)
146                        .lineLimit(2...4)
147                    Picker("Visibility", selection: $visibility) {
148                        Text("Public").tag(Visibility.public)
149                        Text("Unlisted").tag(Visibility.unlisted)
150                        Text("Private").tag(Visibility.private)
151                    }
152                }
153
154                if let error = viewModel.error {
155                    Section {
156                        Label {
157                            Text(error)
158                        } icon: {
159                            Image(systemName: "exclamationmark.triangle.fill")
160                                .foregroundStyle(.red)
161                        }
162                        .foregroundStyle(.red)
163                    }
164                }
165            }
166            .navigationTitle("New Tracker")
167            .navigationBarTitleDisplayMode(.inline)
168            .onDisappear {
169                viewModelBindable.error = nil
170            }
171            .toolbar {
172                ToolbarItem(placement: .cancellationAction) {
173                    Button("Cancel") {
174                        viewModelBindable.error = nil
175                        dismiss()
176                    }
177                }
178                ToolbarItem(placement: .confirmationAction) {
179                    Button {
180                        Task {
181                            if let tracker = await viewModel.createTracker(
182                                name: name,
183                                description: description,
184                                visibility: visibility
185                            ) {
186                                onCreated(tracker)
187                            }
188                        }
189                    } label: {
190                        if viewModel.isCreatingTracker {
191                            ProgressView()
192                                .controlSize(.small)
193                        } else {
194                            Text("Create Tracker")
195                        }
196                    }
197                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker)
198                }
199            }
200        }
201    }
202}
203
204// MARK: - Tracker Row
205
206private struct TrackerRowView: View {
207    let tracker: TrackerSummary
208
209    var body: some View {
210        VStack(alignment: .leading, spacing: 4) {
211            HStack {
212                Text(tracker.name)
213                    .font(.subheadline.weight(.medium))
214
215                Spacer()
216
217                VisibilityBadge(visibility: tracker.visibility)
218            }
219
220            if let owner = tracker.owner.canonicalName.split(separator: "~").last {
221                Text("~\(owner)")
222                    .font(.caption)
223                    .foregroundStyle(.secondary)
224            }
225
226            if let description = tracker.description, !description.isEmpty {
227                Text(description)
228                    .font(.caption)
229                    .foregroundStyle(.secondary)
230                    .lineLimit(2)
231            }
232
233            Text(tracker.updated.relativeDescription)
234                .font(.caption2)
235                .foregroundStyle(.tertiary)
236        }
237        .padding(.vertical, 2)
238    }
239}