a native ios client for gitbay

client ios swift

https://gitbay.org

gitbay/Views/Repos/RepoSettingsView.swift

main
gitbay-ios/gitbay/Views/Repos/RepoSettingsView.swift history · blame · raw

274 lines · 10278 bytes

  1import SwiftUI
  2
  3/// Admin knobs, one command per control. A non-admin sees the server's
  4/// refusal instead of a half-working form.
  5struct RepoSettingsView: View {
  6
  7    @State private var model: RepoSettingsViewModel
  8    @State private var descriptionText = ""
  9    @State private var websiteText = ""
 10    @State private var newTopic = ""
 11    @State private var newBranch = ""
 12    @State private var loadedOnce = false
 13
 14    init(client: GitbayClient, repo: String) {
 15        _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo))
 16    }
 17
 18    var body: some View {
 19        List {
 20            if let loaded = model.state.value {
 21                if let error = model.actionError {
 22                    Section {
 23                        GBNotice(error, .gbWarn)
 24                    }
 25                }
 26                aboutSection(loaded)
 27                topicsSection(loaded)
 28                visibilitySection(loaded)
 29                branchesSection(loaded)
 30                mergeRulesSection(loaded)
 31                daemonSection(loaded)
 32            }
 33        }
 34        .overlay { LoadStateOverlay(state: model.state) }
 35        .navigationTitle("Settings")
 36        .navigationBarTitleDisplayMode(.inline)
 37        .task {
 38            await model.load()
 39            if !loadedOnce, let loaded = model.state.value {
 40                descriptionText = loaded.detail.description ?? ""
 41                websiteText = loaded.settings.website ?? ""
 42                loadedOnce = true
 43            }
 44        }
 45        .refreshable { await model.load() }
 46    }
 47
 48    // MARK: - Sections
 49
 50    private func aboutSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
 51        Section("About") {
 52            HStack {
 53                TextField("Description", text: $descriptionText, axis: .vertical)
 54                    .lineLimit(1...3)
 55                    .accessibilityIdentifier("settings-description")
 56                if descriptionText != (loaded.detail.description ?? "") {
 57                    Button("Save") {
 58                        Task { await model.setDescription(descriptionText) }
 59                    }
 60                    .font(.gbSans(.caption))
 61                    .disabled(model.working)
 62                    .accessibilityIdentifier("settings-description-save")
 63                }
 64            }
 65            HStack {
 66                TextField("Website", text: $websiteText)
 67                    .keyboardType(.URL)
 68                    .autocorrectionDisabled()
 69                    .textInputAutocapitalization(.never)
 70                    .accessibilityIdentifier("settings-website")
 71                if websiteText != (loaded.settings.website ?? "") {
 72                    Button("Save") {
 73                        Task { await model.setWebsite(websiteText) }
 74                    }
 75                    .font(.gbSans(.caption))
 76                    .disabled(model.working)
 77                }
 78            }
 79        }
 80    }
 81
 82    private func topicsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
 83        Section("Topics") {
 84            if let topics = loaded.detail.topics, !topics.isEmpty {
 85                ScrollView(.horizontal, showsIndicators: false) {
 86                    HStack(spacing: 6) {
 87                        ForEach(topics, id: \.self) { topic in
 88                            HStack(spacing: 3) {
 89                                Text(topic)
 90                                Button {
 91                                    Task { await model.removeTopic(topic) }
 92                                } label: {
 93                                    Image(systemName: "xmark.circle.fill")
 94                                        .foregroundStyle(.tertiary)
 95                                }
 96                                .disabled(model.working)
 97                            }
 98                            .font(.gbSans(.caption))
 99                            .foregroundStyle(Color.gbAccent)
100                            .padding(.horizontal, 8)
101                            .padding(.vertical, 3)
102                            .background(Color.gbAccent.opacity(0.07), in: gbChipShape)
103                            .overlay(gbChipShape.stroke(Color.gbAccent.opacity(0.35), lineWidth: 1))
104                        }
105                    }
106                }
107            }
108            HStack {
109                TextField("Add topic", text: $newTopic)
110                    .autocorrectionDisabled()
111                    .textInputAutocapitalization(.never)
112                    .accessibilityIdentifier("settings-add-topic")
113                Button {
114                    let topic = newTopic.trimmingCharacters(in: .whitespaces)
115                    newTopic = ""
116                    Task { await model.addTopic(topic) }
117                } label: {
118                    Image(systemName: "plus.circle.fill")
119                }
120                .disabled(newTopic.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
121                .accessibilityIdentifier("settings-add-topic-submit")
122            }
123        }
124    }
125
126    private func visibilitySection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
127        Section {
128            Picker("Visibility", selection: Binding(
129                get: { loaded.detail.visibility },
130                set: { newValue in Task { await model.setVisibility(newValue) } }
131            )) {
132                Text("Public").tag("public")
133                Text("Private").tag("private")
134            }
135            .disabled(model.working)
136        } footer: {
137            Text("Private repositories are visible only to the owner and granted accounts.")
138        }
139    }
140
141    private func branchesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
142        Section {
143            ForEach(loaded.settings.branches, id: \.self) { branch in
144                HStack {
145                    Label(branch, systemImage: "lock.shield")
146                        .font(.gbSans(.subheadline))
147                    Spacer()
148                    Button("Unprotect") {
149                        Task { await model.unprotectBranch(branch) }
150                    }
151                    .font(.gbSans(.caption))
152                    .disabled(model.working)
153                }
154            }
155            HStack {
156                TextField("Protect branch", text: $newBranch)
157                    .autocorrectionDisabled()
158                    .textInputAutocapitalization(.never)
159                Button {
160                    let branch = newBranch.trimmingCharacters(in: .whitespaces)
161                    newBranch = ""
162                    Task { await model.protectBranch(branch) }
163                } label: {
164                    Image(systemName: "plus.circle.fill")
165                }
166                .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
167            }
168        } header: {
169            Text("Protected branches")
170        } footer: {
171            Text("Protected branches refuse force pushes and deletion.")
172        }
173    }
174
175    private func mergeRulesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
176        Section {
177            ApprovalsStepper(
178                serverValue: loaded.settings.requireApprovals ?? 0
179            ) { newValue in
180                await model.setRequireApprovals(newValue)
181            }
182            .disabled(model.working)
183            toggle("Require threads resolved", loaded.settings.requireResolved ?? false) {
184                await model.setRequireResolved($0)
185            }
186            toggle("Require green checks", loaded.settings.requireChecks ?? false) {
187                await model.setRequireChecks($0)
188            }
189            toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) {
190                await model.setRequireSigned($0)
191            }
192        } header: {
193            Text("Merge requirements")
194        } footer: {
195            Text("The server enforces these on every merge, whatever the surface.")
196        }
197    }
198
199    private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
200        Section {
201            toggle("git:// daemon", loaded.settings.gitDaemon ?? false) {
202                await model.setGitDaemon($0)
203            }
204        } footer: {
205            Text("Anonymous, unauthenticated read access over the git protocol.")
206        }
207    }
208
209    private func toggle(
210        _ title: String,
211        _ value: Bool,
212        set: @escaping (Bool) async -> Void
213    ) -> some View {
214        SettingToggle(title: title, serverValue: value, write: set)
215            .disabled(model.working)
216    }
217}
218
219/// Same local-state mirror as SettingToggle, for the approvals count.
220private struct ApprovalsStepper: View {
221
222    let serverValue: Int
223    let write: (Int) async -> Void
224
225    @State private var count = 0
226    @State private var seeded = false
227
228    var body: some View {
229        Stepper("Required approvals: \(count)", value: $count, in: 0...10)
230            .onAppear {
231                if !seeded {
232                    count = serverValue
233                    seeded = true
234                }
235            }
236            .onChange(of: serverValue) { _, newValue in
237                count = newValue
238            }
239            .onChange(of: count) { _, newValue in
240                guard newValue != serverValue else { return }
241                Task { await write(newValue) }
242            }
243    }
244}
245
246/// A Toggle backed by real local state that mirrors the server value.
247/// A computed Binding whose setter spawns a Task proved unreliable under
248/// synthesized taps; a plain @State toggle is not.
249private struct SettingToggle: View {
250
251    let title: String
252    let serverValue: Bool
253    let write: (Bool) async -> Void
254
255    @State private var isOn = false
256    @State private var seeded = false
257
258    var body: some View {
259        Toggle(title, isOn: $isOn)
260            .onAppear {
261                if !seeded {
262                    isOn = serverValue
263                    seeded = true
264                }
265            }
266            .onChange(of: serverValue) { _, newValue in
267                isOn = newValue
268            }
269            .onChange(of: isOn) { _, newValue in
270                guard newValue != serverValue else { return }
271                Task { await write(newValue) }
272            }
273    }
274}