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) { fun onDisconnected(message: String) {
_healthOk.value = false _healthOk.value = false
_errorText.value = message // Not an error; keep connection status in the UI pill.
_errorText.value = null
clearPendingRuns() clearPendingRuns()
pendingToolCallsById.clear() pendingToolCallsById.clear()
publishPendingToolCalls() publishPendingToolCalls()

View File

@@ -4,6 +4,8 @@ import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.Spacer
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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding 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.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.Button
import androidx.compose.material3.HorizontalDivider 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.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -35,6 +42,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.MainViewModel import com.steipete.clawdis.node.MainViewModel
@@ -56,9 +64,11 @@ fun SettingsSheet(viewModel: MainViewModel) {
val serverName by viewModel.serverName.collectAsState() val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState() val remoteAddress by viewModel.remoteAddress.collectAsState()
val bridges by viewModel.bridges.collectAsState() val bridges by viewModel.bridges.collectAsState()
val listState = rememberLazyListState()
val listState = rememberLazyListState()
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
val permissionLauncher = val permissionLauncher =
@@ -67,6 +77,29 @@ fun SettingsSheet(viewModel: MainViewModel) {
viewModel.setCameraEnabled(cameraOk) 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( LazyColumn(
state = listState, state = listState,
modifier = modifier =
@@ -76,9 +109,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
.imePadding() .imePadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
contentPadding = PaddingValues(16.dp), 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 { item {
OutlinedTextField( OutlinedTextField(
value = displayName, value = displayName,
@@ -87,11 +120,11 @@ fun SettingsSheet(viewModel: MainViewModel) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
item { Text("Instance ID: $instanceId") } item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
item { HorizontalDivider() } item { HorizontalDivider() }
item { Text("Wake Words") } item { Text("Wake Words", style = MaterialTheme.typography.titleSmall) }
item { item {
OutlinedTextField( OutlinedTextField(
value = wakeWordsText, value = wakeWordsText,
@@ -123,56 +156,50 @@ fun SettingsSheet(viewModel: MainViewModel) {
} else { } else {
"Connect to a gateway to sync wake words globally." "Connect to a gateway to sync wake words globally."
}, },
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
item { HorizontalDivider() } item { HorizontalDivider() }
item { Text("Camera") } item { Text("Camera", style = MaterialTheme.typography.titleSmall) }
item { item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { ListItem(
Switch( headlineContent = { Text("Allow Camera") },
checked = cameraEnabled, supportingContent = { Text("Allows the bridge to request photos or short video clips (foreground only).") },
onCheckedChange = { enabled -> trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
if (!enabled) { )
viewModel.setCameraEnabled(false) }
return@Switch item {
} Text(
"Tip: grant Microphone permission for video clips with audio.",
val cameraOk = color = MaterialTheme.colorScheme.onSurfaceVariant,
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")
}
} }
item { Text("Tip: grant Microphone permission for video clips with audio.") }
item { HorizontalDivider() } item { HorizontalDivider() }
item { Text("Screen") } item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
item { item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { ListItem(
Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) headlineContent = { Text("Prevent Sleep") },
Text(if (preventSleep) "Prevent Sleep" else "Allow 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 { HorizontalDivider() }
item { Text("Bridge") } item { Text("Bridge", style = MaterialTheme.typography.titleSmall) }
item { Text("Status: $statusText") } item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) }
item { if (serverName != null) Text("Server: $serverName") } if (serverName != null) {
item { if (remoteAddress != null) Text("Address: $remoteAddress") } item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) }
}
if (remoteAddress != null) {
item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) }
}
item { item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (isConnected) {
Button( Button(
onClick = { onClick = {
viewModel.disconnect() viewModel.disconnect()
@@ -186,72 +213,95 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { HorizontalDivider() } item { HorizontalDivider() }
item { Text("Advanced") } item { Text("Discovered Bridges", style = MaterialTheme.typography.titleSmall) }
item { if (bridges.isEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) } else {
Text(if (manualEnabled) "Manual Bridge Enabled" else "Manual Bridge Disabled") 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 { item {
OutlinedTextField( Text(
value = manualHost, bridgeDiscoveryFooterText,
onValueChange = viewModel::setManualHost,
label = { Text("Host") },
modifier = Modifier.fillMaxWidth(), 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 { HorizontalDivider() }
item { Text("Discovered Bridges") } item {
if (bridges.isEmpty()) { ListItem(
item { Text("No bridges found yet.") } headlineContent = { Text("Advanced") },
} else { supportingContent = { Text("Manual bridge connection") },
items(items = bridges, key = { it.stableId }) { bridge -> trailingContent = {
Row( Icon(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
horizontalArrangement = Arrangement.SpaceBetween, contentDescription = if (advancedExpanded) "Collapse" else "Expand",
) { )
Column(modifier = Modifier.weight(1f)) { },
Text(bridge.name) modifier =
Text("${bridge.host}:${bridge.port}") Modifier.clickable {
} setAdvancedExpanded(!advancedExpanded)
Spacer(modifier = Modifier.padding(4.dp)) },
)
}
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( Button(
onClick = { onClick = {
NodeForegroundService.start(context) 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)) } item { Spacer(modifier = Modifier.height(20.dp)) }
} }
} }

View File

@@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun ChatComposer( fun ChatComposer(
sessionKey: String,
healthOk: Boolean, healthOk: Boolean,
thinkingLevel: String, thinkingLevel: String,
pendingRunCount: Int, pendingRunCount: Int,
@@ -112,7 +111,6 @@ fun ChatComposer(
) )
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ConnectionPill(sessionKey = sessionKey, healthOk = healthOk)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
if (pendingRunCount > 0) { 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 @Composable
fun ChatMessageListCard( fun ChatMessageListCard(
sessionKey: String, sessionKey: String,
isBridgeConnected: Boolean,
healthOk: Boolean, healthOk: Boolean,
messages: List<ChatMessage>, messages: List<ChatMessage>,
pendingRunCount: Int, pendingRunCount: Int,
@@ -97,6 +98,7 @@ fun ChatMessageListCard(
ChatStatusPill( ChatStatusPill(
sessionKey = sessionKey, sessionKey = sessionKey,
isBridgeConnected = isBridgeConnected,
healthOk = healthOk, healthOk = healthOk,
onShowSessions = onShowSessions, onShowSessions = onShowSessions,
onRefresh = onRefresh, onRefresh = onRefresh,
@@ -113,11 +115,25 @@ fun ChatMessageListCard(
@Composable @Composable
private fun ChatStatusPill( private fun ChatStatusPill(
sessionKey: String, sessionKey: String,
isBridgeConnected: Boolean,
healthOk: Boolean, healthOk: Boolean,
onShowSessions: () -> Unit, onShowSessions: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
modifier: Modifier = Modifier, 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( Surface(
modifier = modifier, modifier = modifier,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
@@ -133,7 +149,7 @@ private fun ChatStatusPill(
Surface( Surface(
modifier = Modifier.size(7.dp), modifier = Modifier.size(7.dp),
shape = androidx.compose.foundation.shape.CircleShape, shape = androidx.compose.foundation.shape.CircleShape,
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12), color = statusColor,
) {} ) {}
Text( Text(
@@ -141,7 +157,7 @@ private fun ChatStatusPill(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
) )
Text( Text(
text = if (healthOk) "Connected" else "Connecting…", text = statusText,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alpha(0.9f), modifier = Modifier.alpha(0.9f),

View File

@@ -33,6 +33,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val messages by viewModel.chatMessages.collectAsState() val messages by viewModel.chatMessages.collectAsState()
val errorText by viewModel.chatError.collectAsState() val errorText by viewModel.chatError.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState() val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val isBridgeConnected by viewModel.isConnected.collectAsState()
val healthOk by viewModel.chatHealthOk.collectAsState() val healthOk by viewModel.chatHealthOk.collectAsState()
val sessionKey by viewModel.chatSessionKey.collectAsState() val sessionKey by viewModel.chatSessionKey.collectAsState()
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState() val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
@@ -79,6 +80,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
) { ) {
ChatMessageListCard( ChatMessageListCard(
sessionKey = sessionKey, sessionKey = sessionKey,
isBridgeConnected = isBridgeConnected,
healthOk = healthOk, healthOk = healthOk,
messages = messages, messages = messages,
pendingRunCount = pendingRunCount, pendingRunCount = pendingRunCount,
@@ -90,7 +92,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
) )
ChatComposer( ChatComposer(
sessionKey = sessionKey,
healthOk = healthOk, healthOk = healthOk,
thinkingLevel = thinkingLevel, thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount, pendingRunCount = pendingRunCount,