krz/hutch

an ios client for sourcehut

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

v3.11.0: 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}