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}