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 savedURLConfirmation: String? @FocusState private var urlFocused: Bool 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 { Button { showVoicePicker = true } label: { LabeledContent("Speaking voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID) } Picker("Conversation mode", selection: $settings.textMode) { Text("Voice").tag(false) Text("Type · Microphone off").tag(true) } } header: { Text("Make it yours") } footer: { Text("Voice and typing preferences are saved on this iPhone. You can switch modes from the Talk tab.") } Section { Text("Server URL") .font(.footnote.weight(.semibold)) .foregroundStyle(Nearlight.quiet) TextField(SettingsStore.defaultServerURL, text: $urlText) .keyboardType(.URL) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityLabel("Server URL") .focused($urlFocused) .onAppear { urlText = settings.serverURL } .onChange(of: urlText) { _, newValue in // Keep confirmation across the asynchronous onChange // caused by successful whitespace normalization. guard newValue != savedURLConfirmation else { return } savedURLConfirmation = nil urlError = nil } .overlay(alignment: .trailing) { if savedURLConfirmation != nil { Image(systemName: "checkmark.circle") .foregroundStyle(Nearlight.quiet) .padding(.trailing, 8) .accessibilityHidden(true) } } if let urlError { Label(urlError, systemImage: "exclamationmark.circle") .font(.footnote) .foregroundStyle(Nearlight.alert) .accessibilityIdentifier("settings.server.error") } else if savedURLConfirmation != nil { Text("Server address saved.") .font(.footnote) .foregroundStyle(Nearlight.quiet) .accessibilityIdentifier("settings.server.saved") } Button("Save server address") { applyServerURL() } .disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL) if !canApplyServerURL { Text(session.state == .tearingDown ? "Waiting for failure cleanup to finish…" : "End the conversation before changing servers.") .font(.footnote) .foregroundStyle(Nearlight.quiet) } NavigationLink("Connection test") { ConnectionTestView(settings: settings, bearerToken: session.bootstrapBearerToken) } } header: { Text("Connection") } footer: { Text("Hope runs on your own server. Your iPhone needs to be on the same network or a network that can reach it.") } } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .scrollContentBackground(.hidden) .background(Nearlight.ground) .tint(Nearlight.rosewood) .sheet(isPresented: $showVoicePicker) { VoicePickerView(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() { savedURLConfirmation = nil 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 savedURLConfirmation = trimmed urlFocused = false case .failure(let error): urlError = error.localizedDescription urlFocused = true } } } struct VoicePickerView: View { @ObservedObject var settings: SettingsStore @ObservedObject 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(Nearlight.rosewood) } } } .accessibilityAddTraits(settings.voiceID == voice.id ? .isSelected : []) } } .navigationTitle("Voice") .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) }) }