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 { 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 } }