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
snowspeeder ca2d26f171 feat: voice and model pickers with sync-on-join (Phase 4)
- Voices.swift: 13 Azure voices, default en-US-Ava
- SettingsStore: persist voiceID/modelID, fallback to default voice
- VoiceSession: sync set_voice/set_model on agent_ready, selectVoice/selectModel, track models + model_changed
- SettingsView: voice and model picker sheets
- ControlChannel: publish via publish(data:) reliable data channel instead of sendText DataStream (agent listens on legacy data_received; DataStream text was dropped)
2026-08-30 07:50:45 -04:00

150 lines
4.9 KiB
Swift

import SwiftUI
struct SettingsView: View {
@ObservedObject var settings: SettingsStore
var session: VoiceSession?
@State private var urlText: String = ""
@State private var showVoicePicker = false
@State private var showModelPicker = false
init(settings: SettingsStore, session: VoiceSession? = nil) {
self.settings = settings
self.session = session
}
var body: some View {
Form {
Section("Server") {
TextField("https://192.168.86.2:9178", text: $urlText)
.keyboardType(.URL)
.autocorrectionDisabled()
.onAppear { urlText = settings.serverURL }
.onChange(of: urlText) { _, newValue in
settings.serverURL = newValue
}
}
Section("Voice") {
Button {
showVoicePicker = true
} label: {
LabeledContent("Voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID)
}
}
Section("Model") {
if let session, !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.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("Interaction") {
Toggle("Text mode", isOn: $settings.textMode)
}
Section {
NavigationLink("Connection test") {
ConnectionTestView(settings: settings)
}
}
}
.navigationTitle("Settings")
.sheet(isPresented: $showVoicePicker) {
VoicePickerView(settings: settings, session: session)
}
.sheet(isPresented: $showModelPicker) {
ModelPickerView(settings: settings, session: session)
}
}
}
struct VoicePickerView: View {
@ObservedObject var settings: SettingsStore
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(.blue)
}
}
}
}
}
.navigationTitle("Voice")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}
struct ModelPickerView: View {
@ObservedObject var settings: SettingsStore
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 }) {
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(.blue)
}
}
}
}
}
.navigationTitle("Model")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}
#Preview {
NavigationStack {
SettingsView(settings: SettingsStore())
}
}