This repository has been archived on 2026-10-04. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
hope-voice-ios/Sources/Features/Settings/SettingsView.swift
T

189 lines
8.0 KiB
Swift

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)
})
}