Files
hope-voice-api/Sources/Features/Conversation/OrbView.swift
T
2026-08-31 19:50:46 -04:00

151 lines
4.9 KiB
Swift

import SwiftUI
struct OrbView: View {
enum OrbState {
case idle
case connecting
case listening
case speaking
case thinking
case 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)
}
}
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
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) {
HStack {
Text("Transcript")
.font(.caption.bold())
.foregroundStyle(.secondary)
Spacer()
Button(role: .destructive, action: onClear) {
Label("Clear", systemImage: "trash")
.font(.caption)
}
}
.padding(.horizontal)
.padding(.bottom, 4)
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(visibleRows) { event in
transcriptRow(event)
}
}
.padding(.horizontal)
}
.onChange(of: visibleRows.count) { _, _ in
if let last = visibleRows.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
}
}
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)
}
Text(stripped(event.text))
.font(.body)
.opacity(textOpacity)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(rowBackground)
.cornerRadius(10)
.id(event.id)
}
private func stripped(_ text: String) -> String {
text.replacingOccurrences(
of: Self.deliveryMarkers,
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespaces)
}
}