krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.3.1: Hutch/Views/Repositories/RepositoryACLView.swift · raw
1import SwiftUI
2
3struct RepositoryACLView: View {
4 let repository: RepositorySummary
5 let client: SRHTClient
6 let showsDoneButton: Bool
7
8 @Environment(\.dismiss) private var dismiss
9 @Environment(\.isAMOLEDTheme) private var isAMOLED
10 @State private var viewModel: RepositoryACLViewModel?
11 @State private var pendingDeletion: RepositoryACLEntry?
12 @State private var showAddSheet = false
13
14 var body: some View {
15 Group {
16 if let viewModel {
17 content(viewModel)
18 } else {
19 SRHTLoadingStateView(message: "Loading access…")
20 }
21 }
22 .navigationTitle("Access")
23 .navigationBarTitleDisplayMode(.inline)
24 .toolbar {
25 if showsDoneButton {
26 ToolbarItem(placement: .cancellationAction) {
27 Button("Done") { dismiss() }
28 }
29 }
30
31 if viewModel != nil {
32 ToolbarItem(placement: .primaryAction) {
33 Button {
34 showAddSheet = true
35 } label: {
36 Image(systemName: "plus")
37 }
38 .accessibilityLabel("Add User")
39 }
40 }
41 }
42 .task {
43 if viewModel == nil {
44 let service = RepositoryACLService(client: client, service: repository.service)
45 let vm = RepositoryACLViewModel(repository: repository, service: service)
46 viewModel = vm
47 await vm.load()
48 }
49 }
50 }
51
52 @ViewBuilder
53 private func content(_ viewModel: RepositoryACLViewModel) -> some View {
54 Group {
55 if viewModel.isLoading && !viewModel.hasEntries && viewModel.loadError == nil {
56 SRHTLoadingStateView(message: "Loading access…")
57 } else if let loadError = viewModel.loadError, !viewModel.hasEntries {
58 SRHTErrorStateView(
59 title: "Couldn't Load Access",
60 message: loadError,
61 retryAction: { await viewModel.load() }
62 )
63 } else {
64 List {
65 if viewModel.visibleEntries.isEmpty {
66 ContentUnavailableView {
67 Label("No Additional Access", systemImage: "person.2.slash")
68 } description: {
69 Text("Only the repository owner currently has access.")
70 }
71 .frame(maxWidth: .infinity)
72 .listRowBackground(isAMOLED ? Color.black : Color.clear)
73 } else {
74 Section {
75 ForEach(viewModel.visibleEntries) { entry in
76 RepositoryACLEntryRow(
77 entry: entry,
78 isUpdating: viewModel.isUpdating(entry),
79 isDeleting: viewModel.isDeleting(entry),
80 onSelectMode: { mode in
81 Task { await viewModel.updatePermission(for: entry, to: mode) }
82 },
83 onDelete: {
84 pendingDeletion = entry
85 }
86 )
87 }
88 .themedRow()
89 }
90 }
91 }
92 .themedList()
93 .listStyle(.insetGrouped)
94 .refreshable {
95 await viewModel.load()
96 }
97 }
98 }
99 .srhtErrorBanner(
100 error: Binding(
101 get: { viewModel.error },
102 set: { viewModel.error = $0 }
103 )
104 )
105 .alert("Remove Access?", isPresented: Binding(
106 get: { pendingDeletion != nil },
107 set: { isPresented in
108 if !isPresented {
109 pendingDeletion = nil
110 }
111 }
112 )) {
113 Button("Cancel", role: .cancel) {
114 /* Dismiss only; removal is confirmed separately. */
115 }
116 Button("Remove Access", role: .destructive) {
117 guard let entry = pendingDeletion else { return }
118 Task {
119 await viewModel.removeEntry(entry)
120 pendingDeletion = nil
121 }
122 }
123 } message: {
124 if let entry = pendingDeletion {
125 Text("\(entry.entity.canonicalName) will lose \(entry.mode.displayName.lowercased()) access to this repository.")
126 }
127 }
128 .sheet(isPresented: $showAddSheet) {
129 NavigationStack {
130 RepositoryACLAddUserView(viewModel: viewModel) {
131 showAddSheet = false
132 }
133 }
134 }
135 }
136}
137
138private struct RepositoryACLEntryRow: View {
139 let entry: RepositoryACLEntry
140 let isUpdating: Bool
141 let isDeleting: Bool
142 let onSelectMode: (AccessMode) -> Void
143 let onDelete: () -> Void
144
145 var body: some View {
146 HStack(alignment: .center, spacing: 12) {
147 Text(entry.entity.canonicalName)
148 .font(.body.monospaced())
149 .lineLimit(2)
150 .truncationMode(.middle)
151 .frame(maxWidth: .infinity, alignment: .leading)
152
153 if isUpdating || isDeleting {
154 ProgressView()
155 .controlSize(.small)
156 }
157
158 Menu {
159 ForEach(AccessMode.allCases, id: \.self) { mode in
160 Button {
161 onSelectMode(mode)
162 } label: {
163 if mode == entry.mode {
164 Label(mode.displayName, systemImage: "checkmark")
165 } else {
166 Text(mode.displayName)
167 }
168 }
169 }
170 } label: {
171 Text(entry.mode.shortLabel)
172 .font(.caption.monospaced())
173 .foregroundStyle(.secondary)
174 .padding(.horizontal, 10)
175 .padding(.vertical, 6)
176 .background(.quaternary, in: Capsule())
177 }
178 .disabled(isUpdating || isDeleting)
179 }
180 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
181 Button(role: .destructive) {
182 onDelete()
183 } label: {
184 Label("Remove", systemImage: "trash")
185 }
186 .disabled(isUpdating || isDeleting)
187 }
188 }
189}
190
191private struct RepositoryACLAddUserView: View {
192 @Environment(\.dismiss) private var dismiss
193
194 @Bindable var viewModel: RepositoryACLViewModel
195 let onAdded: () -> Void
196
197 var body: some View {
198 Form {
199 Section("User") {
200 TextField("Username or ~username", text: $viewModel.addUsername)
201 .autocorrectionDisabled()
202 .textInputAutocapitalization(.never)
203 .themedRow()
204
205 if let validation = inlineValidationMessage {
206 Text(validation)
207 .font(.caption)
208 .foregroundStyle(.secondary)
209 .themedRow()
210 }
211 }
212
213 Section("Permission") {
214 Picker("Permission", selection: $viewModel.addMode) {
215 ForEach(AccessMode.allCases, id: \.self) { mode in
216 Text(mode.shortLabel).tag(mode)
217 }
218 }
219 .pickerStyle(.segmented)
220 .themedRow()
221 }
222 }
223 .themedList()
224 .navigationTitle("Add User")
225 .navigationBarTitleDisplayMode(.inline)
226 .toolbar {
227 ToolbarItem(placement: .cancellationAction) {
228 Button("Cancel") {
229 dismiss()
230 }
231 }
232
233 ToolbarItem(placement: .confirmationAction) {
234 Button("Add") {
235 Task {
236 if await viewModel.addEntry() {
237 onAdded()
238 }
239 }
240 }
241 .disabled(!viewModel.canSubmitNewEntry)
242 }
243 }
244 }
245
246 private var inlineValidationMessage: String? {
247 let trimmed = viewModel.addUsername.trimmingCharacters(in: .whitespacesAndNewlines)
248 guard !trimmed.isEmpty else { return nil }
249 return viewModel.addValidationMessage
250 }
251}