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/Conversation/ConversationView.swift
T

348 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// Owns one session across all tabs. Changing tabs never ends a conversation.
struct ConversationView: View {
@ObservedObject var settings: SettingsStore
@State private var session: VoiceSession
@State private var activeServerURL: String
@State private var pendingServerURL: String?
@State private var isReplacingSession = false
@State private var sessionID = UUID()
@State private var selectedTab = 0
init(settings: SettingsStore) {
self.settings = settings
_activeServerURL = State(initialValue: settings.trimmedServerURL)
_session = State(initialValue: Self.makeSession(settings.trimmedServerURL, settings: settings))
}
private static func makeSession(_ url: String, settings: SettingsStore) -> VoiceSession {
guard case .success(let config) = ServerConfig.parse(url) else {
preconditionFailure("SettingsStore must supply a validated server URL")
}
let session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-fixtures") {
session.transcriptHandler.receiveDataTranscript(role: .user, text: "Help me plan a quiet weekend.")
session.transcriptHandler.receiveDataTranscript(role: .agent, text: "Let’s leave some room to breathe. A walk, a good book, and time for your favorite music would be a lovely start.")
session.taskStore.injectSampleEvents()
}
#endif
return session
}
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
ConversationContent(settings: settings, session: session)
.toolbar { settingsLink }
}
.tabItem { Label("Talk", systemImage: "waveform") }
.tag(0)
NavigationStack {
MediaPanelView(playerController: session.mediaController, settings: settings)
.toolbar { settingsLink }
}
.tabItem { Label("Music", systemImage: "music.note") }
.tag(1)
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
.tag(2)
}
.id(sessionID)
.tint(HopeStyle.tint)
.onChange(of: settings.textMode) { _, enabled in
Task { await session.applyTextMode(enabled) }
}
.onChange(of: settings.serverURL) { _, value in replaceServer(value) }
}
@ToolbarContentBuilder private var settingsLink: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
SettingsView(settings: settings, session: session)
} label: {
Image(systemName: "gearshape").frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel("Settings")
}
}
private func replaceServer(_ value: String) {
let url = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard case .success = ServerConfig.parse(url) else { return }
pendingServerURL = url
guard !isReplacingSession else { return }
isReplacingSession = true
Task { @MainActor in
// Coalesce rapid Apply actions; construct from the captured URL.
while let next = pendingServerURL {
pendingServerURL = nil
guard next != activeServerURL else { continue }
await session.disconnect()
session.mediaController.resetScope()
activeServerURL = next
session = Self.makeSession(next, settings: settings)
sessionID = UUID()
}
isReplacingSession = false
}
}
}
private struct ActivityTab: View {
@ObservedObject var taskStore: TaskStore
@ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession
var body: some View {
NavigationStack {
TaskPanelView(taskStore: taskStore)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
SettingsView(settings: settings, session: session)
} label: {
Image(systemName: "gearshape").frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel("Settings")
}
}
}
.tabItem { Label("Activity", systemImage: "checklist") }
.badge(taskStore.activeCount)
}
}
private struct ConversationContent: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession
@ObservedObject private var transcripts: TranscriptHandler
@ObservedObject private var conversation: ConversationStateModel
@State private var composerText = ""
@State private var sendError: String?
@State private var isSending = false
@FocusState private var composerFocused: Bool
init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings
self.session = session
transcripts = session.transcriptHandler
conversation = session.stateModel
}
var body: some View {
VStack(spacing: 0) {
if transcripts.events.isEmpty && !session.isReady {
welcome
} else {
connectionStatus.padding(.horizontal, 20).padding(.vertical, 12)
TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll)
}
}
.background(Color(.systemBackground))
.navigationTitle("Hope")
.safeAreaInset(edge: .bottom, spacing: 0) { conversationControls }
.onChange(of: session.state) { _, state in
if state == .ready && settings.textMode { composerFocused = true }
}
}
private var welcome: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
OrbView(state: orbState).padding(.top, 24).accessibilityHidden(true)
VStack(alignment: .leading, spacing: 12) {
Text("A little help.\nA good conversation.")
.font(.largeTitle.weight(.semibold))
.fixedSize(horizontal: false, vertical: true)
Text("Talk things through, find something out, or put on a favorite song.")
.font(.body).foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 16) {
Label("Think out loud", systemImage: "bubble.left.and.bubble.right")
Label("Find an answer", systemImage: "sparkle.magnifyingglass")
Label("Make time for music", systemImage: "music.note")
}
.font(.subheadline).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24).padding(.bottom, 24)
}
}
private var connectionStatus: some View {
HStack(spacing: 12) {
Image(systemName: statusSymbol).font(.title3)
.foregroundStyle(session.state.isFailed ? Color.red : HopeStyle.tint)
.frame(width: 32, height: 36).accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(statusTitle).font(.headline)
Text(statusDetail).font(.subheadline).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
if session.isReady && !settings.textMode && !session.isMuted {
ProgressView(value: min(conversation.micLevel * 2.5, 1))
.frame(width: 46).accessibilityLabel("Microphone level")
}
}
.accessibilityElement(children: .combine)
}
private var conversationControls: some View {
VStack(spacing: 12) {
if let failure = failureMessage {
VStack(alignment: .leading, spacing: 6) {
Label("Couldn’t connect to Hope", systemImage: "wifi.exclamationmark")
.font(.subheadline.weight(.semibold))
Text(failure).font(.footnote).foregroundStyle(.secondary)
NavigationLink("Check connection settings") {
SettingsView(settings: settings, session: session)
}
.font(.subheadline).frame(minHeight: 44)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if session.isReconnecting {
Label("Reconnecting… Your conversation is still here.", systemImage: "wifi.exclamationmark")
.font(.footnote)
}
if let error = session.micError, !settings.textMode {
VStack(alignment: .leading, spacing: 4) {
Text(error).font(.footnote)
Button("Switch to typing") { settings.textMode = true }.frame(minHeight: 44)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if let sendError {
Text(sendError).font(.footnote).foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
}
Picker("Conversation mode", selection: $settings.textMode) {
Label("Voice", systemImage: "mic").tag(false)
Label("Type", systemImage: "keyboard").tag(true)
}
.pickerStyle(.segmented).disabled(isBusy)
if session.isReady {
if settings.textMode { textComposer }
else {
HStack(spacing: 12) {
Button {
Task { await session.setMuted(!session.isMuted) }
} label: {
Label(session.isMuted ? "Unmute" : "Mute", systemImage: session.isMuted ? "mic.slash.fill" : "mic.fill")
.frame(maxWidth: .infinity, minHeight: 44)
}
.buttonStyle(.bordered)
endButton
}
}
} else {
Button {
sendError = nil
Task { await session.connect() }
} label: {
HStack(spacing: 10) {
if isBusy { ProgressView().tint(.white) }
else { Image(systemName: settings.textMode ? "bubble.left.fill" : "waveform") }
Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : settings.textMode ? "Start chatting" : "Let’s talk")
.font(.headline)
}
.frame(maxWidth: .infinity, minHeight: 48)
}
.buttonStyle(.borderedProminent).disabled(isBusy)
Text(settings.textMode ? "Your microphone stays off." : "Your microphone turns on when you connect.")
.font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
}
.padding(.horizontal, 20).padding(.top, 12).padding(.bottom, 16)
.background(.bar)
}
private var textComposer: some View {
VStack(spacing: 6) {
HStack(alignment: .bottom, spacing: 8) {
TextField("Message Hope", text: $composerText, axis: .vertical)
.lineLimit(1...5).padding(12)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
.focused($composerFocused).disabled(isSending)
.accessibilityLabel("Message Hope")
Button(action: sendComposer) {
if isSending { ProgressView().frame(width: 44, height: 44) }
else { Image(systemName: "arrow.up.circle.fill").font(.title).frame(width: 44, height: 44) }
}
.disabled(composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending || session.isReconnecting)
.accessibilityLabel("Send message")
}
endButton
}
}
private var endButton: some View {
Button(role: .destructive) {
composerFocused = false
Task { await session.disconnect() }
} label: {
Label("End conversation", systemImage: "phone.down.fill")
.font(.subheadline.weight(.medium)).frame(minHeight: 44)
}
.buttonStyle(.bordered)
}
private var isBusy: Bool {
session.state == .connecting || session.state == .waitingAgent || session.state == .tearingDown
}
private var failureMessage: String? {
if case .failed(let message) = session.state { return message }
return nil
}
private var statusTitle: String {
switch session.state {
case .idle: return "Ready when you are"
case .connecting: return "Connecting…"
case .waitingAgent: return "Getting Hope ready…"
case .tearingDown: return "Ending conversation…"
case .failed: return "Connection unavailable"
case .ready:
if settings.textMode { return "You’re chatting with Hope" }
if session.isMuted { return "Microphone muted" }
switch conversation.phase {
case .thinking: return "Thinking it through…"
case .speaking: return "Hope is speaking"
default: return "I’m listening"
}
}
}
private var statusDetail: String {
if !session.isReady { return "Your recent conversation is below." }
if settings.textMode { return "Microphone off · Type whenever you’re ready" }
if session.isMuted { return "You can still hear Hope." }
return conversation.phase == .speaking ? "You can interrupt at any time." : "Speak naturally. Take your time."
}
private var statusSymbol: String {
session.state.isFailed ? "wifi.exclamationmark" : !session.isReady ? "bubble.left.and.bubble.right" : settings.textMode ? "keyboard" : session.isMuted ? "mic.slash" : "waveform"
}
private var orbState: OrbView.OrbState {
if session.state.isFailed { return .failed }
return isBusy ? .connecting : .idle
}
private func sendComposer() {
let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isSending else { return }
isSending = true
sendError = nil
Task {
do {
try await session.sendText(text)
composerText = ""
} catch {
sendError = "Your message wasn’t sent. Try again when connected."
}
isSending = false
}
}
}
#Preview { ConversationView(settings: SettingsStore()) }