| 1 | import SwiftUI |
| 2 | |
| 3 | /// The Calls server setting, as one Form section: in Calls → Settings… |
| 4 | /// on a Mac and in the home screen's settings sheet on iPhone. |
| 5 | /// |
| 6 | /// The field is a draft until Use this server; changing the server while |
| 7 | /// signed in asks first, because it signs out. |
| 8 | @MainActor public struct ServerSettingsSection: View { |
| 9 | @ObservedObject private var model: CallsModel |
| 10 | @State private var draft: String |
| 11 | @State private var problem = "" |
| 12 | @State private var confirming: String? |
| 13 | |
| 14 | public init(model: CallsModel) { |
| 15 | self.model = model |
| 16 | _draft = State(initialValue: model.server) |
| 17 | } |
| 18 | |
| 19 | private var changed: Bool { |
| 20 | guard case .success(let origin) = CallsServer.validate(draft) else { return true } |
| 21 | return origin != model.server |
| 22 | } |
| 23 | |
| 24 | public var body: some View { |
| 25 | Section { |
| 26 | TextField("Server address", text: $draft, prompt: Text(CallsServer.defaultOrigin)) |
| 27 | .accessibilityIdentifier("serverAddress") |
| 28 | #if os(iOS) |
| 29 | .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled() |
| 30 | #endif |
| 31 | .onSubmit(apply) |
| 32 | .onChange(of: draft) { problem = "" } |
| 33 | if !problem.isEmpty { |
| 34 | Label(problem, systemImage: "exclamationmark.circle.fill") |
| 35 | .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) |
| 36 | .accessibilityLabel("Error: \(problem)") |
| 37 | } |
| 38 | HStack { |
| 39 | Button("Use this server", action: apply) |
| 40 | .disabled(!changed || model.joining).accessibilityIdentifier("useServer") |
| 41 | Spacer() |
| 42 | Button("Reset to \(CallsServer.host(of: CallsServer.defaultOrigin))") { |
| 43 | draft = CallsServer.defaultOrigin |
| 44 | apply() |
| 45 | } |
| 46 | .disabled(model.server == CallsServer.defaultOrigin || model.joining) |
| 47 | } |
| 48 | #if os(iOS) |
| 49 | .buttonStyle(.borderless) |
| 50 | #endif |
| 51 | } header: { |
| 52 | Text("Calls server") |
| 53 | } footer: { |
| 54 | Text( |
| 55 | model.joining |
| 56 | ? "Leave the current meeting to change the server." |
| 57 | : "Sign in goes to this server. If you run your own Calls, enter its address here. Links to meetings on other servers still open, just for that meeting." |
| 58 | ) |
| 59 | } |
| 60 | .confirmationDialog( |
| 61 | "Change server and sign out?", isPresented: Binding( |
| 62 | get: { confirming != nil }, set: { if !$0 { confirming = nil } }), |
| 63 | titleVisibility: .visible |
| 64 | ) { |
| 65 | Button("Change and sign out", role: .destructive) { |
| 66 | if let origin = confirming { model.changeServer(to: origin) } |
| 67 | confirming = nil |
| 68 | } |
| 69 | Button("Cancel", role: .cancel) { |
| 70 | confirming = nil |
| 71 | draft = model.server |
| 72 | } |
| 73 | } message: { |
| 74 | Text( |
| 75 | "You’re signed in to \(model.host). Changing the server signs you out there; you can sign in to \(CallsServer.host(of: confirming ?? "")) next." |
| 76 | ) |
| 77 | } |
| 78 | .onChange(of: model.server) { draft = model.server } |
| 79 | } |
| 80 | |
| 81 | private func apply() { |
| 82 | switch CallsServer.validate(draft) { |
| 83 | case .failure(let failure): |
| 84 | problem = failure.message |
| 85 | case .success(let origin): |
| 86 | draft = origin |
| 87 | guard origin != model.server else { return } |
| 88 | if model.changingServerSignsOut { |
| 89 | confirming = origin |
| 90 | } else { |
| 91 | model.changeServer(to: origin) |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | /// iPhone's settings: there is no Settings scene, so the home screen's |
| 98 | /// gear opens this. |
| 99 | @MainActor public struct ServerSettingsSheet: View { |
| 100 | @ObservedObject private var model: CallsModel |
| 101 | private let done: () -> Void |
| 102 | @AppStorage(CallLayoutStyle.key) private var callLayout = CallLayoutStyle.defaultStyle |
| 103 | |
| 104 | public init(model: CallsModel, done: @escaping () -> Void) { |
| 105 | self.model = model |
| 106 | self.done = done |
| 107 | } |
| 108 | |
| 109 | public var body: some View { |
| 110 | NavigationStack { |
| 111 | Form { |
| 112 | Section { |
| 113 | Picker("Call layout", selection: $callLayout) { |
| 114 | ForEach(CallLayoutStyle.allCases) { style in |
| 115 | Text(style.title).tag(style) |
| 116 | } |
| 117 | } |
| 118 | .accessibilityIdentifier("callLayout") |
| 119 | } header: { |
| 120 | Text("During a call") |
| 121 | } footer: { |
| 122 | Text( |
| 123 | callLayout == .fullWindow |
| 124 | ? "The call fills the screen. Tap it to show the controls." |
| 125 | : "The call sits above a row of controls that are always showing." |
| 126 | ) |
| 127 | } |
| 128 | ServerSettingsSection(model: model) |
| 129 | QualityReportsSection() |
| 130 | } |
| 131 | .formStyle(.grouped) |
| 132 | .navigationTitle("Settings") |
| 133 | #if os(iOS) |
| 134 | .navigationBarTitleDisplayMode(.inline) |
| 135 | #endif |
| 136 | .toolbar { |
| 137 | ToolbarItem(placement: .confirmationAction) { Button("Done", action: done) } |
| 138 | } |
| 139 | } |
| 140 | .tint(CallsAppearance.accent) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | /// The automatic quality reports (spec §15.26): said, and a switch. Off, |
| 145 | /// no automatic reports go; "Report a problem" still works, because that |
| 146 | /// one is sent by the person on purpose. |
| 147 | @MainActor public struct QualityReportsSection: View { |
| 148 | @AppStorage(QualityReporting.enabledKey) private var send = true |
| 149 | |
| 150 | public init() {} |
| 151 | |
| 152 | public var body: some View { |
| 153 | Section { |
| 154 | Toggle("Send anonymous quality reports", isOn: $send) |
| 155 | .accessibilityIdentifier("qualityReports") |
| 156 | } header: { |
| 157 | Text("Call quality") |
| 158 | } footer: { |
| 159 | Text( |
| 160 | QualityReporting.disclosure |
| 161 | + " They hold connection numbers only — no names, no audio or video, nothing you type.") |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | |