Files
hope-voice-api/Sources/Features/Conversation/OrbView.swift
T

114 lines
5.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
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")
}
}
struct TranscriptListView: View {
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*"#
var body: some View {
ScrollViewReader { proxy in
VStack(spacing: 0) {
HStack {
Text("Conversation").font(.subheadline.weight(.semibold))
Spacer()
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, 20)
ScrollView {
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, 20).padding(.bottom, 20)
}
.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 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)
}
.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) }
}
.accessibilityElement(children: .combine)
.id(event.id)
}
}