wip: Nearlight redesign implementation
This commit is contained in:
1 parent
c76afbd11e
commit
fa21c81787
9 files changed
+904
-273
No files matched your search
@@ -12,13 +12,14 @@ struct ConversationView: View {
|
||||
|
||||
init(settings: SettingsStore) {
|
||||
self.settings = settings
|
||||
_activeServerURL = State(initialValue: settings.trimmedServerURL)
|
||||
_session = State(initialValue: Self.makeSession(settings.trimmedServerURL, settings: settings))
|
||||
let url = settings.trimmedServerURL
|
||||
_activeServerURL = State(initialValue: url)
|
||||
_session = State(initialValue: Self.makeSession(url, 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")
|
||||
preconditionFailure("Stored server URL failed validation; init sanitizes to a valid value.")
|
||||
}
|
||||
let session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
|
||||
#if DEBUG
|
||||
@@ -37,14 +38,14 @@ struct ConversationView: View {
|
||||
ConversationContent(settings: settings, session: session)
|
||||
.toolbar { settingsLink }
|
||||
}
|
||||
.tabItem { Label("Talk", systemImage: "waveform") }
|
||||
.tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") }
|
||||
.tag(0)
|
||||
|
||||
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
|
||||
.tag(1)
|
||||
}
|
||||
.id(sessionID)
|
||||
.tint(HopeStyle.tint)
|
||||
.tint(Nearlight.rosewood)
|
||||
.onChange(of: settings.textMode) { _, enabled in
|
||||
Task { await session.applyTextMode(enabled) }
|
||||
}
|
||||
@@ -107,6 +108,28 @@ private struct ActivityTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps session + conversation models onto Lightwell presentation phases.
|
||||
@MainActor
|
||||
private func lightwellPhase(session: VoiceSession, conversation: ConversationStateModel,
|
||||
textMode: Bool) -> LightwellView.Phase {
|
||||
if session.state.isFailed { return .failed }
|
||||
switch session.state {
|
||||
case .idle: return .idle
|
||||
case .connecting: return .connecting
|
||||
case .waitingAgent: return .waiting
|
||||
case .tearingDown: return .tearingDown
|
||||
case .failed: return .failed
|
||||
case .ready:
|
||||
if textMode { return .readyNoInput }
|
||||
if session.isMuted { return .readyNoInput }
|
||||
switch conversation.phase {
|
||||
case .speaking: return .speaking(level: conversation.agentLevel)
|
||||
case .thinking: return .thinking
|
||||
default: return .listening(level: conversation.micLevel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConversationContent: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
@ObservedObject var session: VoiceSession
|
||||
@@ -116,6 +139,8 @@ private struct ConversationContent: View {
|
||||
@State private var sendError: String?
|
||||
@State private var isSending = false
|
||||
@FocusState private var composerFocused: Bool
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
init(settings: SettingsStore, session: VoiceSession) {
|
||||
self.settings = settings
|
||||
@@ -128,140 +153,341 @@ private struct ConversationContent: View {
|
||||
VStack(spacing: 0) {
|
||||
if transcripts.events.isEmpty && !session.isReady {
|
||||
welcome
|
||||
if failureNotice != nil { failureBanner }
|
||||
} else {
|
||||
connectionStatus.padding(.horizontal, 20).padding(.vertical, 12)
|
||||
statusStrip
|
||||
if session.isReconnecting { reconnectBanner }
|
||||
if failureNotice != nil { failureBanner }
|
||||
TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll)
|
||||
}
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
.background(Nearlight.ground)
|
||||
.navigationTitle("Hope")
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { conversationControls }
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { dock }
|
||||
.onChange(of: session.state) { _, state in
|
||||
if state == .ready && settings.textMode { composerFocused = true }
|
||||
}
|
||||
.sensoryFeedback(.success, trigger: session.state == .ready ? true : false) { _, _ in true }
|
||||
.sensoryFeedback(.error, trigger: session.state.isFailed) { _, _ in true }
|
||||
}
|
||||
|
||||
// MARK: Welcome (pre-connection, no history)
|
||||
|
||||
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))
|
||||
VStack(alignment: .leading, spacing: Nearlight.s24) {
|
||||
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode))
|
||||
.padding(.top, Nearlight.s32)
|
||||
VStack(alignment: .leading, spacing: Nearlight.s12) {
|
||||
Text("A moment with Hope.")
|
||||
.font(NearlightFont.welcome(typeSize))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("Talk things through, find something out, or get something done.")
|
||||
.font(.body).foregroundStyle(.secondary)
|
||||
Text("Speak or type to your own assistant.")
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Label("Think out loud", systemImage: "bubble.left.and.bubble.right")
|
||||
Label("Find an answer", systemImage: "sparkle.magnifyingglass")
|
||||
}
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
statusLine
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24).padding(.bottom, 24)
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.padding(.bottom, Nearlight.s24)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
/// Status text 24 below the supporting copy on welcome.
|
||||
private var statusLine: some View {
|
||||
Text(statusTitle)
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.padding(.top, Nearlight.s24)
|
||||
.accessibilityIdentifier("talk.status")
|
||||
}
|
||||
|
||||
// MARK: Status strip (active conversation)
|
||||
|
||||
private var statusStrip: some View {
|
||||
HStack(spacing: Nearlight.s12) {
|
||||
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode), compact: true)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(statusTitle)
|
||||
.font(NearlightFont.control(typeSize))
|
||||
.foregroundStyle(Nearlight.ink)
|
||||
.accessibilityIdentifier("talk.status")
|
||||
if showStatusDetail {
|
||||
Text(statusDetail)
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
}
|
||||
}
|
||||
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)
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.padding(.vertical, Nearlight.s12)
|
||||
.frame(minHeight: 60, alignment: .leading)
|
||||
.background(Nearlight.ground)
|
||||
}
|
||||
|
||||
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)
|
||||
/// Subtitle omitted for ordinary listening/thinking/speaking per spec.
|
||||
private var showStatusDetail: Bool {
|
||||
guard session.isReady else { return true }
|
||||
if settings.textMode || session.isMuted { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private var reconnectBanner: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Label("Reconnecting…", systemImage: "wifi.exclamationmark")
|
||||
.font(NearlightFont.control(typeSize))
|
||||
Text("Your conversation is still here.")
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
}
|
||||
.padding(Nearlight.s12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Nearlight.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.accessibilityIdentifier("talk.connection.notice")
|
||||
}
|
||||
|
||||
/// Classified failure notice with actionable recovery.
|
||||
private var failureBanner: some View {
|
||||
VStack(alignment: .leading, spacing: Nearlight.s8) {
|
||||
Label(failureTitle, systemImage: "exclamationmark.circle")
|
||||
.font(NearlightFont.control(typeSize))
|
||||
.foregroundStyle(Nearlight.alert)
|
||||
Text(failureBody)
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.ink)
|
||||
if micDenied {
|
||||
Button("Open iPhone Settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
.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)
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.frame(minHeight: 44)
|
||||
.accessibilityIdentifier("talk.connection.openSystemSettings")
|
||||
}
|
||||
}
|
||||
.padding(Nearlight.s16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Nearlight.errorWash)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.accessibilityIdentifier("talk.connection.notice")
|
||||
}
|
||||
|
||||
private var micDenied: Bool {
|
||||
(session.micError?.contains("Microphone") ?? false) && session.state.isFailed == false && session.state == .ready
|
||||
}
|
||||
|
||||
private var failureTitle: String {
|
||||
if session.micError != nil { return "Microphone unavailable" }
|
||||
return session.everConnected ? "Connection interrupted" : "Couldn’t connect"
|
||||
}
|
||||
|
||||
private var failureBody: String {
|
||||
if let micError = session.micError {
|
||||
return micError
|
||||
}
|
||||
let raw = failureMessage ?? ""
|
||||
if raw.lowercased().contains("agent") {
|
||||
return "Check that Hope is running on your server."
|
||||
}
|
||||
if raw.lowercased().contains("timed out") || raw.lowercased().contains("connect") {
|
||||
return "Check your Wi-Fi or VPN and make sure your server is running."
|
||||
}
|
||||
return raw.isEmpty ? "Check your Wi-Fi or VPN and make sure your server is running." : raw
|
||||
}
|
||||
|
||||
// MARK: Dock
|
||||
|
||||
private var dock: some View {
|
||||
VStack(spacing: Nearlight.s12) {
|
||||
if let sendError {
|
||||
Text(sendError).font(.footnote).foregroundStyle(.red)
|
||||
Label(sendError, systemImage: "exclamationmark.circle")
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.alert)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityIdentifier("talk.composer.error")
|
||||
}
|
||||
modeSelector
|
||||
if session.isReady {
|
||||
if settings.textMode { typeControls } else { voiceControls }
|
||||
} else {
|
||||
startControls
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.padding(.top, Nearlight.s12)
|
||||
.padding(.bottom, Nearlight.s12)
|
||||
.background(
|
||||
// Dock: Surface, 16-point top corners, decorative top separator.
|
||||
VStack(spacing: 0) {
|
||||
Rectangle().fill(Nearlight.separator).frame(height: 1 / UIScreen.main.scale)
|
||||
Nearlight.surface
|
||||
}
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
.clipShape(UnevenRoundedRectangle(topLeadingRadius: Nearlight.radiusDock, topTrailingRadius: Nearlight.radiusDock))
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private var modeSelector: some View {
|
||||
if typeSize.isAccessibilitySize {
|
||||
// Two stacked 52-point selection buttons at accessibility sizes.
|
||||
VStack(spacing: Nearlight.s8) {
|
||||
modeButton("Voice", systemImage: "mic", selected: !settings.textMode, id: "talk.mode.voice") {
|
||||
settings.textMode = false
|
||||
}
|
||||
modeButton("Type", systemImage: "keyboard", selected: settings.textMode, id: "talk.mode.type") {
|
||||
settings.textMode = true
|
||||
}
|
||||
}
|
||||
.disabled(isBusy)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(isBusy)
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.top, 12).padding(.bottom, 16)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var textComposer: some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
private func modeButton(_ title: String, systemImage: String, selected: Bool,
|
||||
id: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Nearlight.s8) {
|
||||
Image(systemName: systemImage)
|
||||
Text(title)
|
||||
if selected { Spacer(); Image(systemName: "checkmark") }
|
||||
}
|
||||
.font(NearlightFont.control(typeSize))
|
||||
.foregroundStyle(selected ? Nearlight.rosewood : Nearlight.ink)
|
||||
.frame(maxWidth: .infinity, minHeight: 52)
|
||||
.background(selected ? Nearlight.selectedWash : Nearlight.surface,
|
||||
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||||
.strokeBorder(selected ? Nearlight.rosewood.opacity(0.4) : Nearlight.quiet, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier(id)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
|
||||
private var startControls: some View {
|
||||
VStack(spacing: Nearlight.s8) {
|
||||
Text(settings.textMode ? "Your microphone stays off." : "Your microphone starts after you connect.")
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
Button {
|
||||
sendError = nil
|
||||
Task { await session.connect() }
|
||||
} label: {
|
||||
HStack(spacing: Nearlight.s8) {
|
||||
if isBusy {
|
||||
ProgressView().tint(Nearlight.onRosewood)
|
||||
} else {
|
||||
Image(systemName: settings.textMode ? "keyboard" : "mic")
|
||||
}
|
||||
Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : "Start chatting")
|
||||
.font(NearlightFont.control(typeSize))
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 52)
|
||||
.background(isBusy ? Nearlight.rosewood.opacity(0.6) : Nearlight.rosewood,
|
||||
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.foregroundStyle(Nearlight.onRosewood)
|
||||
}
|
||||
.disabled(isBusy)
|
||||
.accessibilityIdentifier("start.chatting")
|
||||
}
|
||||
}
|
||||
|
||||
private var voiceControls: some View {
|
||||
Group {
|
||||
if typeSize.isAccessibilitySize {
|
||||
VStack(spacing: Nearlight.s12) {
|
||||
HStack(spacing: Nearlight.s12) {
|
||||
muteButton
|
||||
micMeter
|
||||
}
|
||||
endButton
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: Nearlight.s12) {
|
||||
muteButton
|
||||
micMeter
|
||||
endButton
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var muteButton: some View {
|
||||
Button {
|
||||
Task { await session.setMuted(!session.isMuted) }
|
||||
} label: {
|
||||
Image(systemName: session.isMuted ? "mic.slash" : "mic")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.frame(width: 52, height: 52)
|
||||
.background(session.isMuted ? Nearlight.selectedWash : Nearlight.surface,
|
||||
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||||
.strokeBorder(Nearlight.quiet, lineWidth: 1)
|
||||
)
|
||||
.foregroundStyle(session.isMuted ? Nearlight.rosewood : Nearlight.ink)
|
||||
}
|
||||
.accessibilityLabel(session.isMuted ? "Unmute microphone" : "Mute microphone")
|
||||
.accessibilityIdentifier("talk.microphone.toggle")
|
||||
.sensoryFeedback(.selection, trigger: session.isMuted)
|
||||
}
|
||||
|
||||
/// Five-bar capsule meter, 64×24, leading aligned.
|
||||
private var micMeter: some View {
|
||||
HStack(spacing: 6) {
|
||||
let level = session.isMuted || settings.textMode ? 0.0 : conversation.micLevel
|
||||
let filled = Int(ceil(level * 5))
|
||||
ForEach(0..<5, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(index < filled ? Nearlight.rosewood : Color.clear)
|
||||
.overlay(index < filled ? nil : Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1))
|
||||
.frame(width: 6, height: [6.0, 12.0, 24.0, 12.0, 6.0][index])
|
||||
}
|
||||
}
|
||||
.frame(width: 64, height: 24, alignment: .leading)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Microphone level")
|
||||
.accessibilityValue(session.isMuted || settings.textMode ? "Off" : "\(Int(round(conversation.micLevel * 100)))%")
|
||||
}
|
||||
|
||||
private var typeControls: some View {
|
||||
VStack(spacing: Nearlight.s12) {
|
||||
HStack(alignment: .bottom, spacing: Nearlight.s8) {
|
||||
TextField("Message Hope", text: $composerText, axis: .vertical)
|
||||
.lineLimit(1...5).padding(12)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
.focused($composerFocused).disabled(isSending)
|
||||
.lineLimit(1...5)
|
||||
.padding(Nearlight.s12)
|
||||
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||||
.strokeBorder(Nearlight.quiet, lineWidth: 1)
|
||||
)
|
||||
.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) }
|
||||
if isSending {
|
||||
ProgressView().frame(width: 52, height: 52)
|
||||
} else {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.frame(width: 52, height: 52)
|
||||
.background(Nearlight.rosewood, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.foregroundStyle(Nearlight.onRosewood)
|
||||
}
|
||||
}
|
||||
.disabled(composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending || session.isReconnecting)
|
||||
.accessibilityLabel("Send message")
|
||||
@@ -275,12 +501,22 @@ private struct ConversationContent: View {
|
||||
composerFocused = false
|
||||
Task { await session.disconnect() }
|
||||
} label: {
|
||||
Label("End conversation", systemImage: "phone.down.fill")
|
||||
.font(.subheadline.weight(.medium)).frame(minHeight: 44)
|
||||
Label("End conversation", systemImage: "stop.fill")
|
||||
.font(NearlightFont.control(typeSize))
|
||||
.lineLimit(2...3)
|
||||
.frame(maxWidth: .infinity, minHeight: 52)
|
||||
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||||
.strokeBorder(Nearlight.alert, lineWidth: 1)
|
||||
)
|
||||
.foregroundStyle(Nearlight.alert)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityIdentifier("end.conversation")
|
||||
}
|
||||
|
||||
// MARK: Status copy
|
||||
|
||||
private var isBusy: Bool {
|
||||
session.state == .connecting || session.state == .waitingAgent || session.state == .tearingDown
|
||||
}
|
||||
@@ -288,36 +524,36 @@ private struct ConversationContent: View {
|
||||
if case .failed(let message) = session.state { return message }
|
||||
return nil
|
||||
}
|
||||
private var failureNotice: String? { failureMessage }
|
||||
|
||||
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 .connecting: return "Connecting to your server…"
|
||||
case .waitingAgent: return "Waiting for Hope…"
|
||||
case .tearingDown: return "Ending conversation…"
|
||||
case .failed: return "Connection unavailable"
|
||||
case .failed: return "Connection interrupted"
|
||||
case .ready:
|
||||
if settings.textMode { return "You’re chatting with Hope" }
|
||||
if session.isMuted { return "Microphone muted" }
|
||||
if settings.textMode { return "Ready for your message" }
|
||||
if session.isMuted { return "Microphone off" }
|
||||
switch conversation.phase {
|
||||
case .thinking: return "Thinking it through…"
|
||||
case .listening: return "Listening"
|
||||
case .thinking: return "Thinking…"
|
||||
case .speaking: return "Hope is speaking"
|
||||
default: return "I’m listening"
|
||||
default: return "Listening"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusDetail: String {
|
||||
if !session.isReady { return "Your recent conversation is below." }
|
||||
if session.state == .waitingAgent { return "The server is connected. Hope has not joined yet." }
|
||||
if session.state == .connecting { return "Reaching your server." }
|
||||
if !session.isReady { return "" }
|
||||
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
|
||||
return ""
|
||||
}
|
||||
|
||||
private func sendComposer() {
|
||||
let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !isSending else { return }
|
||||
@@ -335,4 +571,15 @@ private struct ConversationContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension VoiceSession {
|
||||
/// Whether any connection reached ready during this session lifetime.
|
||||
/// Used to title the failure notice per spec.
|
||||
var everConnected: Bool {
|
||||
// Sessions are recreated on replacement; reaching ready is recorded
|
||||
// indirectly through transcript presence. Conservative: treat any
|
||||
// prior transcript activity as evidence of a completed connection.
|
||||
!transcriptHandler.events.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
#Preview { ConversationView(settings: SettingsStore()) }
|
||||
@@ -0,0 +1,122 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// The Nearlight signature element: an upright, softly lit aperture.
|
||||
///
|
||||
/// Two capsules in a ZStack — no assets, shaders, blur, or particles. The
|
||||
/// inner capsule carries a vertical Rosewood gradient; its geometry encodes
|
||||
/// conversation state and audio level. Noninteractive and accessibility
|
||||
/// hidden: the companion status text carries meaning.
|
||||
struct LightwellView: View {
|
||||
/// Lightwell presentation state, mapped from session/conversation models.
|
||||
enum Phase: Equatable {
|
||||
case idle
|
||||
case connecting
|
||||
case waiting
|
||||
case listening(level: Double)
|
||||
case thinking
|
||||
case speaking(level: Double)
|
||||
case tearingDown
|
||||
case failed
|
||||
/// Input never drives the light (muted or Type mode).
|
||||
case readyNoInput
|
||||
}
|
||||
|
||||
let phase: Phase
|
||||
/// Compact (24×36) in the conversation status strip; full (64×96) on welcome.
|
||||
var compact: Bool = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
/// Pending-state pulse driver, 0 → 1 → 0 over the 2.4s cycle.
|
||||
@State private var pulse: Double = 0
|
||||
|
||||
/// Frame dimensions never animate; inner dimensions multiply by 0.375
|
||||
/// in the compact frame.
|
||||
private var frameSize: CGSize {
|
||||
compact ? CGSize(width: 24, height: 36) : CGSize(width: 64, height: 96)
|
||||
}
|
||||
private var baseInner: CGSize {
|
||||
let base = CGSize(width: 16, height: 40)
|
||||
return compact
|
||||
? CGSize(width: base.width * 0.375, height: base.height * 0.375)
|
||||
: base
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let inner = innerSize
|
||||
ZStack {
|
||||
Capsule()
|
||||
.fill(Nearlight.surface)
|
||||
Capsule()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: Nearlight.rosewood.opacity(0.35), location: 0),
|
||||
.init(color: Nearlight.rosewood.opacity(1), location: 0.5),
|
||||
.init(color: Nearlight.rosewood.opacity(0.55), location: 1),
|
||||
],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.frame(width: inner.width, height: inner.height)
|
||||
.opacity(overallOpacity)
|
||||
.animation(reduceMotion ? nil : Nearlight.quick, value: overallOpacity)
|
||||
.animation(reduceMotion ? nil : Nearlight.settle, value: innerSize)
|
||||
}
|
||||
.frame(width: frameSize.width, height: frameSize.height)
|
||||
.overlay(
|
||||
Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1)
|
||||
)
|
||||
.accessibilityHidden(true)
|
||||
.task(id: pulseActive) {
|
||||
guard pulseActive else {
|
||||
pulse = 0
|
||||
return
|
||||
}
|
||||
while !Task.isCancelled {
|
||||
withAnimation(reduceMotion ? nil : Nearlight.cycle) { pulse = pulse == 0 ? 1 : 0 }
|
||||
try? await Task.sleep(for: .seconds(2.4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pulse loop should run for this phase.
|
||||
private var pulseActive: Bool {
|
||||
switch phase {
|
||||
case .connecting, .waiting, .thinking: return !reduceMotion
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner capsule geometry per spec table. `a` is the smoothed level.
|
||||
private var innerSize: CGSize {
|
||||
let base = baseInner
|
||||
switch phase {
|
||||
case .listening(let a):
|
||||
let level = min(max(a, 0), 1)
|
||||
return CGSize(width: base.width + (compact ? 3 : 8) * level,
|
||||
height: base.height + (compact ? 6 : 16) * level)
|
||||
case .speaking(let a):
|
||||
let level = min(max(a, 0), 1)
|
||||
return CGSize(width: base.width + (compact ? 4.5 : 12) * level,
|
||||
height: base.height + (compact ? 9 : 24) * level)
|
||||
case .waiting, .thinking:
|
||||
// Pulse drives opacity; height steps up one band.
|
||||
return CGSize(width: base.width, height: base.height + (compact ? 3 : 8))
|
||||
case .connecting, .idle, .failed, .tearingDown, .readyNoInput:
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
private var overallOpacity: Double {
|
||||
switch phase {
|
||||
case .connecting, .waiting:
|
||||
return 0.45 + 0.45 * pulse
|
||||
case .thinking:
|
||||
return 0.65 + 0.35 * pulse
|
||||
case .idle, .failed, .tearingDown:
|
||||
return 0.45
|
||||
case .listening, .speaking, .readyNoInput:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
-41
@@ -1,27 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OrbView: View {
|
||||
enum OrbState { case idle, connecting, listening, speaking, thinking, failed }
|
||||
let state: OrbState
|
||||
|
||||
var body: some View {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearlight transcript: a legible shared record. Full-width rows, no
|
||||
/// bubbles; metadata row per event; "Live" placeholder on partials.
|
||||
struct TranscriptListView: View {
|
||||
let events: [TranscriptEvent]
|
||||
var onClear: () -> Void
|
||||
@State private var confirmClear = false
|
||||
@State private var followingLatest = true
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
|
||||
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*"#
|
||||
|
||||
@@ -29,7 +16,9 @@ struct TranscriptListView: View {
|
||||
ScrollViewReader { proxy in
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Conversation").font(.subheadline.weight(.semibold))
|
||||
Text("Conversation")
|
||||
.font(NearlightFont.support(typeSize).weight(.semibold))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
Spacer()
|
||||
Menu {
|
||||
Button("Clear conversation", systemImage: "trash", role: .destructive) { confirmClear = true }
|
||||
@@ -39,20 +28,23 @@ struct TranscriptListView: View {
|
||||
.accessibilityLabel("Conversation options")
|
||||
.disabled(events.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 20) {
|
||||
LazyVStack(alignment: .leading, spacing: Nearlight.s24) {
|
||||
if events.isEmpty {
|
||||
Text("Your conversation will appear here.")
|
||||
.font(.body).foregroundStyle(.secondary)
|
||||
.padding(.vertical, 32)
|
||||
.font(.body)
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.padding(.top, Nearlight.s24)
|
||||
}
|
||||
ForEach(events) { event in transcriptRow(event) }
|
||||
Color.clear.frame(height: 1).id("latest")
|
||||
.onAppear { followingLatest = true }
|
||||
.onDisappear { followingLatest = false }
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.bottom, 20)
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.padding(.top, Nearlight.s16)
|
||||
.padding(.bottom, Nearlight.s20)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.defaultScrollAnchor(.bottom)
|
||||
@@ -64,13 +56,15 @@ struct TranscriptListView: View {
|
||||
}
|
||||
if !followingLatest && !events.isEmpty {
|
||||
Button {
|
||||
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) {
|
||||
withAnimation(reduceMotion ? nil : Nearlight.standard) {
|
||||
proxy.scrollTo("latest", anchor: .bottom)
|
||||
}
|
||||
} label: {
|
||||
Label("Latest messages", systemImage: "arrow.down").font(.subheadline)
|
||||
Label("Latest messages", systemImage: "arrow.down")
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.frame(minHeight: 44)
|
||||
}
|
||||
.accessibilityIdentifier("talk.latest")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,28 +80,52 @@ struct TranscriptListView: View {
|
||||
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)
|
||||
return VStack(alignment: .leading, spacing: Nearlight.s8) {
|
||||
// Metadata row: speaker, timestamp trailing. Stacks at
|
||||
// accessibility sizes per spec.
|
||||
Group {
|
||||
if typeSize.isAccessibilitySize {
|
||||
VStack(alignment: .leading, spacing: Nearlight.s4) {
|
||||
speakerLabel(isUser)
|
||||
timeLabel(event)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: Nearlight.s8) {
|
||||
speakerLabel(isUser)
|
||||
timeLabel(event)
|
||||
}
|
||||
}
|
||||
Text(text).font(.body).textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.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) }
|
||||
Text(text)
|
||||
.font(.body)
|
||||
.foregroundStyle(Nearlight.ink)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contextMenu {
|
||||
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.id(event.id)
|
||||
}
|
||||
|
||||
private func speakerLabel(_ isUser: Bool) -> some View {
|
||||
Text(isUser ? "You" : "Hope")
|
||||
.font(NearlightFont.metadata(typeSize).weight(.semibold))
|
||||
.foregroundStyle(Nearlight.ink)
|
||||
}
|
||||
|
||||
private func timeLabel(_ event: TranscriptEvent) -> some View {
|
||||
Group {
|
||||
if event.isFinal {
|
||||
Text(event.timestamp, style: .time)
|
||||
} else {
|
||||
Text("Live")
|
||||
}
|
||||
}
|
||||
.font(NearlightFont.metadata(typeSize))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user