import SwiftUI struct ConversationView: View { @ObservedObject var settings: SettingsStore @StateObject private var session: VoiceSession @State private var composerText = "" @State private var connectError: String? init(settings: SettingsStore) { self.settings = settings let config = ServerConfig.parse(settings.trimmedServerURL) if case .success(let value) = config { _session = StateObject(wrappedValue: VoiceSession( config: value, tokenClient: TokenClient(config: value) )) } else { let fallback = ServerConfig.parse("https://192.168.86.2:9178") if case .success(let value) = fallback { _session = StateObject(wrappedValue: VoiceSession( config: value, tokenClient: TokenClient(config: value) )) } else { fatalError("Unreachable: default server URL is valid") } } } var body: some View { NavigationStack { VStack(spacing: 0) { statusHeader TranscriptListView(events: session.transcriptHandler.events) composer } .navigationTitle("Hope Voice") .toolbar { toolbarContent } .onAppear { if case .failure(let error) = ServerConfig.parse(settings.trimmedServerURL) { connectError = error.localizedDescription } } } } private var statusHeader: some View { VStack(spacing: 12) { OrbView(state: orbState) Text(statusText) .font(.headline) if let agent = session.agent { Text("Agent: \(agent.name ?? agent.identity)") .font(.caption) .foregroundStyle(.secondary) } if session.state == .ready { HStack(spacing: 16) { Toggle(isOn: Binding( get: { !session.isMuted }, set: { newValue in Task { await session.setMuted(!newValue) } } )) { Label(session.isMuted ? "Muted" : "Mic on", systemImage: session.isMuted ? "mic.slash" : "mic") } .toggleStyle(.button) Toggle("Text mode", isOn: $settings.textMode) .toggleStyle(.button) } } if let connectError { Label(connectError, systemImage: "exclamationmark.triangle") .font(.footnote) .foregroundStyle(.red) } } .padding() } private var composer: some View { HStack(spacing: 8) { switch session.state { case .idle, .failed: Button(action: startSession) { Label("Start", systemImage: "phone.fill") } .buttonStyle(.borderedProminent) case .connecting, .waitingAgent: ProgressView() case .ready: TextField(settings.textMode ? "Type a message…" : "Voice mode active", text: $composerText, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(1...4) .disabled(!settings.textMode) Button(action: sendComposer) { Image(systemName: "arrow.up.circle.fill") .font(.title2) } .disabled(!settings.textMode || composerText.isEmpty) Button(role: .destructive, action: endSession) { Image(systemName: "phone.down.fill") } } } .padding() } private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { NavigationLink { SettingsView(settings: settings) } label: { Image(systemName: "gearshape") } } } private var orbState: OrbView.OrbState { switch session.state { case .idle: .idle case .connecting, .waitingAgent: .connecting case .ready: settings.textMode ? .idle : .listening case .failed: .failed } } private var statusText: String { switch session.state { case .idle: "Ready to connect" case .connecting: "Connecting…" case .waitingAgent: "Waiting for agent…" case .ready: settings.textMode ? "Text mode" : "Listening" case .failed(let message): "Failed: \(message)" } } private func startSession() { connectError = nil session.transcriptHandler.clear() Task { await session.connect() } } private func endSession() { Task { await session.disconnect() } } private func sendComposer() { let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } composerText = "" Task { do { try await session.sendText(text) } catch { connectError = error.localizedDescription } } } } #Preview { ConversationView(settings: SettingsStore()) }