krz/hutch

an ios client for sourcehut

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

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