diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt index 50ae7bc9c..d37e9ff46 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt @@ -62,7 +62,8 @@ class ChatController( fun onDisconnected(message: String) { _healthOk.value = false - _errorText.value = message + // Not an error; keep connection status in the UI pill. + _errorText.value = null clearPendingRuns() pendingToolCallsById.clear() publishPendingToolCalls() diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt index 699b96299..e6626d009 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt @@ -4,6 +4,8 @@ import android.Manifest import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -11,19 +13,24 @@ 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.fillMaxWidth import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing 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.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -35,6 +42,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.steipete.clawdis.node.MainViewModel @@ -56,9 +64,11 @@ fun SettingsSheet(viewModel: MainViewModel) { val serverName by viewModel.serverName.collectAsState() val remoteAddress by viewModel.remoteAddress.collectAsState() val bridges by viewModel.bridges.collectAsState() - val listState = rememberLazyListState() + val listState = rememberLazyListState() val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } + val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } + LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } val permissionLauncher = @@ -67,6 +77,29 @@ fun SettingsSheet(viewModel: MainViewModel) { viewModel.setCameraEnabled(cameraOk) } + fun setCameraEnabledChecked(checked: Boolean) { + if (!checked) { + viewModel.setCameraEnabled(false) + return + } + + val cameraOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + if (cameraOk) { + viewModel.setCameraEnabled(true) + } else { + permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)) + } + } + + val bridgeDiscoveryFooterText = + if (bridges.isEmpty()) { + "Searching for bridges…" + } else { + "Discovery active • ${bridges.size} bridge${if (bridges.size == 1) "" else "s"} found" + } + LazyColumn( state = listState, modifier = @@ -76,9 +109,9 @@ fun SettingsSheet(viewModel: MainViewModel) { .imePadding() .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - item { Text("Node") } + item { Text("Node", style = MaterialTheme.typography.titleSmall) } item { OutlinedTextField( value = displayName, @@ -87,11 +120,11 @@ fun SettingsSheet(viewModel: MainViewModel) { modifier = Modifier.fillMaxWidth(), ) } - item { Text("Instance ID: $instanceId") } + item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) } item { HorizontalDivider() } - item { Text("Wake Words") } + item { Text("Wake Words", style = MaterialTheme.typography.titleSmall) } item { OutlinedTextField( value = wakeWordsText, @@ -123,56 +156,50 @@ fun SettingsSheet(viewModel: MainViewModel) { } else { "Connect to a gateway to sync wake words globally." }, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } item { HorizontalDivider() } - item { Text("Camera") } + item { Text("Camera", style = MaterialTheme.typography.titleSmall) } item { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { - Switch( - checked = cameraEnabled, - onCheckedChange = { enabled -> - if (!enabled) { - viewModel.setCameraEnabled(false) - return@Switch - } - - val cameraOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == - PackageManager.PERMISSION_GRANTED - if (cameraOk) { - viewModel.setCameraEnabled(true) - } else { - permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)) - } - }, - ) - Text(if (cameraEnabled) "Allow Camera" else "Camera Disabled") - } + ListItem( + headlineContent = { Text("Allow Camera") }, + supportingContent = { Text("Allows the bridge to request photos or short video clips (foreground only).") }, + trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, + ) + } + item { + Text( + "Tip: grant Microphone permission for video clips with audio.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - item { Text("Tip: grant Microphone permission for video clips with audio.") } item { HorizontalDivider() } - item { Text("Screen") } + item { Text("Screen", style = MaterialTheme.typography.titleSmall) } item { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { - Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) - Text(if (preventSleep) "Prevent Sleep" else "Allow Sleep") - } + ListItem( + headlineContent = { Text("Prevent Sleep") }, + supportingContent = { Text("Keeps the screen awake while Clawdis is open.") }, + trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, + ) } - item { Text("Keeps the screen awake while Clawdis is open.") } item { HorizontalDivider() } - item { Text("Bridge") } - item { Text("Status: $statusText") } - item { if (serverName != null) Text("Server: $serverName") } - item { if (remoteAddress != null) Text("Address: $remoteAddress") } + item { Text("Bridge", style = MaterialTheme.typography.titleSmall) } + item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) } + if (serverName != null) { + item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) } + } + if (remoteAddress != null) { + item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) } + } item { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (isConnected) { Button( onClick = { viewModel.disconnect() @@ -186,72 +213,95 @@ fun SettingsSheet(viewModel: MainViewModel) { item { HorizontalDivider() } - item { Text("Advanced") } - item { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { - Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) - Text(if (manualEnabled) "Manual Bridge Enabled" else "Manual Bridge Disabled") + item { Text("Discovered Bridges", style = MaterialTheme.typography.titleSmall) } + if (bridges.isEmpty()) { + item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) } + } else { + items(items = bridges, key = { it.stableId }) { bridge -> + ListItem( + headlineContent = { Text(bridge.name) }, + supportingContent = { Text("${bridge.host}:${bridge.port}") }, + trailingContent = { + Button( + onClick = { + NodeForegroundService.start(context) + viewModel.connect(bridge) + }, + ) { + Text("Connect") + } + }, + ) } } item { - OutlinedTextField( - value = manualHost, - onValueChange = viewModel::setManualHost, - label = { Text("Host") }, + Text( + bridgeDiscoveryFooterText, modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - item { - OutlinedTextField( - value = manualPort.toString(), - onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) }, - label = { Text("Port") }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - ) - } - item { - Button( - onClick = { - NodeForegroundService.start(context) - viewModel.connectManual() - }, - enabled = manualEnabled, - ) { - Text("Connect (Manual)") - } - } item { HorizontalDivider() } - item { Text("Discovered Bridges") } - if (bridges.isEmpty()) { - item { Text("No bridges found yet.") } - } else { - items(items = bridges, key = { it.stableId }) { bridge -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(bridge.name) - Text("${bridge.host}:${bridge.port}") - } - Spacer(modifier = Modifier.padding(4.dp)) + item { + ListItem( + headlineContent = { Text("Advanced") }, + supportingContent = { Text("Manual bridge connection") }, + trailingContent = { + Icon( + imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (advancedExpanded) "Collapse" else "Expand", + ) + }, + modifier = + Modifier.clickable { + setAdvancedExpanded(!advancedExpanded) + }, + ) + } + item { + AnimatedVisibility(visible = advancedExpanded) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + ListItem( + headlineContent = { Text("Use Manual Bridge") }, + supportingContent = { Text("Use this when discovery is blocked.") }, + trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) }, + ) + + OutlinedTextField( + value = manualHost, + onValueChange = viewModel::setManualHost, + label = { Text("Host") }, + modifier = Modifier.fillMaxWidth(), + enabled = manualEnabled, + ) + OutlinedTextField( + value = manualPort.toString(), + onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) }, + label = { Text("Port") }, + modifier = Modifier.fillMaxWidth(), + enabled = manualEnabled, + ) + + val hostOk = manualHost.trim().isNotEmpty() + val portOk = manualPort in 1..65535 Button( onClick = { NodeForegroundService.start(context) - viewModel.connect(bridge) + viewModel.connectManual() }, + enabled = manualEnabled && hostOk && portOk, ) { - Text("Connect") + Text("Connect (Manual)") } } - HorizontalDivider() } } item { Spacer(modifier = Modifier.height(20.dp)) } } } + diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt index 4cf60f641..da1760d64 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp @Composable fun ChatComposer( - sessionKey: String, healthOk: Boolean, thinkingLevel: String, pendingRunCount: Int, @@ -112,7 +111,6 @@ fun ChatComposer( ) Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - ConnectionPill(sessionKey = sessionKey, healthOk = healthOk) Spacer(modifier = Modifier.weight(1f)) if (pendingRunCount > 0) { @@ -220,29 +218,3 @@ private fun AttachmentChip(fileName: String, onRemove: () -> Unit) { } } } - -@Composable -private fun ConnectionPill(sessionKey: String, healthOk: Boolean) { - Surface( - shape = RoundedCornerShape(999.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - modifier = Modifier.size(7.dp), - shape = androidx.compose.foundation.shape.CircleShape, - color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12), - ) {} - Text(sessionKey, style = MaterialTheme.typography.labelSmall) - Text( - if (healthOk) "Connected" else "Connecting…", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMessageListCard.kt index ef4138641..3f0217897 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMessageListCard.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatMessageListCard.kt @@ -34,6 +34,7 @@ import com.steipete.clawdis.node.chat.ChatPendingToolCall @Composable fun ChatMessageListCard( sessionKey: String, + isBridgeConnected: Boolean, healthOk: Boolean, messages: List, pendingRunCount: Int, @@ -97,6 +98,7 @@ fun ChatMessageListCard( ChatStatusPill( sessionKey = sessionKey, + isBridgeConnected = isBridgeConnected, healthOk = healthOk, onShowSessions = onShowSessions, onRefresh = onRefresh, @@ -113,11 +115,25 @@ fun ChatMessageListCard( @Composable private fun ChatStatusPill( sessionKey: String, + isBridgeConnected: Boolean, healthOk: Boolean, onShowSessions: () -> Unit, onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { + val statusText = + when { + !isBridgeConnected -> "Offline" + healthOk -> "Connected" + else -> "Connecting…" + } + val statusColor = + when { + !isBridgeConnected -> Color(0xFF9E9E9E) + healthOk -> Color(0xFF2ECC71) + else -> Color(0xFFF39C12) + } + Surface( modifier = modifier, shape = MaterialTheme.shapes.large, @@ -133,7 +149,7 @@ private fun ChatStatusPill( Surface( modifier = Modifier.size(7.dp), shape = androidx.compose.foundation.shape.CircleShape, - color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12), + color = statusColor, ) {} Text( @@ -141,7 +157,7 @@ private fun ChatStatusPill( style = MaterialTheme.typography.labelMedium, ) Text( - text = if (healthOk) "Connected" else "Connecting…", + text = statusText, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.alpha(0.9f), diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSheetContent.kt index 4a81cff86..4d26a3bd0 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSheetContent.kt @@ -33,6 +33,7 @@ fun ChatSheetContent(viewModel: MainViewModel) { val messages by viewModel.chatMessages.collectAsState() val errorText by viewModel.chatError.collectAsState() val pendingRunCount by viewModel.pendingRunCount.collectAsState() + val isBridgeConnected by viewModel.isConnected.collectAsState() val healthOk by viewModel.chatHealthOk.collectAsState() val sessionKey by viewModel.chatSessionKey.collectAsState() val thinkingLevel by viewModel.chatThinkingLevel.collectAsState() @@ -79,6 +80,7 @@ fun ChatSheetContent(viewModel: MainViewModel) { ) { ChatMessageListCard( sessionKey = sessionKey, + isBridgeConnected = isBridgeConnected, healthOk = healthOk, messages = messages, pendingRunCount = pendingRunCount, @@ -90,7 +92,6 @@ fun ChatSheetContent(viewModel: MainViewModel) { ) ChatComposer( - sessionKey = sessionKey, healthOk = healthOk, thinkingLevel = thinkingLevel, pendingRunCount = pendingRunCount,