Redesign Hope iOS UI, harden playback, and document project handoff

This commit is contained in:
snowspeeder committed 2026-09-09 20:23:44 -04:00
1 parent 40aaf4fea7
commit 24f95639dc
24 files changed
+1470 -594

No files matched your search

@@ -1,343 +1,347 @@
import SwiftUI
import Combine
/// Owns one session across all tabs. Changing tabs never ends a conversation.
struct ConversationView: View {
@ObservedObject var settings: SettingsStore
@State private var sessionID = UUID()
@State private var session: VoiceSession
/// The exact server URL the current session was constructed with. Used to
/// avoid redundant session replacement when the applied URL hasn't actually
/// changed (e.g. re-applying the same value from Settings).
@State private var activeServerURL: String
/// Identity guard: prevents a second `.onChange` from starting a concurrent
/// session replacement while the first is still disconnecting the old one.
@State private var isReplacingSession = false
/// Latest accepted server URL that has not yet been applied to a session.
/// Coalesced: rapid Applies during an active replacement retain the newest
/// value rather than dropping it. A single retained worker loop drains
/// arbitrary pending URLs until none remain (Iteration 08 Item 2).
@State private var pendingServerURL: String?
@State private var composerText = ""
@State private var connectError: String?
@State private var tick = 0
@State private var isReplacingSession = false
@State private var sessionID = UUID()
@State private var selectedTab = 0
init(settings: SettingsStore) {
self.settings = settings
let initialSession = Self.makeSession(settings: settings)
_session = State(initialValue: initialSession)
_activeServerURL = State(initialValue: settings.trimmedServerURL)
_session = State(initialValue: Self.makeSession(settings.trimmedServerURL, settings: settings))
}
/// Creates a session from the configured server URL. `SettingsStore.init`
/// guarantees the stored value is always valid (legacy invalid values are
/// sanitized to the documented default), so the failure branch is a safety
/// net only.
private static func makeSession(settings: SettingsStore) -> VoiceSession {
let config = ServerConfig.parse(settings.trimmedServerURL)
switch config {
case .success(let value):
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
case .failure:
// Defensive: should be unreachable because SettingsStore sanitizes.
let fallback = ServerConfig.parse(SettingsStore.defaultServerURL)
if case .success(let value) = fallback {
settings.serverURL = SettingsStore.defaultServerURL
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
}
fatalError("Unreachable: default server URL is valid")
private static func makeSession(_ url: String, settings: SettingsStore) -> VoiceSession {
guard case .success(let config) = ServerConfig.parse(url) else {
preconditionFailure("SettingsStore must supply a validated server URL")
}
let session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-fixtures") {
session.transcriptHandler.receiveDataTranscript(role: .user, text: "Help me plan a quiet weekend.")
session.transcriptHandler.receiveDataTranscript(role: .agent, text: "Let’s leave some room to breathe. A walk, a good book, and time for your favorite music would be a lovely start.")
session.taskStore.injectSampleEvents()
}
#endif
return session
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
statusHeader
TranscriptListView(
events: session.transcriptHandler.events,
onClear: {
// Atomically clear the canonical visible collection,
// dedupe state, and persistence so Clear is truthful.
session.transcriptHandler.clearAll()
}
)
composer
TabView(selection: $selectedTab) {
NavigationStack {
ConversationContent(settings: settings, session: session)
.toolbar { settingsLink }
}
.navigationTitle("Hope Voice")
.toolbar { toolbarContent }
.id(sessionID)
.onAppear {
if case .failure(let error) = ServerConfig.parse(settings.trimmedServerURL) {
connectError = error.localizedDescription
}
}
.onChange(of: settings.textMode) { _, newValue in
guard session.isReady else { return }
Task { await session.applyTextMode(newValue) }
}
}
.onReceive(session.objectWillChange.receive(on: RunLoop.main)) { _ in
tick &+= 1
}
.onChange(of: settings.serverURL) { _, newValue in
// The URL field now uses explicit Apply semantics (SettingsView),
// so this fires only when the user has applied a value that passed
// validation. Coalesce rapid accepted values: every accepted Apply
// writes `pendingServerURL` (latest wins). A single retained worker
// loop drains arbitrary pending URLs until none remain, covering
// A→B→C semantics where C arrives while B is disconnecting
// (Iteration 08 Item 2).
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, case .success = ServerConfig.parse(trimmed) else { return }
.tabItem { Label("Talk", systemImage: "waveform") }
.tag(0)
pendingServerURL = trimmed
// Start the worker if not already active. The loop consumes
// pending values until empty, so a single Task handles any number
// of rapid Applies without nested tasks.
guard !isReplacingSession else { return }
isReplacingSession = true
Task { @MainActor [settings] in
while let url = self.pendingServerURL {
self.pendingServerURL = nil
guard url != self.activeServerURL else { continue }
let oldSession = self.session
await oldSession.disconnect()
self.activeServerURL = url
self.sessionID = UUID()
// Construct from the captured validated config, not by
// rereading settings.serverURL (which may have changed
// during disconnect).
switch ServerConfig.parse(url) {
case .success(let config):
self.session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
case .failure:
let fallback = ServerConfig.parse(SettingsStore.defaultServerURL)
if case .success(let value) = fallback {
self.session = VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
} else {
fatalError("Unreachable: default server URL is valid")
}
}
}
self.isReplacingSession = false
}
}
}
private var statusHeader: some View {
VStack(spacing: 12) {
OrbView(state: orbState)
Text(statusText)
.font(.headline)
if session.state == .ready, session.stateModel.isThinkingChipVisible {
Label("Thinking…", systemImage: "ellipsis")
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.orange.opacity(0.2), in: Capsule())
}
if session.state == .ready, !settings.textMode {
micMeter
}
if session.state == .ready, settings.textMode {
Text("Barge in anytime — type to reply")
.font(.caption)
.foregroundStyle(.secondary)
}
if let agent = session.agent {
Text("Agent: \(agent.name ?? agent.identity)")
.font(.caption)
.foregroundStyle(.secondary)
}
if session.state == .ready {
Text("\(Voices.display(for: settings.voiceID) ?? settings.voiceID) · \(session.selectedModel ?? settings.modelID ?? "—")")
.font(.caption)
.foregroundStyle(.secondary)
}
if session.state == .ready {
HStack(spacing: 16) {
// The mic toggle is disabled in text mode to enforce the
// privacy invariant at the UI level as well (Finding 4).
Toggle(isOn: Binding(
get: { !session.isMuted },
set: { newValue in Task { await session.setMuted(!newValue) } }
)) {
Label(session.isMuted ? "Muted" : "Mic on", systemImage: session.isMuted ? "mic.slash" : "mic")
}
.toggleStyle(.button)
.disabled(settings.textMode)
Toggle("Text mode", isOn: textModeBinding)
.toggleStyle(.button)
}
}
if session.state == .ready, session.isReconnecting {
Label("Connection lost — reconnecting…", systemImage: "arrow.triangle.2.circlepath")
.font(.footnote)
.foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let micError = session.micError {
Label(micError, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let connectError {
Label(connectError, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.red)
}
}
.padding()
}
private var micMeter: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(Color(.systemFill))
Capsule()
.fill(Color.green.gradient)
.frame(width: min(geo.size.width, geo.size.width * session.stateModel.micLevel * 2.5))
}
}
.frame(height: 4)
}
private var textModeBinding: Binding<Bool> {
$settings.textMode
}
private var composer: some View {
HStack(spacing: 8) {
switch session.state {
case .idle, .failed:
Button(action: startSession) {
Label("Start", systemImage: "phone.fill")
}
.buttonStyle(.borderedProminent)
case .connecting, .waitingAgent, .tearingDown:
ProgressView()
case .ready:
TextField(settings.textMode ? "Type a message…" : "Voice mode active",
text: $composerText,
axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.disabled(!settings.textMode)
Button(action: sendComposer) {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
.accessibilityLabel("Send message")
.disabled(!settings.textMode || composerText.isEmpty)
Button(role: .destructive, action: endSession) {
Image(systemName: "phone.down.fill")
}
.accessibilityLabel("End conversation")
}
}
.padding()
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
TaskPanelView(taskStore: session.taskStore)
} label: {
Image(systemName: "tray.full")
.overlay(alignment: .topTrailing) {
if session.taskStore.activeCount > 0 {
Text("\(session.taskStore.activeCount)")
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(3)
.background(Color.red, in: Circle())
.offset(x: 6, y: -6)
}
}
}
.accessibilityLabel("Background tasks")
}
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
NavigationStack {
MediaPanelView(playerController: session.mediaController, settings: settings)
} label: {
Image(systemName: "music.note")
.toolbar { settingsLink }
}
.accessibilityLabel("Media player")
.tabItem { Label("Music", systemImage: "music.note") }
.tag(1)
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
.tag(2)
}
.id(sessionID)
.tint(HopeStyle.tint)
.onChange(of: settings.textMode) { _, enabled in
Task { await session.applyTextMode(enabled) }
}
.onChange(of: settings.serverURL) { _, value in replaceServer(value) }
}
@ToolbarContentBuilder private var settingsLink: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
SettingsView(settings: settings, session: session)
} label: {
Image(systemName: "gearshape")
Image(systemName: "gearshape").frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel("Settings")
}
}
private var orbState: OrbView.OrbState {
switch session.state {
case .idle: return .idle
case .connecting, .waitingAgent, .tearingDown: return .connecting
case .ready:
guard !settings.textMode else { return .idle }
switch session.stateModel.phase {
case .idle: return .idle
case .connecting: return .connecting
case .listening: return .listening
case .thinking: return .thinking
case .speaking: return .speaking
private func replaceServer(_ value: String) {
let url = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard case .success = ServerConfig.parse(url) else { return }
pendingServerURL = url
guard !isReplacingSession else { return }
isReplacingSession = true
Task { @MainActor in
// Coalesce rapid Apply actions; construct from the captured URL.
while let next = pendingServerURL {
pendingServerURL = nil
guard next != activeServerURL else { continue }
await session.disconnect()
session.mediaController.resetScope()
activeServerURL = next
session = Self.makeSession(next, settings: settings)
sessionID = UUID()
}
case .failed: return .failed
isReplacingSession = false
}
}
}
private struct ActivityTab: View {
@ObservedObject var taskStore: TaskStore
@ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession
var body: some View {
NavigationStack {
TaskPanelView(taskStore: taskStore)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
SettingsView(settings: settings, session: session)
} label: {
Image(systemName: "gearshape").frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel("Settings")
}
}
}
.tabItem { Label("Activity", systemImage: "checklist") }
.badge(taskStore.activeCount)
}
}
private struct ConversationContent: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession
@ObservedObject private var transcripts: TranscriptHandler
@ObservedObject private var conversation: ConversationStateModel
@State private var composerText = ""
@State private var sendError: String?
@State private var isSending = false
@FocusState private var composerFocused: Bool
init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings
self.session = session
transcripts = session.transcriptHandler
conversation = session.stateModel
}
var body: some View {
VStack(spacing: 0) {
if transcripts.events.isEmpty && !session.isReady {
welcome
} else {
connectionStatus.padding(.horizontal, 20).padding(.vertical, 12)
TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll)
}
}
.background(Color(.systemBackground))
.navigationTitle("Hope")
.safeAreaInset(edge: .bottom, spacing: 0) { conversationControls }
.onChange(of: session.state) { _, state in
if state == .ready && settings.textMode { composerFocused = true }
}
}
private var statusText: String {
private var welcome: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
OrbView(state: orbState).padding(.top, 24).accessibilityHidden(true)
VStack(alignment: .leading, spacing: 12) {
Text("A little help.\nA good conversation.")
.font(.largeTitle.weight(.semibold))
.fixedSize(horizontal: false, vertical: true)
Text("Talk things through, find something out, or put on a favorite song.")
.font(.body).foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 16) {
Label("Think out loud", systemImage: "bubble.left.and.bubble.right")
Label("Find an answer", systemImage: "sparkle.magnifyingglass")
Label("Make time for music", systemImage: "music.note")
}
.font(.subheadline).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24).padding(.bottom, 24)
}
}
private var connectionStatus: some View {
HStack(spacing: 12) {
Image(systemName: statusSymbol).font(.title3)
.foregroundStyle(session.state.isFailed ? Color.red : HopeStyle.tint)
.frame(width: 32, height: 36).accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(statusTitle).font(.headline)
Text(statusDetail).font(.subheadline).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
if session.isReady && !settings.textMode && !session.isMuted {
ProgressView(value: min(conversation.micLevel * 2.5, 1))
.frame(width: 46).accessibilityLabel("Microphone level")
}
}
.accessibilityElement(children: .combine)
}
private var conversationControls: some View {
VStack(spacing: 12) {
if let failure = failureMessage {
VStack(alignment: .leading, spacing: 6) {
Label("Couldn’t connect to Hope", systemImage: "wifi.exclamationmark")
.font(.subheadline.weight(.semibold))
Text(failure).font(.footnote).foregroundStyle(.secondary)
NavigationLink("Check connection settings") {
SettingsView(settings: settings, session: session)
}
.font(.subheadline).frame(minHeight: 44)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if session.isReconnecting {
Label("Reconnecting… Your conversation is still here.", systemImage: "wifi.exclamationmark")
.font(.footnote)
}
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)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if let sendError {
Text(sendError).font(.footnote).foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
}
Picker("Conversation mode", selection: $settings.textMode) {
Label("Voice", systemImage: "mic").tag(false)
Label("Type", systemImage: "keyboard").tag(true)
}
.pickerStyle(.segmented).disabled(isBusy)
if session.isReady {
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 {
Button {
sendError = nil
Task { await session.connect() }
} label: {
HStack(spacing: 10) {
if isBusy { ProgressView().tint(.white) }
else { Image(systemName: settings.textMode ? "bubble.left.fill" : "waveform") }
Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : settings.textMode ? "Start chatting" : "Let’s talk")
.font(.headline)
}
.frame(maxWidth: .infinity, minHeight: 48)
}
.buttonStyle(.borderedProminent).disabled(isBusy)
Text(settings.textMode ? "Your microphone stays off." : "Your microphone turns on when you connect.")
.font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
}
.padding(.horizontal, 20).padding(.top, 12).padding(.bottom, 16)
.background(.bar)
}
private var textComposer: some View {
VStack(spacing: 6) {
HStack(alignment: .bottom, spacing: 8) {
TextField("Message Hope", text: $composerText, axis: .vertical)
.lineLimit(1...5).padding(12)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
.focused($composerFocused).disabled(isSending)
.accessibilityLabel("Message Hope")
Button(action: sendComposer) {
if isSending { ProgressView().frame(width: 44, height: 44) }
else { Image(systemName: "arrow.up.circle.fill").font(.title).frame(width: 44, height: 44) }
}
.disabled(composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending || session.isReconnecting)
.accessibilityLabel("Send message")
}
endButton
}
}
private var endButton: some View {
Button(role: .destructive) {
composerFocused = false
Task { await session.disconnect() }
} label: {
Label("End conversation", systemImage: "phone.down.fill")
.font(.subheadline.weight(.medium)).frame(minHeight: 44)
}
.buttonStyle(.bordered)
}
private var isBusy: Bool {
session.state == .connecting || session.state == .waitingAgent || session.state == .tearingDown
}
private var failureMessage: String? {
if case .failed(let message) = session.state { return message }
return nil
}
private var statusTitle: String {
switch session.state {
case .idle: return "Ready to connect"
case .idle: return "Ready when you are"
case .connecting: return "Connecting…"
case .waitingAgent: return "Waiting for agent…"
case .tearingDown: return "Cleaning up…"
case .waitingAgent: return "Getting Hope ready…"
case .tearingDown: return "Ending conversation…"
case .failed: return "Connection unavailable"
case .ready:
if settings.textMode { return "Text mode" }
switch session.stateModel.phase {
case .listening: return "Listening"
case .thinking: return "Thinking it through"
if settings.textMode { return "You’re chatting with Hope" }
if session.isMuted { return "Microphone muted" }
switch conversation.phase {
case .thinking: return "Thinking it through…"
case .speaking: return "Hope is speaking"
default: return "Ready when you are"
default: return "I’m listening"
}
case .failed(let message): return "Failed: \(message)"
}
}
private func startSession() {
connectError = nil
// Starting a conversation preserves the restored last-50 history; only
// the explicit Clear button erases it.
Task { await session.connect() }
private var statusDetail: String {
if !session.isReady { return "Your recent conversation is below." }
if settings.textMode { return "Microphone off · Type whenever you’re ready" }
if session.isMuted { return "You can still hear Hope." }
return conversation.phase == .speaking ? "You can interrupt at any time." : "Speak naturally. Take your time."
}
private func endSession() {
Task { await session.disconnect() }
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() {
let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
composerText = ""
guard !text.isEmpty, !isSending else { return }
isSending = true
sendError = nil
Task {
do {
try await session.sendText(text)
composerText = ""
} catch {
connectError = error.localizedDescription
sendError = "Your message wasn’t sent. Try again when connected."
}
isSending = false
}
}
}
#Preview {
ConversationView(settings: SettingsStore())
}
#Preview { ConversationView(settings: SettingsStore()) }
+76 -113
View File
@@ -1,150 +1,113 @@
import SwiftUI
struct OrbView: View {
enum OrbState {
case idle
case connecting
case listening
case speaking
case thinking
case failed
}
enum OrbState { case idle, connecting, listening, speaking, thinking, failed }
let state: OrbState
@State private var animating = false
private var baseColor: Color {
switch state {
case .idle, .connecting: .blue
case .listening: .green
case .speaking, .thinking: .orange
case .failed: .red
}
}
private var scale: CGFloat {
switch state {
case .connecting, .listening, .speaking, .thinking: 1.1
default: 1.0
}
}
private var label: String {
switch state {
case .idle: "Idle"
case .connecting: "Connecting"
case .listening: "Listening"
case .speaking: "Speaking"
case .thinking: "Thinking"
case .failed: "Connection failed"
}
}
var body: some View {
let shape = Circle()
.fill(baseColor.gradient)
.frame(width: 120, height: 120)
let animated = shape
.scaleEffect(animating ? scale : 0.95)
.opacity(state == .idle || state == .failed ? 0.7 : 1.0)
let anim: Animation? = state == .idle || state == .failed
? nil
: .easeInOut(duration: 0.8).repeatForever(autoreverses: true)
animated
.animation(anim, value: animating)
.onAppear { animating = true }
.onChange(of: state) { _, _ in animating = true }
.accessibilityElement()
.accessibilityLabel("Conversation status")
.accessibilityValue(label)
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 {
/// The single canonical visible transcript (restored persisted finals plus
/// current live events). The view renders exactly this collection — there is
/// no second "restored" store, so the UI and persistence can never diverge.
let events: [TranscriptEvent]
var onClear: () -> Void
@State private var confirmClear = false
@State private var followingLatest = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private static let deliveryMarkers = #"\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*"#
private static let timestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
private var visibleRows: [TranscriptEvent] { events }
var body: some View {
ScrollViewReader { proxy in
VStack(alignment: .leading, spacing: 0) {
VStack(spacing: 0) {
HStack {
Text("Transcript")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text("Conversation").font(.subheadline.weight(.semibold))
Spacer()
Button(role: .destructive, action: onClear) {
Label("Clear", systemImage: "trash")
.font(.caption)
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)
.padding(.bottom, 4)
.padding(.horizontal, 20)
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(visibleRows) { event in
transcriptRow(event)
LazyVStack(alignment: .leading, spacing: 20) {
if events.isEmpty {
Text("Your conversation will appear here.")
.font(.body).foregroundStyle(.secondary)
.padding(.vertical, 32)
}
ForEach(events) { event in transcriptRow(event) }
Color.clear.frame(height: 1).id("latest")
.onAppear { followingLatest = true }
.onDisappear { followingLatest = false }
}
.padding(.horizontal)
.padding(.horizontal, 20).padding(.bottom, 20)
}
.onChange(of: visibleRows.count) { _, _ in
if let last = visibleRows.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
.scrollDismissesKeyboard(.interactively)
.defaultScrollAnchor(.bottom)
.onChange(of: events.last?.text) { _, _ in
if followingLatest { proxy.scrollTo("latest", anchor: .bottom) }
}
.onChange(of: events.count) { _, _ in
if followingLatest { proxy.scrollTo("latest", anchor: .bottom) }
}
if !followingLatest && !events.isEmpty {
Button {
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) {
proxy.scrollTo("latest", anchor: .bottom)
}
} label: {
Label("Latest messages", systemImage: "arrow.down").font(.subheadline)
.frame(minHeight: 44)
}
}
}
}
.alert("Clear this conversation?", isPresented: $confirmClear) {
Button("Clear conversation", role: .destructive, action: onClear)
Button("Cancel", role: .cancel) {}
} message: {
Text("This removes the transcript saved on this iPhone. It doesn’t reset Hope’s memory or end your current conversation.")
}
}
private func transcriptRow(_ event: TranscriptEvent) -> some View {
let roleName = event.role == .user ? "You" : "Hope"
let nameColor: Color = event.role == .user ? .secondary : .blue
let rowBackground: Color = event.role == .user
? Color(.secondarySystemBackground)
: Color.blue.opacity(0.08)
let textOpacity: Double = event.isFinal ? 1.0 : 0.55
return VStack(alignment: .leading, spacing: 2) {
HStack {
Text(roleName)
.font(.caption2.bold())
.foregroundStyle(nameColor)
Spacer()
Text(Self.timestampFormatter.string(from: event.timestamp))
.font(.caption2)
.foregroundStyle(.tertiary)
let isUser = event.role == .user
let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespaces)
return HStack(alignment: .top, spacing: 0) {
if isUser { Spacer(minLength: 32) }
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 8) {
Text(isUser ? "You" : "Hope").font(.subheadline.weight(.semibold))
Text(event.timestamp, style: .time).font(.caption).foregroundStyle(.secondary)
if !event.isFinal {
Text("Live").font(.caption).foregroundStyle(.secondary)
}
}
Text(text).font(.body).textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
Text(stripped(event.text))
.font(.body)
.opacity(textOpacity)
.padding(isUser ? 14 : 0)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isUser ? Color(.secondarySystemBackground) : .clear, in: RoundedRectangle(cornerRadius: 16))
.contextMenu {
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
}
if !isUser { Spacer(minLength: 16) }
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(rowBackground)
.cornerRadius(10)
.accessibilityElement(children: .combine)
.id(event.id)
}
private func stripped(_ text: String) -> String {
text.replacingOccurrences(
of: Self.deliveryMarkers,
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespaces)
}
}
+159 -65
View File
@@ -14,31 +14,76 @@ struct MediaPanelView: View {
@State private var streamURL = ""
@State private var streamMessage: String?
@State private var trackToDelete: MediaTrack?
@State private var browseMode = 0
@State private var showAddStream = false
@State private var isAddingStream = false
@State private var isLoadingLibrary = false
@State private var seekPosition: Double = 0
@State private var isSeeking = false
var body: some View {
ScrollView {
VStack(spacing: 20) {
if let stateError = playerController.stateError {
Label(stateError, systemImage: "wifi.exclamationmark")
.font(.subheadline)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color.red.opacity(0.1), in: RoundedRectangle(cornerRadius: 14))
LazyVStack(alignment: .leading, spacing: 24) {
if playerController.stateError != nil {
VStack(alignment: .leading, spacing: 8) {
Label("Music server unavailable", systemImage: "wifi.exclamationmark")
.font(.headline)
Text("Check that your Hope server is running and you’re connected to its network. We’ll reconnect automatically.")
.font(.subheadline).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if playerController.track != nil {
VStack(spacing: 20) {
nowPlayingCard
seekSection
transportControls
volumeSection
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16))
}
if let error = playerController.playbackError {
Label(error, systemImage: "exclamationmark.circle")
.font(.subheadline).foregroundStyle(.red)
}
nowPlayingCard
transportControls
seekSection
volumeSection
pendingSection
choicesSection
searchSection
librarySection
addStreamSection
Picker("Browse music", selection: $browseMode) {
Text("Library").tag(0)
Text("Search").tag(1)
}.pickerStyle(.segmented)
if browseMode == 0 { librarySection }
else { searchSection }
}
.padding()
}
.navigationTitle("Media")
.background(Color(.systemGroupedBackground))
.navigationTitle("Music")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
streamMessage = nil
showAddStream = true
} label: {
Image(systemName: "plus").frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel("Add radio station")
}
}
.sheet(isPresented: $showAddStream) {
NavigationStack {
Form { addStreamSection }
.navigationTitle("Add a station")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showAddStream = false }.disabled(isAddingStream)
}
}
}
.interactiveDismissDisabled(isAddingStream)
}
.onAppear {
playerController.startDefaultPolling()
refreshLibrary()
@@ -60,18 +105,7 @@ struct MediaPanelView: View {
private var nowPlayingCard: some View {
HStack(spacing: 12) {
if let artURL = playerController.track?.artURL, let url = URL(string: absolute(artURL)) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
Image(systemName: "music.note")
.font(.title2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGray6))
}
}
PinnedArtwork(url: url)
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
@@ -92,7 +126,7 @@ struct MediaPanelView: View {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.lineLimit(2)
if playerController.track != nil {
Text(statusText)
.font(.caption)
@@ -101,8 +135,6 @@ struct MediaPanelView: View {
}
Spacer()
}
.padding(12)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
}
private var transportControls: some View {
@@ -110,13 +142,15 @@ struct MediaPanelView: View {
Button(action: seekBackward) {
Image(systemName: "gobackward.10")
.font(.title3)
.frame(width: 44, height: 44)
}
.accessibilityLabel("Seek back 10 seconds")
.disabled(playerController.track == nil)
.disabled(playerController.duration <= 0 || playerController.track?.kind == "stream")
Button(action: togglePlayPause) {
Image(systemName: playerController.desired == "playing" && playerController.track != nil ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 44))
.font(.largeTitle)
.frame(width: 56, height: 56)
.foregroundStyle(playerController.track == nil ? Color.secondary : Color.accentColor)
}
.accessibilityLabel(playerController.desired == "playing" && playerController.track != nil ? "Pause" : "Play")
@@ -125,13 +159,15 @@ struct MediaPanelView: View {
Button(action: seekForward) {
Image(systemName: "goforward.10")
.font(.title3)
.frame(width: 44, height: 44)
}
.accessibilityLabel("Seek forward 10 seconds")
.disabled(playerController.track == nil)
.disabled(playerController.duration <= 0 || playerController.track?.kind == "stream")
Button(role: .destructive, action: stopPlayback) {
Image(systemName: "stop.circle")
.font(.title2)
.frame(width: 44, height: 44)
}
.accessibilityLabel("Stop playback")
.disabled(playerController.track == nil)
@@ -140,14 +176,23 @@ struct MediaPanelView: View {
private var seekSection: some View {
VStack(spacing: 4) {
if playerController.track != nil {
if playerController.track?.kind == "stream" {
Label("Live radio", systemImage: "dot.radiowaves.left.and.right")
.font(.subheadline).foregroundStyle(.secondary)
} else if playerController.track != nil {
Slider(
value: Binding(
get: { playerController.currentTime },
set: { newValue in Task { playerController.seek(to: newValue) } }
get: { min(isSeeking ? seekPosition : playerController.currentTime, max(playerController.duration, 1)) },
set: { seekPosition = $0 }
),
in: 0...max(playerController.duration, 1)
in: 0...max(playerController.duration, 1),
onEditingChanged: { editing in
isSeeking = editing
if !editing { playerController.seek(to: seekPosition) }
}
)
.disabled(playerController.duration <= 0)
.accessibilityLabel("Playback position")
HStack {
Text(fmtTime(playerController.currentTime))
.font(.caption.monospacedDigit())
@@ -172,6 +217,7 @@ struct MediaPanelView: View {
Image(systemName: "speaker.fill")
.foregroundStyle(.secondary)
Slider(value: $settings.volume, in: 0...1)
.accessibilityLabel("Music volume")
.onChange(of: settings.volume) { _, newValue in
playerController.setVolume(newValue)
}
@@ -227,15 +273,19 @@ struct MediaPanelView: View {
private var searchSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Search YouTube Music")
Text("Find your next favorite")
.font(.headline)
Text("Search songs and artists on YouTube Music.")
.font(.subheadline).foregroundStyle(.secondary)
HStack(spacing: 8) {
TextField("Song, artist, lyrics…", text: $searchQuery)
.textFieldStyle(.roundedBorder)
.submitLabel(.search)
.onSubmit(performSearch)
Button("Find", action: performSearch)
Button("Search", action: performSearch)
.accessibilityIdentifier("music.search")
.buttonStyle(.bordered)
.frame(minHeight: 44)
.disabled(searchQuery.trimmingCharacters(in: .whitespaces).isEmpty || isSearching)
}
if isSearching {
@@ -251,7 +301,7 @@ struct MediaPanelView: View {
.foregroundStyle(.red)
} else if let results = searchResults {
if results.isEmpty {
Text("No results.")
Text("No matches. Try a different song or artist.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
@@ -286,19 +336,24 @@ struct MediaPanelView: View {
.font(.headline)
Spacer()
Button(action: refreshLibrary) {
Image(systemName: "arrow.clockwise")
Image(systemName: "arrow.clockwise").frame(width: 44, height: 44)
}
.disabled(isLoadingLibrary)
.accessibilityLabel("Refresh library")
}
if let libraryError {
if let libraryError, playerController.stateError == nil {
Text(libraryError)
.font(.subheadline)
.foregroundStyle(.red)
} else if let tracks = libraryTracks {
if tracks.isEmpty {
Text("Downloaded songs and streams live here.")
.font(.subheadline)
.foregroundStyle(.secondary)
ContentUnavailableView {
Label("A home for your favorites", systemImage: "music.note.list")
} description: {
Text("Search for a song to save it here, or add a radio station using the + button.")
} actions: {
Button("Find music") { browseMode = 1 }.buttonStyle(.bordered)
}
} else {
ForEach(tracks, id: \.id) { track in
HStack(spacing: 8) {
@@ -319,23 +374,24 @@ struct MediaPanelView: View {
Image(systemName: "xmark")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.red)
.frame(width: 44, height: 44)
}
.accessibilityLabel("Remove \(track.title ?? track.id)")
}
}
}
} else if isLoadingLibrary {
ProgressView("Loading your library…").frame(maxWidth: .infinity).padding(.vertical, 24)
}
}
}
private var addStreamSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Add live stream")
.font(.headline)
Section {
TextField("Station name", text: $streamName)
.textFieldStyle(.roundedBorder)
.accessibilityLabel("Station name")
TextField("https://… audio stream URL", text: $streamURL)
.textFieldStyle(.roundedBorder)
.accessibilityLabel("Audio stream URL")
.keyboardType(.URL)
.autocorrectionDisabled()
.textContentType(.URL)
@@ -345,9 +401,10 @@ struct MediaPanelView: View {
.font(.subheadline)
.foregroundStyle(streamMessage.hasPrefix("Added") ? Color.green : Color.red)
}
Button("Add", action: addStream)
.buttonStyle(.bordered)
.disabled(streamURL.trimmingCharacters(in: .whitespaces).isEmpty)
Button(isAddingStream ? "Adding station…" : "Add station", action: addStream)
.disabled(streamURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isAddingStream)
} footer: {
Text("Use a direct MP3 or AAC audio stream address, rather than the station’s website.")
}
}
@@ -361,22 +418,22 @@ struct MediaPanelView: View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.lineLimit(2)
if !detail.isEmpty {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.lineLimit(2)
}
}
Spacer()
Button("Play", action: onPlay)
.buttonStyle(.bordered)
.controlSize(.small)
Button(action: onPlay) {
Image(systemName: "play.fill").frame(width: 44, height: 44)
}
.accessibilityLabel("Play \(title)")
.disabled(!playEnabled)
}
.padding(10)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
.padding(.vertical, 10)
}
private func rowDetail(channel: String?, duration: Double?, status: String? = nil, inLibrary: Bool? = nil) -> String {
@@ -389,8 +446,8 @@ struct MediaPanelView: View {
}
private func performSearch() {
let query = searchQuery.trimmingCharacters(in: .whitespaces)
guard !query.isEmpty else { return }
let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty, !isSearching else { return }
isSearching = true
searchError = nil
Task {
@@ -405,7 +462,10 @@ struct MediaPanelView: View {
}
private func refreshLibrary() {
guard !isLoadingLibrary else { return }
isLoadingLibrary = true
Task {
defer { isLoadingLibrary = false }
do {
let library = try await MediaAPI(config: serverConfig).library()
libraryTracks = library.tracks
@@ -417,15 +477,22 @@ struct MediaPanelView: View {
}
private func addStream() {
let url = streamURL.trimmingCharacters(in: .whitespaces)
guard !url.isEmpty else { return }
let url = streamURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard !isAddingStream else { return }
guard let parsed = URL(string: url), ["https", "http"].contains(parsed.scheme?.lowercased() ?? ""), parsed.host != nil else {
streamMessage = "Enter a complete http:// or https:// audio stream address."
return
}
isAddingStream = true
let name = streamName.trimmingCharacters(in: .whitespaces).isEmpty ? url : streamName.trimmingCharacters(in: .whitespaces)
Task {
defer { isAddingStream = false }
do {
try await MediaAPI(config: serverConfig).addStream(room: "default", name: name, url: url)
streamMessage = "Added \(name)"
streamName = ""
streamURL = ""
showAddStream = false
refreshLibrary()
} catch {
streamMessage = "Could not add stream: \(error.localizedDescription)"
@@ -514,3 +581,30 @@ struct MediaPanelView: View {
return h > 0 ? "\(h):\(String(format: "%02d", m)):\(String(format: "%02d", s))" : "\(m):\(String(format: "%02d", s))"
}
}
/// Artwork uses the same trust policy as token and media requests.
private struct PinnedArtwork: View {
let url: URL
@State private var artwork: UIImage?
var body: some View {
Group {
if let artwork { Image(uiImage: artwork).resizable().scaledToFill() }
else {
Image(systemName: "music.note").font(.title2).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.tertiarySystemFill))
}
}
.accessibilityHidden(true)
.task(id: url) {
artwork = nil
let session = TLSPinning.session()
defer { session.invalidateAndCancel() }
guard let (data, response) = try? await session.data(from: url),
(response as? HTTPURLResponse)?.statusCode == 200,
!Task.isCancelled else { return }
artwork = UIImage(data: data)
}
}
}
@@ -26,7 +26,7 @@ struct ConnectionTestView: View {
if isBusy {
ProgressView()
} else {
Text("Get token")
Text("Test connection")
}
Spacer()
}
@@ -44,7 +44,7 @@ struct ConnectionTestView: View {
LabeledContent("User") {
Text(response.user.displayName ?? response.user.id)
}
Label("Token endpoint and TLS are working.", systemImage: "checkmark.circle")
Label("Connected to your server. Start a conversation to check Hope’s voice and replies.", systemImage: "checkmark.circle")
.foregroundStyle(.green)
}
case .failure(let error):
@@ -57,6 +57,7 @@ struct ConnectionTestView: View {
}
}
.navigationTitle("Connection test")
.navigationBarTitleDisplayMode(.inline)
}
private func runTest() {
+44 -32
View File
@@ -30,18 +30,48 @@ struct SettingsView: View {
var body: some View {
Form {
Section("Server") {
Section {
Button {
showVoicePicker = true
} label: {
LabeledContent("Speaking voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID)
}
Picker("Conversation mode", selection: $settings.textMode) {
Text("Voice").tag(false)
Text("Type · Microphone off").tag(true)
}
} header: {
Text("Make it yours")
} footer: {
Text("Voice and typing preferences are saved on this iPhone. You can switch modes from the Talk tab.")
}
Section {
if session.isReady, !session.models.isEmpty {
Button {
showModelPicker = true
} label: {
LabeledContent("Model", value: session.selectedModel ?? settings.modelID ?? "Server default")
}
} else {
LabeledContent("Model", value: settings.modelID ?? "Server default")
Text("Connect from Talk to see the models available on your server.")
.font(.footnote).foregroundStyle(.secondary)
}
} header: { Text("Intelligence") }
Section {
TextField(SettingsStore.defaultServerURL, text: $urlText)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.accessibilityLabel("Server URL")
.onAppear { urlText = settings.serverURL }
if let urlError {
Text(urlError)
.font(.footnote)
.foregroundStyle(.red)
}
Button("Apply server URL") { applyServerURL() }
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button("Save server address") { applyServerURL() }
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
if !canApplyServerURL {
Text(session.state == .tearingDown
? "Waiting for failure cleanup to finish…"
@@ -49,37 +79,17 @@ struct SettingsView: View {
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("Voice") {
Button {
showVoicePicker = true
} label: {
LabeledContent("Voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID)
}
}
Section("Model") {
if session.isReady, !session.models.isEmpty {
Button {
showModelPicker = true
} label: {
LabeledContent("Model", value: session.selectedModel ?? settings.modelID ?? "—")
}
} else {
Text("Start a conversation to load the model list.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("Interaction") {
Toggle("Text mode", isOn: $settings.textMode)
}
Section {
NavigationLink("Connection test") {
ConnectionTestView(settings: settings)
}
} header: {
Text("Connection")
} footer: {
Text("Hope runs on your own server. Your iPhone needs to be on the same network or a network that can reach it.")
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showVoicePicker) {
VoicePickerView(settings: settings, session: session)
}
@@ -115,7 +125,7 @@ struct SettingsView: View {
struct VoicePickerView: View {
@ObservedObject var settings: SettingsStore
var session: VoiceSession
@ObservedObject var session: VoiceSession
@Environment(\.dismiss) private var dismiss
var body: some View {
@@ -132,10 +142,11 @@ struct VoicePickerView: View {
if settings.voiceID == voice.id {
Spacer()
Image(systemName: "checkmark")
.foregroundStyle(.blue)
.foregroundStyle(HopeStyle.tint)
}
}
}
.accessibilityAddTraits(settings.voiceID == voice.id ? .isSelected : [])
}
}
.navigationTitle("Voice")
@@ -150,7 +161,7 @@ struct VoicePickerView: View {
struct ModelPickerView: View {
@ObservedObject var settings: SettingsStore
var session: VoiceSession
@ObservedObject var session: VoiceSession
@Environment(\.dismiss) private var dismiss
private var activeModelID: String? {
@@ -176,10 +187,11 @@ struct ModelPickerView: View {
if activeModelID == model.id {
Spacer()
Image(systemName: "checkmark")
.foregroundStyle(.blue)
.foregroundStyle(HopeStyle.tint)
}
}
}
.accessibilityAddTraits(activeModelID == model.id ? .isSelected : [])
}
}
.navigationTitle("Model")
+47 -43
View File
@@ -5,10 +5,12 @@ struct TaskPanelView: View {
var body: some View {
ScrollView {
VStack(spacing: 12) {
LazyVStack(alignment: .leading, spacing: 20) {
if taskStore.sortedTasks.isEmpty {
emptyState
} 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.")
.font(.subheadline).foregroundStyle(.secondary)
ForEach(taskStore.sortedTasks) { task in
TaskCardView(task: task)
}
@@ -16,57 +18,48 @@ struct TaskPanelView: View {
}
.padding()
}
.navigationTitle("Background tasks")
#if DEBUG
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Sample", action: { taskStore.injectSampleEvents() })
.font(.caption)
}
}
#endif
.navigationTitle("Activity")
.background(Color(.systemGroupedBackground))
}
private var emptyState: some View {
VStack(spacing: 8) {
Text("Nothing running")
.font(.headline)
Text("Ask Hope to research or compare something.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
ContentUnavailableView {
Label("A little help, in the background", systemImage: "checklist")
} description: {
Text("Ask Hope to research a topic or compare your options. Follow the progress and read the results here.")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.top, 48)
}
}
struct TaskCardView: View {
let task: TaskItem
@State private var showSteps = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
statusDot
Text(task.description)
.font(.subheadline.weight(.semibold))
.lineLimit(2)
VStack(alignment: .leading, spacing: 14) {
HStack {
Label(statusTitle, systemImage: statusSymbol)
.font(.subheadline.weight(.medium))
.foregroundStyle(dotColor)
Spacer()
Text(task.createdAt, style: .time).font(.caption).foregroundStyle(.secondary)
}
Text(task.description).font(.headline).fixedSize(horizontal: false, vertical: true)
if !task.steps.isEmpty {
VStack(alignment: .leading, spacing: 4) {
DisclosureGroup("\(task.steps.count) progress updates", isExpanded: $showSteps) {
VStack(alignment: .leading, spacing: 12) {
ForEach(task.steps) { step in
HStack(alignment: .top, spacing: 6) {
VStack(alignment: .leading, spacing: 4) {
Text(stepLabel(step.role))
.font(.caption2.weight(.semibold))
.foregroundStyle(stepColor(step.role))
.font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Text(step.content ?? "")
.font(.caption)
.foregroundStyle(stepColor(step.role))
.lineLimit(3)
.font(.subheadline).textSelection(.enabled)
}
}
}.padding(.top, 8)
}
.font(.subheadline)
}
if let result = task.result {
block(result, color: .green, background: Color.green.opacity(0.12))
@@ -75,21 +68,33 @@ struct TaskCardView: View {
block(error, color: .red, background: Color.red.opacity(0.12))
}
}
.padding(12)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16))
}
private var statusDot: some View {
Circle()
.fill(dotColor)
.frame(width: 9, height: 9)
private var statusTitle: String {
switch task.status {
case .pending: "Queued"
case .running: "In progress"
case .completed: "Complete"
case .failed: "Needs attention"
}
}
private var statusSymbol: String {
switch task.status {
case .pending: "clock"
case .running: "arrow.triangle.2.circlepath"
case .completed: "checkmark.circle.fill"
case .failed: "exclamationmark.circle"
}
}
private var dotColor: Color {
switch task.status {
case .pending: return .gray
case .running: return .blue
case .running: return HopeStyle.tint
case .completed: return .green
case .failed: return .red
}
@@ -97,11 +102,10 @@ struct TaskCardView: View {
private func block(_ text: String, color: Color, background: Color) -> some View {
Text(text)
.font(.subheadline)
.foregroundStyle(color)
.font(.body)
.foregroundStyle(.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(background, in: RoundedRectangle(cornerRadius: 10))
}
private func stepLabel(_ role: String?) -> String {