From e6ba373d0886674bd2e451fa5363a037ba377a73 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 17 Dec 2025 22:00:12 +0100 Subject: [PATCH] feat(android): add status pill overlay --- apps/android/app/build.gradle.kts | 1 + .../steipete/clawdis/node/ui/RootScreen.kt | 82 +++++++++++++---- .../steipete/clawdis/node/ui/StatusPill.kt | 88 +++++++++++++++++++ 3 files changed, 155 insertions(+), 16 deletions(-) create mode 100644 apps/android/app/src/main/java/com/steipete/clawdis/node/ui/StatusPill.kt diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 361e9c244..b70b4315d 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") implementation("androidx.navigation:navigation-compose:2.9.6") debugImplementation("androidx.compose.ui:ui-tooling") diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt index d706e8de2..9d021f311 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt @@ -1,25 +1,31 @@ package com.steipete.clawdis.node.ui import android.annotation.SuppressLint +import android.Manifest +import android.content.pm.PackageManager import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -31,6 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.core.content.ContextCompat import com.steipete.clawdis.node.MainViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -38,25 +45,55 @@ import com.steipete.clawdis.node.MainViewModel fun RootScreen(viewModel: MainViewModel) { var sheet by remember { mutableStateOf(null) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val safeButtonInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + val context = LocalContext.current + val serverName by viewModel.serverName.collectAsState() + val statusText by viewModel.statusText.collectAsState() + + val bridgeState = + remember(serverName, statusText) { + when { + serverName != null -> BridgeState.Connected + statusText.contains("connecting", ignoreCase = true) || + statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting + statusText.contains("error", ignoreCase = true) -> BridgeState.Error + else -> BridgeState.Disconnected + } + } + + val voiceEnabled = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED Box(modifier = Modifier.fillMaxSize()) { CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize()) } // Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches. - Popup(alignment = Alignment.TopCenter, properties = PopupProperties(focusable = false)) { - Row( - modifier = - Modifier - .fillMaxWidth() - .windowInsetsPadding(safeButtonInsets) - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) { + StatusPill( + bridge = bridgeState, + voiceEnabled = voiceEnabled, + onClick = { sheet = Sheet.Settings }, + modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp), + ) + } + + Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) { + Column( + modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.End, ) { - Button(onClick = { sheet = Sheet.Chat }) { Text("Chat") } - Button(onClick = { sheet = Sheet.Settings }) { Text("Settings") } + OverlayIconButton( + onClick = { sheet = Sheet.Chat }, + icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") }, + ) + + OverlayIconButton( + onClick = { sheet = Sheet.Settings }, + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + ) } } @@ -79,6 +116,19 @@ private enum class Sheet { Settings, } +@Composable +private fun OverlayIconButton( + onClick: () -> Unit, + icon: @Composable () -> Unit, +) { + FilledTonalIconButton( + onClick = onClick, + modifier = Modifier.size(44.dp), + ) { + icon() + } +} + @SuppressLint("SetJavaScriptEnabled") @Composable private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) { diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/StatusPill.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/StatusPill.kt new file mode 100644 index 000000000..f9ff325e7 --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/StatusPill.kt @@ -0,0 +1,88 @@ +package com.steipete.clawdis.node.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +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.unit.dp + +@Composable +fun StatusPill( + bridge: BridgeState, + voiceEnabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + onClick = onClick, + modifier = modifier, + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.86f), + shadowElevation = 10.dp, + border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.18f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Surface( + modifier = Modifier.size(9.dp), + shape = CircleShape, + color = bridge.color, + ) {} + + Text( + text = bridge.title, + style = MaterialTheme.typography.labelLarge, + ) + } + + VerticalDivider( + modifier = Modifier.height(14.dp).alpha(0.35f), + color = MaterialTheme.colorScheme.onSurface, + ) + + Icon( + imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff, + contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled", + tint = + if (voiceEnabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + + Spacer(modifier = Modifier.width(2.dp)) + } + } +} + +enum class BridgeState(val title: String, val color: Color) { + Connected("Connected", Color(0xFF2ECC71)), + Connecting("Connecting…", Color(0xFFF1C40F)), + Error("Error", Color(0xFFE74C3C)), + Disconnected("Offline", Color(0xFF9E9E9E)), +}