Archived
status
This commit is contained in:
1 parent
c9c25f86eb
commit
40aaf4fea7
56 files changed
+5683
-179
No files matched your search
@@ -2,26 +2,53 @@ import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
var session: VoiceSession?
|
||||
/// 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? = nil) {
|
||||
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("https://192.168.86.2:9178", text: $urlText)
|
||||
TextField(SettingsStore.defaultServerURL, text: $urlText)
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.onAppear { urlText = settings.serverURL }
|
||||
.onChange(of: urlText) { _, newValue in
|
||||
settings.serverURL = newValue
|
||||
}
|
||||
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 {
|
||||
@@ -31,14 +58,14 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
Section("Model") {
|
||||
if let session, !session.models.isEmpty {
|
||||
if session.isReady, !session.models.isEmpty {
|
||||
Button {
|
||||
showModelPicker = true
|
||||
} label: {
|
||||
LabeledContent("Model", value: session.selectedModel ?? settings.modelID ?? "—")
|
||||
}
|
||||
} else {
|
||||
Text(session != nil ? "Loading available models…" : "Start a conversation to load the model list.")
|
||||
Text("Start a conversation to load the model list.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -60,11 +87,35 @@ struct SettingsView: View {
|
||||
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?
|
||||
var session: VoiceSession
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -73,7 +124,7 @@ struct VoicePickerView: View {
|
||||
ForEach(Voices.all, id: \.id) { voice in
|
||||
Button {
|
||||
settings.voiceID = voice.id
|
||||
Task { await session?.selectVoice(voice.id) }
|
||||
Task { await session.selectVoice(voice.id) }
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
@@ -99,11 +150,10 @@ struct VoicePickerView: View {
|
||||
|
||||
struct ModelPickerView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
var session: VoiceSession?
|
||||
var session: VoiceSession
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var activeModelID: String? {
|
||||
guard let session else { return settings.modelID }
|
||||
if !session.models.isEmpty,
|
||||
let selected = session.selectedModel ?? settings.modelID,
|
||||
session.models.contains(where: { $0.id == selected }) {
|
||||
@@ -115,10 +165,10 @@ struct ModelPickerView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(session?.models ?? [], id: \.id) { model in
|
||||
ForEach(session.models, id: \.id) { model in
|
||||
Button {
|
||||
settings.modelID = model.id
|
||||
Task { await session?.selectModel(model.id) }
|
||||
Task { await session.selectModel(model.id) }
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
@@ -143,7 +193,12 @@ struct ModelPickerView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
SettingsView(settings: SettingsStore())
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user