feat: hardening, error states, interruption handling, a11y (Phase 8)

This commit is contained in:
snowspeeder committed 2026-08-30 09:37:05 -04:00
1 parent 9f1f26ba54
commit 12ded179eb
6 files changed
+233 -38

No files matched your search

@@ -1,32 +1,29 @@
import SwiftUI
import Combine
struct ConversationView: View {
@ObservedObject var settings: SettingsStore
@StateObject private var session: VoiceSession
@State private var sessionID = UUID()
@State private var session: VoiceSession
@State private var composerText = ""
@State private var connectError: String?
@State private var tick = 0
init(settings: SettingsStore) {
self.settings = settings
_session = State(initialValue: Self.makeSession(settings: settings))
}
private static func makeSession(settings: SettingsStore) -> VoiceSession {
let config = ServerConfig.parse(settings.trimmedServerURL)
if case .success(let value) = config {
_session = StateObject(wrappedValue: VoiceSession(
config: value,
tokenClient: TokenClient(config: value),
settings: settings
))
} else {
let fallback = ServerConfig.parse("https://192.168.86.2:9178")
if case .success(let value) = fallback {
_session = StateObject(wrappedValue: VoiceSession(
config: value,
tokenClient: TokenClient(config: value),
settings: settings
))
} else {
fatalError("Unreachable: default server URL is valid")
}
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
}
let fallback = ServerConfig.parse("https://192.168.86.2:9178")
if case .success(let value) = fallback {
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
}
fatalError("Unreachable: default server URL is valid")
}
var body: some View {
@@ -42,12 +39,25 @@ struct ConversationView: View {
}
.navigationTitle("Hope Voice")
.toolbar { toolbarContent }
.id(sessionID)
.onAppear {
if case .failure(let error) = ServerConfig.parse(settings.trimmedServerURL) {
connectError = error.localizedDescription
}
}
}
.onReceive(session.objectWillChange.receive(on: RunLoop.main)) { _ in
tick &+= 1
}
.onChange(of: settings.serverURL) { _, _ in
guard session.state == .idle else { return }
let oldSession = session
Task { @MainActor [settings] in
await oldSession.disconnect()
self.sessionID = UUID()
self.session = Self.makeSession(settings: settings)
}
}
}
private var statusHeader: some View {
@@ -93,6 +103,12 @@ struct ConversationView: View {
.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)
@@ -163,10 +179,12 @@ struct ConversationView: View {
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()
@@ -190,6 +208,7 @@ struct ConversationView: View {
}
}
}
.accessibilityLabel("Background tasks")
}
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
@@ -197,6 +216,7 @@ struct ConversationView: View {
} label: {
Image(systemName: "music.note")
}
.accessibilityLabel("Media player")
}
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
@@ -204,6 +224,7 @@ struct ConversationView: View {
} label: {
Image(systemName: "gearshape")
}
.accessibilityLabel("Settings")
}
}
@@ -30,6 +30,17 @@ struct OrbView: View {
}
}
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)
@@ -44,6 +55,9 @@ struct OrbView: View {
.animation(anim, value: animating)
.onAppear { animating = true }
.onChange(of: state) { _, _ in animating = true }
.accessibilityElement()
.accessibilityLabel("Conversation status")
.accessibilityValue(label)
}
}