krz/hutch

an ios client for sourcehut

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

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

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