Android: polish settings UI

This commit is contained in:
Peter Steinberger
2025-12-18 00:07:52 +01:00
parent 0e201c4c18
commit 5f0e474be1
5 changed files with 161 additions and 121 deletions

View File

@@ -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()

View File

@@ -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)) }
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -34,6 +34,7 @@ import com.steipete.clawdis.node.chat.ChatPendingToolCall
@Composable
fun ChatMessageListCard(
sessionKey: String,
isBridgeConnected: Boolean,
healthOk: Boolean,
messages: List<ChatMessage>,
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),

View File

@@ -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,