gitbay/Views/Repos/RepoSettingsView.swift

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

734 lines · 30327 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        }
555    }
556
557    /// `repo rename`. Delete and transfer stay SSH-only by design; a
558    /// rename keeps the owner and is routine for someone with many repos.
559    private var renameSection: some View {
560        Section {
561            Button {
562                newName = String(model.repoPath.split(separator: "/").last ?? "")
563                renameConfirm = ""
564                renaming = true
565            } label: {
566                Label("Rename repository", systemImage: "pencil")
567                    .font(.gbSans(.subheadline))
568            }
569            .disabled(model.working)
570            .accessibilityIdentifier("settings-rename-button")
571        } footer: {
572            Text("The owner stays; the name changes, and so do the clone URLs.")
573        }
574    }
575
576    private var transferSection: some View {
577        Section {
578            Button {
579                newOwner = ""
580                transferConfirm = ""
581                transferring = true
582            } label: {
583                Label("Transfer repository", systemImage: "arrow.left.arrow.right")
584                    .font(.gbSans(.subheadline))
585            }
586            .disabled(model.working)
587            .accessibilityIdentifier("settings-transfer-button")
588        } footer: {
589            Text("To another account or an organization you can create repositories in.")
590        }
591    }
592
593    private var deleteSection: some View {
594        Section {
595            Button(role: .destructive) {
596                deleteConfirm = ""
597                deleting = true
598            } label: {
599                Label("Delete repository", systemImage: "trash")
600                    .font(.gbSans(.subheadline))
601            }
602            .disabled(model.working)
603            .accessibilityIdentifier("settings-delete-button")
604        } footer: {
605            Text("Deletes it with everything in it. Only its owner or an admin can.")
606        }
607    }
608
609    private func depsSection() -> some View {
610        Section {
611            if let deps = model.deps {
612                SettingToggle(title: "Dependency checks", serverValue: deps.enabled) {
613                    await model.setDepsEnabled($0)
614                }
615                .disabled(model.working)
616
617                if deps.enabled {
618                    if let lastCheck = deps.lastCheck {
619                        (Text("Last checked ") + Text(lastCheck, format: .relative(presentation: .named)))
620                            .font(.gbSans(.caption))
621                            .foregroundStyle(.secondary)
622                    } else {
623                        Text("Never checked")
624                            .font(.gbSans(.caption))
625                            .foregroundStyle(.secondary)
626                    }
627                    if let lastError = deps.lastError {
628                        GBNotice(lastError, .gbWarn)
629                    }
630                    if let issueNumber = deps.issueNumber {
631                        NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issueNumber)) {
632                            Text("Tracking issue #\(issueNumber)")
633                        }
634                    }
635                    if deps.behind.isEmpty {
636                        if deps.lastCheck != nil {
637                            Text("Everything is current.")
638                                .font(.gbSans(.caption))
639                                .foregroundStyle(.secondary)
640                        }
641                    } else {
642                        ForEach(deps.behind) { dep in
643                            VStack(alignment: .leading, spacing: 2) {
644                                Text(dep.name)
645                                    .font(.gbSans(.subheadline))
646                                Text("\(dep.ecosystem) · \(dep.current) → \(dep.latest)")
647                                    .font(.gbSans(.caption))
648                                    .foregroundStyle(.secondary)
649                            }
650                        }
651                    }
652                }
653            } else if let depsError = model.depsError {
654                GBNotice(depsError, .gbWarn)
655            } else {
656                HStack {
657                    Spacer()
658                    ProgressView()
659                    Spacer()
660                }
661            }
662        } header: {
663            Text("Dependencies")
664        } footer: {
665            Text("Checks tell a public registry what this repository depends on.")
666        }
667    }
668
669    private func toggle(
670        _ title: String,
671        _ value: Bool,
672        set: @escaping (Bool) async -> Void
673    ) -> some View {
674        SettingToggle(title: title, serverValue: value, write: set)
675            .disabled(model.working)
676    }
677}
678
679/// Same local-state mirror as SettingToggle, for the approvals count.
680private struct ApprovalsStepper: View {
681
682    let serverValue: Int
683    let write: (Int) async -> Void
684
685    @State private var count = 0
686    @State private var seeded = false
687
688    var body: some View {
689        Stepper("Required approvals: \(count)", value: $count, in: 0...10)
690            .onAppear {
691                if !seeded {
692                    count = serverValue
693                    seeded = true
694                }
695            }
696            .onChange(of: serverValue) { _, newValue in
697                count = newValue
698            }
699            .onChange(of: count) { _, newValue in
700                guard newValue != serverValue else { return }
701                Task { await write(newValue) }
702            }
703    }
704}
705
706/// A Toggle backed by real local state that mirrors the server value.
707/// A computed Binding whose setter spawns a Task proved unreliable under
708/// synthesized taps; a plain @State toggle is not.
709struct SettingToggle: View {
710
711    let title: String
712    let serverValue: Bool
713    let write: (Bool) async -> Void
714
715    @State private var isOn = false
716    @State private var seeded = false
717
718    var body: some View {
719        Toggle(title, isOn: $isOn)
720            .onAppear {
721                if !seeded {
722                    isOn = serverValue
723                    seeded = true
724                }
725            }
726            .onChange(of: serverValue) { _, newValue in
727                isOn = newValue
728            }
729            .onChange(of: isOn) { _, newValue in
730                guard newValue != serverValue else { return }
731                Task { await write(newValue) }
732            }
733    }
734}