wip: Nearlight redesign implementation
This commit is contained in:
1 parent
c76afbd11e
commit
fa21c81787
9 files changed
+883
-252
No files matched your search
@@ -0,0 +1,119 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Nearlight design tokens (design spec: local://ui-design-spec.md).
|
||||||
|
///
|
||||||
|
/// Views reference semantic roles; the primitive hex values live only here.
|
||||||
|
/// All custom surfaces are opaque; native system materials are untouched.
|
||||||
|
enum Nearlight {
|
||||||
|
// MARK: Palette (light / dark variants)
|
||||||
|
|
||||||
|
/// Screen background.
|
||||||
|
static let ground = Color(light: UIColor(red: 0.957, green: 0.949, blue: 0.965, alpha: 1),
|
||||||
|
dark: UIColor(red: 0.110, green: 0.098, blue: 0.125, alpha: 1))
|
||||||
|
/// Controls, grouped rows, dock.
|
||||||
|
static let surface = Color(light: .white,
|
||||||
|
dark: UIColor(red: 0.157, green: 0.137, blue: 0.180, alpha: 1))
|
||||||
|
/// Primary text.
|
||||||
|
static let ink = Color(light: UIColor(red: 0.161, green: 0.137, blue: 0.184, alpha: 1),
|
||||||
|
dark: UIColor(red: 0.965, green: 0.937, blue: 0.969, alpha: 1))
|
||||||
|
/// Supporting text, inactive icons.
|
||||||
|
static let quiet = Color(light: UIColor(red: 0.396, green: 0.365, blue: 0.424, alpha: 1),
|
||||||
|
dark: UIColor(red: 0.749, green: 0.698, blue: 0.776, alpha: 1))
|
||||||
|
/// Action tint, selection, Lightwell.
|
||||||
|
static let rosewood = Color(light: UIColor(red: 0.529, green: 0.278, blue: 0.380, alpha: 1),
|
||||||
|
dark: UIColor(red: 0.890, green: 0.635, blue: 0.737, alpha: 1))
|
||||||
|
/// Errors and destructive actions.
|
||||||
|
static let alert = Color(light: UIColor(red: 0.675, green: 0.208, blue: 0.282, alpha: 1),
|
||||||
|
dark: UIColor(red: 1.0, green: 0.604, blue: 0.675, alpha: 1))
|
||||||
|
|
||||||
|
// MARK: Derived roles
|
||||||
|
|
||||||
|
/// Decorative separator: Ink at 14% over its parent.
|
||||||
|
static var separator: Color { ink.opacity(0.14) }
|
||||||
|
/// Selected wash: Rosewood at 10% over Surface.
|
||||||
|
static var selectedWash: Color { rosewood.opacity(0.10) }
|
||||||
|
/// Error wash: Alert at 8% over Surface.
|
||||||
|
static var errorWash: Color { alert.opacity(0.08) }
|
||||||
|
/// Primary-action label: Surface in light, Ground in dark.
|
||||||
|
static var onRosewood: Color {
|
||||||
|
UITraitCollection.current.userInterfaceStyle == .dark ? ground : surface
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Spacing scale
|
||||||
|
|
||||||
|
static let s4: CGFloat = 4
|
||||||
|
static let s8: CGFloat = 8
|
||||||
|
static let s12: CGFloat = 12
|
||||||
|
static let s16: CGFloat = 16
|
||||||
|
static let s20: CGFloat = 20
|
||||||
|
static let s24: CGFloat = 24
|
||||||
|
static let s32: CGFloat = 32
|
||||||
|
static let s48: CGFloat = 48
|
||||||
|
|
||||||
|
// MARK: Radius scale
|
||||||
|
|
||||||
|
static let radiusSegment: CGFloat = 8
|
||||||
|
static let radiusControl: CGFloat = 12
|
||||||
|
static let radiusDock: CGFloat = 16
|
||||||
|
|
||||||
|
// MARK: Motion tokens
|
||||||
|
|
||||||
|
/// quick = 0.12s easeOut.
|
||||||
|
static let quick: Animation = .easeOut(duration: 0.12)
|
||||||
|
/// standard = 0.22s timingCurve(0.22, 1, 0.36, 1).
|
||||||
|
static let standard: Animation = .timingCurve(0.22, 1, 0.36, 1, duration: 0.22)
|
||||||
|
/// settle = 0.32s timingCurve(0.22, 1, 0.36, 1).
|
||||||
|
static let settle: Animation = .timingCurve(0.22, 1, 0.36, 1, duration: 0.32)
|
||||||
|
/// cycle = 2.4s easeInOut (Lightwell pulses).
|
||||||
|
static let cycle: Animation = .easeInOut(duration: 2.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
/// Builds an adaptive color from explicit light/dark UIColors without
|
||||||
|
/// requiring an asset catalog.
|
||||||
|
init(light: UIColor, dark: UIColor) {
|
||||||
|
self.init(uiColor: UIColor { traits in
|
||||||
|
traits.userInterfaceStyle == .dark ? dark : light
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared typography roles from the spec. SF Pro default design except the
|
||||||
|
/// rounded welcome heading and wordmark.
|
||||||
|
enum NearlightFont {
|
||||||
|
/// Welcome heading: 32 medium, rounded, largeTitle metrics.
|
||||||
|
static func welcome(_ sizeCategory: DynamicTypeSize) -> Font {
|
||||||
|
scaled(.largeTitle, base: 32, weight: .medium, design: .rounded, sizeCategory: sizeCategory)
|
||||||
|
}
|
||||||
|
/// "Hope" wordmark: 20 semibold, rounded, title3 metrics.
|
||||||
|
static func wordmark(_ sizeCategory: DynamicTypeSize) -> Font {
|
||||||
|
scaled(.title3, base: 20, weight: .semibold, design: .rounded, sizeCategory: sizeCategory)
|
||||||
|
}
|
||||||
|
/// Section/detail heading: 22 semibold, title2 metrics.
|
||||||
|
static func heading(_ sizeCategory: DynamicTypeSize) -> Font {
|
||||||
|
scaled(.title2, base: 22, weight: .semibold, sizeCategory: sizeCategory)
|
||||||
|
}
|
||||||
|
/// Control/row label: 17 semibold, body metrics.
|
||||||
|
static func control(_ sizeCategory: DynamicTypeSize) -> Font {
|
||||||
|
scaled(.body, base: 17, weight: .semibold, sizeCategory: sizeCategory)
|
||||||
|
}
|
||||||
|
/// Status/support: 15 regular, subheadline metrics.
|
||||||
|
static func support(_ sizeCategory: DynamicTypeSize) -> Font {
|
||||||
|
scaled(.subheadline, base: 15, weight: .regular, sizeCategory: sizeCategory)
|
||||||
|
}
|
||||||
|
/// Speaker/timestamp: 13 medium, footnote metrics.
|
||||||
|
static func metadata(_ sizeCategory: DynamicTypeSize) -> Font {
|
||||||
|
scaled(.footnote, base: 13, weight: .medium, sizeCategory: sizeCategory)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scales a base size with the given text style via UIFontMetrics and
|
||||||
|
/// returns a SwiftUI font with the requested weight/design.
|
||||||
|
private static func scaled(_ textStyle: UIFont.TextStyle, base: CGFloat, weight: Font.Weight,
|
||||||
|
design: Font.Design = .default, sizeCategory: DynamicTypeSize) -> Font {
|
||||||
|
// UIFontMetrics scales the base size against the CURRENT Dynamic Type
|
||||||
|
// setting at render time (no explicit traits: inherits environment).
|
||||||
|
let scaledSize = UIFontMetrics(forTextStyle: textStyle).scaledValue(for: base)
|
||||||
|
return .system(size: scaledSize, weight: weight, design: design)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,13 +12,14 @@ struct ConversationView: View {
|
|||||||
|
|
||||||
init(settings: SettingsStore) {
|
init(settings: SettingsStore) {
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
_activeServerURL = State(initialValue: settings.trimmedServerURL)
|
let url = settings.trimmedServerURL
|
||||||
_session = State(initialValue: Self.makeSession(settings.trimmedServerURL, settings: settings))
|
_activeServerURL = State(initialValue: url)
|
||||||
|
_session = State(initialValue: Self.makeSession(url, settings: settings))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func makeSession(_ url: String, settings: SettingsStore) -> VoiceSession {
|
private static func makeSession(_ url: String, settings: SettingsStore) -> VoiceSession {
|
||||||
guard case .success(let config) = ServerConfig.parse(url) else {
|
guard case .success(let config) = ServerConfig.parse(url) else {
|
||||||
preconditionFailure("SettingsStore must supply a validated server URL")
|
preconditionFailure("Stored server URL failed validation; init sanitizes to a valid value.")
|
||||||
}
|
}
|
||||||
let session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
|
let session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -37,14 +38,14 @@ struct ConversationView: View {
|
|||||||
ConversationContent(settings: settings, session: session)
|
ConversationContent(settings: settings, session: session)
|
||||||
.toolbar { settingsLink }
|
.toolbar { settingsLink }
|
||||||
}
|
}
|
||||||
.tabItem { Label("Talk", systemImage: "waveform") }
|
.tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") }
|
||||||
.tag(0)
|
.tag(0)
|
||||||
|
|
||||||
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
|
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
|
||||||
.tag(1)
|
.tag(1)
|
||||||
}
|
}
|
||||||
.id(sessionID)
|
.id(sessionID)
|
||||||
.tint(HopeStyle.tint)
|
.tint(Nearlight.rosewood)
|
||||||
.onChange(of: settings.textMode) { _, enabled in
|
.onChange(of: settings.textMode) { _, enabled in
|
||||||
Task { await session.applyTextMode(enabled) }
|
Task { await session.applyTextMode(enabled) }
|
||||||
}
|
}
|
||||||
@@ -107,6 +108,28 @@ private struct ActivityTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps session + conversation models onto Lightwell presentation phases.
|
||||||
|
@MainActor
|
||||||
|
private func lightwellPhase(session: VoiceSession, conversation: ConversationStateModel,
|
||||||
|
textMode: Bool) -> LightwellView.Phase {
|
||||||
|
if session.state.isFailed { return .failed }
|
||||||
|
switch session.state {
|
||||||
|
case .idle: return .idle
|
||||||
|
case .connecting: return .connecting
|
||||||
|
case .waitingAgent: return .waiting
|
||||||
|
case .tearingDown: return .tearingDown
|
||||||
|
case .failed: return .failed
|
||||||
|
case .ready:
|
||||||
|
if textMode { return .readyNoInput }
|
||||||
|
if session.isMuted { return .readyNoInput }
|
||||||
|
switch conversation.phase {
|
||||||
|
case .speaking: return .speaking(level: conversation.agentLevel)
|
||||||
|
case .thinking: return .thinking
|
||||||
|
default: return .listening(level: conversation.micLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct ConversationContent: View {
|
private struct ConversationContent: View {
|
||||||
@ObservedObject var settings: SettingsStore
|
@ObservedObject var settings: SettingsStore
|
||||||
@ObservedObject var session: VoiceSession
|
@ObservedObject var session: VoiceSession
|
||||||
@@ -116,6 +139,8 @@ private struct ConversationContent: View {
|
|||||||
@State private var sendError: String?
|
@State private var sendError: String?
|
||||||
@State private var isSending = false
|
@State private var isSending = false
|
||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
|
@Environment(\.dynamicTypeSize) private var typeSize
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
init(settings: SettingsStore, session: VoiceSession) {
|
init(settings: SettingsStore, session: VoiceSession) {
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -128,140 +153,341 @@ private struct ConversationContent: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if transcripts.events.isEmpty && !session.isReady {
|
if transcripts.events.isEmpty && !session.isReady {
|
||||||
welcome
|
welcome
|
||||||
|
if failureNotice != nil { failureBanner }
|
||||||
} else {
|
} else {
|
||||||
connectionStatus.padding(.horizontal, 20).padding(.vertical, 12)
|
statusStrip
|
||||||
|
if session.isReconnecting { reconnectBanner }
|
||||||
|
if failureNotice != nil { failureBanner }
|
||||||
TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll)
|
TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color(.systemBackground))
|
.background(Nearlight.ground)
|
||||||
.navigationTitle("Hope")
|
.navigationTitle("Hope")
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) { conversationControls }
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.safeAreaInset(edge: .bottom, spacing: 0) { dock }
|
||||||
.onChange(of: session.state) { _, state in
|
.onChange(of: session.state) { _, state in
|
||||||
if state == .ready && settings.textMode { composerFocused = true }
|
if state == .ready && settings.textMode { composerFocused = true }
|
||||||
}
|
}
|
||||||
|
.sensoryFeedback(.success, trigger: session.state == .ready ? true : false) { _, _ in true }
|
||||||
|
.sensoryFeedback(.error, trigger: session.state.isFailed) { _, _ in true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Welcome (pre-connection, no history)
|
||||||
|
|
||||||
private var welcome: some View {
|
private var welcome: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 28) {
|
VStack(alignment: .leading, spacing: Nearlight.s24) {
|
||||||
OrbView(state: orbState).padding(.top, 24).accessibilityHidden(true)
|
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode))
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
.padding(.top, Nearlight.s32)
|
||||||
Text("A little help.\nA good conversation.")
|
VStack(alignment: .leading, spacing: Nearlight.s12) {
|
||||||
.font(.largeTitle.weight(.semibold))
|
Text("A moment with Hope.")
|
||||||
|
.font(NearlightFont.welcome(typeSize))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Text("Talk things through, find something out, or get something done.")
|
Text("Speak or type to your own assistant.")
|
||||||
.font(.body).foregroundStyle(.secondary)
|
.font(NearlightFont.support(typeSize))
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
statusLine
|
||||||
Label("Think out loud", systemImage: "bubble.left.and.bubble.right")
|
|
||||||
Label("Find an answer", systemImage: "sparkle.magnifyingglass")
|
|
||||||
}
|
|
||||||
.font(.subheadline).foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 24).padding(.bottom, 24)
|
.padding(.horizontal, Nearlight.s20)
|
||||||
|
.padding(.bottom, Nearlight.s24)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var connectionStatus: some View {
|
/// Status text 24 below the supporting copy on welcome.
|
||||||
HStack(spacing: 12) {
|
private var statusLine: some View {
|
||||||
Image(systemName: statusSymbol).font(.title3)
|
Text(statusTitle)
|
||||||
.foregroundStyle(session.state.isFailed ? Color.red : HopeStyle.tint)
|
.font(NearlightFont.support(typeSize))
|
||||||
.frame(width: 32, height: 36).accessibilityHidden(true)
|
.foregroundStyle(Nearlight.quiet)
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
.padding(.top, Nearlight.s24)
|
||||||
Text(statusTitle).font(.headline)
|
.accessibilityIdentifier("talk.status")
|
||||||
Text(statusDetail).font(.subheadline).foregroundStyle(.secondary)
|
}
|
||||||
|
|
||||||
|
// MARK: Status strip (active conversation)
|
||||||
|
|
||||||
|
private var statusStrip: some View {
|
||||||
|
HStack(spacing: Nearlight.s12) {
|
||||||
|
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode), compact: true)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(statusTitle)
|
||||||
|
.font(NearlightFont.control(typeSize))
|
||||||
|
.foregroundStyle(Nearlight.ink)
|
||||||
|
.accessibilityIdentifier("talk.status")
|
||||||
|
if showStatusDetail {
|
||||||
|
Text(statusDetail)
|
||||||
|
.font(NearlightFont.support(typeSize))
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
if session.isReady && !settings.textMode && !session.isMuted {
|
|
||||||
ProgressView(value: min(conversation.micLevel * 2.5, 1))
|
|
||||||
.frame(width: 46).accessibilityLabel("Microphone level")
|
|
||||||
}
|
}
|
||||||
}
|
.padding(.horizontal, Nearlight.s20)
|
||||||
.accessibilityElement(children: .combine)
|
.padding(.vertical, Nearlight.s12)
|
||||||
|
.frame(minHeight: 60, alignment: .leading)
|
||||||
|
.background(Nearlight.ground)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var conversationControls: some View {
|
/// Subtitle omitted for ordinary listening/thinking/speaking per spec.
|
||||||
VStack(spacing: 12) {
|
private var showStatusDetail: Bool {
|
||||||
if let failure = failureMessage {
|
guard session.isReady else { return true }
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
if settings.textMode || session.isMuted { return true }
|
||||||
Label("Couldn’t connect to Hope", systemImage: "wifi.exclamationmark")
|
return false
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
Text(failure).font(.footnote).foregroundStyle(.secondary)
|
|
||||||
NavigationLink("Check connection settings") {
|
|
||||||
SettingsView(settings: settings, session: session)
|
|
||||||
}
|
}
|
||||||
.font(.subheadline).frame(minHeight: 44)
|
|
||||||
|
private var reconnectBanner: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Label("Reconnecting…", systemImage: "wifi.exclamationmark")
|
||||||
|
.font(NearlightFont.control(typeSize))
|
||||||
|
Text("Your conversation is still here.")
|
||||||
|
.font(NearlightFont.support(typeSize))
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
}
|
}
|
||||||
|
.padding(Nearlight.s12)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Nearlight.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||||
|
.padding(.horizontal, Nearlight.s20)
|
||||||
|
.accessibilityIdentifier("talk.connection.notice")
|
||||||
}
|
}
|
||||||
if session.isReconnecting {
|
|
||||||
Label("Reconnecting… Your conversation is still here.", systemImage: "wifi.exclamationmark")
|
/// Classified failure notice with actionable recovery.
|
||||||
.font(.footnote)
|
private var failureBanner: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Nearlight.s8) {
|
||||||
|
Label(failureTitle, systemImage: "exclamationmark.circle")
|
||||||
|
.font(NearlightFont.control(typeSize))
|
||||||
|
.foregroundStyle(Nearlight.alert)
|
||||||
|
Text(failureBody)
|
||||||
|
.font(NearlightFont.support(typeSize))
|
||||||
|
.foregroundStyle(Nearlight.ink)
|
||||||
|
if micDenied {
|
||||||
|
Button("Open iPhone Settings") {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
if let error = session.micError, !settings.textMode {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(error).font(.footnote)
|
|
||||||
Button("Switch to typing") { settings.textMode = true }.frame(minHeight: 44)
|
|
||||||
}
|
}
|
||||||
|
.font(NearlightFont.support(typeSize))
|
||||||
|
.frame(minHeight: 44)
|
||||||
|
.accessibilityIdentifier("talk.connection.openSystemSettings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Nearlight.s16)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Nearlight.errorWash)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||||
|
.padding(.horizontal, Nearlight.s20)
|
||||||
|
.accessibilityIdentifier("talk.connection.notice")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var micDenied: Bool {
|
||||||
|
(session.micError?.contains("Microphone") ?? false) && session.state.isFailed == false && session.state == .ready
|
||||||
|
}
|
||||||
|
|
||||||
|
private var failureTitle: String {
|
||||||
|
if session.micError != nil { return "Microphone unavailable" }
|
||||||
|
return session.everConnected ? "Connection interrupted" : "Couldn’t connect"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var failureBody: String {
|
||||||
|
if let micError = session.micError {
|
||||||
|
return micError
|
||||||
|
}
|
||||||
|
let raw = failureMessage ?? ""
|
||||||
|
if raw.lowercased().contains("agent") {
|
||||||
|
return "Check that Hope is running on your server."
|
||||||
|
}
|
||||||
|
if raw.lowercased().contains("timed out") || raw.lowercased().contains("connect") {
|
||||||
|
return "Check your Wi-Fi or VPN and make sure your server is running."
|
||||||
|
}
|
||||||
|
return raw.isEmpty ? "Check your Wi-Fi or VPN and make sure your server is running." : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Dock
|
||||||
|
|
||||||
|
private var dock: some View {
|
||||||
|
VStack(spacing: Nearlight.s12) {
|
||||||
if let sendError {
|
if let sendError {
|
||||||
Text(sendError).font(.footnote).foregroundStyle(.red)
|
Label(sendError, systemImage: "exclamationmark.circle")
|
||||||
|
.font(NearlightFont.support(typeSize))
|
||||||
|
.foregroundStyle(Nearlight.alert)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.accessibilityIdentifier("talk.composer.error")
|
||||||
}
|
}
|
||||||
|
modeSelector
|
||||||
|
if session.isReady {
|
||||||
|
if settings.textMode { typeControls } else { voiceControls }
|
||||||
|
} else {
|
||||||
|
startControls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Nearlight.s20)
|
||||||
|
.padding(.top, Nearlight.s12)
|
||||||
|
.padding(.bottom, Nearlight.s12)
|
||||||
|
.background(
|
||||||
|
// Dock: Surface, 16-point top corners, decorative top separator.
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Rectangle().fill(Nearlight.separator).frame(height: 1 / UIScreen.main.scale)
|
||||||
|
Nearlight.surface
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(.container, edges: .bottom)
|
||||||
|
.clipShape(UnevenRoundedRectangle(topLeadingRadius: Nearlight.radiusDock, topTrailingRadius: Nearlight.radiusDock))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var modeSelector: some View {
|
||||||
|
if typeSize.isAccessibilitySize {
|
||||||
|
// Two stacked 52-point selection buttons at accessibility sizes.
|
||||||
|
VStack(spacing: Nearlight.s8) {
|
||||||
|
modeButton("Voice", systemImage: "mic", selected: !settings.textMode, id: "talk.mode.voice") {
|
||||||
|
settings.textMode = false
|
||||||
|
}
|
||||||
|
modeButton("Type", systemImage: "keyboard", selected: settings.textMode, id: "talk.mode.type") {
|
||||||
|
settings.textMode = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isBusy)
|
||||||
|
} else {
|
||||||
Picker("Conversation mode", selection: $settings.textMode) {
|
Picker("Conversation mode", selection: $settings.textMode) {
|
||||||
Label("Voice", systemImage: "mic").tag(false)
|
Label("Voice", systemImage: "mic").tag(false)
|
||||||
Label("Type", systemImage: "keyboard").tag(true)
|
Label("Type", systemImage: "keyboard").tag(true)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented).disabled(isBusy)
|
.pickerStyle(.segmented)
|
||||||
if session.isReady {
|
.disabled(isBusy)
|
||||||
if settings.textMode { textComposer }
|
|
||||||
else {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button {
|
|
||||||
Task { await session.setMuted(!session.isMuted) }
|
|
||||||
} label: {
|
|
||||||
Label(session.isMuted ? "Unmute" : "Mute", systemImage: session.isMuted ? "mic.slash.fill" : "mic.fill")
|
|
||||||
.frame(maxWidth: .infinity, minHeight: 44)
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
endButton
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
private func modeButton(_ title: String, systemImage: String, selected: Bool,
|
||||||
|
id: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: Nearlight.s8) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
Text(title)
|
||||||
|
if selected { Spacer(); Image(systemName: "checkmark") }
|
||||||
|
}
|
||||||
|
.font(NearlightFont.control(typeSize))
|
||||||
|
.foregroundStyle(selected ? Nearlight.rosewood : Nearlight.ink)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 52)
|
||||||
|
.background(selected ? Nearlight.selectedWash : Nearlight.surface,
|
||||||
|
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||||||
|
.strokeBorder(selected ? Nearlight.rosewood.opacity(0.4) : Nearlight.quiet, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(id)
|
||||||
|
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
private var startControls: some View {
|
||||||
|
VStack(spacing: Nearlight.s8) {
|
||||||
|
Text(settings.textMode ? "Your microphone stays off." : "Your microphone starts after you connect.")
|
||||||
|
.font(NearlightFont.support(typeSize))
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
Button {
|
Button {
|
||||||
sendError = nil
|
sendError = nil
|
||||||
Task { await session.connect() }
|
Task { await session.connect() }
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: Nearlight.s8) {
|
||||||
if isBusy { ProgressView().tint(.white) }
|
if isBusy {
|
||||||
else { Image(systemName: settings.textMode ? "bubble.left.fill" : "waveform") }
|
ProgressView().tint(Nearlight.onRosewood)
|
||||||
Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : settings.textMode ? "Start chatting" : "Let’s talk")
|
} else {
|
||||||
.font(.headline)
|
Image(systemName: settings.textMode ? "keyboard" : "mic")
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, minHeight: 48)
|
Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : "Start chatting")
|
||||||
|
.font(NearlightFont.control(typeSize))
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent).disabled(isBusy)
|
.frame(maxWidth: .infinity, minHeight: 52)
|
||||||
Text(settings.textMode ? "Your microphone stays off." : "Your microphone turns on when you connect.")
|
.background(isBusy ? Nearlight.rosewood.opacity(0.6) : Nearlight.rosewood,
|
||||||
.font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||||
|
.foregroundStyle(Nearlight.onRosewood)
|
||||||
}
|
}
|
||||||
|
.disabled(isBusy)
|
||||||
|
.accessibilityIdentifier("start.chatting")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20).padding(.top, 12).padding(.bottom, 16)
|
|
||||||
.background(.bar)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var textComposer: some View {
|
private var voiceControls: some View {
|
||||||
VStack(spacing: 6) {
|
Group {
|
||||||
HStack(alignment: .bottom, spacing: 8) {
|
if typeSize.isAccessibilitySize {
|
||||||
|
VStack(spacing: Nearlight.s12) {
|
||||||
|
HStack(spacing: Nearlight.s12) {
|
||||||
|
muteButton
|
||||||
|
micMeter
|
||||||
|
}
|
||||||
|
endButton
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: Nearlight.s12) {
|
||||||
|
muteButton
|
||||||
|
micMeter
|
||||||
|
endButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var muteButton: some View {
|
||||||
|
Button {
|
||||||
|
Task { await session.setMuted(!session.isMuted) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: session.isMuted ? "mic.slash" : "mic")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.frame(width: 52, height: 52)
|
||||||
|
.background(session.isMuted ? Nearlight.selectedWash : Nearlight.surface,
|
||||||
|
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||||||
|
.strokeBorder(Nearlight.quiet, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.foregroundStyle(session.isMuted ? Nearlight.rosewood : Nearlight.ink)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(session.isMuted ? "Unmute microphone" : "Mute microphone")
|
||||||
|
.accessibilityIdentifier("talk.microphone.toggle")
|
||||||
|
.sensoryFeedback(.selection, trigger: session.isMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Five-bar capsule meter, 64×24, leading aligned.
|
||||||
|
private var micMeter: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
let level = session.isMuted || settings.textMode ? 0.0 : conversation.micLevel
|
||||||
|
let filled = Int(ceil(level * 5))
|
||||||
|
ForEach(0..<5, id: \.self) { index in
|
||||||
|
Capsule()
|
||||||
|
.fill(index < filled ? Nearlight.rosewood : Color.clear)
|
||||||
|
.overlay(index < filled ? nil : Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1))
|
||||||
|
.frame(width: 6, height: [6.0, 12.0, 24.0, 12.0, 6.0][index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 64, height: 24, alignment: .leading)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel("Microphone level")
|
||||||
|
.accessibilityValue(session.isMuted || settings.textMode ? "Off" : "\(Int(round(conversation.micLevel * 100)))%")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var typeControls: some View {
|
||||||
|
VStack(spacing: Nearlight.s12) {
|
||||||
|
HStack(alignment: .bottom, spacing: Nearlight.s8) {
|
||||||
TextField("Message Hope", text: $composerText, axis: .vertical)
|
TextField("Message Hope", text: $composerText, axis: .vertical)
|
||||||
.lineLimit(1...5).padding(12)
|
.lineLimit(1...5)
|
||||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
.padding(Nearlight.s12)
|
||||||
.focused($composerFocused).disabled(isSending)
|
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||||||
|
.strokeBorder(Nearlight.quiet, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.focused($composerFocused)
|
||||||
|
.disabled(isSending)
|
||||||
.accessibilityLabel("Message Hope")
|
.accessibilityLabel("Message Hope")
|
||||||
Button(action: sendComposer) {
|
Button(action: sendComposer) {
|
||||||
if isSending { ProgressView().frame(width: 44, height: 44) }
|
if isSending {
|
||||||
else { Image(systemName: "arrow.up.circle.fill").font(.title).frame(width: 44, height: 44) }
|
ProgressView().frame(width: 52, height: 52)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.up")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.frame(width: 52, height: 52)
|
||||||
|
.background(Nearlight.rosewood, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||||
|
.foregroundStyle(Nearlight.onRosewood)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.disabled(composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending || session.isReconnecting)
|
.disabled(composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending || session.isReconnecting)
|
||||||
.accessibilityLabel("Send message")
|
.accessibilityLabel("Send message")
|
||||||
@@ -275,12 +501,22 @@ private struct ConversationContent: View {
|
|||||||
composerFocused = false
|
composerFocused = false
|
||||||
Task { await session.disconnect() }
|
Task { await session.disconnect() }
|
||||||
} label: {
|
} label: {
|
||||||
Label("End conversation", systemImage: "phone.down.fill")
|
Label("End conversation", systemImage: "stop.fill")
|
||||||
.font(.subheadline.weight(.medium)).frame(minHeight: 44)
|
.font(NearlightFont.control(typeSize))
|
||||||
|
.lineLimit(2...3)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 52)
|
||||||
|
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||||||
|
.strokeBorder(Nearlight.alert, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.foregroundStyle(Nearlight.alert)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.accessibilityIdentifier("end.conversation")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Status copy
|
||||||
|
|
||||||
private var isBusy: Bool {
|
private var isBusy: Bool {
|
||||||
session.state == .connecting || session.state == .waitingAgent || session.state == .tearingDown
|
session.state == .connecting || session.state == .waitingAgent || session.state == .tearingDown
|
||||||
}
|
}
|
||||||
@@ -288,36 +524,36 @@ private struct ConversationContent: View {
|
|||||||
if case .failed(let message) = session.state { return message }
|
if case .failed(let message) = session.state { return message }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
private var failureNotice: String? { failureMessage }
|
||||||
|
|
||||||
private var statusTitle: String {
|
private var statusTitle: String {
|
||||||
switch session.state {
|
switch session.state {
|
||||||
case .idle: return "Ready when you are"
|
case .idle: return "Ready when you are"
|
||||||
case .connecting: return "Connecting…"
|
case .connecting: return "Connecting to your server…"
|
||||||
case .waitingAgent: return "Getting Hope ready…"
|
case .waitingAgent: return "Waiting for Hope…"
|
||||||
case .tearingDown: return "Ending conversation…"
|
case .tearingDown: return "Ending conversation…"
|
||||||
case .failed: return "Connection unavailable"
|
case .failed: return "Connection interrupted"
|
||||||
case .ready:
|
case .ready:
|
||||||
if settings.textMode { return "You’re chatting with Hope" }
|
if settings.textMode { return "Ready for your message" }
|
||||||
if session.isMuted { return "Microphone muted" }
|
if session.isMuted { return "Microphone off" }
|
||||||
switch conversation.phase {
|
switch conversation.phase {
|
||||||
case .thinking: return "Thinking it through…"
|
case .listening: return "Listening"
|
||||||
|
case .thinking: return "Thinking…"
|
||||||
case .speaking: return "Hope is speaking"
|
case .speaking: return "Hope is speaking"
|
||||||
default: return "I’m listening"
|
default: return "Listening"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusDetail: String {
|
private var statusDetail: String {
|
||||||
if !session.isReady { return "Your recent conversation is below." }
|
if session.state == .waitingAgent { return "The server is connected. Hope has not joined yet." }
|
||||||
|
if session.state == .connecting { return "Reaching your server." }
|
||||||
|
if !session.isReady { return "" }
|
||||||
if settings.textMode { return "Microphone off · Type whenever you’re ready" }
|
if settings.textMode { return "Microphone off · Type whenever you’re ready" }
|
||||||
if session.isMuted { return "You can still hear Hope." }
|
if session.isMuted { return "You can still hear Hope." }
|
||||||
return conversation.phase == .speaking ? "You can interrupt at any time." : "Speak naturally. Take your time."
|
return ""
|
||||||
}
|
|
||||||
private var statusSymbol: String {
|
|
||||||
session.state.isFailed ? "wifi.exclamationmark" : !session.isReady ? "bubble.left.and.bubble.right" : settings.textMode ? "keyboard" : session.isMuted ? "mic.slash" : "waveform"
|
|
||||||
}
|
|
||||||
private var orbState: OrbView.OrbState {
|
|
||||||
if session.state.isFailed { return .failed }
|
|
||||||
return isBusy ? .connecting : .idle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendComposer() {
|
private func sendComposer() {
|
||||||
let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !text.isEmpty, !isSending else { return }
|
guard !text.isEmpty, !isSending else { return }
|
||||||
@@ -335,4 +571,15 @@ private struct ConversationContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension VoiceSession {
|
||||||
|
/// Whether any connection reached ready during this session lifetime.
|
||||||
|
/// Used to title the failure notice per spec.
|
||||||
|
var everConnected: Bool {
|
||||||
|
// Sessions are recreated on replacement; reaching ready is recorded
|
||||||
|
// indirectly through transcript presence. Conservative: treat any
|
||||||
|
// prior transcript activity as evidence of a completed connection.
|
||||||
|
!transcriptHandler.events.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview { ConversationView(settings: SettingsStore()) }
|
#Preview { ConversationView(settings: SettingsStore()) }
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// The Nearlight signature element: an upright, softly lit aperture.
|
||||||
|
///
|
||||||
|
/// Two capsules in a ZStack — no assets, shaders, blur, or particles. The
|
||||||
|
/// inner capsule carries a vertical Rosewood gradient; its geometry encodes
|
||||||
|
/// conversation state and audio level. Noninteractive and accessibility
|
||||||
|
/// hidden: the companion status text carries meaning.
|
||||||
|
struct LightwellView: View {
|
||||||
|
/// Lightwell presentation state, mapped from session/conversation models.
|
||||||
|
enum Phase: Equatable {
|
||||||
|
case idle
|
||||||
|
case connecting
|
||||||
|
case waiting
|
||||||
|
case listening(level: Double)
|
||||||
|
case thinking
|
||||||
|
case speaking(level: Double)
|
||||||
|
case tearingDown
|
||||||
|
case failed
|
||||||
|
/// Input never drives the light (muted or Type mode).
|
||||||
|
case readyNoInput
|
||||||
|
}
|
||||||
|
|
||||||
|
let phase: Phase
|
||||||
|
/// Compact (24×36) in the conversation status strip; full (64×96) on welcome.
|
||||||
|
var compact: Bool = false
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
/// Pending-state pulse driver, 0 → 1 → 0 over the 2.4s cycle.
|
||||||
|
@State private var pulse: Double = 0
|
||||||
|
|
||||||
|
/// Frame dimensions never animate; inner dimensions multiply by 0.375
|
||||||
|
/// in the compact frame.
|
||||||
|
private var frameSize: CGSize {
|
||||||
|
compact ? CGSize(width: 24, height: 36) : CGSize(width: 64, height: 96)
|
||||||
|
}
|
||||||
|
private var baseInner: CGSize {
|
||||||
|
let base = CGSize(width: 16, height: 40)
|
||||||
|
return compact
|
||||||
|
? CGSize(width: base.width * 0.375, height: base.height * 0.375)
|
||||||
|
: base
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let inner = innerSize
|
||||||
|
ZStack {
|
||||||
|
Capsule()
|
||||||
|
.fill(Nearlight.surface)
|
||||||
|
Capsule()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: Nearlight.rosewood.opacity(0.35), location: 0),
|
||||||
|
.init(color: Nearlight.rosewood.opacity(1), location: 0.5),
|
||||||
|
.init(color: Nearlight.rosewood.opacity(0.55), location: 1),
|
||||||
|
],
|
||||||
|
startPoint: .top, endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: inner.width, height: inner.height)
|
||||||
|
.opacity(overallOpacity)
|
||||||
|
.animation(reduceMotion ? nil : Nearlight.quick, value: overallOpacity)
|
||||||
|
.animation(reduceMotion ? nil : Nearlight.settle, value: innerSize)
|
||||||
|
}
|
||||||
|
.frame(width: frameSize.width, height: frameSize.height)
|
||||||
|
.overlay(
|
||||||
|
Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
.task(id: pulseActive) {
|
||||||
|
guard pulseActive else {
|
||||||
|
pulse = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
while !Task.isCancelled {
|
||||||
|
withAnimation(reduceMotion ? nil : Nearlight.cycle) { pulse = pulse == 0 ? 1 : 0 }
|
||||||
|
try? await Task.sleep(for: .seconds(2.4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the pulse loop should run for this phase.
|
||||||
|
private var pulseActive: Bool {
|
||||||
|
switch phase {
|
||||||
|
case .connecting, .waiting, .thinking: return !reduceMotion
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inner capsule geometry per spec table. `a` is the smoothed level.
|
||||||
|
private var innerSize: CGSize {
|
||||||
|
let base = baseInner
|
||||||
|
switch phase {
|
||||||
|
case .listening(let a):
|
||||||
|
let level = min(max(a, 0), 1)
|
||||||
|
return CGSize(width: base.width + (compact ? 3 : 8) * level,
|
||||||
|
height: base.height + (compact ? 6 : 16) * level)
|
||||||
|
case .speaking(let a):
|
||||||
|
let level = min(max(a, 0), 1)
|
||||||
|
return CGSize(width: base.width + (compact ? 4.5 : 12) * level,
|
||||||
|
height: base.height + (compact ? 9 : 24) * level)
|
||||||
|
case .waiting, .thinking:
|
||||||
|
// Pulse drives opacity; height steps up one band.
|
||||||
|
return CGSize(width: base.width, height: base.height + (compact ? 3 : 8))
|
||||||
|
case .connecting, .idle, .failed, .tearingDown, .readyNoInput:
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var overallOpacity: Double {
|
||||||
|
switch phase {
|
||||||
|
case .connecting, .waiting:
|
||||||
|
return 0.45 + 0.45 * pulse
|
||||||
|
case .thinking:
|
||||||
|
return 0.65 + 0.35 * pulse
|
||||||
|
case .idle, .failed, .tearingDown:
|
||||||
|
return 0.45
|
||||||
|
case .listening, .speaking, .readyNoInput:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-37
@@ -1,27 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct OrbView: View {
|
/// Nearlight transcript: a legible shared record. Full-width rows, no
|
||||||
enum OrbState { case idle, connecting, listening, speaking, thinking, failed }
|
/// bubbles; metadata row per event; "Live" placeholder on partials.
|
||||||
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 {
|
struct TranscriptListView: View {
|
||||||
let events: [TranscriptEvent]
|
let events: [TranscriptEvent]
|
||||||
var onClear: () -> Void
|
var onClear: () -> Void
|
||||||
@State private var confirmClear = false
|
@State private var confirmClear = false
|
||||||
@State private var followingLatest = true
|
@State private var followingLatest = true
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@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*"#
|
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*"#
|
||||||
|
|
||||||
@@ -29,7 +16,9 @@ struct TranscriptListView: View {
|
|||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Conversation").font(.subheadline.weight(.semibold))
|
Text("Conversation")
|
||||||
|
.font(NearlightFont.support(typeSize).weight(.semibold))
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
Spacer()
|
Spacer()
|
||||||
Menu {
|
Menu {
|
||||||
Button("Clear conversation", systemImage: "trash", role: .destructive) { confirmClear = true }
|
Button("Clear conversation", systemImage: "trash", role: .destructive) { confirmClear = true }
|
||||||
@@ -39,20 +28,23 @@ struct TranscriptListView: View {
|
|||||||
.accessibilityLabel("Conversation options")
|
.accessibilityLabel("Conversation options")
|
||||||
.disabled(events.isEmpty)
|
.disabled(events.isEmpty)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, Nearlight.s20)
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 20) {
|
LazyVStack(alignment: .leading, spacing: Nearlight.s24) {
|
||||||
if events.isEmpty {
|
if events.isEmpty {
|
||||||
Text("Your conversation will appear here.")
|
Text("Your conversation will appear here.")
|
||||||
.font(.body).foregroundStyle(.secondary)
|
.font(.body)
|
||||||
.padding(.vertical, 32)
|
.foregroundStyle(Nearlight.quiet)
|
||||||
|
.padding(.top, Nearlight.s24)
|
||||||
}
|
}
|
||||||
ForEach(events) { event in transcriptRow(event) }
|
ForEach(events) { event in transcriptRow(event) }
|
||||||
Color.clear.frame(height: 1).id("latest")
|
Color.clear.frame(height: 1).id("latest")
|
||||||
.onAppear { followingLatest = true }
|
.onAppear { followingLatest = true }
|
||||||
.onDisappear { followingLatest = false }
|
.onDisappear { followingLatest = false }
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20).padding(.bottom, 20)
|
.padding(.horizontal, Nearlight.s20)
|
||||||
|
.padding(.top, Nearlight.s16)
|
||||||
|
.padding(.bottom, Nearlight.s20)
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.defaultScrollAnchor(.bottom)
|
.defaultScrollAnchor(.bottom)
|
||||||
@@ -64,13 +56,15 @@ struct TranscriptListView: View {
|
|||||||
}
|
}
|
||||||
if !followingLatest && !events.isEmpty {
|
if !followingLatest && !events.isEmpty {
|
||||||
Button {
|
Button {
|
||||||
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) {
|
withAnimation(reduceMotion ? nil : Nearlight.standard) {
|
||||||
proxy.scrollTo("latest", anchor: .bottom)
|
proxy.scrollTo("latest", anchor: .bottom)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Latest messages", systemImage: "arrow.down").font(.subheadline)
|
Label("Latest messages", systemImage: "arrow.down")
|
||||||
|
.font(NearlightFont.support(typeSize))
|
||||||
.frame(minHeight: 44)
|
.frame(minHeight: 44)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("talk.latest")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,28 +80,52 @@ struct TranscriptListView: View {
|
|||||||
let isUser = event.role == .user
|
let isUser = event.role == .user
|
||||||
let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression)
|
let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression)
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces)
|
||||||
return HStack(alignment: .top, spacing: 0) {
|
return VStack(alignment: .leading, spacing: Nearlight.s8) {
|
||||||
if isUser { Spacer(minLength: 32) }
|
// Metadata row: speaker, timestamp trailing. Stacks at
|
||||||
VStack(alignment: .leading, spacing: 7) {
|
// accessibility sizes per spec.
|
||||||
HStack(spacing: 8) {
|
Group {
|
||||||
Text(isUser ? "You" : "Hope").font(.subheadline.weight(.semibold))
|
if typeSize.isAccessibilitySize {
|
||||||
Text(event.timestamp, style: .time).font(.caption).foregroundStyle(.secondary)
|
VStack(alignment: .leading, spacing: Nearlight.s4) {
|
||||||
if !event.isFinal {
|
speakerLabel(isUser)
|
||||||
Text("Live").font(.caption).foregroundStyle(.secondary)
|
timeLabel(event)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: Nearlight.s8) {
|
||||||
|
speakerLabel(isUser)
|
||||||
|
timeLabel(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(text).font(.body).textSelection(.enabled)
|
}
|
||||||
|
Text(text)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Nearlight.ink)
|
||||||
|
.textSelection(.enabled)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
.padding(isUser ? 14 : 0)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(isUser ? Color(.secondarySystemBackground) : .clear, in: RoundedRectangle(cornerRadius: 16))
|
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
|
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
|
||||||
}
|
}
|
||||||
if !isUser { Spacer(minLength: 16) }
|
|
||||||
}
|
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
.id(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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ struct SettingsView: View {
|
|||||||
@State private var urlText: String = ""
|
@State private var urlText: String = ""
|
||||||
@State private var urlError: String?
|
@State private var urlError: String?
|
||||||
@State private var showVoicePicker = false
|
@State private var showVoicePicker = false
|
||||||
|
@State private var urlJustSaved = false
|
||||||
@State private var showModelPicker = false
|
@State private var showModelPicker = false
|
||||||
|
|
||||||
init(settings: SettingsStore, session: VoiceSession) {
|
init(settings: SettingsStore, session: VoiceSession) {
|
||||||
@@ -55,7 +56,7 @@ struct SettingsView: View {
|
|||||||
} else {
|
} else {
|
||||||
LabeledContent("Model", value: settings.modelID ?? "Server default")
|
LabeledContent("Model", value: settings.modelID ?? "Server default")
|
||||||
Text("Connect from Talk to see the models available on your server.")
|
Text("Connect from Talk to see the models available on your server.")
|
||||||
.font(.footnote).foregroundStyle(.secondary)
|
.font(.footnote).foregroundStyle(Nearlight.quiet)
|
||||||
}
|
}
|
||||||
} header: { Text("Intelligence") }
|
} header: { Text("Intelligence") }
|
||||||
Section {
|
Section {
|
||||||
@@ -65,10 +66,24 @@ struct SettingsView: View {
|
|||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.accessibilityLabel("Server URL")
|
.accessibilityLabel("Server URL")
|
||||||
.onAppear { urlText = settings.serverURL }
|
.onAppear { urlText = settings.serverURL }
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
if urlJustSaved {
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
if let urlError {
|
if let urlError {
|
||||||
Text(urlError)
|
Label(urlError, systemImage: "exclamationmark.circle")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(Nearlight.alert)
|
||||||
|
.accessibilityIdentifier("settings.server.error")
|
||||||
|
} else if urlJustSaved {
|
||||||
|
Text("Server address saved.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
|
.accessibilityIdentifier("settings.server.saved")
|
||||||
}
|
}
|
||||||
Button("Save server address") { applyServerURL() }
|
Button("Save server address") { applyServerURL() }
|
||||||
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
|
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
|
||||||
@@ -77,7 +92,7 @@ struct SettingsView: View {
|
|||||||
? "Waiting for failure cleanup to finish…"
|
? "Waiting for failure cleanup to finish…"
|
||||||
: "End the conversation before changing servers.")
|
: "End the conversation before changing servers.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Nearlight.quiet)
|
||||||
}
|
}
|
||||||
NavigationLink("Connection test") {
|
NavigationLink("Connection test") {
|
||||||
ConnectionTestView(settings: settings)
|
ConnectionTestView(settings: settings)
|
||||||
@@ -90,6 +105,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.tint(Nearlight.quiet)
|
||||||
.sheet(isPresented: $showVoicePicker) {
|
.sheet(isPresented: $showVoicePicker) {
|
||||||
VoicePickerView(settings: settings, session: session)
|
VoicePickerView(settings: settings, session: session)
|
||||||
}
|
}
|
||||||
@@ -103,6 +119,7 @@ struct SettingsView: View {
|
|||||||
/// left untouched so the active session keeps using its original URL.
|
/// left untouched so the active session keeps using its original URL.
|
||||||
/// Guarded: only applies when the session is idle (no active call).
|
/// Guarded: only applies when the session is idle (no active call).
|
||||||
private func applyServerURL() {
|
private func applyServerURL() {
|
||||||
|
urlJustSaved = false
|
||||||
guard canApplyServerURL else {
|
guard canApplyServerURL else {
|
||||||
urlError = "End the conversation before changing servers."
|
urlError = "End the conversation before changing servers."
|
||||||
return
|
return
|
||||||
@@ -117,6 +134,7 @@ struct SettingsView: View {
|
|||||||
settings.serverURL = trimmed
|
settings.serverURL = trimmed
|
||||||
urlText = trimmed
|
urlText = trimmed
|
||||||
urlError = nil
|
urlError = nil
|
||||||
|
urlJustSaved = true
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
urlError = error.localizedDescription
|
urlError = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +1,240 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Nearlight Activity: a working log. Plain rows on Ground, no cards;
|
||||||
|
/// live updates land in place without reordering while reading.
|
||||||
struct TaskPanelView: View {
|
struct TaskPanelView: View {
|
||||||
@ObservedObject var taskStore: TaskStore
|
@ObservedObject var taskStore: TaskStore
|
||||||
|
@Environment(\.dynamicTypeSize) private var typeSize
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
Group {
|
||||||
LazyVStack(alignment: .leading, spacing: 20) {
|
|
||||||
if taskStore.sortedTasks.isEmpty {
|
if taskStore.sortedTasks.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
Text(taskStore.activeCount > 0 ? "Hope is working on \(taskStore.activeCount) task\(taskStore.activeCount == 1 ? "" : "s"). You can keep talking." : "Results from the things you’ve asked Hope to work on.")
|
taskList
|
||||||
.font(.subheadline).foregroundStyle(.secondary)
|
|
||||||
ForEach(taskStore.sortedTasks) { task in
|
|
||||||
TaskCardView(task: task)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.navigationTitle("Activity")
|
.navigationTitle("Activity")
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Nearlight.ground)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var taskList: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(taskStore.sortedTasks) { task in
|
||||||
|
NavigationLink {
|
||||||
|
TaskDetailView(task: task)
|
||||||
|
} label: {
|
||||||
|
TaskRowView(task: task)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Divider()
|
||||||
|
.overlay(Nearlight.separator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Nearlight.s20)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
ContentUnavailableView {
|
VStack(alignment: .leading, spacing: Nearlight.s8) {
|
||||||
Label("A little help, in the background", systemImage: "checklist")
|
Image(systemName: "checklist")
|
||||||
} description: {
|
.font(.system(size: 28))
|
||||||
Text("Ask Hope to research a topic or compare your options. Follow the progress and read the results here.")
|
.foregroundStyle(Nearlight.quiet)
|
||||||
|
Text("No tasks yet")
|
||||||
|
.font(NearlightFont.heading(typeSize))
|
||||||
|
Text("Tasks from your conversations will appear here.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
}
|
}
|
||||||
.padding(.top, 48)
|
.padding(.top, Nearlight.s32)
|
||||||
|
.padding(.horizontal, Nearlight.s20)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.accessibilityIdentifier("activity.empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TaskCardView: View {
|
/// One activity row: status symbol, title, current step/status, chevron.
|
||||||
|
private struct TaskRowView: View {
|
||||||
let task: TaskItem
|
let task: TaskItem
|
||||||
@State private var showSteps = false
|
@Environment(\.dynamicTypeSize) private var typeSize
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
HStack(spacing: Nearlight.s12) {
|
||||||
HStack {
|
Image(systemName: statusSymbol)
|
||||||
Label(statusTitle, systemImage: statusSymbol)
|
.font(.system(size: 20))
|
||||||
.font(.subheadline.weight(.medium))
|
.foregroundStyle(statusColor)
|
||||||
.foregroundStyle(dotColor)
|
.accessibilityHidden(true)
|
||||||
Spacer()
|
VStack(alignment: .leading, spacing: Nearlight.s4) {
|
||||||
Text(task.createdAt, style: .time).font(.caption).foregroundStyle(.secondary)
|
Text(statusTitle)
|
||||||
|
.font(NearlightFont.support(typeSize))
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Text(task.description)
|
||||||
|
.font(NearlightFont.control(typeSize))
|
||||||
|
.foregroundStyle(Nearlight.ink)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if let latest = latestStep {
|
||||||
|
Text(latest)
|
||||||
|
.font(NearlightFont.support(typeSize))
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
Text(task.description).font(.headline).fixedSize(horizontal: false, vertical: true)
|
Text(task.createdAt, style: .time)
|
||||||
if !task.steps.isEmpty {
|
.font(NearlightFont.metadata(typeSize))
|
||||||
DisclosureGroup("\(task.steps.count) progress updates", isExpanded: $showSteps) {
|
.foregroundStyle(Nearlight.quiet)
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
.monospacedDigit()
|
||||||
ForEach(task.steps) { step in
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(stepLabel(step.role))
|
|
||||||
.font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
|
||||||
Text(step.content ?? "")
|
|
||||||
.font(.subheadline).textSelection(.enabled)
|
|
||||||
}
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
}.padding(.top, 8)
|
.padding(.vertical, Nearlight.s16)
|
||||||
}
|
.contentShape(Rectangle())
|
||||||
.font(.subheadline)
|
|
||||||
}
|
|
||||||
if let result = task.result {
|
|
||||||
block(result, color: .green, background: Color.green.opacity(0.12))
|
|
||||||
}
|
|
||||||
if let error = task.error {
|
|
||||||
block(error, color: .red, background: Color.red.opacity(0.12))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusTitle: String {
|
private var statusTitle: String {
|
||||||
switch task.status {
|
switch task.status {
|
||||||
case .pending: "Queued"
|
case .pending: return "Queued"
|
||||||
case .running: "In progress"
|
case .running: return "In progress"
|
||||||
case .completed: "Complete"
|
case .completed: return "Complete"
|
||||||
case .failed: "Needs attention"
|
case .failed: return "Needs attention"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var latestStep: String? {
|
||||||
|
if let error = task.error { return error }
|
||||||
|
if let result = task.result { return result }
|
||||||
|
return task.steps.last?.content
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusSymbol: String {
|
||||||
|
switch task.status {
|
||||||
|
case .pending: return "circle.dotted"
|
||||||
|
case .running: return "arrow.triangle.2.circlepath"
|
||||||
|
case .completed: return "checkmark.circle"
|
||||||
|
case .failed: return "exclamationmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch task.status {
|
||||||
|
case .pending: return Nearlight.quiet
|
||||||
|
case .running: return Nearlight.rosewood
|
||||||
|
case .completed: return Nearlight.quiet
|
||||||
|
case .failed: return Nearlight.alert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native push detail for one task: title, status, steps, result/error.
|
||||||
|
struct TaskDetailView: View {
|
||||||
|
let task: TaskItem
|
||||||
|
@Environment(\.dynamicTypeSize) private var typeSize
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: Nearlight.s24) {
|
||||||
|
VStack(alignment: .leading, spacing: Nearlight.s12) {
|
||||||
|
Text(task.description)
|
||||||
|
.font(NearlightFont.heading(typeSize))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Label(statusTitle, systemImage: statusSymbol)
|
||||||
|
.font(NearlightFont.support(typeSize))
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
Text(task.createdAt, style: .time)
|
||||||
|
.font(NearlightFont.metadata(typeSize))
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("Steps")
|
||||||
|
.font(NearlightFont.heading(typeSize))
|
||||||
|
.padding(.bottom, Nearlight.s8)
|
||||||
|
if task.steps.isEmpty {
|
||||||
|
Text("No steps reported yet.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
|
} else {
|
||||||
|
ForEach(task.steps) { step in
|
||||||
|
VStack(alignment: .leading, spacing: Nearlight.s4) {
|
||||||
|
if let role = step.role {
|
||||||
|
Text(roleLabel(role))
|
||||||
|
.font(NearlightFont.support(typeSize).weight(.semibold))
|
||||||
|
.foregroundStyle(Nearlight.quiet)
|
||||||
|
}
|
||||||
|
Text(step.content ?? "")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Nearlight.ink)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.vertical, Nearlight.s16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let result = task.result {
|
||||||
|
Text(result)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Nearlight.ink)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
if let error = task.error {
|
||||||
|
Text(error)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Nearlight.alert)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Nearlight.s20)
|
||||||
|
.padding(.top, Nearlight.s24)
|
||||||
|
.padding(.bottom, Nearlight.s24)
|
||||||
|
}
|
||||||
|
.background(Nearlight.ground)
|
||||||
|
.navigationTitle("Task")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusTitle: String {
|
||||||
|
switch task.status {
|
||||||
|
case .pending: return "Queued"
|
||||||
|
case .running: return "In progress"
|
||||||
|
case .completed: return "Complete"
|
||||||
|
case .failed: return "Needs attention"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusSymbol: String {
|
private var statusSymbol: String {
|
||||||
switch task.status {
|
switch task.status {
|
||||||
case .pending: "clock"
|
case .pending: return "circle.dotted"
|
||||||
case .running: "arrow.triangle.2.circlepath"
|
case .running: return "arrow.triangle.2.circlepath"
|
||||||
case .completed: "checkmark.circle.fill"
|
case .completed: return "checkmark.circle"
|
||||||
case .failed: "exclamationmark.circle"
|
case .failed: return "exclamationmark.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dotColor: Color {
|
private var statusColor: Color {
|
||||||
switch task.status {
|
switch task.status {
|
||||||
case .pending: return .gray
|
case .pending: return Nearlight.quiet
|
||||||
case .running: return HopeStyle.tint
|
case .running: return Nearlight.rosewood
|
||||||
case .completed: return .green
|
case .completed: return Nearlight.quiet
|
||||||
case .failed: return .red
|
case .failed: return Nearlight.alert
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func block(_ text: String, color: Color, background: Color) -> some View {
|
private func roleLabel(_ role: String) -> String {
|
||||||
Text(text)
|
|
||||||
.font(.body)
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stepLabel(_ role: String?) -> String {
|
|
||||||
switch role {
|
switch role {
|
||||||
case "thinking": return "Thinking"
|
case "thinking": return "Thinking"
|
||||||
case "tool_call": return "Tool"
|
case "tool_call": return "Tool"
|
||||||
case "tool_result": return "Result"
|
default: return role
|
||||||
case "text": return "Note"
|
|
||||||
default: return role ?? "Step"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stepColor(_ role: String?) -> Color {
|
|
||||||
switch role {
|
|
||||||
case "thinking": return .blue
|
|
||||||
case "tool_call": return .indigo
|
|
||||||
case "tool_result": return .secondary
|
|
||||||
default: return .primary
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
TaskPanelView(taskStore: {
|
TaskPanelView(taskStore: TaskStore())
|
||||||
let store = TaskStore()
|
|
||||||
#if DEBUG
|
|
||||||
store.injectSampleEvents()
|
|
||||||
#endif
|
|
||||||
return store
|
|
||||||
}())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+10
-10
@@ -13,15 +13,15 @@ final class VoiceUITests: XCTestCase {
|
|||||||
|
|
||||||
func testWelcomeNavigationAndOfflineRecovery() {
|
func testWelcomeNavigationAndOfflineRecovery() {
|
||||||
app.launch()
|
app.launch()
|
||||||
XCTAssertTrue(app.buttons["Let’s talk"].waitForExistence(timeout: 10))
|
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10))
|
||||||
XCTAssertTrue(app.staticTexts["Your microphone turns on when you connect."].exists)
|
XCTAssertTrue(app.staticTexts["Your microphone starts after you connect."].exists)
|
||||||
app.buttons["Type"].tap()
|
app.buttons["Type"].tap()
|
||||||
XCTAssertTrue(app.buttons["Start chatting"].exists)
|
XCTAssertTrue(app.buttons["Start chatting"].exists)
|
||||||
XCTAssertTrue(app.staticTexts["Your microphone stays off."].exists)
|
XCTAssertTrue(app.staticTexts["Your microphone stays off."].exists)
|
||||||
app.buttons["Start chatting"].tap()
|
app.buttons["Start chatting"].tap()
|
||||||
XCTAssertTrue(app.buttons["Try again"].waitForExistence(timeout: 20))
|
XCTAssertTrue(app.buttons["Try again"].waitForExistence(timeout: 20))
|
||||||
XCTAssertTrue(app.staticTexts["Couldn’t connect to Hope"].exists)
|
XCTAssertTrue(app.staticTexts["Couldn’t connect"].exists)
|
||||||
app.buttons["Check connection settings"].tap()
|
app.buttons["Settings"].tap()
|
||||||
XCTAssertTrue(app.textFields["Server URL"].waitForExistence(timeout: 5))
|
XCTAssertTrue(app.textFields["Server URL"].waitForExistence(timeout: 5))
|
||||||
capture("Offline recovery")
|
capture("Offline recovery")
|
||||||
}
|
}
|
||||||
@@ -59,23 +59,23 @@ final class VoiceUITests: XCTestCase {
|
|||||||
app.buttons["Conversation options"].tap()
|
app.buttons["Conversation options"].tap()
|
||||||
app.buttons["Clear conversation"].tap()
|
app.buttons["Clear conversation"].tap()
|
||||||
app.buttons["Clear conversation"].tap()
|
app.buttons["Clear conversation"].tap()
|
||||||
XCTAssertTrue(app.buttons["Let’s talk"].exists)
|
XCTAssertTrue(app.buttons["Start chatting"].exists)
|
||||||
XCTAssertFalse(app.staticTexts["Help me plan a quiet weekend."].exists)
|
XCTAssertFalse(app.staticTexts["Help me plan a quiet weekend."].exists)
|
||||||
app.tabBars.buttons["Activity"].tap()
|
app.tabBars.buttons["Activity"].tap()
|
||||||
XCTAssertTrue(app.staticTexts["Needs attention"].waitForExistence(timeout: 5))
|
XCTAssertTrue(app.staticTexts["Needs attention"].waitForExistence(timeout: 5))
|
||||||
XCTAssertTrue(app.staticTexts["Search backend timed out after 30s"].exists)
|
XCTAssertTrue(app.staticTexts["Search backend timed out after 30s"].exists)
|
||||||
app.buttons["1 progress updates"].tap()
|
app.staticTexts["Search backend timed out after 30s"].tap()
|
||||||
XCTAssertTrue(app.staticTexts["Fetching benchmark articles…"].exists)
|
XCTAssertTrue(app.staticTexts["Fetching benchmark articles…"].waitForExistence(timeout: 5))
|
||||||
capture("Activity details")
|
capture("Activity details")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLargeTypeNavigation() {
|
func testLargeTypeNavigation() {
|
||||||
app.launchArguments += ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]
|
app.launchArguments += ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]
|
||||||
app.launch()
|
app.launch()
|
||||||
XCTAssertTrue(app.buttons["Let’s talk"].waitForExistence(timeout: 10))
|
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10))
|
||||||
XCTAssertTrue(app.buttons["Let’s talk"].isHittable)
|
XCTAssertTrue(app.buttons["Start chatting"].isHittable)
|
||||||
app.tabBars.buttons["Activity"].tap()
|
app.tabBars.buttons["Activity"].tap()
|
||||||
XCTAssertTrue(app.staticTexts["A little help, in the background"].waitForExistence(timeout: 5))
|
XCTAssertTrue(app.staticTexts["No tasks yet"].waitForExistence(timeout: 5))
|
||||||
app.buttons["Settings"].tap()
|
app.buttons["Settings"].tap()
|
||||||
XCTAssertTrue(app.navigationBars["Settings"].exists)
|
XCTAssertTrue(app.navigationBars["Settings"].exists)
|
||||||
capture("Accessibility text size")
|
capture("Accessibility text size")
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C71C48230C683D199D6F4B /* ControlChannel.swift */; };
|
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C71C48230C683D199D6F4B /* ControlChannel.swift */; };
|
||||||
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; };
|
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; };
|
||||||
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; };
|
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; };
|
||||||
3E01A0AF44FD3324FA72C43C /* OrbView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */; };
|
|
||||||
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; };
|
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; };
|
||||||
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C23305A6C55A83F8617D9D /* ConversationState.swift */; };
|
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C23305A6C55A83F8617D9D /* ConversationState.swift */; };
|
||||||
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; };
|
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; };
|
||||||
@@ -20,14 +19,17 @@
|
|||||||
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; };
|
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; };
|
||||||
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; };
|
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; };
|
||||||
943BCE971A01F220FAE9E250 /* TaskPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85BACA267ADD462306C9920 /* TaskPanelView.swift */; };
|
943BCE971A01F220FAE9E250 /* TaskPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85BACA267ADD462306C9920 /* TaskPanelView.swift */; };
|
||||||
|
9461C0886FA294FDD9742E51 /* TranscriptListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */; };
|
||||||
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; };
|
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; };
|
||||||
A5868238CF4958D6D0C5908E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */; };
|
A5868238CF4958D6D0C5908E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */; };
|
||||||
|
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */; };
|
||||||
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; };
|
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; };
|
||||||
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */; };
|
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */; };
|
||||||
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */; };
|
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */; };
|
||||||
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */; };
|
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */; };
|
||||||
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9681E7410B684D2D38752A70 /* TokenClient.swift */; };
|
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9681E7410B684D2D38752A70 /* TokenClient.swift */; };
|
||||||
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */; };
|
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */; };
|
||||||
|
D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6FA2A93116B237CEA712E6 /* Nearlight.swift */; };
|
||||||
D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; };
|
D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; };
|
||||||
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5DF207EDB787F63CB3A64D /* Voices.swift */; };
|
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5DF207EDB787F63CB3A64D /* Voices.swift */; };
|
||||||
FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */; };
|
FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */; };
|
||||||
@@ -64,13 +66,15 @@
|
|||||||
68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = "<group>"; };
|
68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = "<group>"; };
|
||||||
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
|
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
|
||||||
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; };
|
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; };
|
||||||
|
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = "<group>"; };
|
||||||
9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = "<group>"; };
|
9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = "<group>"; };
|
||||||
A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.swift; sourceTree = "<group>"; };
|
A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.swift; sourceTree = "<group>"; };
|
||||||
A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrbView.swift; sourceTree = "<group>"; };
|
|
||||||
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = "<group>"; };
|
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = "<group>"; };
|
||||||
|
BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptListView.swift; sourceTree = "<group>"; };
|
||||||
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationStateTests.swift; sourceTree = "<group>"; };
|
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationStateTests.swift; sourceTree = "<group>"; };
|
||||||
C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
|
C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
|
||||||
C22975C69240AACD3418091F /* VoiceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
C22975C69240AACD3418091F /* VoiceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
CA6FA2A93116B237CEA712E6 /* Nearlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nearlight.swift; sourceTree = "<group>"; };
|
||||||
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = "<group>"; };
|
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = "<group>"; };
|
||||||
D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = "<group>"; };
|
D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = "<group>"; };
|
||||||
E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = "<group>"; };
|
E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = "<group>"; };
|
||||||
@@ -147,7 +151,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */,
|
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */,
|
||||||
A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */,
|
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */,
|
||||||
|
BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */,
|
||||||
);
|
);
|
||||||
path = Conversation;
|
path = Conversation;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -175,6 +180,7 @@
|
|||||||
CE49A38E40AAF659A9281B85 /* App */ = {
|
CE49A38E40AAF659A9281B85 /* App */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
CA6FA2A93116B237CEA712E6 /* Nearlight.swift */,
|
||||||
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */,
|
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */,
|
||||||
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */,
|
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */,
|
||||||
2E5DF207EDB787F63CB3A64D /* Voices.swift */,
|
2E5DF207EDB787F63CB3A64D /* Voices.swift */,
|
||||||
@@ -282,7 +288,7 @@
|
|||||||
TestTargetID = 57E3A292BDE525A22EB7ADE4;
|
TestTargetID = 57E3A292BDE525A22EB7ADE4;
|
||||||
};
|
};
|
||||||
57E3A292BDE525A22EB7ADE4 = {
|
57E3A292BDE525A22EB7ADE4 = {
|
||||||
DevelopmentTeam = H8D4WY66F6;
|
DevelopmentTeam = YJFYRB92NW;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -321,7 +327,8 @@
|
|||||||
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */,
|
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */,
|
||||||
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */,
|
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */,
|
||||||
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
|
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
|
||||||
3E01A0AF44FD3324FA72C43C /* OrbView.swift in Sources */,
|
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */,
|
||||||
|
D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */,
|
||||||
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */,
|
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */,
|
||||||
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */,
|
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */,
|
||||||
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */,
|
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */,
|
||||||
@@ -331,6 +338,7 @@
|
|||||||
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */,
|
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */,
|
||||||
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */,
|
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */,
|
||||||
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */,
|
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */,
|
||||||
|
9461C0886FA294FDD9742E51 /* TranscriptListView.swift in Sources */,
|
||||||
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */,
|
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */,
|
||||||
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */,
|
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */,
|
||||||
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */,
|
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */,
|
||||||
@@ -498,7 +506,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = H8D4WY66F6;
|
DEVELOPMENT_TEAM = YJFYRB92NW;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Resources/Info.plist;
|
INFOPLIST_FILE = Resources/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -521,7 +529,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = H8D4WY66F6;
|
DEVELOPMENT_TEAM = YJFYRB92NW;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Resources/Info.plist;
|
INFOPLIST_FILE = Resources/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ targets:
|
|||||||
TARGETED_DEVICE_FAMILY: "1"
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
CURRENT_PROJECT_VERSION: 1
|
CURRENT_PROJECT_VERSION: 1
|
||||||
MARKETING_VERSION: "0.1.0"
|
MARKETING_VERSION: "0.1.0"
|
||||||
DEVELOPMENT_TEAM: H8D4WY66F6
|
DEVELOPMENT_TEAM: YJFYRB92NW
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
VoiceTests:
|
VoiceTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
|
|||||||
Reference in new issue
Block a user