Files
openclaw/apps/ios/Sources/HomeToolbar.swift
Nimrod Gutman 19e4c37c37 feat(ios): show Talk voice mode (#86798)
Merged via squash.

Prepared head SHA: bd24da3f3b
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-05-26 17:31:31 +03:00

606 lines
22 KiB
Swift

import SwiftUI
struct HomeToolbar: View {
var gateway: StatusPill.GatewayState
var voiceWakeEnabled: Bool
var activity: StatusPill.Activity?
var brighten: Bool
var talkButtonEnabled: Bool
var talkActive: Bool
var talkTint: Color
var onStatusTap: () -> Void
var onChatTap: () -> Void
var onTalkTap: () -> Void
var onSettingsTap: () -> Void
@Environment(\.colorSchemeContrast) private var contrast
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12)))
.frame(height: self.contrast == .increased ? 1.0 : 0.6)
.allowsHitTesting(false)
HStack(spacing: 12) {
HomeToolbarStatusButton(
gateway: self.gateway,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.activity,
brighten: self.brighten,
onTap: self.onStatusTap)
Spacer(minLength: 0)
HStack(spacing: 8) {
HomeToolbarActionButton(
systemImage: "text.bubble.fill",
accessibilityLabel: "Chat",
brighten: self.brighten,
action: self.onChatTap)
if self.talkButtonEnabled {
HomeToolbarActionButton(
systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle",
accessibilityLabel: self.talkActive ? "Talk Mode On" : "Talk Mode Off",
brighten: self.brighten,
tint: self.talkTint,
isActive: self.talkActive,
action: self.onTalkTap)
}
HomeToolbarActionButton(
systemImage: "gearshape.fill",
accessibilityLabel: "Settings",
brighten: self.brighten,
action: self.onSettingsTap)
}
}
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 8)
}
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
.overlay(alignment: .top) {
LinearGradient(
colors: [
.white.opacity(self.brighten ? 0.10 : 0.06),
.clear,
],
startPoint: .top,
endPoint: .bottom)
.allowsHitTesting(false)
}
}
}
struct TalkToolbarTray: View {
var brighten: Bool
var tint: Color
var statusText: String
var agentName: String
var micLevel: Double
var isListening: Bool
var isSpeaking: Bool
var isUserSpeechDetected: Bool
var permissionState: TalkGatewayPermissionState
var voiceModeTitle: String
var voiceModeSubtitle: String?
var onEnableTalk: () -> Void
var onStopTalk: () -> Void
@Environment(\.colorSchemeContrast) private var contrast
private var state: TalkToolbarTrayState {
TalkToolbarTrayState(
statusText: self.statusText,
isListening: self.isListening,
isSpeaking: self.isSpeaking,
isUserSpeechDetected: self.isUserSpeechDetected,
permissionState: self.permissionState)
}
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(self.tint.opacity(self.state.iconFillOpacity))
.frame(width: 36, height: 36)
Image(systemName: self.state.systemImage)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(self.state.iconColor(tint: self.tint))
}
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 8) {
Text(self.state.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
if self.state.showsProgress {
ProgressView()
.controlSize(.mini)
}
}
HStack(spacing: 8) {
TalkWaveformView(
mode: self.state.waveformMode(micLevel: self.micLevel),
tint: self.state.waveformTint(tint: self.tint))
.frame(width: 84, height: 18)
.accessibilityHidden(true)
Text(self.subtitle)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let voiceModeText = self.voiceModeText {
Text(voiceModeText)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
switch self.state.action {
case .enable:
Button(action: self.onEnableTalk) {
Label("Enable Talk", systemImage: "key.fill")
.labelStyle(.titleAndIcon)
}
.font(.caption.weight(.semibold))
.buttonStyle(.borderedProminent)
.controlSize(.small)
case .stop:
Button(action: self.onStopTalk) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .bold))
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
.background {
Circle()
.fill(Color.black.opacity(self.brighten ? 0.10 : 0.18))
.overlay {
Circle()
.strokeBorder(
.white.opacity(self.contrast == .increased ? 0.42 : 0.16),
lineWidth: self.contrast == .increased ? 1.0 : 0.6)
}
}
.accessibilityLabel("Stop Talk")
case .none:
EmptyView()
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
.overlay(alignment: .top) {
Rectangle()
.fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12)))
.frame(height: self.contrast == .increased ? 1.0 : 0.6)
.allowsHitTesting(false)
}
.overlay(alignment: .bottom) {
LinearGradient(
colors: [
self.tint.opacity(self.brighten ? 0.12 : 0.16),
.clear,
],
startPoint: .leading,
endPoint: .trailing)
.frame(height: 1)
.allowsHitTesting(false)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Talk Mode")
.accessibilityValue(self.accessibilityValue)
}
private var accessibilityValue: String {
if let voiceModeText {
return "\(self.state.title), \(self.subtitle), \(voiceModeText)"
}
return "\(self.state.title), \(self.subtitle)"
}
private var voiceModeText: String? {
guard !self.state.prefersPermissionCopy else { return nil }
let title = self.voiceModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty, title != "Not loaded" else { return nil }
let subtitle = (self.voiceModeSubtitle ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return subtitle.isEmpty ? title : "\(title)\(subtitle)"
}
private var subtitle: String {
let trimmedAgent = self.agentName.trimmingCharacters(in: .whitespacesAndNewlines)
if self.state.prefersPermissionCopy {
return "Gateway approval needed"
}
if !trimmedAgent.isEmpty {
return trimmedAgent
}
return "OpenClaw"
}
}
private enum TalkToolbarTrayAction {
case none
case enable
case stop
}
private enum TalkWaveformMode: Equatable {
case level(Double)
case inputSpeech
case speaking
case indeterminate
case still
}
private struct TalkToolbarTrayState: Equatable {
let statusText: String
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 {
switch self.permissionState {
case .missingScope, .requestFailed:
return "Gateway permission required"
case .requestingUpgrade:
return "Requesting approval"
case .upgradeRequested:
return "Approval requested"
default:
break
}
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.normalizedStatus == "ready" { return "Ready to talk" }
if self.normalizedStatus.isEmpty || self.normalizedStatus == "off" { return "Talk" }
return self.statusText
}
var systemImage: String {
switch self.permissionState {
case .missingScope, .requestFailed:
return "key.fill"
case .requestingUpgrade:
return "paperplane.fill"
case .upgradeRequested:
return "hourglass"
default:
break
}
if self.isSpeaking { return "speaker.wave.2.fill" }
if self.isListening { return "mic.fill" }
if self.normalizedStatus.contains("thinking") { return "sparkles" }
if self.normalizedStatus.contains("connecting") { return "dot.radiowaves.left.and.right" }
return "waveform"
}
var action: TalkToolbarTrayAction {
switch self.permissionState {
case .missingScope, .requestFailed:
.enable
case .requestingUpgrade, .upgradeRequested:
.none
default:
.stop
}
}
var showsProgress: Bool {
switch self.permissionState {
case .requestingUpgrade, .upgradeRequested:
true
default:
self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking")
}
}
var prefersPermissionCopy: Bool {
switch self.permissionState {
case .missingScope, .requestingUpgrade, .upgradeRequested, .requestFailed:
true
default:
false
}
}
var iconFillOpacity: Double {
self.prefersPermissionCopy ? 0.18 : 0.24
}
func iconColor(tint: Color) -> Color {
switch self.permissionState {
case .requestFailed:
.red
case .missingScope, .requestingUpgrade, .upgradeRequested:
.orange
default:
tint
}
}
func waveformTint(tint: Color) -> Color {
switch self.permissionState {
case .requestFailed:
.red
case .missingScope, .requestingUpgrade, .upgradeRequested:
.orange
default:
tint
}
}
func waveformMode(micLevel: Double) -> TalkWaveformMode {
switch self.permissionState {
case .requestingUpgrade, .upgradeRequested:
return .indeterminate
case .missingScope, .requestFailed:
return .still
default:
break
}
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 .still
}
}
private struct TalkWaveformView: View {
var mode: TalkWaveformMode
var tint: Color
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let barCount = 14
var body: some View {
TimelineView(.periodic(from: .now, by: 1.0 / 24.0)) { timeline in
HStack(alignment: .center, spacing: 3) {
ForEach(0..<self.barCount, id: \.self) { index in
Capsule(style: .continuous)
.fill(self.tint.opacity(self.opacity(for: index)))
.frame(width: 3, height: self.height(for: index, date: timeline.date))
}
}
.frame(maxHeight: .infinity)
}
}
private func height(for index: Int, date: Date) -> CGFloat {
let minimum: Double = 4
let maximum: Double = 18
let amplitude = self.amplitude(for: index, date: date)
return CGFloat(minimum + ((maximum - minimum) * amplitude))
}
private func opacity(for index: Int) -> Double {
switch self.mode {
case .still:
index == self.barCount / 2 ? 0.64 : 0.32
default:
0.78
}
}
private func amplitude(for index: Int, date: Date) -> Double {
if self.reduceMotion {
switch self.mode {
case let .level(level):
return min(max(level, 0.10), 1.0)
case .inputSpeech:
return 0.72
case .speaking:
return 0.62
case .indeterminate:
return 0.34
case .still:
return 0.18
}
}
let t = date.timeIntervalSinceReferenceDate
let phase = Double(index) * 0.52
switch self.mode {
case let .level(level):
let clamped = min(max(level, 0), 1)
let shaped = 0.12 + (0.88 * clamped)
let variation = 0.72 + (0.28 * sin((t * 12.0) + phase))
return min(max(shaped * variation, 0.10), 1.0)
case .inputSpeech:
let primary = 0.5 + (0.5 * sin((t * 14.0) + phase))
let secondary = 0.5 + (0.5 * sin((t * 5.0) + (phase * 1.35)))
return min(max(0.16 + (0.60 * primary) + (0.24 * secondary), 0.14), 1.0)
case .speaking:
let wave = 0.5 + (0.5 * sin((t * 7.5) + phase))
let secondary = 0.5 + (0.5 * sin((t * 3.0) + (phase * 0.7)))
return min(max(0.18 + (0.58 * wave) + (0.24 * secondary), 0.12), 1.0)
case .indeterminate:
let center = (sin((t * 3.2) + phase) + 1) / 2
return 0.16 + (0.42 * center)
case .still:
return index == self.barCount / 2 ? 0.32 : 0.16
}
}
}
private struct HomeToolbarStatusButton: View {
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
var gateway: StatusPill.GatewayState
var voiceWakeEnabled: Bool
var activity: StatusPill.Activity?
var brighten: Bool
var onTap: () -> Void
@State private var pulse: Bool = false
var body: some View {
Button(action: self.onTap) {
HStack(spacing: 8) {
HStack(spacing: 6) {
Circle()
.fill(self.gateway.color)
.frame(width: 8, height: 8)
.scaleEffect(
self.gateway == .connecting && !self.reduceMotion
? (self.pulse ? 1.15 : 0.85)
: 1.0)
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title)
.font(.footnote.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
if let activity {
Image(systemName: activity.systemImage)
.font(.footnote.weight(.semibold))
.foregroundStyle(activity.tint ?? .primary)
.transition(.opacity.combined(with: .move(edge: .top)))
} else {
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
.font(.footnote.weight(.semibold))
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.black.opacity(self.brighten ? 0.12 : 0.18))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(
.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.22 : 0.16)),
lineWidth: self.contrast == .increased ? 1.0 : 0.6)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel("Connection Status")
.accessibilityValue(self.accessibilityValue)
.accessibilityHint(
self.gateway == .connected
? "Double tap for gateway actions"
: "Double tap to open settings")
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
.onDisappear { self.pulse = false }
.onChange(of: self.gateway) { _, newValue in
self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion)
}
.onChange(of: self.scenePhase) { _, newValue in
self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion)
}
.onChange(of: self.reduceMotion) { _, newValue in
self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue)
}
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
}
private var accessibilityValue: String {
if let activity {
return "\(self.gateway.title), \(activity.title)"
}
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
}
private func updatePulse(for gateway: StatusPill.GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) {
guard gateway == .connecting, scenePhase == .active, !reduceMotion else {
withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false }
return
}
guard !self.pulse else { return }
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
self.pulse = true
}
}
}
private struct HomeToolbarActionButton: View {
@Environment(\.colorSchemeContrast) private var contrast
let systemImage: String
let accessibilityLabel: String
let brighten: Bool
var tint: Color?
var isActive: Bool = false
let action: () -> Void
var body: some View {
Button(action: self.action) {
Image(systemName: self.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
.frame(width: 40, height: 40)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.black.opacity(self.brighten ? 0.12 : 0.18))
.overlay {
if let tint {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
tint.opacity(self.isActive ? 0.22 : 0.14),
tint.opacity(self.isActive ? 0.08 : 0.04),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(
(self.tint ?? .white).opacity(
self.isActive
? 0.34
: (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16))),
lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6))
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(self.accessibilityLabel)
}
}