Archived
wip: Nearlight redesign implementation
This commit is contained in:
1 parent
c76afbd11e
commit
fa21c81787
9 files changed
+904
-273
No files matched your search
@@ -0,0 +1,131 @@
|
||||
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
|
||||
@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*"#
|
||||
|
||||
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) }
|
||||
Color.clear.frame(height: 1).id("latest")
|
||||
.onAppear { followingLatest = true }
|
||||
.onDisappear { followingLatest = false }
|
||||
}
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.padding(.top, Nearlight.s16)
|
||||
.padding(.bottom, Nearlight.s20)
|
||||
}
|
||||
.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 : Nearlight.standard) {
|
||||
proxy.scrollTo("latest", anchor: .bottom)
|
||||
}
|
||||
} label: {
|
||||
Label("Latest messages", systemImage: "arrow.down")
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.frame(minHeight: 44)
|
||||
}
|
||||
.accessibilityIdentifier("talk.latest")
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 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)
|
||||
.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