import SwiftUI struct SettingsView: View { @ObservedObject var settings: SettingsStore /// The current voice session, directly observed so the Apply button's /// disabled state and help text update reactively when the session /// transitions (e.g. `.tearingDown` → `.failed`). @ObservedObject var session: VoiceSession /// Local draft of the server URL. Only written to `settings` when the user /// explicitly applies a value that passes validation — keystrokes never /// reach storage or the network layer. @State private var urlText: String = "" @State private var urlError: String? @State private var showVoicePicker = false @State private var showModelPicker = false init(settings: SettingsStore, session: VoiceSession) { self.settings = settings self.session = session } /// Whether the current session is safe to replace. Apply is permitted for /// `.idle` (no active call) and `.failed` (fully torn down after failure — /// the user needs to correct an unreachable server without relaunching). /// Disabled during `.tearingDown`, `.connecting`, `.waitingAgent`, and /// `.ready` to avoid disconnecting an active call or racing cleanup. private var canApplyServerURL: Bool { session.state == .idle || session.state.isFailed } var body: some View { Form { Section("Server") { TextField(SettingsStore.defaultServerURL, text: $urlText) .keyboardType(.URL) .autocorrectionDisabled() .onAppear { urlText = settings.serverURL } if let urlError { Text(urlError) .font(.footnote) .foregroundStyle(.red) } Button("Apply server URL") { applyServerURL() } .disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) if !canApplyServerURL { Text(session.state == .tearingDown ? "Waiting for failure cleanup to finish…" : "End the conversation before changing servers.") .font(.footnote) .foregroundStyle(.secondary) } } Section("Voice") { Button { showVoicePicker = true } label: { LabeledContent("Voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID) } } Section("Model") { if session.isReady, !session.models.isEmpty { Button { showModelPicker = true } label: { LabeledContent("Model", value: session.selectedModel ?? settings.modelID ?? "—") } } else { Text("Start a conversation to load the model list.") .font(.footnote) .foregroundStyle(.secondary) } } Section("Interaction") { Toggle("Text mode", isOn: $settings.textMode) } Section { NavigationLink("Connection test") { ConnectionTestView(settings: settings) } } } .navigationTitle("Settings") .sheet(isPresented: $showVoicePicker) { VoicePickerView(settings: settings, session: session) } .sheet(isPresented: $showModelPicker) { ModelPickerView(settings: settings, session: session) } } /// Validates the draft and persists it only on success. On failure the /// draft stays in the field with an inline error; `settings.serverURL` is /// left untouched so the active session keeps using its original URL. /// Guarded: only applies when the session is idle (no active call). private func applyServerURL() { guard canApplyServerURL else { urlError = "End the conversation before changing servers." return } let trimmed = urlText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { urlError = "Enter a server URL." return } switch ServerConfig.parse(trimmed) { case .success: settings.serverURL = trimmed urlText = trimmed urlError = nil case .failure(let error): urlError = error.localizedDescription } } } struct VoicePickerView: View { @ObservedObject var settings: SettingsStore var session: VoiceSession @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { List { ForEach(Voices.all, id: \.id) { voice in Button { settings.voiceID = voice.id Task { await session.selectVoice(voice.id) } dismiss() } label: { HStack { Text(voice.displayName) if settings.voiceID == voice.id { Spacer() Image(systemName: "checkmark") .foregroundStyle(.blue) } } } } } .navigationTitle("Voice") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } } } } struct ModelPickerView: View { @ObservedObject var settings: SettingsStore var session: VoiceSession @Environment(\.dismiss) private var dismiss private var activeModelID: String? { if !session.models.isEmpty, let selected = session.selectedModel ?? settings.modelID, session.models.contains(where: { $0.id == selected }) { return selected } return session.models.first?.id } var body: some View { NavigationStack { List { ForEach(session.models, id: \.id) { model in Button { settings.modelID = model.id Task { await session.selectModel(model.id) } dismiss() } label: { HStack { Text(model.name ?? model.id) if activeModelID == model.id { Spacer() Image(systemName: "checkmark") .foregroundStyle(.blue) } } } } } .navigationTitle("Model") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } } } } #Preview { let previewSettings = SettingsStore() guard case .success(let previewConfig) = ServerConfig.parse(SettingsStore.defaultServerURL) else { return AnyView(Text("Invalid default server URL")) } let previewSession = VoiceSession(config: previewConfig, tokenClient: TokenClient(config: previewConfig), settings: previewSettings) return AnyView(NavigationStack { SettingsView(settings: previewSettings, session: previewSession) }) }