Files
openclaw/apps/ios/Sources/Design/TalkProTab.swift
Marvin Kaunda 5370f64cf0 improve: native iOS look with stock SwiftUI navigation, forms, chat, and talk visualizer (#99231)
* improve(ios): adopt native SwiftUI chrome across the app

* fix(ios): keep phone chat on the native navigation bar

* fix(ios): render licenses screen as native settings rows

* fix(ios): complete native interface polish

* fix(ios): preserve branded native interface

* test(ios): align gateway badge source guard

* fix(ios): center onboarding on iPad

* fix(ios): remove dead reskin symbols

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-03 18:17:09 -07:00

456 lines
17 KiB
Swift

import SwiftUI
struct TalkProTab: View {
@Environment(NodeAppModel.self) private var appModel
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage(TalkSpeechLocale.storageKey) private var talkSpeechLocale: String = TalkSpeechLocale.automaticID
@AppStorage(TalkDefaults.speakerphoneEnabledKey) private var talkSpeakerphoneEnabled: Bool =
TalkDefaults.speakerphoneEnabledByDefault
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
@State private var showPermissionPrompt = false
@State private var showTalkIssueDetails = false
let headerLeadingAction: OpenClawSidebarHeaderAction?
let ownsNavigationStack: Bool
var openSettings: () -> Void
var openVoiceSettings: () -> Void
init(
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
ownsNavigationStack: Bool = true,
openSettings: @escaping () -> Void,
openVoiceSettings: (() -> Void)? = nil)
{
self.headerLeadingAction = headerLeadingAction
self.ownsNavigationStack = ownsNavigationStack
self.openSettings = openSettings
self.openVoiceSettings = openVoiceSettings ?? openSettings
}
private var state: TalkProState {
TalkProState(
gatewayConnected: self.gatewayConnected,
isDemoMode: self.appModel.isAppleReviewDemoModeEnabled,
isEnabled: self.appModel.talkMode.isEnabled || self.talkEnabled,
statusText: self.appModel.talkMode.statusText,
isConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded,
isListening: self.appModel.talkMode.isListening,
isSpeaking: self.appModel.talkMode.isSpeaking,
isUserSpeechDetected: self.appModel.talkMode.isUserSpeechDetected,
permissionState: self.appModel.talkMode.gatewayTalkPermissionState)
}
var body: some View {
Group {
if self.ownsNavigationStack {
NavigationStack {
self.content
}
} else {
self.content
}
}
.sheet(isPresented: self.$showPermissionPrompt) {
NavigationStack {
TalkPermissionPromptView(
style: .sheet,
onPermissionReady: {
self.showPermissionPrompt = false
self.startTalk()
})
.padding()
.navigationTitle("Enable Talk")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
self.showPermissionPrompt = false
} label: {
Text("Not Now")
.font(OpenClawType.subheadSemiBold)
}
}
}
}
.presentationDetents([.medium, .large])
.openClawSheetChrome()
}
.sheet(isPresented: self.$showTalkIssueDetails) {
if let fallbackIssue = self.fallbackIssue {
TalkRuntimeIssueDetailsSheet(
issue: fallbackIssue,
onOpenSettings: self.openVoiceSettings)
.openClawSheetChrome()
}
}
.onAppear { self.alignPersistedTalkState() }
}
private var content: some View {
List {
if let fallbackIssue = self.fallbackIssue {
Section {
TalkRuntimeIssueBanner(
issue: fallbackIssue,
onOpenSettings: self.openVoiceSettings,
onShowDetails: {
self.showTalkIssueDetails = true
})
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
self.heroSection
self.conversationSection
self.voiceModeSection
self.controlsSection
}
.navigationTitle("Talk")
.toolbar {
if let headerLeadingAction {
ToolbarItem(placement: .topBarLeading) {
OpenClawSidebarRevealButton(action: headerLeadingAction)
}
}
}
}
private var heroSection: some View {
Section {
VStack(spacing: 16) {
TalkSiriWaveView(mode: self.state.waveformMode(micLevel: self.appModel.talkMode.micLevel))
.frame(height: 130)
.accessibilityHidden(true)
VStack(spacing: 4) {
Text(self.state.title)
.font(OpenClawType.title3SemiBold)
.multilineTextAlignment(.center)
Text(self.heroSubtitle)
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Button(action: self.handlePrimaryAction) {
Label(self.state.primaryButtonTitle, systemImage: self.state.primaryButtonIcon)
.font(OpenClawType.subheadSemiBold)
// Match the icon to the label; otherwise the symbol picks up the tint color.
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.tint(self.state.color)
.disabled(self.state.primaryAction == .waiting)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.listRowBackground(Color.clear)
}
}
private var conversationSection: some View {
Section("Conversation") {
LabeledContent("Agent", value: self.appModel.chatAgentName)
LabeledContent("Session", value: self.appModel.chatSessionKey)
LabeledContent("Runtime", value: self.appModel.talkMode.statusText)
}
}
private var voiceModeSection: some View {
Section("Voice Mode") {
LabeledContent("Configured", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
LabeledContent("Active", value: self.activeModeText)
LabeledContent("Transport", value: self.transportText)
if let issueText = self.talkIssueText {
LabeledContent("Last issue", value: issueText)
}
LabeledContent("Permission", value: self.permissionText)
LabeledContent("Speech language", value: self.speechLocaleText)
}
}
private var controlsSection: some View {
Section("Controls") {
Toggle(isOn: self.talkSpeakerphoneBinding) {
Text("Speakerphone")
.font(OpenClawType.body)
}
.accessibilityIdentifier("talk-speakerphone-control")
Toggle(isOn: self.$talkBackgroundEnabled) {
Text("Background listening")
.font(OpenClawType.body)
}
.accessibilityIdentifier("talk-background-listening-control")
Button(action: self.openVoiceSettings) {
HStack {
Label("Voice & Talk Settings", systemImage: "slider.horizontal.3")
.font(OpenClawType.body)
.foregroundStyle(.primary)
Spacer()
Image(systemName: "chevron.forward")
.font(OpenClawType.footnoteSemiBold)
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("talk-voice-settings-control")
}
}
private var gatewayConnected: Bool {
!self.appModel.isAppleReviewDemoModeEnabled &&
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
private var fallbackIssue: TalkRuntimeIssue? {
guard self.gatewayConnected else { return nil }
return self.appModel.talkMode.gatewayTalkCurrentFallbackIssue
}
private var heroSubtitle: String {
if self.state
.prefersPermissionCopy { return "Gateway approval is required before this phone can capture voice." }
if self.appModel.isAppleReviewDemoModeEnabled { return "Voice is disabled in Apple Review demo mode." }
if !self.gatewayConnected { return "Connect to your gateway to start a voice conversation." }
if !self.appModel.talkMode.gatewayTalkConfigLoaded {
return "Open Voice settings after the gateway loads Talk configuration."
}
let subtitle = (appModel.talkMode.gatewayTalkVoiceModeSubtitle ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !subtitle.isEmpty { return subtitle }
return "Routes voice to \(self.appModel.chatAgentName)."
}
private var transportText: String {
let provider = self.appModel.talkMode.gatewayTalkProviderLabel.trimmingCharacters(in: .whitespacesAndNewlines)
let transport = self.appModel.talkMode.gatewayTalkTransportLabel.trimmingCharacters(in: .whitespacesAndNewlines)
if provider.isEmpty || provider == "Not loaded" { return transport.isEmpty ? "Not loaded" : transport }
if transport.isEmpty || transport == "Not loaded" { return provider }
return "\(provider)\(transport)"
}
private var activeModeText: String {
let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = (appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if title.isEmpty { return "Not active" }
if subtitle.isEmpty { return title }
return "\(title)\(subtitle)"
}
private var talkIssueText: String? {
let text = (appModel.talkMode.gatewayTalkLastIssueText ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
private var permissionText: String {
if let failure = appModel.talkMode.gatewayTalkPermissionState.failureMessage {
return failure
}
return self.appModel.talkMode.gatewayTalkPermissionState.statusLabel
}
private var speechLocaleText: String {
if self.talkSpeechLocale == TalkSpeechLocale.automaticID { return "Automatic" }
return self.talkSpeechLocale
}
private func alignPersistedTalkState() {
if self.appModel.isAppleReviewDemoModeEnabled,
self.talkEnabled || self.appModel.talkMode.isEnabled
{
self.stopTalk()
} else if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction,
self.talkEnabled || self.appModel.talkMode.isEnabled
{
self.stopTalk()
} else if self.talkEnabled != self.appModel.talkMode.isEnabled {
self.appModel.setTalkEnabled(self.talkEnabled)
}
}
private var talkSpeakerphoneBinding: Binding<Bool> {
Binding(
get: { self.talkSpeakerphoneEnabled },
set: { enabled in
self.talkSpeakerphoneEnabled = enabled
self.appModel.setTalkSpeakerphoneEnabled(enabled)
})
}
private func handlePrimaryAction() {
switch self.state.primaryAction {
case .start:
self.startTalk()
case .stop:
self.stopTalk()
case .enablePermission:
self.stopTalk()
self.showPermissionPrompt = true
case .openSettings:
self.openPrimarySettings()
case .waiting:
break
}
}
private func startTalk() {
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
self.talkEnabled = true
self.appModel.talkMode.updateMainSessionKey(self.appModel.chatSessionKey)
self.appModel.setTalkEnabled(true)
}
private func stopTalk() {
self.talkEnabled = false
self.appModel.setTalkEnabled(false)
}
private func openPrimarySettings() {
if self.gatewayConnected {
self.openVoiceSettings()
} else {
self.openSettings()
}
}
}
enum TalkProPrimaryAction: Equatable {
case start
case stop
case enablePermission
case openSettings
case waiting
}
enum TalkProWaveformMode: Equatable {
case level(Double)
case inputSpeech
case speaking
case indeterminate
case still
}
struct TalkProState: Equatable {
let gatewayConnected: Bool
let isDemoMode: Bool
let isEnabled: Bool
let statusText: String
let isConfigLoaded: Bool
let isListening: Bool
let isSpeaking: Bool
let isUserSpeechDetected: Bool
let permissionState: TalkGatewayPermissionState
private var normalizedStatus: String {
self.statusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
var title: String {
if self.isDemoMode { return "Demo mode only" }
if !self.gatewayConnected { return "Gateway offline" }
switch self.permissionState {
case .missingScope, .requestFailed:
return "Gateway permission required"
case .requestingUpgrade:
return "Requesting approval"
case .upgradeRequested:
return "Approval requested"
case .apiKeyMissing:
return "Voice API key missing"
case .loadFailed:
return "Voice config failed"
default:
break
}
if !self.isConfigLoaded { return "Voice config unavailable" }
if self.isSpeaking { return "Speaking" }
if self.isListening { return "Listening" }
if self.normalizedStatus.contains("connecting") { return "Connecting" }
if self.normalizedStatus.contains("thinking") { return "Asking OpenClaw" }
if self.isEnabled { return "Ready to talk" }
return "Talk is off"
}
var color: Color {
if self.isDemoMode { return .secondary }
if !self.gatewayConnected { return .secondary }
switch self.permissionState {
case .requestFailed, .loadFailed:
return OpenClawBrand.danger
case .missingScope, .requestingUpgrade, .upgradeRequested, .apiKeyMissing:
return OpenClawBrand.warn
default:
if !self.isConfigLoaded { return OpenClawBrand.warn }
return self.isEnabled ? OpenClawBrand.ok : OpenClawBrand.accentHot
}
}
var primaryAction: TalkProPrimaryAction {
if self.isDemoMode { return .waiting }
if !self.gatewayConnected { return .openSettings }
switch self.permissionState {
case .missingScope, .requestFailed:
return .enablePermission
case .requestingUpgrade, .upgradeRequested:
return .waiting
case .apiKeyMissing, .loadFailed:
return .openSettings
default:
return self.isEnabled ? .stop : .start
}
}
var primaryButtonTitle: String {
switch self.primaryAction {
case .start: "Start Talk"
case .stop: "Stop Talk"
case .enablePermission: "Enable Talk"
case .openSettings: self.gatewayConnected ? "Open Voice Settings" : "Open Gateway Settings"
case .waiting: self.isDemoMode ? "Demo Mode Only" : "Waiting for Approval"
}
}
var primaryButtonIcon: String {
switch self.primaryAction {
case .start: "play.fill"
case .stop: "stop.fill"
case .enablePermission: "key.fill"
case .openSettings: "gearshape.fill"
case .waiting: self.isDemoMode ? "lock.fill" : "hourglass"
}
}
var prefersPermissionCopy: Bool {
switch self.permissionState {
case .missingScope, .requestingUpgrade, .upgradeRequested, .requestFailed:
true
default:
false
}
}
func waveformMode(micLevel: Double) -> TalkProWaveformMode {
if self.isDemoMode { return .still }
if !self.gatewayConnected { return .still }
switch self.permissionState {
case .requestingUpgrade, .upgradeRequested:
return .indeterminate
case .missingScope, .requestFailed, .apiKeyMissing, .loadFailed:
return .still
default:
break
}
if !self.isConfigLoaded { return .still }
if self.isSpeaking { return .speaking }
if self.isListening, self.isUserSpeechDetected { return .inputSpeech }
if self.isListening { return .level(micLevel) }
if self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking") {
return .indeterminate
}
return self.isEnabled ? .indeterminate : .still
}
}