krz/hutch

an ios client for sourcehut

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

v2.12.1: 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            ForEach(viewModel.filteredJobs) { job in
 69                NavigationLink(value: job) {
 70                    BuildRowView(job: job)
 71                }
 72                .swipeActions(edge: .leading, allowsFullSwipe: true) {
 73                    if swipeActionsEnabled, job.status.isCancellable {
 74                        Button {
 75                            Task {
 76                                await viewModel.cancelJob(job)
 77                            }
 78                        } label: {
 79                            Label("Cancel", systemImage: "xmark.circle")
 80                        }
 81                        .tint(.red)
 82                    }
 83                }
 84                .task {
 85                    await viewModel.loadMoreIfNeeded(currentItem: job)
 86                }
 87            }
 88
 89            if viewModel.isLoadingMore {
 90                HStack {
 91                    Spacer()
 92                    ProgressView()
 93                    Spacer()
 94                }
 95                .listRowSeparator(.hidden)
 96            }
 97        }
 98        .listStyle(.plain)
 99        .searchable(
100            text: $vm.searchText,
101            placement: .navigationBarDrawer(displayMode: .always),
102            prompt: "Search builds"
103        )
104        .overlay {
105            if viewModel.isLoading, viewModel.jobs.isEmpty {
106                SRHTLoadingStateView(message: "Loading builds…")
107            } else if let error = viewModel.error, viewModel.jobs.isEmpty {
108                SRHTErrorStateView(
109                    title: "Couldn't Load Builds",
110                    message: error,
111                    retryAction: { await viewModel.loadJobs() }
112                )
113            } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty {
114                ContentUnavailableView.search(text: viewModel.searchText)
115            } else if viewModel.jobs.isEmpty, viewModel.error == nil {
116                ContentUnavailableView(
117                    "No Builds",
118                    systemImage: "hammer",
119                    description: Text("Your build jobs will appear here.")
120                )
121            }
122        }
123        .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) {
124            await viewModel.loadJobs()
125        }
126        .srhtErrorBanner(error: $vm.error)
127        .refreshable {
128            await viewModel.loadJobs()
129        }
130    }
131}
132
133private struct SubmitBuildSheet: View {
134    let viewModel: BuildListViewModel
135    let onSubmitted: (Int) -> Void
136
137    @Environment(\.dismiss) private var dismiss
138    @Bindable var viewModelBindable: BuildListViewModel
139    @State private var manifest = ""
140    @State private var tagsText = ""
141    @State private var note = ""
142    @State private var secrets = false
143    @State private var execute = true
144    @State private var visibility: Visibility = .public
145
146    init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) {
147        self.viewModel = viewModel
148        self._viewModelBindable = Bindable(viewModel)
149        self.onSubmitted = onSubmitted
150    }
151
152    var body: some View {
153        NavigationStack {
154            Form {
155                Section("Build Manifest") {
156                    TextField("Paste a build manifest", text: $manifest, axis: .vertical)
157                        .font(.system(.body, design: .monospaced))
158                        .lineLimit(12...24)
159                        .textInputAutocapitalization(.never)
160                        .autocorrectionDisabled()
161                }
162
163                Section("Build Options") {
164                    TextField("Note (optional)", text: $note)
165                    TextField("Tags (comma-separated, optional)", text: $tagsText)
166                        .textInputAutocapitalization(.never)
167                        .autocorrectionDisabled()
168                    Picker("Visibility", selection: $visibility) {
169                        Text("Public").tag(Visibility.public)
170                        Text("Unlisted").tag(Visibility.unlisted)
171                        Text("Private").tag(Visibility.private)
172                    }
173                    Toggle("Start build now", isOn: $execute)
174                    Toggle("Allow build secrets", isOn: $secrets)
175                }
176
177                Section {
178                    Text("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.")
179                        .font(.footnote)
180                        .foregroundStyle(.secondary)
181                }
182
183                if let error = viewModel.error {
184                    Section {
185                        Label {
186                            Text(error)
187                        } icon: {
188                            Image(systemName: "exclamationmark.triangle.fill")
189                                .foregroundStyle(.red)
190                        }
191                        .foregroundStyle(.red)
192                    }
193                }
194            }
195            .navigationTitle("Submit Build")
196            .navigationBarTitleDisplayMode(.inline)
197            .onDisappear {
198                viewModelBindable.error = nil
199            }
200            .toolbar {
201                ToolbarItem(placement: .cancellationAction) {
202                    Button("Cancel") {
203                        viewModelBindable.error = nil
204                        dismiss()
205                    }
206                }
207                ToolbarItem(placement: .confirmationAction) {
208                    Button {
209                        Task {
210                            let tags = tagsText
211                                .split(separator: ",")
212                                .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
213                                .filter { !$0.isEmpty }
214                            if let jobId = await viewModel.submitBuild(
215                                manifest: manifest,
216                                tags: tags,
217                                note: note,
218                                secrets: secrets,
219                                execute: execute,
220                                visibility: visibility
221                            ) {
222                                onSubmitted(jobId)
223                            }
224                        }
225                    } label: {
226                        if viewModel.isSubmitting {
227                            ProgressView()
228                                .controlSize(.small)
229                        } else {
230                            Text("Submit Build")
231                        }
232                    }
233                    .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
234                }
235            }
236        }
237    }
238}