114 lines
5.1 KiB
Swift
114 lines
5.1 KiB
Swift
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)
|
||
}
|
||
}
|