gitbay/Views/Repos/RepoSettingsView.swift
772 lines · 32090 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 newTagGlob = ""
13 @State private var newContext = ""
14 @State private var renaming = false
15 @State private var newName = ""
16 @State private var renameConfirm = ""
17 /// Set once a rename succeeds: this screen's repository is gone and
18 /// the renamed one is a value push away.
19 @State private var renamedTo: String?
20 @State private var transferring = false
21 @State private var newOwner = ""
22 @State private var transferConfirm = ""
23 @State private var deleting = false
24 @State private var deleteConfirm = ""
25 /// Set once the repository is deleted: nothing on this screen applies.
26 @State private var deleted = false
27 /// A refused rename, transfer or delete, shown where the user is: the
28 /// screen's banner sits at the top of a long list, out of sight.
29 @State private var refusal: String?
30 @State private var loadedOnce = false
31 @State private var removingTopic: String?
32 @State private var newGrantUser = ""
33 @State private var newGrantRole = "read"
34 @State private var revokingGrant: AccessGrant?
35
36 init(client: GitbayClient, repo: String) {
37 _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo))
38 }
39
40 var body: some View {
41 List {
42 if let renamedTo {
43 Section {
44 NavigationLink(value: RepoRoute.repo(renamedTo)) {
45 Label("Open \(renamedTo)", systemImage: "arrow.right.circle")
46 .font(.gbSans(.subheadline))
47 }
48 .accessibilityIdentifier("settings-renamed-link")
49 } header: {
50 Text("Moved")
51 } footer: {
52 Text("This screen's repository moved; nothing here applies to it any more.")
53 }
54 }
55 if deleted {
56 Section {
57 Label("\(model.repoPath) is deleted", systemImage: "trash")
58 .font(.gbSans(.subheadline))
59 .accessibilityIdentifier("settings-deleted")
60 } footer: {
61 Text("Its issues, merge requests, releases and builds went with it.")
62 }
63 }
64 if !deleted, renamedTo == nil, let loaded = model.state.value {
65 if let error = model.actionError {
66 Section {
67 GBNotice(error, .gbWarn)
68 }
69 }
70 aboutSection(loaded)
71 topicsSection(loaded)
72 visibilitySection(loaded)
73 defaultBranchSection(loaded)
74 branchesSection(loaded)
75 tagsSection(loaded)
76 mergeRulesSection(loaded)
77 daemonSection(loaded)
78 accessSection
79 webhooksSection
80 depsSection()
81 renameSection
82 transferSection
83 deleteSection
84 }
85 }
86 .overlay { LoadStateOverlay(state: model.state) }
87 .navigationTitle("Settings")
88 .navigationBarTitleDisplayMode(.inline)
89 .task {
90 // A screen returned to after its repository moved or went has
91 // nothing to load; the notice is all it shows.
92 guard !deleted, renamedTo == nil else { return }
93 await model.load()
94 if !loadedOnce, let loaded = model.state.value {
95 descriptionText = loaded.detail.description ?? ""
96 websiteText = loaded.settings.website ?? ""
97 loadedOnce = true
98 }
99 }
100 .task { await model.loadDeps() }
101 .task { await model.loadGrants() }
102 .task { await model.loadBranches() }
103 .refreshable {
104 // Nothing to reload once the repository is gone or moved.
105 guard !deleted, renamedTo == nil else { return }
106 await model.load()
107 await model.loadDeps()
108 await model.loadGrants()
109 await model.loadBranches()
110 }
111 .confirmationDialog(
112 "Revoke \(revokingGrant?.user ?? "")'s \(revokingGrant?.role ?? "") access?",
113 isPresented: Binding(
114 get: { revokingGrant != nil },
115 set: { if !$0 { revokingGrant = nil } }
116 )
117 ) {
118 Button("Revoke", role: .destructive) {
119 if let grant = revokingGrant {
120 Task { await model.revoke(user: grant.user) }
121 }
122 revokingGrant = nil
123 }
124 Button("Cancel", role: .cancel) {}
125 }
126 .alert("Rename \(model.repoPath)", isPresented: $renaming) {
127 TextField("New name", text: $newName)
128 .autocorrectionDisabled()
129 .textInputAutocapitalization(.never)
130 TextField("type \(model.repoPath) to confirm", text: $renameConfirm)
131 .autocorrectionDisabled()
132 .textInputAutocapitalization(.never)
133 Button("Rename") {
134 Task {
135 if let path = await model.rename(to: newName) {
136 renamedTo = path
137 } else {
138 refusal = model.actionError
139 }
140 }
141 }
142 .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty
143 || renameConfirm != model.repoPath)
144 Button("Cancel", role: .cancel) {}
145 } message: {
146 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.")
147 }
148 .alert("Not done", isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })) {
149 Button("OK", role: .cancel) {}
150 } message: {
151 Text(refusal ?? "")
152 }
153 .alert("Transfer \(model.repoPath)", isPresented: $transferring) {
154 TextField("New owner", text: $newOwner)
155 .autocorrectionDisabled()
156 .textInputAutocapitalization(.never)
157 TextField("type \(model.repoPath) to confirm", text: $transferConfirm)
158 .autocorrectionDisabled()
159 .textInputAutocapitalization(.never)
160 Button("Transfer") {
161 Task {
162 if let path = await model.transfer(to: newOwner) {
163 renamedTo = path
164 } else {
165 refusal = model.actionError
166 }
167 }
168 }
169 .disabled(newOwner.trimmingCharacters(in: .whitespaces).isEmpty
170 || transferConfirm != model.repoPath)
171 Button("Cancel", role: .cancel) {}
172 } message: {
173 Text("The repository moves to another account or organization, and its clone URLs change. Type \(model.repoPath) to confirm.")
174 }
175 .alert("Delete \(model.repoPath)", isPresented: $deleting) {
176 TextField("type \(model.repoPath) to confirm", text: $deleteConfirm)
177 .autocorrectionDisabled()
178 .textInputAutocapitalization(.never)
179 Button("Delete", role: .destructive) {
180 Task {
181 if await model.delete() { deleted = true } else { refusal = model.actionError }
182 }
183 }
184 .disabled(deleteConfirm != model.repoPath)
185 Button("Cancel", role: .cancel) {}
186 } message: {
187 Text("This deletes the repository with its issues, merge requests, releases and builds, and cannot be undone. Type \(model.repoPath) to confirm.")
188 }
189 .confirmationDialog(
190 "Remove topic \(removingTopic ?? "")?",
191 isPresented: Binding(
192 get: { removingTopic != nil },
193 set: { if !$0 { removingTopic = nil } }
194 )
195 ) {
196 Button("Remove", role: .destructive) {
197 if let topic = removingTopic {
198 Task { await model.removeTopic(topic) }
199 }
200 removingTopic = nil
201 }
202 Button("Cancel", role: .cancel) {}
203 }
204 }
205
206 // MARK: - Sections
207
208 private func aboutSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
209 Section("About") {
210 HStack {
211 TextField("Description", text: $descriptionText, axis: .vertical)
212 .lineLimit(1...3)
213 .accessibilityIdentifier("settings-description")
214 if descriptionText != (loaded.detail.description ?? "") {
215 Button("Save") {
216 Task { await model.setDescription(descriptionText) }
217 }
218 .font(.gbSans(.caption))
219 .disabled(model.working)
220 .accessibilityIdentifier("settings-description-save")
221 }
222 }
223 HStack {
224 TextField("Website", text: $websiteText)
225 .keyboardType(.URL)
226 .autocorrectionDisabled()
227 .textInputAutocapitalization(.never)
228 .accessibilityIdentifier("settings-website")
229 if websiteText != (loaded.settings.website ?? "") {
230 Button("Save") {
231 Task { await model.setWebsite(websiteText) }
232 }
233 .font(.gbSans(.caption))
234 .disabled(model.working)
235 }
236 }
237 }
238 }
239
240 private func topicsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
241 Section("Topics") {
242 if let topics = loaded.detail.topics, !topics.isEmpty {
243 ScrollView(.horizontal, showsIndicators: false) {
244 HStack(spacing: 6) {
245 ForEach(topics, id: \.self) { topic in
246 HStack(spacing: 3) {
247 Text(topic)
248 Button {
249 removingTopic = topic
250 } label: {
251 Image(systemName: "xmark.circle.fill")
252 .foregroundStyle(.tertiary)
253 }
254 .accessibilityLabel("Remove \(topic)")
255 .disabled(model.working)
256 .accessibilityIdentifier("settings-remove-topic-\(topic)")
257 }
258 .font(.gbSans(.caption))
259 .foregroundStyle(Color.gbAccent)
260 .padding(.horizontal, 8)
261 .padding(.vertical, 3)
262 .background(Color.gbAccent.opacity(0.07), in: gbChipShape)
263 .overlay(gbChipShape.stroke(Color.gbAccent.opacity(0.35), lineWidth: 1))
264 }
265 }
266 }
267 }
268 HStack {
269 TextField("Add topic", text: $newTopic)
270 .autocorrectionDisabled()
271 .textInputAutocapitalization(.never)
272 .accessibilityIdentifier("settings-add-topic")
273 Button {
274 let topic = newTopic.trimmingCharacters(in: .whitespaces)
275 newTopic = ""
276 Task { await model.addTopic(topic) }
277 } label: {
278 Image(systemName: "plus.circle.fill")
279 }
280 .accessibilityLabel("Add topic")
281 .disabled(newTopic.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
282 .accessibilityIdentifier("settings-add-topic-submit")
283 }
284 }
285 }
286
287 private func visibilitySection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
288 Section {
289 Picker("Visibility", selection: Binding(
290 get: { loaded.detail.visibility },
291 set: { newValue in Task { await model.setVisibility(newValue) } }
292 )) {
293 Text("Public").tag("public")
294 Text("Private").tag("private")
295 }
296 .disabled(model.working)
297 } footer: {
298 Text("Private repositories are visible only to the owner and granted accounts.")
299 }
300 }
301
302 private func branchesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
303 Section {
304 ForEach(loaded.settings.branches, id: \.self) { branch in
305 HStack {
306 Label(branch, systemImage: "lock.shield")
307 .font(.gbSans(.subheadline))
308 Spacer()
309 Button("Unprotect") {
310 Task { await model.unprotectBranch(branch) }
311 }
312 .font(.gbSans(.caption))
313 .disabled(model.working)
314 }
315 }
316 HStack {
317 TextField("Protect branch", text: $newBranch)
318 .autocorrectionDisabled()
319 .textInputAutocapitalization(.never)
320 Button {
321 let branch = newBranch.trimmingCharacters(in: .whitespaces)
322 newBranch = ""
323 Task { await model.protectBranch(branch) }
324 } label: {
325 Image(systemName: "plus.circle.fill")
326 }
327 .accessibilityLabel("Protect branch")
328 .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
329 }
330 toggle("Merge requests only", loaded.settings.requireMR ?? false) {
331 await model.setRequireMR($0)
332 }
333 } header: {
334 Text("Protected branches")
335 } footer: {
336 Text("Protected branches refuse force pushes and deletion. Merge requests only also refuses direct pushes to them; merging a merge request becomes the only way to change them.")
337 }
338 }
339
340 /// `repo settings default-branch`: a menu over the branches the
341 /// server listed, since it refuses a name that does not exist.
342 private func defaultBranchSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
343 Section {
344 Menu {
345 ForEach(model.branches, id: \.self) { branch in
346 Button(branch) {
347 if branch != loaded.detail.defaultBranch {
348 Task { await model.setDefaultBranch(branch) }
349 }
350 }
351 }
352 } label: {
353 HStack {
354 Label(loaded.detail.defaultBranch, systemImage: "arrow.triangle.branch")
355 .font(.gbSans(.subheadline))
356 Spacer()
357 Image(systemName: "chevron.up.chevron.down")
358 .font(.gbSans(.caption))
359 .foregroundStyle(.secondary)
360 }
361 }
362 .disabled(model.working || model.branches.isEmpty)
363 .accessibilityIdentifier("settings-default-branch-menu")
364 } header: {
365 Text("Default branch")
366 } footer: {
367 Text("What a clone checks out, and what merge requests target by default.")
368 }
369 }
370
371 /// `repo settings protect-tag` / `unprotect-tag`: globs, path.Match style.
372 private func tagsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
373 Section {
374 ForEach(loaded.settings.tags, id: \.self) { glob in
375 HStack {
376 Label(glob, systemImage: "tag")
377 .font(.gbMono(.caption))
378 Spacer()
379 Button("Unprotect") {
380 Task { await model.unprotectTag(glob) }
381 }
382 .font(.gbSans(.caption))
383 .disabled(model.working)
384 .accessibilityIdentifier("settings-unprotect-tag-\(glob)")
385 }
386 }
387 HStack {
388 TextField("Protect tags matching", text: $newTagGlob)
389 .autocorrectionDisabled()
390 .textInputAutocapitalization(.never)
391 .accessibilityIdentifier("settings-protect-tag")
392 Button {
393 let glob = newTagGlob.trimmingCharacters(in: .whitespaces)
394 newTagGlob = ""
395 Task { await model.protectTag(glob) }
396 } label: {
397 Image(systemName: "plus.circle.fill")
398 }
399 .accessibilityLabel("Protect tag")
400 .disabled(newTagGlob.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
401 .accessibilityIdentifier("settings-protect-tag-submit")
402 }
403 } header: {
404 Text("Protected tags")
405 } footer: {
406 Text("A matching tag is created once and then refuses moves and deletion. Globs such as v*.")
407 }
408 }
409
410 private func mergeRulesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
411 Section {
412 ApprovalsStepper(
413 serverValue: loaded.settings.requireApprovals ?? 0
414 ) { newValue in
415 await model.setRequireApprovals(newValue)
416 }
417 .disabled(model.working)
418 toggle("Require threads resolved", loaded.settings.requireResolved ?? false) {
419 await model.setRequireResolved($0)
420 }
421 toggle("Require green checks", loaded.settings.requireChecks ?? false) {
422 await model.setRequireChecks($0)
423 }
424 VStack(alignment: .leading, spacing: 6) {
425 Text(contextsHeading(loaded.settings))
426 .font(.gbSans(.subheadline))
427 RemovableChips(items: loaded.settings.contexts, idPrefix: "settings-context",
428 disabled: model.working,
429 remove: { context in Task { await model.removeRequiredContext(context) } })
430 HStack {
431 TextField("ci/test", text: $newContext)
432 .font(.gbMono(.subheadline))
433 .autocorrectionDisabled()
434 .textInputAutocapitalization(.never)
435 .onSubmit { if !model.working { addContext() } }
436 .accessibilityIdentifier("settings-context-field")
437 Button {
438 addContext()
439 } label: {
440 Image(systemName: "plus.circle.fill")
441 }
442 .accessibilityLabel("Require check")
443 .disabled(newContext.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
444 .accessibilityIdentifier("settings-context-add")
445 }
446 }
447 toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) {
448 await model.setRequireSigned($0)
449 }
450 toggle("Require code owner review", loaded.settings.requireCodeowners ?? false) {
451 await model.setRequireCodeowners($0)
452 }
453 } header: {
454 Text("Merge requirements")
455 } footer: {
456 Text("The server enforces these on every merge, whatever the surface. Naming a check turns the checks gate on.")
457 }
458 }
459
460 private func addContext() {
461 let context = newContext
462 Task {
463 if await model.addRequiredContext(context), newContext == context { newContext = "" }
464 }
465 }
466
467 private func contextsHeading(_ settings: RepoSettings) -> String {
468 if !(settings.requireChecks ?? false) {
469 return settings.contexts.isEmpty ? "Name a check to require it" : "Named checks, not required while the gate is off"
470 }
471 return settings.contexts.isEmpty ? "Every check on the head must pass" : "These checks must pass"
472 }
473
474 private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
475 Section {
476 toggle("git:// daemon", loaded.settings.gitDaemon ?? false) {
477 await model.setGitDaemon($0)
478 }
479 } footer: {
480 Text("Anonymous, unauthenticated read access over the git protocol.")
481 }
482 }
483
484 /// `repo access list`, grant and revoke. Owners and org members are
485 /// not grants and do not appear here.
486 private var accessSection: some View {
487 Section {
488 if let error = model.grantsError {
489 GBNotice(error, .gbWarn)
490 }
491 ForEach(model.grants ?? []) { grant in
492 HStack {
493 NavigationLink(value: RepoRoute.profile(grant.user)) {
494 Text(grant.user)
495 .font(.gbSans(.subheadline))
496 }
497 Spacer()
498 if let source = grant.source, source != "direct" {
499 GBChip(source, .secondary)
500 }
501 Menu {
502 ForEach(["read", "write", "admin"], id: \.self) { role in
503 Button(role) { Task { await model.grant(user: grant.user, role: role) } }
504 }
505 } label: {
506 GBChip(grant.role, .secondary)
507 }
508 .disabled(model.working)
509 }
510 .swipeActions {
511 if grant.isDirect {
512 Button("Revoke", role: .destructive) {
513 revokingGrant = grant
514 }
515 }
516 }
517 }
518 VStack(spacing: 8) {
519 TextField("Grant user", text: $newGrantUser)
520 .autocorrectionDisabled()
521 .textInputAutocapitalization(.never)
522 .accessibilityIdentifier("settings-grant-user")
523 HStack {
524 Picker("Role", selection: $newGrantRole) {
525 Text("read").tag("read")
526 Text("write").tag("write")
527 Text("admin").tag("admin")
528 }
529 .pickerStyle(.segmented)
530 Button("Grant") {
531 let user = newGrantUser
532 newGrantUser = ""
533 Task { await model.grant(user: user, role: newGrantRole) }
534 }
535 .buttonStyle(.borderless)
536 .disabled(newGrantUser.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
537 .accessibilityIdentifier("settings-grant-submit")
538 }
539 }
540 } header: {
541 Text("Access")
542 } footer: {
543 Text("Everyone who can reach this repository and how. Only a direct grant can be revoked here; a role change grants directly.")
544 }
545 }
546
547 private var webhooksSection: some View {
548 Section {
549 NavigationLink(value: RepoRoute.webhooks(repo: model.repoPath)) {
550 Label("Webhooks", systemImage: "antenna.radiowaves.left.and.right")
551 .font(.gbSans(.subheadline))
552 }
553 .accessibilityIdentifier("settings-webhooks-link")
554 NavigationLink(value: RepoRoute.buildSecrets(repo: model.repoPath)) {
555 Label("Build secrets", systemImage: "key.horizontal")
556 .font(.gbSans(.subheadline))
557 }
558 .accessibilityIdentifier("settings-secrets-link")
559 NavigationLink(value: RepoRoute.deployKeys(repo: model.repoPath)) {
560 Label("Deploy keys", systemImage: "key")
561 .font(.gbSans(.subheadline))
562 }
563 .accessibilityIdentifier("settings-deploy-keys-link")
564 NavigationLink(value: RepoRoute.mirrors(repo: model.repoPath)) {
565 Label("Mirrors", systemImage: "arrow.triangle.branch")
566 .font(.gbSans(.subheadline))
567 }
568 .accessibilityIdentifier("settings-mirrors-link")
569 NavigationLink(value: RepoRoute.pageDomains(repo: model.repoPath)) {
570 Label("Custom domains", systemImage: "globe")
571 .font(.gbSans(.subheadline))
572 }
573 .accessibilityIdentifier("settings-domains-link")
574 }
575 }
576
577 /// `repo rename`. Delete and transfer stay SSH-only by design; a
578 /// rename keeps the owner and is routine for someone with many repos.
579 private var renameSection: some View {
580 Section {
581 Button {
582 newName = String(model.repoPath.split(separator: "/").last ?? "")
583 renameConfirm = ""
584 renaming = true
585 } label: {
586 Label("Rename repository", systemImage: "pencil")
587 .font(.gbSans(.subheadline))
588 }
589 .disabled(model.working)
590 .accessibilityIdentifier("settings-rename-button")
591 } footer: {
592 Text("The owner stays; the name changes, and so do the clone URLs.")
593 }
594 }
595
596 private var transferSection: some View {
597 Section {
598 Button {
599 newOwner = ""
600 transferConfirm = ""
601 transferring = true
602 } label: {
603 Label("Transfer repository", systemImage: "arrow.left.arrow.right")
604 .font(.gbSans(.subheadline))
605 }
606 .disabled(model.working)
607 .accessibilityIdentifier("settings-transfer-button")
608 } footer: {
609 Text("To another account or an organization you can create repositories in.")
610 }
611 }
612
613 private var deleteSection: some View {
614 Section {
615 Button(role: .destructive) {
616 deleteConfirm = ""
617 deleting = true
618 } label: {
619 Label("Delete repository", systemImage: "trash")
620 .font(.gbSans(.subheadline))
621 }
622 .disabled(model.working)
623 .accessibilityIdentifier("settings-delete-button")
624 } footer: {
625 Text("Deletes it with everything in it. Only its owner or an admin can.")
626 }
627 }
628
629 private func depsSection() -> some View {
630 Section {
631 if let deps = model.deps {
632 SettingToggle(title: "Dependency checks", serverValue: deps.enabled) {
633 await model.setDepsEnabled($0)
634 }
635 .disabled(model.working)
636
637 if deps.enabled {
638 if let lastCheck = deps.lastCheck {
639 (Text("Last checked ") + Text(lastCheck, format: .relative(presentation: .named)))
640 .font(.gbSans(.caption))
641 .foregroundStyle(.secondary)
642 } else {
643 Text("Never checked")
644 .font(.gbSans(.caption))
645 .foregroundStyle(.secondary)
646 }
647 if let lastError = deps.lastError {
648 GBNotice(lastError, .gbWarn)
649 }
650 if let issueNumber = deps.issueNumber {
651 NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issueNumber)) {
652 Text("Tracking issue #\(issueNumber)")
653 }
654 }
655 if deps.behind.isEmpty {
656 if deps.lastCheck != nil {
657 Text("Everything is current.")
658 .font(.gbSans(.caption))
659 .foregroundStyle(.secondary)
660 }
661 } else {
662 ForEach(deps.behind) { dep in
663 VStack(alignment: .leading, spacing: 2) {
664 Text(dep.name)
665 .font(.gbSans(.subheadline))
666 Text("\(dep.ecosystem) · \(dep.current) → \(dep.latest)")
667 .font(.gbSans(.caption))
668 .foregroundStyle(.secondary)
669 }
670 }
671 }
672 }
673 } else if let depsError = model.depsError {
674 GBNotice(depsError, .gbWarn)
675 } else {
676 HStack {
677 Spacer()
678 ProgressView()
679 Spacer()
680 }
681 }
682 } header: {
683 Text("Dependencies")
684 } footer: {
685 Text("Checks tell a public registry what this repository depends on.")
686 }
687 }
688
689 private func toggle(
690 _ title: String,
691 _ value: Bool,
692 set: @escaping (Bool) async -> Void
693 ) -> some View {
694 SettingToggle(title: title, serverValue: value, write: set)
695 .disabled(model.working)
696 }
697}
698
699/// Same local-state mirror as SettingToggle, for the approvals count.
700private struct ApprovalsStepper: View {
701
702 let serverValue: Int
703 let write: (Int) async -> Void
704
705 @State private var count = 0
706 @State private var seeded = false
707
708 var body: some View {
709 Stepper("Required approvals: \(count)", value: $count, in: 0...10)
710 .onAppear {
711 if !seeded {
712 count = serverValue
713 seeded = true
714 }
715 }
716 .onChange(of: serverValue) { _, newValue in
717 count = newValue
718 }
719 .onChange(of: count) { _, newValue in
720 guard newValue != serverValue else { return }
721 Task { await write(newValue) }
722 }
723 }
724}
725
726/// A Toggle backed by real local state that mirrors the server value.
727/// A computed Binding whose setter spawns a Task proved unreliable under
728/// synthesized taps; a plain @State toggle is not.
729///
730/// A write that says whether the server took it lets the switch go back
731/// when it did not, rather than showing what was refused. A write that
732/// says nothing is taken as accepted, and the reload decides.
733struct SettingToggle: View {
734
735 let title: String
736 let serverValue: Bool
737 let write: (Bool) async -> Bool
738
739 @State private var isOn = false
740 @State private var seeded = false
741
742 init(title: String, serverValue: Bool, write: @escaping (Bool) async -> Void) {
743 self.title = title
744 self.serverValue = serverValue
745 self.write = { await write($0); return true }
746 }
747
748 init(title: String, serverValue: Bool, accepted write: @escaping (Bool) async -> Bool) {
749 self.title = title
750 self.serverValue = serverValue
751 self.write = write
752 }
753
754 var body: some View {
755 Toggle(title, isOn: $isOn)
756 .onAppear {
757 if !seeded {
758 isOn = serverValue
759 seeded = true
760 }
761 }
762 .onChange(of: serverValue) { _, newValue in
763 isOn = newValue
764 }
765 .onChange(of: isOn) { _, newValue in
766 guard newValue != serverValue else { return }
767 Task {
768 if !(await write(newValue)) { isOn = !newValue }
769 }
770 }
771 }
772}