Fix canvas input bar: fullscreen on right, always visible, keyboard handling

This commit is contained in:
yishai 2026-01-29 04:03:01 +02:00
parent bf4bd9604c
commit 275b908d0e

View File

@ -19,19 +19,37 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.webkit.WebSettingsCompat import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature import androidx.webkit.WebViewFeature
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
@ -40,6 +58,8 @@ import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ScreenShare import androidx.compose.material.icons.automirrored.filled.ScreenShare
@ -47,6 +67,8 @@ import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.FiberManualRecord import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.RecordVoiceOver import androidx.compose.material.icons.filled.RecordVoiceOver
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
@ -75,6 +97,8 @@ import com.clawdbot.android.MainViewModel
@Composable @Composable
fun RootScreen(viewModel: MainViewModel) { fun RootScreen(viewModel: MainViewModel) {
var sheet by remember { mutableStateOf<Sheet?>(null) } var sheet by remember { mutableStateOf<Sheet?>(null) }
var canvasFullscreen by remember { mutableStateOf(false) }
var chatInput by remember { mutableStateOf("") }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
val context = LocalContext.current val context = LocalContext.current
@ -89,6 +113,7 @@ fun RootScreen(viewModel: MainViewModel) {
val talkStatusText by viewModel.talkStatusText.collectAsState() val talkStatusText by viewModel.talkStatusText.collectAsState()
val talkIsListening by viewModel.talkIsListening.collectAsState() val talkIsListening by viewModel.talkIsListening.collectAsState()
val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState() val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState()
val canvasHasContent by viewModel.canvasHasContent.collectAsState()
val seamColorArgb by viewModel.seamColorArgb.collectAsState() val seamColorArgb by viewModel.seamColorArgb.collectAsState()
val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) } val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) }
val audioPermissionLauncher = val audioPermissionLauncher =
@ -202,11 +227,16 @@ fun RootScreen(viewModel: MainViewModel) {
} }
// Camera flash must be in a Popup to render above the WebView. // Camera flash must be in a Popup to render above the WebView.
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { // Only show when flash is active to avoid blocking touches.
CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize()) if (cameraFlashToken != 0L) {
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize())
}
} }
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches. // Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
// Hide when canvas is in fullscreen mode (except the toggle button)
if (!canvasFullscreen) {
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) { Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
StatusPill( StatusPill(
gateway = gatewayState, gateway = gatewayState,
@ -267,11 +297,31 @@ fun RootScreen(viewModel: MainViewModel) {
} }
} }
// PTT orb overlay - always visible, static when off, pulsing when active // Status text above orb
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.offset(y = (-140).dp),
) {
val statusPhrase = when {
!talkEnabled -> "Tap to talk"
talkIsSpeaking -> "Speaking..."
talkIsListening -> "Listening..."
else -> "Thinking..."
}
Text(
text = statusPhrase,
color = ComposeColor.White.copy(alpha = 0.8f),
fontSize = 14.sp,
)
}
}
// Orb in center
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
TalkOrbOverlay( TalkOrbOverlay(
seamColor = seamColor, seamColor = seamColor,
statusText = talkStatusText, statusText = "",
isListening = talkIsListening, isListening = talkIsListening,
isSpeaking = talkIsSpeaking, isSpeaking = talkIsSpeaking,
isActive = talkEnabled, isActive = talkEnabled,
@ -279,6 +329,92 @@ fun RootScreen(viewModel: MainViewModel) {
) )
} }
} // end if (!canvasFullscreen)
// Chat input at bottom - ALWAYS visible (even in fullscreen)
// Fullscreen button on RIGHT side (above text field), send button at far right
Popup(alignment = Alignment.BottomCenter, properties = PopupProperties(focusable = true, dismissOnClickOutside = false)) {
Row(
modifier = Modifier
.imePadding()
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 12.dp, vertical = 16.dp)
.fillMaxWidth(0.92f)
.background(
color = ComposeColor(0xFF1E1E1E),
shape = RoundedCornerShape(28.dp),
)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Text field - takes most of the space
BasicTextField(
value = chatInput,
onValueChange = { chatInput = it },
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
textStyle = TextStyle(color = ComposeColor.White, fontSize = 16.sp),
cursorBrush = SolidColor(seamColor),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(
onSend = {
if (chatInput.isNotBlank()) {
viewModel.sendChat(message = chatInput.trim(), thinking = "low", attachments = emptyList())
chatInput = ""
}
}
),
decorationBox = { innerTextField ->
Box(modifier = Modifier.fillMaxWidth()) {
if (chatInput.isEmpty()) {
Text(
"Message Clawd...",
color = ComposeColor.White.copy(alpha = 0.4f),
fontSize = 16.sp,
)
}
innerTextField()
}
},
)
// Fullscreen button - RIGHT side (before send)
FilledTonalIconButton(
onClick = { canvasFullscreen = !canvasFullscreen },
modifier = Modifier.size(40.dp),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = if (canvasFullscreen) ComposeColor(0xFFFF5722) else ComposeColor.White.copy(alpha = 0.1f),
contentColor = ComposeColor.White,
),
) {
Icon(
if (canvasFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
contentDescription = "Fullscreen",
modifier = Modifier.size(20.dp),
)
}
// Send button - far right
FilledTonalIconButton(
onClick = {
if (chatInput.isNotBlank()) {
viewModel.sendChat(message = chatInput.trim(), thinking = "low", attachments = emptyList())
chatInput = ""
}
},
modifier = Modifier.size(40.dp),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = if (chatInput.isNotBlank()) seamColor else ComposeColor.White.copy(alpha = 0.1f),
contentColor = ComposeColor.White,
),
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", modifier = Modifier.size(20.dp))
}
}
}
val currentSheet = sheet val currentSheet = sheet
if (currentSheet != null) { if (currentSheet != null) {
ModalBottomSheet( ModalBottomSheet(
@ -345,6 +481,40 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
isHorizontalScrollBarEnabled = true isHorizontalScrollBarEnabled = true
webViewClient = webViewClient =
object : WebViewClient() { object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean {
val url = request.url
val scheme = url.scheme ?: return false
// Allow data: and file: URLs to load in WebView
if (scheme == "data" || scheme == "file" || scheme == "http" || scheme == "https") {
// For http/https, check if it's an external app link (like waze.com)
if (scheme == "https" || scheme == "http") {
val host = url.host ?: return false
if (host.contains("waze.com") || host.contains("maps.google") || host.contains("goo.gl")) {
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, url)
context.startActivity(intent)
return true
} catch (e: Exception) {
Log.e("ClawdbotWebView", "Failed to open external URL: $url", e)
}
}
}
return false
}
// External URL scheme (waze://, tel://, mailto://, etc.) - open externally
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, url)
context.startActivity(intent)
return true
} catch (e: Exception) {
Log.e("ClawdbotWebView", "Failed to open external URL: $url", e)
return false
}
}
override fun onReceivedError( override fun onReceivedError(
view: WebView, view: WebView,
request: WebResourceRequest, request: WebResourceRequest,