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}