diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt index d431bedcbc4..9149a0f0886 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt @@ -23,9 +23,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -33,18 +33,25 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -52,9 +59,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.app.ActivityCompat @@ -63,6 +72,8 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import ai.openclaw.android.MainViewModel +import ai.openclaw.android.voice.VoiceConversationEntry +import ai.openclaw.android.voice.VoiceConversationRole import kotlin.math.PI import kotlin.math.max import kotlin.math.sin @@ -78,11 +89,15 @@ fun VoiceTabScreen(viewModel: MainViewModel) { val gatewayStatus by viewModel.statusText.collectAsState() val micEnabled by viewModel.micEnabled.collectAsState() val micStatusText by viewModel.micStatusText.collectAsState() - val liveTranscript by viewModel.micLiveTranscript.collectAsState() - val queuedMessages by viewModel.micQueuedMessages.collectAsState() + val micLiveTranscript by viewModel.micLiveTranscript.collectAsState() + val micQueuedMessages by viewModel.micQueuedMessages.collectAsState() + val micConversation by viewModel.micConversation.collectAsState() val micInputLevel by viewModel.micInputLevel.collectAsState() val micIsSending by viewModel.micIsSending.collectAsState() + val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming } + val showThinkingBubble = micIsSending && !hasStreamingAssistant + var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) } var pendingMicEnable by remember { mutableStateOf(false) } @@ -106,233 +121,305 @@ fun VoiceTabScreen(viewModel: MainViewModel) { pendingMicEnable = false } - LazyColumn( - state = listState, + LaunchedEffect(micConversation.size, showThinkingBubble) { + val total = micConversation.size + if (showThinkingBubble) 1 else 0 + if (total > 0) { + listState.animateScrollToItem(total - 1) + } + } + + Column( modifier = Modifier - .fillMaxWidth() + .fillMaxSize() + .background(mobileBackgroundGradient) .imePadding() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - item { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( "VOICE", style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), color = mobileAccent, ) - Text("Mic capture", style = mobileTitle2, color = mobileText) + Text("Voice mode", style = mobileTitle2, color = mobileText) + } + Surface( + shape = RoundedCornerShape(999.dp), + color = if (isConnected) mobileAccentSoft else mobileSurfaceStrong, + border = BorderStroke(1.dp, if (isConnected) mobileAccent.copy(alpha = 0.25f) else mobileBorderStrong), + ) { Text( - if (isConnected) { - "Mic on captures speech continuously. Mic off sends the full transcript queue." - } else { - "Gateway offline. Mic off will keep messages queued until reconnect." - }, - style = mobileCallout, - color = mobileTextSecondary, + if (isConnected) "Connected" else "Offline", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = mobileCaption1, + color = if (isConnected) mobileAccent else mobileTextSecondary, ) } } - item { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - color = Color.White, - border = BorderStroke(1.dp, mobileBorder), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().weight(1f), + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (micConversation.isEmpty() && !showThinkingBubble) { + item { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text("Gateway", style = mobileCaption1, color = mobileTextSecondary) - Text(gatewayStatus, style = mobileCaption1, color = mobileText) - } - Text(micStatusText, style = mobileHeadline, color = mobileText) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - if (micEnabled) { - viewModel.setMicEnabled(false) - return@Button - } - if (hasMicPermission) { - viewModel.setMicEnabled(true) - } else { - pendingMicEnable = true - requestMicPermission.launch(Manifest.permission.RECORD_AUDIO) - } - }, - shape = RoundedCornerShape(12.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = if (micEnabled) mobileDanger else mobileAccent, - contentColor = Color.White, - ), - ) { - Text( - if (micEnabled) "Mic off" else "Mic on", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - if (!hasMicPermission) { - Button( - onClick = { openAppSettings(context) }, - shape = RoundedCornerShape(12.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = mobileSurfaceStrong, - contentColor = mobileText, - ), - ) { - Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold)) - } - } - } - if (!hasMicPermission) { - val showRationale = - if (activity == null) { - false - } else { - ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO) - } Text( - if (showRationale) { - "Microphone permission required to capture speech." - } else { - "Microphone is blocked. Enable it in app settings." - }, + "Tap the mic and speak. Each pause sends a turn automatically.", style = mobileCallout, - color = mobileWarning, + color = mobileTextSecondary, ) } } } - } - item { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - color = Color.White, - border = BorderStroke(1.dp, mobileBorder), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("Mic waveform", style = mobileHeadline, color = mobileText) - MicWaveform(level = micInputLevel, active = micEnabled) + items(items = micConversation, key = { it.id }) { entry -> + VoiceTurnBubble(entry = entry) + } + + if (showThinkingBubble) { + item { + VoiceThinkingBubble() } } } - item { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - color = Color.White, - border = BorderStroke(1.dp, mobileBorder), + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + Surface( + shape = RoundedCornerShape(999.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), ) { - Text("Live transcript", style = mobileHeadline, color = mobileText) + val queueCount = micQueuedMessages.size + val stateText = + when { + queueCount > 0 -> "$queueCount queued" + micIsSending -> "Sending" + micEnabled -> "Listening" + else -> "Mic off" + } Text( - liveTranscript?.trim().takeUnless { it.isNullOrEmpty() } ?: "Waiting for speech…", - style = mobileCallout, - color = if (liveTranscript.isNullOrBlank()) mobileTextTertiary else mobileText, - ) - } - } - } - - item { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - color = Color.White, - border = BorderStroke(1.dp, mobileBorder), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("Queued messages", style = mobileHeadline, color = mobileText) - Text( - if (queuedMessages.isEmpty()) { - "No queued transcripts." - } else { - "${queuedMessages.size} queued${if (micIsSending) " · sending…" else ""}" - }, - style = mobileCallout, + "$gatewayStatus · $stateText", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + style = mobileCaption1, color = mobileTextSecondary, ) - if (queuedMessages.isNotEmpty()) { - HorizontalDivider(color = mobileBorder) - } - if (queuedMessages.isEmpty()) { - Text("Turn mic off to flush captured speech into the queue.", style = mobileCallout, color = mobileTextTertiary) - } else { - queuedMessages.forEachIndexed { index, item -> - Surface( - modifier = Modifier.fillMaxWidth().padding(bottom = if (index == queuedMessages.lastIndex) 0.dp else 8.dp), - shape = RoundedCornerShape(12.dp), - color = mobileSurface, - border = BorderStroke(1.dp, mobileBorder), - ) { - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp)) { - Text("Message ${index + 1}", style = mobileCaption1, color = mobileTextSecondary) - Text(item, style = mobileCallout, color = mobileText) - } - } - } + } + + if (!micLiveTranscript.isNullOrBlank()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileAccentSoft, + border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)), + ) { + Text( + micLiveTranscript!!.trim(), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = mobileCallout, + color = mobileText, + ) } } + + MicWaveform(level = micInputLevel, active = micEnabled) + + Button( + onClick = { + if (micEnabled) { + viewModel.setMicEnabled(false) + return@Button + } + if (hasMicPermission) { + viewModel.setMicEnabled(true) + } else { + pendingMicEnable = true + requestMicPermission.launch(Manifest.permission.RECORD_AUDIO) + } + }, + shape = CircleShape, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.size(86.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (micEnabled) mobileDanger else mobileAccent, + contentColor = Color.White, + ), + ) { + Icon( + imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic, + contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on", + modifier = Modifier.size(30.dp), + ) + } + + Text( + if (micEnabled) "Tap to stop" else "Tap to speak", + style = mobileCallout, + color = mobileTextSecondary, + ) + + if (!hasMicPermission) { + val showRationale = + if (activity == null) { + false + } else { + ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO) + } + Text( + if (showRationale) { + "Microphone permission is required for voice mode." + } else { + "Microphone blocked. Open app settings to enable it." + }, + style = mobileCaption1, + color = mobileWarning, + textAlign = TextAlign.Center, + ) + Button( + onClick = { openAppSettings(context) }, + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText), + ) { + Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold)) + } + } + + Text( + micStatusText, + style = mobileCaption1, + color = mobileTextTertiary, + ) } } - - item { Spacer(modifier = Modifier.height(24.dp)) } } } +@Composable +private fun VoiceTurnBubble(entry: VoiceConversationEntry) { + val isUser = entry.role == VoiceConversationRole.User + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + ) { + Surface( + modifier = Modifier.fillMaxWidth(0.90f), + shape = RoundedCornerShape(14.dp), + color = if (isUser) mobileAccentSoft else mobileSurface, + border = BorderStroke(1.dp, if (isUser) mobileAccent.copy(alpha = 0.2f) else mobileBorder), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + if (isUser) "You" else "OpenClaw", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = mobileTextSecondary, + ) + Text( + if (entry.isStreaming && entry.text.isBlank()) "Listening response…" else entry.text, + style = mobileCallout, + color = mobileText, + ) + } + } + } +} + +@Composable +private fun VoiceThinkingBubble() { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Surface( + modifier = Modifier.fillMaxWidth(0.68f), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ThinkingDots(color = mobileTextSecondary) + Text("OpenClaw is thinking…", style = mobileCallout, color = mobileTextSecondary) + } + } + } +} + +@Composable +private fun ThinkingDots(color: Color) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { + ThinkingDot(alpha = 0.38f, color = color) + ThinkingDot(alpha = 0.62f, color = color) + ThinkingDot(alpha = 0.90f, color = color) + } +} + +@Composable +private fun ThinkingDot(alpha: Float, color: Color) { + Surface( + modifier = Modifier.size(6.dp).alpha(alpha), + shape = CircleShape, + color = color, + ) {} +} + @Composable private fun MicWaveform(level: Float, active: Boolean) { - val transition = rememberInfiniteTransition(label = "wave") + val transition = rememberInfiniteTransition(label = "voiceWave") val phase by transition.animateFloat( initialValue = 0f, targetValue = 1f, - animationSpec = infiniteRepeatable(animation = tween(1_200, easing = LinearEasing), repeatMode = RepeatMode.Restart), - label = "wavePhase", + animationSpec = infiniteRepeatable(animation = tween(1_000, easing = LinearEasing), repeatMode = RepeatMode.Restart), + label = "voiceWavePhase", ) + val effective = if (active) level.coerceIn(0f, 1f) else 0f - val base = max(effective, if (active) 0.08f else 0f) + val base = max(effective, if (active) 0.05f else 0f) + Row( - modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp), horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, ) { - repeat(22) { index -> + repeat(16) { index -> val pulse = if (!active) { 0f } else { - ((sin(((phase * 2f * PI) + (index * 0.5f)).toDouble()) + 1.0) * 0.5).toFloat() + ((sin(((phase * 2f * PI) + (index * 0.55f)).toDouble()) + 1.0) * 0.5).toFloat() } - val barHeight = 8.dp + (52.dp * (base * pulse)) + val barHeight = 6.dp + (24.dp * (base * pulse)) Box( modifier = Modifier - .width(6.dp) + .width(5.dp) .height(barHeight) .background(if (active) mobileAccent else mobileBorderStrong, RoundedCornerShape(999.dp)), )