237 lines
10 KiB
Swift
237 lines
10 KiB
Swift
import SwiftUI
|
||
|
||
/// 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
|
||
@State private var selectionActive = false
|
||
@State private var viewportHeight: CGFloat = 0
|
||
@State private var latestBottom: CGFloat = 0
|
||
@AccessibilityFocusState private var focusedEventID: String?
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
@Environment(\.dynamicTypeSize) private var typeSize
|
||
|
||
init(events: [TranscriptEvent], onClear: @escaping () -> Void) {
|
||
self.events = events
|
||
self.onClear = onClear
|
||
_followingLatest = State(initialValue: events.isEmpty)
|
||
}
|
||
|
||
/// Auto-follow eligibility: never scroll while the user is selecting
|
||
/// text or reading history.
|
||
private var isFollowingEligible: Bool {
|
||
followingLatest && !selectionActive && focusedEventID == nil
|
||
}
|
||
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(NearlightFont.support(typeSize).weight(.semibold))
|
||
.foregroundStyle(Nearlight.quiet)
|
||
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, Nearlight.s20)
|
||
ScrollView {
|
||
LazyVStack(alignment: .leading, spacing: Nearlight.s24) {
|
||
if events.isEmpty {
|
||
Text("Your conversation will appear here.")
|
||
.font(.body)
|
||
.foregroundStyle(Nearlight.quiet)
|
||
.padding(.top, Nearlight.s24)
|
||
}
|
||
ForEach(events) { event in transcriptRow(event) }
|
||
GeometryReader { geometry in
|
||
Color.clear.preference(
|
||
key: TranscriptLatestBottomKey.self,
|
||
value: geometry.frame(in: .named("transcriptScroll")).maxY
|
||
)
|
||
}
|
||
.frame(height: 1)
|
||
.id("latest")
|
||
}
|
||
.padding(.horizontal, Nearlight.s20)
|
||
.padding(.top, Nearlight.s16)
|
||
.padding(.bottom, Nearlight.s20)
|
||
.frame(minHeight: 200, alignment: .topLeading)
|
||
}
|
||
.coordinateSpace(name: "transcriptScroll")
|
||
.background {
|
||
GeometryReader { geometry in
|
||
Color.clear.preference(
|
||
key: TranscriptViewportHeightKey.self,
|
||
value: geometry.size.height
|
||
)
|
||
}
|
||
}
|
||
.scrollDismissesKeyboard(.interactively)
|
||
.defaultScrollAnchor(.top)
|
||
.simultaneousGesture(DragGesture().onChanged { _ in
|
||
// Any manual drag suspends automatic following.
|
||
followingLatest = false
|
||
})
|
||
.onChange(of: events.last?.text) { _, _ in
|
||
guard isFollowingEligible else { return }
|
||
proxy.scrollTo("latest", anchor: .bottom)
|
||
}
|
||
.onChange(of: events.count) { _, count in
|
||
if count == 0 {
|
||
selectionActive = false
|
||
followingLatest = true
|
||
} else if isFollowingEligible {
|
||
proxy.scrollTo("latest", anchor: .bottom)
|
||
}
|
||
}
|
||
.onChange(of: selectionActive) { _, active in
|
||
// Beginning text selection suspends auto-follow so the
|
||
// user's reading position is never disturbed.
|
||
if active { followingLatest = false }
|
||
}
|
||
.onChange(of: focusedEventID) { _, eventID in
|
||
// VoiceOver focus on any transcript row means the user is
|
||
// reading history. Only Latest resumes automatic following.
|
||
if eventID != nil { followingLatest = false }
|
||
}
|
||
.onPreferenceChange(TranscriptViewportHeightKey.self) { height in
|
||
viewportHeight = height
|
||
updateFollowingEligibility(bottom: latestBottom, viewport: height)
|
||
}
|
||
.onPreferenceChange(TranscriptLatestBottomKey.self) { bottom in
|
||
latestBottom = bottom
|
||
updateFollowingEligibility(bottom: bottom, viewport: viewportHeight)
|
||
}
|
||
if !followingLatest && !events.isEmpty {
|
||
HStack {
|
||
Spacer()
|
||
Button {
|
||
// Explicit Latest restores full eligibility,
|
||
// clearing any stale selection suspension.
|
||
selectionActive = false
|
||
focusedEventID = nil
|
||
followingLatest = true
|
||
withAnimation(reduceMotion ? nil : Nearlight.standard) {
|
||
proxy.scrollTo("latest", anchor: .bottom)
|
||
}
|
||
} label: {
|
||
Label("Latest", systemImage: "arrow.down")
|
||
.font(NearlightFont.support(typeSize))
|
||
.padding(.horizontal, Nearlight.s12)
|
||
.frame(minHeight: 44)
|
||
.background(Nearlight.surface, in: Capsule())
|
||
.overlay(Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1))
|
||
}
|
||
.accessibilityIdentifier("talk.latest")
|
||
}
|
||
.padding(.horizontal, Nearlight.s12)
|
||
.padding(.bottom, Nearlight.s12)
|
||
}
|
||
}
|
||
}
|
||
.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 updateFollowingEligibility(bottom: CGFloat, viewport: CGFloat) {
|
||
guard bottom > 0,
|
||
viewport > 0,
|
||
!selectionActive,
|
||
focusedEventID == nil else { return }
|
||
let shouldFollow = bottom <= viewport + 48
|
||
if followingLatest != shouldFollow {
|
||
followingLatest = shouldFollow
|
||
}
|
||
}
|
||
|
||
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 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)
|
||
.foregroundStyle(Nearlight.ink)
|
||
.textSelection(.enabled)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.onLongPressGesture(minimumDuration: 0.05, pressing: { pressing in
|
||
// Text-selection interaction begins with long-press on the row;
|
||
// suspend auto-follow while pressing/held and restore when the
|
||
// press ends so following can resume.
|
||
selectionActive = pressing
|
||
}, perform: {})
|
||
.contextMenu {
|
||
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
|
||
}
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityFocused($focusedEventID, equals: event.id)
|
||
.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()
|
||
}
|
||
}
|
||
|
||
private struct TranscriptViewportHeightKey: PreferenceKey {
|
||
static var defaultValue: CGFloat = 0
|
||
|
||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||
value = nextValue()
|
||
}
|
||
}
|
||
|
||
private struct TranscriptLatestBottomKey: PreferenceKey {
|
||
static var defaultValue: CGFloat = 0
|
||
|
||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||
value = nextValue()
|
||
}
|
||
}
|