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? @State private var pendingServerChange: 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("Change server") { requestServerURLChange() } .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) } .confirmationDialog("Change Hope server?", isPresented: Binding(get: { pendingServerChange != nil }, set: { if !$0 { pendingServerChange = nil } }), titleVisibility: .visible) { Button("Change server and sign out", role: .destructive) { if let pendingServerChange { applyServerURL(pendingServerChange) } pendingServerChange = nil } Button("Cancel", role: .cancel) { pendingServerChange = nil } } message: { Text("Changing servers signs you out and ends the current session. Conversations and account data remain with the current server.") } } /// 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 requestServerURLChange() { 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(let config): pendingServerChange = config.baseURL.absoluteString urlError = nil case .failure(let error): urlError = error.localizedDescription urlFocused = true } } private func applyServerURL(_ canonicalURL: String) { settings.serverURL = canonicalURL urlText = canonicalURL savedURLConfirmation = canonicalURL urlError = nil urlFocused = false } } struct VoicePickerView: View { @ObservedObject var settings: SettingsStore @ObservedObject var session: VoiceSession @Environment(\.dismiss) private var dismiss @State private var voices = Voices.all @State private var search = "" @State private var loading = false @State private var catalogMessage: String? @State private var catalogAvailable = false @State private var requestID = UUID() @State private var applyingVoiceID: String? @State private var voiceError: String? private var filteredVoices: [VoiceOption] { let query = search.trimmingCharacters(in: .whitespacesAndNewlines) return voices.filter { query.isEmpty || "\($0.name) \($0.details)".localizedCaseInsensitiveContains(query) } } @MainActor private func loadVoices() async { guard !loading else { return } loading = true let id = UUID() requestID = id defer { if requestID == id { loading = false } } do { guard case .success(let config) = ServerConfig.parse(settings.serverURL) else { throw AuthError.invalidResponse } let response = try await AuthClient(config: config).voices(token: session.bootstrapBearerToken) try Task.checkCancellation() guard requestID == id else { return } let valid = response.voices.filter { $0.id.range(of: "^[A-Za-z0-9:_-]{1,120}$", options: .regularExpression) != nil } guard !valid.isEmpty else { throw AuthError.invalidResponse } voices = valid catalogAvailable = response.catalogAvailable catalogMessage = response.catalogAvailable ? nil : response.message } catch is CancellationError { return } catch { guard !Task.isCancelled, requestID == id else { return } catalogAvailable = false catalogMessage = "Couldn’t load all English voices. Your current choices remain available. Check your sign-in and server update, then retry." } } var body: some View { NavigationStack { List { Section { Text("Ava Dragon HD Omni is the recommended female default. Search by name, locale, gender, or model.") .font(.footnote) .foregroundStyle(.secondary) if loading { ProgressView("Loading English voices…") } if let catalogMessage { Text(catalogMessage).font(.footnote).foregroundStyle(.secondary) } if let voiceError { Label(voiceError, systemImage: "exclamationmark.circle").font(.footnote).foregroundStyle(.red) } if !catalogAvailable && !loading { Button("Retry voice catalog") { Task { await loadVoices() } } } } if filteredVoices.isEmpty { Text("No matching voices. Try another name or locale.").foregroundStyle(.secondary) } ForEach(filteredVoices, id: \.id) { voice in Button { guard applyingVoiceID == nil else { return } applyingVoiceID = voice.id voiceError = nil Task { let applied = await session.selectVoice(voice.id) applyingVoiceID = nil if applied { dismiss() } else { voiceError = "Couldn’t apply this voice to the live conversation. Your previous voice is still selected." } } } label: { HStack { VStack(alignment: .leading, spacing: 3) { Text(voice.name) Text(voice.details).font(.caption).foregroundStyle(.secondary) } if settings.voiceID == voice.id { Spacer() Image(systemName: "checkmark") .foregroundStyle(Nearlight.rosewood) } } } .disabled(applyingVoiceID != nil) .accessibilityAddTraits(settings.voiceID == voice.id ? .isSelected : []) } } .navigationTitle("English voices") .searchable(text: $search, prompt: "Name, locale, gender, or Omni") .task { await loadVoices() } .onDisappear { requestID = UUID(); loading = false } .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) }) }