Redesign Hope iOS UI, harden playback, and document project handoff
This commit is contained in:
1 parent
40aaf4fea7
commit
24f95639dc
24 files changed
+1470
-594
No files matched your search
@@ -1,343 +1,347 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Owns one session across all tabs. Changing tabs never ends a conversation.
|
||||
struct ConversationView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
@State private var sessionID = UUID()
|
||||
@State private var session: VoiceSession
|
||||
/// The exact server URL the current session was constructed with. Used to
|
||||
/// avoid redundant session replacement when the applied URL hasn't actually
|
||||
/// changed (e.g. re-applying the same value from Settings).
|
||||
@State private var activeServerURL: String
|
||||
/// Identity guard: prevents a second `.onChange` from starting a concurrent
|
||||
/// session replacement while the first is still disconnecting the old one.
|
||||
@State private var isReplacingSession = false
|
||||
/// Latest accepted server URL that has not yet been applied to a session.
|
||||
/// Coalesced: rapid Applies during an active replacement retain the newest
|
||||
/// value rather than dropping it. A single retained worker loop drains
|
||||
/// arbitrary pending URLs until none remain (Iteration 08 Item 2).
|
||||
@State private var pendingServerURL: String?
|
||||
@State private var composerText = ""
|
||||
@State private var connectError: String?
|
||||
@State private var tick = 0
|
||||
@State private var isReplacingSession = false
|
||||
@State private var sessionID = UUID()
|
||||
@State private var selectedTab = 0
|
||||
|
||||
init(settings: SettingsStore) {
|
||||
self.settings = settings
|
||||
let initialSession = Self.makeSession(settings: settings)
|
||||
_session = State(initialValue: initialSession)
|
||||
_activeServerURL = State(initialValue: settings.trimmedServerURL)
|
||||
_session = State(initialValue: Self.makeSession(settings.trimmedServerURL, settings: settings))
|
||||
}
|
||||
|
||||
/// Creates a session from the configured server URL. `SettingsStore.init`
|
||||
/// guarantees the stored value is always valid (legacy invalid values are
|
||||
/// sanitized to the documented default), so the failure branch is a safety
|
||||
/// net only.
|
||||
private static func makeSession(settings: SettingsStore) -> VoiceSession {
|
||||
let config = ServerConfig.parse(settings.trimmedServerURL)
|
||||
switch config {
|
||||
case .success(let value):
|
||||
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
|
||||
case .failure:
|
||||
// Defensive: should be unreachable because SettingsStore sanitizes.
|
||||
let fallback = ServerConfig.parse(SettingsStore.defaultServerURL)
|
||||
if case .success(let value) = fallback {
|
||||
settings.serverURL = SettingsStore.defaultServerURL
|
||||
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
|
||||
}
|
||||
fatalError("Unreachable: default server URL is valid")
|
||||
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 {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
statusHeader
|
||||
TranscriptListView(
|
||||
events: session.transcriptHandler.events,
|
||||
onClear: {
|
||||
// Atomically clear the canonical visible collection,
|
||||
// dedupe state, and persistence so Clear is truthful.
|
||||
session.transcriptHandler.clearAll()
|
||||
}
|
||||
)
|
||||
composer
|
||||
TabView(selection: $selectedTab) {
|
||||
NavigationStack {
|
||||
ConversationContent(settings: settings, session: session)
|
||||
.toolbar { settingsLink }
|
||||
}
|
||||
.navigationTitle("Hope Voice")
|
||||
.toolbar { toolbarContent }
|
||||
.id(sessionID)
|
||||
.onAppear {
|
||||
if case .failure(let error) = ServerConfig.parse(settings.trimmedServerURL) {
|
||||
connectError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.onChange(of: settings.textMode) { _, newValue in
|
||||
guard session.isReady else { return }
|
||||
Task { await session.applyTextMode(newValue) }
|
||||
}
|
||||
}
|
||||
.onReceive(session.objectWillChange.receive(on: RunLoop.main)) { _ in
|
||||
tick &+= 1
|
||||
}
|
||||
.onChange(of: settings.serverURL) { _, newValue in
|
||||
// The URL field now uses explicit Apply semantics (SettingsView),
|
||||
// so this fires only when the user has applied a value that passed
|
||||
// validation. Coalesce rapid accepted values: every accepted Apply
|
||||
// writes `pendingServerURL` (latest wins). A single retained worker
|
||||
// loop drains arbitrary pending URLs until none remain, covering
|
||||
// A→B→C semantics where C arrives while B is disconnecting
|
||||
// (Iteration 08 Item 2).
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, case .success = ServerConfig.parse(trimmed) else { return }
|
||||
.tabItem { Label("Talk", systemImage: "waveform") }
|
||||
.tag(0)
|
||||
|
||||
pendingServerURL = trimmed
|
||||
|
||||
// Start the worker if not already active. The loop consumes
|
||||
// pending values until empty, so a single Task handles any number
|
||||
// of rapid Applies without nested tasks.
|
||||
guard !isReplacingSession else { return }
|
||||
isReplacingSession = true
|
||||
Task { @MainActor [settings] in
|
||||
while let url = self.pendingServerURL {
|
||||
self.pendingServerURL = nil
|
||||
guard url != self.activeServerURL else { continue }
|
||||
let oldSession = self.session
|
||||
await oldSession.disconnect()
|
||||
self.activeServerURL = url
|
||||
self.sessionID = UUID()
|
||||
// Construct from the captured validated config, not by
|
||||
// rereading settings.serverURL (which may have changed
|
||||
// during disconnect).
|
||||
switch ServerConfig.parse(url) {
|
||||
case .success(let config):
|
||||
self.session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
|
||||
case .failure:
|
||||
let fallback = ServerConfig.parse(SettingsStore.defaultServerURL)
|
||||
if case .success(let value) = fallback {
|
||||
self.session = VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
|
||||
} else {
|
||||
fatalError("Unreachable: default server URL is valid")
|
||||
}
|
||||
}
|
||||
}
|
||||
self.isReplacingSession = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusHeader: some View {
|
||||
VStack(spacing: 12) {
|
||||
OrbView(state: orbState)
|
||||
Text(statusText)
|
||||
.font(.headline)
|
||||
if session.state == .ready, session.stateModel.isThinkingChipVisible {
|
||||
Label("Thinking…", systemImage: "ellipsis")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(.orange.opacity(0.2), in: Capsule())
|
||||
}
|
||||
if session.state == .ready, !settings.textMode {
|
||||
micMeter
|
||||
}
|
||||
if session.state == .ready, settings.textMode {
|
||||
Text("Barge in anytime — type to reply")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let agent = session.agent {
|
||||
Text("Agent: \(agent.name ?? agent.identity)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if session.state == .ready {
|
||||
Text("\(Voices.display(for: settings.voiceID) ?? settings.voiceID) · \(session.selectedModel ?? settings.modelID ?? "—")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if session.state == .ready {
|
||||
HStack(spacing: 16) {
|
||||
// The mic toggle is disabled in text mode to enforce the
|
||||
// privacy invariant at the UI level as well (Finding 4).
|
||||
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)
|
||||
.disabled(settings.textMode)
|
||||
Toggle("Text mode", isOn: textModeBinding)
|
||||
.toggleStyle(.button)
|
||||
}
|
||||
}
|
||||
if session.state == .ready, session.isReconnecting {
|
||||
Label("Connection lost — reconnecting…", systemImage: "arrow.triangle.2.circlepath")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if let micError = session.micError {
|
||||
Label(micError, systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if let connectError {
|
||||
Label(connectError, systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var micMeter: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color(.systemFill))
|
||||
Capsule()
|
||||
.fill(Color.green.gradient)
|
||||
.frame(width: min(geo.size.width, geo.size.width * session.stateModel.micLevel * 2.5))
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
|
||||
private var textModeBinding: Binding<Bool> {
|
||||
$settings.textMode
|
||||
}
|
||||
|
||||
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, .tearingDown:
|
||||
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)
|
||||
}
|
||||
.accessibilityLabel("Send message")
|
||||
.disabled(!settings.textMode || composerText.isEmpty)
|
||||
Button(role: .destructive, action: endSession) {
|
||||
Image(systemName: "phone.down.fill")
|
||||
}
|
||||
.accessibilityLabel("End conversation")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
TaskPanelView(taskStore: session.taskStore)
|
||||
} label: {
|
||||
Image(systemName: "tray.full")
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if session.taskStore.activeCount > 0 {
|
||||
Text("\(session.taskStore.activeCount)")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(3)
|
||||
.background(Color.red, in: Circle())
|
||||
.offset(x: 6, y: -6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Background tasks")
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
NavigationStack {
|
||||
MediaPanelView(playerController: session.mediaController, settings: settings)
|
||||
} label: {
|
||||
Image(systemName: "music.note")
|
||||
.toolbar { settingsLink }
|
||||
}
|
||||
.accessibilityLabel("Media player")
|
||||
.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")
|
||||
Image(systemName: "gearshape").frame(minWidth: 44, minHeight: 44)
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
}
|
||||
|
||||
private var orbState: OrbView.OrbState {
|
||||
switch session.state {
|
||||
case .idle: return .idle
|
||||
case .connecting, .waitingAgent, .tearingDown: return .connecting
|
||||
case .ready:
|
||||
guard !settings.textMode else { return .idle }
|
||||
switch session.stateModel.phase {
|
||||
case .idle: return .idle
|
||||
case .connecting: return .connecting
|
||||
case .listening: return .listening
|
||||
case .thinking: return .thinking
|
||||
case .speaking: return .speaking
|
||||
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()
|
||||
}
|
||||
case .failed: return .failed
|
||||
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 statusText: String {
|
||||
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 to connect"
|
||||
case .idle: return "Ready when you are"
|
||||
case .connecting: return "Connecting…"
|
||||
case .waitingAgent: return "Waiting for agent…"
|
||||
case .tearingDown: return "Cleaning up…"
|
||||
case .waitingAgent: return "Getting Hope ready…"
|
||||
case .tearingDown: return "Ending conversation…"
|
||||
case .failed: return "Connection unavailable"
|
||||
case .ready:
|
||||
if settings.textMode { return "Text mode" }
|
||||
switch session.stateModel.phase {
|
||||
case .listening: return "Listening"
|
||||
case .thinking: return "Thinking it through"
|
||||
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 "Ready when you are"
|
||||
default: return "I’m listening"
|
||||
}
|
||||
case .failed(let message): return "Failed: \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
private func startSession() {
|
||||
connectError = nil
|
||||
// Starting a conversation preserves the restored last-50 history; only
|
||||
// the explicit Clear button erases it.
|
||||
Task { await session.connect() }
|
||||
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 func endSession() {
|
||||
Task { await session.disconnect() }
|
||||
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 else { return }
|
||||
composerText = ""
|
||||
guard !text.isEmpty, !isSending else { return }
|
||||
isSending = true
|
||||
sendError = nil
|
||||
Task {
|
||||
do {
|
||||
try await session.sendText(text)
|
||||
composerText = ""
|
||||
} catch {
|
||||
connectError = error.localizedDescription
|
||||
sendError = "Your message wasn’t sent. Try again when connected."
|
||||
}
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConversationView(settings: SettingsStore())
|
||||
}
|
||||
#Preview { ConversationView(settings: SettingsStore()) }
|
||||
@@ -1,150 +1,113 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OrbView: View {
|
||||
enum OrbState {
|
||||
case idle
|
||||
case connecting
|
||||
case listening
|
||||
case speaking
|
||||
case thinking
|
||||
case failed
|
||||
}
|
||||
|
||||
enum OrbState { case idle, connecting, listening, speaking, thinking, failed }
|
||||
let state: OrbState
|
||||
|
||||
@State private var animating = false
|
||||
|
||||
private var baseColor: Color {
|
||||
switch state {
|
||||
case .idle, .connecting: .blue
|
||||
case .listening: .green
|
||||
case .speaking, .thinking: .orange
|
||||
case .failed: .red
|
||||
}
|
||||
}
|
||||
|
||||
private var scale: CGFloat {
|
||||
switch state {
|
||||
case .connecting, .listening, .speaking, .thinking: 1.1
|
||||
default: 1.0
|
||||
}
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
switch state {
|
||||
case .idle: "Idle"
|
||||
case .connecting: "Connecting"
|
||||
case .listening: "Listening"
|
||||
case .speaking: "Speaking"
|
||||
case .thinking: "Thinking"
|
||||
case .failed: "Connection failed"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let shape = Circle()
|
||||
.fill(baseColor.gradient)
|
||||
.frame(width: 120, height: 120)
|
||||
let animated = shape
|
||||
.scaleEffect(animating ? scale : 0.95)
|
||||
.opacity(state == .idle || state == .failed ? 0.7 : 1.0)
|
||||
let anim: Animation? = state == .idle || state == .failed
|
||||
? nil
|
||||
: .easeInOut(duration: 0.8).repeatForever(autoreverses: true)
|
||||
animated
|
||||
.animation(anim, value: animating)
|
||||
.onAppear { animating = true }
|
||||
.onChange(of: state) { _, _ in animating = true }
|
||||
.accessibilityElement()
|
||||
.accessibilityLabel("Conversation status")
|
||||
.accessibilityValue(label)
|
||||
ZStack {
|
||||
Circle().fill(HopeStyle.tint.opacity(0.10))
|
||||
Image(systemName: state == .failed ? "wifi.exclamationmark" : "waveform")
|
||||
.font(.largeTitle.weight(.medium))
|
||||
.foregroundStyle(HopeStyle.tint)
|
||||
}
|
||||
.frame(width: 76, height: 76)
|
||||
.accessibilityLabel("Hope")
|
||||
}
|
||||
}
|
||||
|
||||
struct TranscriptListView: View {
|
||||
/// The single canonical visible transcript (restored persisted finals plus
|
||||
/// current live events). The view renders exactly this collection — there is
|
||||
/// no second "restored" store, so the UI and persistence can never diverge.
|
||||
let events: [TranscriptEvent]
|
||||
var onClear: () -> Void
|
||||
@State private var confirmClear = false
|
||||
@State private var followingLatest = true
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
private static let deliveryMarkers = #"\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*"#
|
||||
|
||||
private static let timestampFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private var visibleRows: [TranscriptEvent] { events }
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Transcript")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Conversation").font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Button(role: .destructive, action: onClear) {
|
||||
Label("Clear", systemImage: "trash")
|
||||
.font(.caption)
|
||||
Menu {
|
||||
Button("Clear conversation", systemImage: "trash", role: .destructive) { confirmClear = true }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis").frame(width: 44, height: 44)
|
||||
}
|
||||
.accessibilityLabel("Conversation options")
|
||||
.disabled(events.isEmpty)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
.padding(.horizontal, 20)
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(visibleRows) { event in
|
||||
transcriptRow(event)
|
||||
LazyVStack(alignment: .leading, spacing: 20) {
|
||||
if events.isEmpty {
|
||||
Text("Your conversation will appear here.")
|
||||
.font(.body).foregroundStyle(.secondary)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
ForEach(events) { event in transcriptRow(event) }
|
||||
Color.clear.frame(height: 1).id("latest")
|
||||
.onAppear { followingLatest = true }
|
||||
.onDisappear { followingLatest = false }
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal, 20).padding(.bottom, 20)
|
||||
}
|
||||
.onChange(of: visibleRows.count) { _, _ in
|
||||
if let last = visibleRows.last {
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.defaultScrollAnchor(.bottom)
|
||||
.onChange(of: events.last?.text) { _, _ in
|
||||
if followingLatest { proxy.scrollTo("latest", anchor: .bottom) }
|
||||
}
|
||||
.onChange(of: events.count) { _, _ in
|
||||
if followingLatest { proxy.scrollTo("latest", anchor: .bottom) }
|
||||
}
|
||||
if !followingLatest && !events.isEmpty {
|
||||
Button {
|
||||
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) {
|
||||
proxy.scrollTo("latest", anchor: .bottom)
|
||||
}
|
||||
} label: {
|
||||
Label("Latest messages", systemImage: "arrow.down").font(.subheadline)
|
||||
.frame(minHeight: 44)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Clear this conversation?", isPresented: $confirmClear) {
|
||||
Button("Clear conversation", role: .destructive, action: onClear)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This removes the transcript saved on this iPhone. It doesn’t reset Hope’s memory or end your current conversation.")
|
||||
}
|
||||
}
|
||||
|
||||
private func transcriptRow(_ event: TranscriptEvent) -> some View {
|
||||
let roleName = event.role == .user ? "You" : "Hope"
|
||||
let nameColor: Color = event.role == .user ? .secondary : .blue
|
||||
let rowBackground: Color = event.role == .user
|
||||
? Color(.secondarySystemBackground)
|
||||
: Color.blue.opacity(0.08)
|
||||
let textOpacity: Double = event.isFinal ? 1.0 : 0.55
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(roleName)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(nameColor)
|
||||
Spacer()
|
||||
Text(Self.timestampFormatter.string(from: event.timestamp))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
let isUser = event.role == .user
|
||||
let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
return HStack(alignment: .top, spacing: 0) {
|
||||
if isUser { Spacer(minLength: 32) }
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
HStack(spacing: 8) {
|
||||
Text(isUser ? "You" : "Hope").font(.subheadline.weight(.semibold))
|
||||
Text(event.timestamp, style: .time).font(.caption).foregroundStyle(.secondary)
|
||||
if !event.isFinal {
|
||||
Text("Live").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text(text).font(.body).textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Text(stripped(event.text))
|
||||
.font(.body)
|
||||
.opacity(textOpacity)
|
||||
.padding(isUser ? 14 : 0)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(isUser ? Color(.secondarySystemBackground) : .clear, in: RoundedRectangle(cornerRadius: 16))
|
||||
.contextMenu {
|
||||
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
|
||||
}
|
||||
if !isUser { Spacer(minLength: 16) }
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(rowBackground)
|
||||
.cornerRadius(10)
|
||||
.accessibilityElement(children: .combine)
|
||||
.id(event.id)
|
||||
}
|
||||
|
||||
private func stripped(_ text: String) -> String {
|
||||
text.replacingOccurrences(
|
||||
of: Self.deliveryMarkers,
|
||||
with: "",
|
||||
options: .regularExpression
|
||||
).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
@@ -14,31 +14,76 @@ struct MediaPanelView: View {
|
||||
@State private var streamURL = ""
|
||||
@State private var streamMessage: String?
|
||||
@State private var trackToDelete: MediaTrack?
|
||||
@State private var browseMode = 0
|
||||
@State private var showAddStream = false
|
||||
@State private var isAddingStream = false
|
||||
@State private var isLoadingLibrary = false
|
||||
@State private var seekPosition: Double = 0
|
||||
@State private var isSeeking = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
if let stateError = playerController.stateError {
|
||||
Label(stateError, systemImage: "wifi.exclamationmark")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.red)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color.red.opacity(0.1), in: RoundedRectangle(cornerRadius: 14))
|
||||
LazyVStack(alignment: .leading, spacing: 24) {
|
||||
if playerController.stateError != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Music server unavailable", systemImage: "wifi.exclamationmark")
|
||||
.font(.headline)
|
||||
Text("Check that your Hope server is running and you’re connected to its network. We’ll reconnect automatically.")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if playerController.track != nil {
|
||||
VStack(spacing: 20) {
|
||||
nowPlayingCard
|
||||
seekSection
|
||||
transportControls
|
||||
volumeSection
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
if let error = playerController.playbackError {
|
||||
Label(error, systemImage: "exclamationmark.circle")
|
||||
.font(.subheadline).foregroundStyle(.red)
|
||||
}
|
||||
nowPlayingCard
|
||||
transportControls
|
||||
seekSection
|
||||
volumeSection
|
||||
pendingSection
|
||||
choicesSection
|
||||
searchSection
|
||||
librarySection
|
||||
addStreamSection
|
||||
Picker("Browse music", selection: $browseMode) {
|
||||
Text("Library").tag(0)
|
||||
Text("Search").tag(1)
|
||||
}.pickerStyle(.segmented)
|
||||
if browseMode == 0 { librarySection }
|
||||
else { searchSection }
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Media")
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Music")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
streamMessage = nil
|
||||
showAddStream = true
|
||||
} label: {
|
||||
Image(systemName: "plus").frame(minWidth: 44, minHeight: 44)
|
||||
}
|
||||
.accessibilityLabel("Add radio station")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddStream) {
|
||||
NavigationStack {
|
||||
Form { addStreamSection }
|
||||
.navigationTitle("Add a station")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showAddStream = false }.disabled(isAddingStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(isAddingStream)
|
||||
}
|
||||
.onAppear {
|
||||
playerController.startDefaultPolling()
|
||||
refreshLibrary()
|
||||
@@ -60,18 +105,7 @@ struct MediaPanelView: View {
|
||||
private var nowPlayingCard: some View {
|
||||
HStack(spacing: 12) {
|
||||
if let artURL = playerController.track?.artURL, let url = URL(string: absolute(artURL)) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
default:
|
||||
Image(systemName: "music.note")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemGray6))
|
||||
}
|
||||
}
|
||||
PinnedArtwork(url: url)
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
} else {
|
||||
@@ -92,7 +126,7 @@ struct MediaPanelView: View {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.lineLimit(2)
|
||||
if playerController.track != nil {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
@@ -101,8 +135,6 @@ struct MediaPanelView: View {
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private var transportControls: some View {
|
||||
@@ -110,13 +142,15 @@ struct MediaPanelView: View {
|
||||
Button(action: seekBackward) {
|
||||
Image(systemName: "gobackward.10")
|
||||
.font(.title3)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.accessibilityLabel("Seek back 10 seconds")
|
||||
.disabled(playerController.track == nil)
|
||||
.disabled(playerController.duration <= 0 || playerController.track?.kind == "stream")
|
||||
|
||||
Button(action: togglePlayPause) {
|
||||
Image(systemName: playerController.desired == "playing" && playerController.track != nil ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 44))
|
||||
.font(.largeTitle)
|
||||
.frame(width: 56, height: 56)
|
||||
.foregroundStyle(playerController.track == nil ? Color.secondary : Color.accentColor)
|
||||
}
|
||||
.accessibilityLabel(playerController.desired == "playing" && playerController.track != nil ? "Pause" : "Play")
|
||||
@@ -125,13 +159,15 @@ struct MediaPanelView: View {
|
||||
Button(action: seekForward) {
|
||||
Image(systemName: "goforward.10")
|
||||
.font(.title3)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.accessibilityLabel("Seek forward 10 seconds")
|
||||
.disabled(playerController.track == nil)
|
||||
.disabled(playerController.duration <= 0 || playerController.track?.kind == "stream")
|
||||
|
||||
Button(role: .destructive, action: stopPlayback) {
|
||||
Image(systemName: "stop.circle")
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.accessibilityLabel("Stop playback")
|
||||
.disabled(playerController.track == nil)
|
||||
@@ -140,14 +176,23 @@ struct MediaPanelView: View {
|
||||
|
||||
private var seekSection: some View {
|
||||
VStack(spacing: 4) {
|
||||
if playerController.track != nil {
|
||||
if playerController.track?.kind == "stream" {
|
||||
Label("Live radio", systemImage: "dot.radiowaves.left.and.right")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
} else if playerController.track != nil {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { playerController.currentTime },
|
||||
set: { newValue in Task { playerController.seek(to: newValue) } }
|
||||
get: { min(isSeeking ? seekPosition : playerController.currentTime, max(playerController.duration, 1)) },
|
||||
set: { seekPosition = $0 }
|
||||
),
|
||||
in: 0...max(playerController.duration, 1)
|
||||
in: 0...max(playerController.duration, 1),
|
||||
onEditingChanged: { editing in
|
||||
isSeeking = editing
|
||||
if !editing { playerController.seek(to: seekPosition) }
|
||||
}
|
||||
)
|
||||
.disabled(playerController.duration <= 0)
|
||||
.accessibilityLabel("Playback position")
|
||||
HStack {
|
||||
Text(fmtTime(playerController.currentTime))
|
||||
.font(.caption.monospacedDigit())
|
||||
@@ -172,6 +217,7 @@ struct MediaPanelView: View {
|
||||
Image(systemName: "speaker.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
Slider(value: $settings.volume, in: 0...1)
|
||||
.accessibilityLabel("Music volume")
|
||||
.onChange(of: settings.volume) { _, newValue in
|
||||
playerController.setVolume(newValue)
|
||||
}
|
||||
@@ -227,15 +273,19 @@ struct MediaPanelView: View {
|
||||
|
||||
private var searchSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Search YouTube Music")
|
||||
Text("Find your next favorite")
|
||||
.font(.headline)
|
||||
Text("Search songs and artists on YouTube Music.")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
TextField("Song, artist, lyrics…", text: $searchQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.submitLabel(.search)
|
||||
.onSubmit(performSearch)
|
||||
Button("Find", action: performSearch)
|
||||
Button("Search", action: performSearch)
|
||||
.accessibilityIdentifier("music.search")
|
||||
.buttonStyle(.bordered)
|
||||
.frame(minHeight: 44)
|
||||
.disabled(searchQuery.trimmingCharacters(in: .whitespaces).isEmpty || isSearching)
|
||||
}
|
||||
if isSearching {
|
||||
@@ -251,7 +301,7 @@ struct MediaPanelView: View {
|
||||
.foregroundStyle(.red)
|
||||
} else if let results = searchResults {
|
||||
if results.isEmpty {
|
||||
Text("No results.")
|
||||
Text("No matches. Try a different song or artist.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
@@ -286,19 +336,24 @@ struct MediaPanelView: View {
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button(action: refreshLibrary) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
Image(systemName: "arrow.clockwise").frame(width: 44, height: 44)
|
||||
}
|
||||
.disabled(isLoadingLibrary)
|
||||
.accessibilityLabel("Refresh library")
|
||||
}
|
||||
if let libraryError {
|
||||
if let libraryError, playerController.stateError == nil {
|
||||
Text(libraryError)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.red)
|
||||
} else if let tracks = libraryTracks {
|
||||
if tracks.isEmpty {
|
||||
Text("Downloaded songs and streams live here.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
ContentUnavailableView {
|
||||
Label("A home for your favorites", systemImage: "music.note.list")
|
||||
} description: {
|
||||
Text("Search for a song to save it here, or add a radio station using the + button.")
|
||||
} actions: {
|
||||
Button("Find music") { browseMode = 1 }.buttonStyle(.bordered)
|
||||
}
|
||||
} else {
|
||||
ForEach(tracks, id: \.id) { track in
|
||||
HStack(spacing: 8) {
|
||||
@@ -319,23 +374,24 @@ struct MediaPanelView: View {
|
||||
Image(systemName: "xmark")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.red)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.accessibilityLabel("Remove \(track.title ?? track.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if isLoadingLibrary {
|
||||
ProgressView("Loading your library…").frame(maxWidth: .infinity).padding(.vertical, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var addStreamSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Add live stream")
|
||||
.font(.headline)
|
||||
Section {
|
||||
TextField("Station name", text: $streamName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityLabel("Station name")
|
||||
TextField("https://… audio stream URL", text: $streamURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityLabel("Audio stream URL")
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textContentType(.URL)
|
||||
@@ -345,9 +401,10 @@ struct MediaPanelView: View {
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(streamMessage.hasPrefix("Added") ? Color.green : Color.red)
|
||||
}
|
||||
Button("Add", action: addStream)
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(streamURL.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
Button(isAddingStream ? "Adding station…" : "Add station", action: addStream)
|
||||
.disabled(streamURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isAddingStream)
|
||||
} footer: {
|
||||
Text("Use a direct MP3 or AAC audio stream address, rather than the station’s website.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,22 +418,22 @@ struct MediaPanelView: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.lineLimit(2)
|
||||
if !detail.isEmpty {
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Play", action: onPlay)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
Button(action: onPlay) {
|
||||
Image(systemName: "play.fill").frame(width: 44, height: 44)
|
||||
}
|
||||
.accessibilityLabel("Play \(title)")
|
||||
.disabled(!playEnabled)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func rowDetail(channel: String?, duration: Double?, status: String? = nil, inLibrary: Bool? = nil) -> String {
|
||||
@@ -389,8 +446,8 @@ struct MediaPanelView: View {
|
||||
}
|
||||
|
||||
private func performSearch() {
|
||||
let query = searchQuery.trimmingCharacters(in: .whitespaces)
|
||||
guard !query.isEmpty else { return }
|
||||
let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty, !isSearching else { return }
|
||||
isSearching = true
|
||||
searchError = nil
|
||||
Task {
|
||||
@@ -405,7 +462,10 @@ struct MediaPanelView: View {
|
||||
}
|
||||
|
||||
private func refreshLibrary() {
|
||||
guard !isLoadingLibrary else { return }
|
||||
isLoadingLibrary = true
|
||||
Task {
|
||||
defer { isLoadingLibrary = false }
|
||||
do {
|
||||
let library = try await MediaAPI(config: serverConfig).library()
|
||||
libraryTracks = library.tracks
|
||||
@@ -417,15 +477,22 @@ struct MediaPanelView: View {
|
||||
}
|
||||
|
||||
private func addStream() {
|
||||
let url = streamURL.trimmingCharacters(in: .whitespaces)
|
||||
guard !url.isEmpty else { return }
|
||||
let url = streamURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !isAddingStream else { return }
|
||||
guard let parsed = URL(string: url), ["https", "http"].contains(parsed.scheme?.lowercased() ?? ""), parsed.host != nil else {
|
||||
streamMessage = "Enter a complete http:// or https:// audio stream address."
|
||||
return
|
||||
}
|
||||
isAddingStream = true
|
||||
let name = streamName.trimmingCharacters(in: .whitespaces).isEmpty ? url : streamName.trimmingCharacters(in: .whitespaces)
|
||||
Task {
|
||||
defer { isAddingStream = false }
|
||||
do {
|
||||
try await MediaAPI(config: serverConfig).addStream(room: "default", name: name, url: url)
|
||||
streamMessage = "Added \(name)"
|
||||
streamName = ""
|
||||
streamURL = ""
|
||||
showAddStream = false
|
||||
refreshLibrary()
|
||||
} catch {
|
||||
streamMessage = "Could not add stream: \(error.localizedDescription)"
|
||||
@@ -514,3 +581,30 @@ struct MediaPanelView: View {
|
||||
return h > 0 ? "\(h):\(String(format: "%02d", m)):\(String(format: "%02d", s))" : "\(m):\(String(format: "%02d", s))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Artwork uses the same trust policy as token and media requests.
|
||||
private struct PinnedArtwork: View {
|
||||
let url: URL
|
||||
@State private var artwork: UIImage?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let artwork { Image(uiImage: artwork).resizable().scaledToFill() }
|
||||
else {
|
||||
Image(systemName: "music.note").font(.title2).foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.tertiarySystemFill))
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
.task(id: url) {
|
||||
artwork = nil
|
||||
let session = TLSPinning.session()
|
||||
defer { session.invalidateAndCancel() }
|
||||
guard let (data, response) = try? await session.data(from: url),
|
||||
(response as? HTTPURLResponse)?.statusCode == 200,
|
||||
!Task.isCancelled else { return }
|
||||
artwork = UIImage(data: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ struct ConnectionTestView: View {
|
||||
if isBusy {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Get token")
|
||||
Text("Test connection")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -44,7 +44,7 @@ struct ConnectionTestView: View {
|
||||
LabeledContent("User") {
|
||||
Text(response.user.displayName ?? response.user.id)
|
||||
}
|
||||
Label("Token endpoint and TLS are working.", systemImage: "checkmark.circle")
|
||||
Label("Connected to your server. Start a conversation to check Hope’s voice and replies.", systemImage: "checkmark.circle")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
case .failure(let error):
|
||||
@@ -57,6 +57,7 @@ struct ConnectionTestView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connection test")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func runTest() {
|
||||
|
||||
@@ -30,18 +30,48 @@ struct SettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Server") {
|
||||
Section {
|
||||
Button {
|
||||
showVoicePicker = true
|
||||
} label: {
|
||||
LabeledContent("Speaking voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID)
|
||||
}
|
||||
Picker("Conversation mode", selection: $settings.textMode) {
|
||||
Text("Voice").tag(false)
|
||||
Text("Type · Microphone off").tag(true)
|
||||
}
|
||||
} header: {
|
||||
Text("Make it yours")
|
||||
} footer: {
|
||||
Text("Voice and typing preferences are saved on this iPhone. You can switch modes from the Talk tab.")
|
||||
}
|
||||
Section {
|
||||
if session.isReady, !session.models.isEmpty {
|
||||
Button {
|
||||
showModelPicker = true
|
||||
} label: {
|
||||
LabeledContent("Model", value: session.selectedModel ?? settings.modelID ?? "Server default")
|
||||
}
|
||||
} else {
|
||||
LabeledContent("Model", value: settings.modelID ?? "Server default")
|
||||
Text("Connect from Talk to see the models available on your server.")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
} header: { Text("Intelligence") }
|
||||
Section {
|
||||
TextField(SettingsStore.defaultServerURL, text: $urlText)
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Server URL")
|
||||
.onAppear { urlText = settings.serverURL }
|
||||
if let urlError {
|
||||
Text(urlError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Button("Apply server URL") { applyServerURL() }
|
||||
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
Button("Save server address") { applyServerURL() }
|
||||
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
|
||||
if !canApplyServerURL {
|
||||
Text(session.state == .tearingDown
|
||||
? "Waiting for failure cleanup to finish…"
|
||||
@@ -49,37 +79,17 @@ struct SettingsView: View {
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Section("Voice") {
|
||||
Button {
|
||||
showVoicePicker = true
|
||||
} label: {
|
||||
LabeledContent("Voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID)
|
||||
}
|
||||
}
|
||||
Section("Model") {
|
||||
if session.isReady, !session.models.isEmpty {
|
||||
Button {
|
||||
showModelPicker = true
|
||||
} label: {
|
||||
LabeledContent("Model", value: session.selectedModel ?? settings.modelID ?? "—")
|
||||
}
|
||||
} else {
|
||||
Text("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)
|
||||
}
|
||||
} header: {
|
||||
Text("Connection")
|
||||
} footer: {
|
||||
Text("Hope runs on your own server. Your iPhone needs to be on the same network or a network that can reach it.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $showVoicePicker) {
|
||||
VoicePickerView(settings: settings, session: session)
|
||||
}
|
||||
@@ -115,7 +125,7 @@ struct SettingsView: View {
|
||||
|
||||
struct VoicePickerView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
var session: VoiceSession
|
||||
@ObservedObject var session: VoiceSession
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -132,10 +142,11 @@ struct VoicePickerView: View {
|
||||
if settings.voiceID == voice.id {
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(HopeStyle.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityAddTraits(settings.voiceID == voice.id ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.navigationTitle("Voice")
|
||||
@@ -150,7 +161,7 @@ struct VoicePickerView: View {
|
||||
|
||||
struct ModelPickerView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
var session: VoiceSession
|
||||
@ObservedObject var session: VoiceSession
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var activeModelID: String? {
|
||||
@@ -176,10 +187,11 @@ struct ModelPickerView: View {
|
||||
if activeModelID == model.id {
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(HopeStyle.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityAddTraits(activeModelID == model.id ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.navigationTitle("Model")
|
||||
|
||||
@@ -5,10 +5,12 @@ struct TaskPanelView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
LazyVStack(alignment: .leading, spacing: 20) {
|
||||
if taskStore.sortedTasks.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
Text(taskStore.activeCount > 0 ? "Hope is working on \(taskStore.activeCount) task\(taskStore.activeCount == 1 ? "" : "s"). You can keep talking." : "Results from the things you’ve asked Hope to work on.")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
ForEach(taskStore.sortedTasks) { task in
|
||||
TaskCardView(task: task)
|
||||
}
|
||||
@@ -16,57 +18,48 @@ struct TaskPanelView: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Background tasks")
|
||||
#if DEBUG
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Sample", action: { taskStore.injectSampleEvents() })
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.navigationTitle("Activity")
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text("Nothing running")
|
||||
.font(.headline)
|
||||
Text("Ask Hope to research or compare something.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
ContentUnavailableView {
|
||||
Label("A little help, in the background", systemImage: "checklist")
|
||||
} description: {
|
||||
Text("Ask Hope to research a topic or compare your options. Follow the progress and read the results here.")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 48)
|
||||
.padding(.top, 48)
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskCardView: View {
|
||||
let task: TaskItem
|
||||
@State private var showSteps = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
statusDot
|
||||
Text(task.description)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Label(statusTitle, systemImage: statusSymbol)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(dotColor)
|
||||
Spacer()
|
||||
Text(task.createdAt, style: .time).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Text(task.description).font(.headline).fixedSize(horizontal: false, vertical: true)
|
||||
if !task.steps.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
DisclosureGroup("\(task.steps.count) progress updates", isExpanded: $showSteps) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(task.steps) { step in
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(stepLabel(step.role))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(stepColor(step.role))
|
||||
.font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||
Text(step.content ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(stepColor(step.role))
|
||||
.lineLimit(3)
|
||||
.font(.subheadline).textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}.padding(.top, 8)
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
if let result = task.result {
|
||||
block(result, color: .green, background: Color.green.opacity(0.12))
|
||||
@@ -75,21 +68,33 @@ struct TaskCardView: View {
|
||||
block(error, color: .red, background: Color.red.opacity(0.12))
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
|
||||
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var statusDot: some View {
|
||||
Circle()
|
||||
.fill(dotColor)
|
||||
.frame(width: 9, height: 9)
|
||||
private var statusTitle: String {
|
||||
switch task.status {
|
||||
case .pending: "Queued"
|
||||
case .running: "In progress"
|
||||
case .completed: "Complete"
|
||||
case .failed: "Needs attention"
|
||||
}
|
||||
}
|
||||
|
||||
private var statusSymbol: String {
|
||||
switch task.status {
|
||||
case .pending: "clock"
|
||||
case .running: "arrow.triangle.2.circlepath"
|
||||
case .completed: "checkmark.circle.fill"
|
||||
case .failed: "exclamationmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var dotColor: Color {
|
||||
switch task.status {
|
||||
case .pending: return .gray
|
||||
case .running: return .blue
|
||||
case .running: return HopeStyle.tint
|
||||
case .completed: return .green
|
||||
case .failed: return .red
|
||||
}
|
||||
@@ -97,11 +102,10 @@ struct TaskCardView: View {
|
||||
|
||||
private func block(_ text: String, color: Color, background: Color) -> some View {
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(color)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(background, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private func stepLabel(_ role: String?) -> String {
|
||||
|
||||
Reference in new issue
Block a user