348 lines
15 KiB
Swift
348 lines
15 KiB
Swift
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()) }
|