krz/hutch

an ios client for sourcehut

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

v2.14.0: Hutch/Views/Builds/BuildListView.swift · raw

  1import SwiftUI
  2
  3struct BuildListView: View {
  4    @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
  5    @Environment(AppState.self) private var appState
  6    @State private var viewModel: BuildListViewModel?
  7    @State private var showSubmitSheet = false
  8    @State private var submittedJobId: Int?
  9
 10    var body: some View {
 11        Group {
 12            if let viewModel {
 13                listContent(viewModel)
 14            } else {
 15                SRHTLoadingStateView(message: "Loading builds…")
 16            }
 17        }
 18        .navigationTitle("Builds")
 19        .toolbar {
 20            if viewModel != nil {
 21                ToolbarItem(placement: .topBarTrailing) {
 22                    Button {
 23                        showSubmitSheet = true
 24                    } label: {
 25                        Image(systemName: "plus")
 26                    }
 27                    .accessibilityLabel("Submit build")
 28                }
 29            }
 30        }
 31        .sheet(isPresented: $showSubmitSheet) {
 32            if let viewModel {
 33                SubmitBuildSheet(viewModel: viewModel) { jobId in
 34                    showSubmitSheet = false
 35                    submittedJobId = jobId
 36                }
 37            }
 38        }
 39        .navigationDestination(for: JobSummary.self) { job in
 40            BuildDetailView(jobId: job.id)
 41        }
 42        .navigationDestination(isPresented: Binding(
 43            get: { submittedJobId != nil },
 44            set: { isPresented in
 45                if !isPresented {
 46                    submittedJobId = nil
 47                }
 48            }
 49        )) {
 50            if let submittedJobId {
 51                BuildDetailView(jobId: submittedJobId)
 52            }
 53        }
 54        .task {
 55            if viewModel == nil {
 56                let vm = BuildListViewModel(client: appState.client)
 57                viewModel = vm
 58                await vm.loadJobs()
 59            }
 60        }
 61    }
 62
 63    @ViewBuilder
 64    private func listContent(_ viewModel: BuildListViewModel) -> some View {
 65        @Bindable var vm = viewModel
 66
 67        List {
 68            Section {
 69                Picker("Filter", selection: $vm.filter) {
 70                    ForEach(BuildListFilter.allCases, id: \.self) { filter in
 71                        Text(filter.rawValue).tag(filter)
 72                    }
 73                }
 74                .pickerStyle(.segmented)
 75                .listRowBackground(Color.clear)
 76                .listRowInsets(EdgeInsets())
 77            }
 78
 79            ForEach(viewModel.filteredJobs) { job in
 80                NavigationLink(value: job) {
 81                    BuildRowView(job: job)
 82                }
 83                .swipeActions(edge: .leading, allowsFullSwipe: true) {
 84                    if swipeActionsEnabled, job.status.isCancellable {
 85                        Button {
 86                            Task {
 87                                await viewModel.cancelJob(job)
 88                            }
 89                        } label: {
 90                            Label("Cancel", systemImage: "xmark.circle")
 91                        }
 92                        .tint(.red)
 93                    }
 94                }
 95                .task {
 96                    await viewModel.loadMoreIfNeeded(currentItem: job)
 97                }
 98            }
 99
100            if viewModel.isLoadingMore {
101                HStack {
102                    Spacer()
103                    ProgressView()
104                    Spacer()
105                }
106                .listRowSeparator(.hidden)
107            }
108        }
109        .listStyle(.plain)
110        .searchable(
111            text: $vm.searchText,
112            placement: .navigationBarDrawer(displayMode: .always),
113            prompt: "Search builds"
114        )
115        .overlay {
116            if viewModel.isLoading, viewModel.jobs.isEmpty {
117                SRHTLoadingStateView(message: "Loading builds…")
118            } else if let error = viewModel.error, viewModel.jobs.isEmpty {
119                SRHTErrorStateView(
120                    title: "Couldn't Load Builds",
121                    message: error,
122                    retryAction: { await viewModel.loadJobs() }
123                )
124            } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty {
125                ContentUnavailableView.search(text: viewModel.searchText)
126            } else if viewModel.jobs.isEmpty, viewModel.error == nil {
127                ContentUnavailableView(
128                    "No Builds",
129                    systemImage: "hammer",
130                    description: Text("Your build jobs will appear here.")
131                )
132            }
133        }
134        .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) {
135            await viewModel.loadJobs()
136        }
137        .srhtErrorBanner(error: $vm.error)
138        .refreshable {
139            await viewModel.loadJobs()
140        }
141    }
142}
143
144private struct SubmitBuildSheet: View {
145    let viewModel: BuildListViewModel
146    let onSubmitted: (Int) -> Void
147
148    @Environment(\.dismiss) private var dismiss
149    @Bindable var viewModelBindable: BuildListViewModel
150    @State private var manifest = ""
151    @State private var tagsText = ""
152    @State private var note = ""
153    @State private var secrets = false
154    @State private var execute = true
155    @State private var visibility: Visibility = .public
156
157    init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) {
158        self.viewModel = viewModel
159        self._viewModelBindable = Bindable(viewModel)
160        self.onSubmitted = onSubmitted
161    }
162
163    var body: some View {
164        NavigationStack {
165            Form {
166                Section("Build Manifest") {
167                    TextField("Paste a build manifest", text: $manifest, axis: .vertical)
168                        .font(.system(.body, design: .monospaced))
169                        .lineLimit(12...24)
170                        .textInputAutocapitalization(.never)
171                        .autocorrectionDisabled()
172                }
173
174                Section("Build Options") {
175                    TextField("Note (optional)", text: $note)
176                    TextField("Tags (comma-separated, optional)", text: $tagsText)
177                        .textInputAutocapitalization(.never)
178                        .autocorrectionDisabled()
179                    Picker("Visibility", selection: $visibility) {
180                        Text("Public").tag(Visibility.public)
181                        Text("Unlisted").tag(Visibility.unlisted)
182                        Text("Private").tag(Visibility.private)
183                    }
184                    Toggle("Start build now", isOn: $execute)
185                    Toggle("Allow build secrets", isOn: $secrets)
186                }
187
188                Section {
189                    Text("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.")
190                        .font(.footnote)
191                        .foregroundStyle(.secondary)
192                }
193
194                if let error = viewModel.error {
195                    Section {
196                        Label {
197                            Text(error)
198                        } icon: {
199                            Image(systemName: "exclamationmark.triangle.fill")
200                                .foregroundStyle(.red)
201                        }
202                        .foregroundStyle(.red)
203                    }
204                }
205            }
206            .navigationTitle("Submit Build")
207            .navigationBarTitleDisplayMode(.inline)
208            .onDisappear {
209                viewModelBindable.error = nil
210            }
211            .toolbar {
212                ToolbarItem(placement: .cancellationAction) {
213                    Button("Cancel") {
214                        viewModelBindable.error = nil
215                        dismiss()
216                    }
217                }
218                ToolbarItem(placement: .confirmationAction) {
219                    Button {
220                        Task {
221                            let tags = tagsText
222                                .split(separator: ",")
223                                .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
224                                .filter { !$0.isEmpty }
225                            if let jobId = await viewModel.submitBuild(
226                                manifest: manifest,
227                                tags: tags,
228                                note: note,
229                                secrets: secrets,
230                                execute: execute,
231                                visibility: visibility
232                            ) {
233                                onSubmitted(jobId)
234                            }
235                        }
236                    } label: {
237                        if viewModel.isSubmitting {
238                            ProgressView()
239                                .controlSize(.small)
240                        } else {
241                            Text("Submit Build")
242                        }
243                    }
244                    .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
245                }
246            }
247        }
248    }
249}