- Transcript: selection suspension clears when press ends; Latest re-enables follow fully (clears stale selection state) - Send disabled until transport ready in pending Type composer - Server URL: programmatic success-trim no longer erases its own saved-confirmation; only user edits clear it
257 lines
10 KiB
Swift
257 lines
10 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 urlJustSaved = false
|
|
@State private var showModelPicker = false
|
|
@FocusState private var urlFocused: Bool
|
|
@State private var applyingTrim = false
|
|
/// Guards the success-trim against the edit observer (N3).
|
|
|
|
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 {
|
|
if session.isReady, !session.models.isEmpty {
|
|
Button {
|
|
showModelPicker = true
|
|
} label: {
|
|
LabeledContent("Model", value: session.selectedModel ?? settings.modelID ?? "Server default")
|
|
}
|
|
} else {
|
|
LabeledContent("Model", value: settings.modelID ?? "Server default")
|
|
Text("Connect from Talk to see the models available on your server.")
|
|
.font(.footnote).foregroundStyle(Nearlight.quiet)
|
|
}
|
|
} header: { Text("Intelligence") }
|
|
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) { oldValue, newValue in
|
|
// A programmatic normalization on successful Save must
|
|
// not erase its own confirmation; only user edits do.
|
|
guard applyingTrim == false else { return }
|
|
if newValue != oldValue {
|
|
urlJustSaved = false
|
|
urlError = nil
|
|
}
|
|
}
|
|
.overlay(alignment: .trailing) {
|
|
if urlJustSaved {
|
|
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 urlJustSaved {
|
|
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)
|
|
}
|
|
} 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)
|
|
.tint(Nearlight.quiet)
|
|
.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() {
|
|
urlJustSaved = false
|
|
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
|
|
if trimmed != urlText {
|
|
applyingTrim = true
|
|
urlText = trimmed
|
|
applyingTrim = false
|
|
}
|
|
urlError = nil
|
|
urlJustSaved = true
|
|
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() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ModelPickerView: View {
|
|
@ObservedObject var settings: SettingsStore
|
|
@ObservedObject 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(Nearlight.rosewood)
|
|
}
|
|
}
|
|
}
|
|
.accessibilityAddTraits(activeModelID == model.id ? .isSelected : [])
|
|
}
|
|
}
|
|
.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)
|
|
})
|
|
}
|