gitbay/Views/Repos/RepoSettingsView.swift
752 lines · 31006 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.
709///
710/// A write that says whether the server took it lets the switch go back
711/// when it did not, rather than showing what was refused. A write that
712/// says nothing is taken as accepted, and the reload decides.
713struct SettingToggle: View {
714
715 let title: String
716 let serverValue: Bool
717 let write: (Bool) async -> Bool
718
719 @State private var isOn = false
720 @State private var seeded = false
721
722 init(title: String, serverValue: Bool, write: @escaping (Bool) async -> Void) {
723 self.title = title
724 self.serverValue = serverValue
725 self.write = { await write($0); return true }
726 }
727
728 init(title: String, serverValue: Bool, accepted write: @escaping (Bool) async -> Bool) {
729 self.title = title
730 self.serverValue = serverValue
731 self.write = write
732 }
733
734 var body: some View {
735 Toggle(title, isOn: $isOn)
736 .onAppear {
737 if !seeded {
738 isOn = serverValue
739 seeded = true
740 }
741 }
742 .onChange(of: serverValue) { _, newValue in
743 isOn = newValue
744 }
745 .onChange(of: isOn) { _, newValue in
746 guard newValue != serverValue else { return }
747 Task {
748 if !(await write(newValue)) { isOn = !newValue }
749 }
750 }
751 }
752}