feat: hardening, error states, interruption handling, a11y (Phase 8)
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user