style(android): align settings screen with RN visual system

This commit is contained in:
Ayaan Zaidi 2026-02-24 21:19:39 +05:30 committed by Ayaan Zaidi
parent cf031d6ad4
commit 02e3fbef77

View File

@ -10,9 +10,13 @@ import android.provider.Settings
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.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.fillMaxSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -23,6 +27,7 @@ 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.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing 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
@ -30,16 +35,20 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -56,8 +65,12 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import ai.openclaw.android.BuildConfig import ai.openclaw.android.BuildConfig
@ -114,6 +127,14 @@ fun SettingsSheet(viewModel: MainViewModel) {
versionName versionName
} }
} }
val listItemColors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = mobileText,
supportingColor = mobileTextSecondary,
trailingIconColor = mobileTextSecondary,
leadingIconColor = mobileTextSecondary,
)
if (pendingTrust != null) { if (pendingTrust != null) {
val prompt = pendingTrust!! val prompt = pendingTrust!!
@ -284,41 +305,78 @@ fun SettingsSheet(viewModel: MainViewModel) {
"Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found" "Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found"
} }
LazyColumn( Box(
state = listState,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxSize()
.fillMaxHeight() .background(mobileBackgroundGradient),
.imePadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
LazyColumn(
state = listState,
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight()
.imePadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
"SETTINGS",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
Text("Device + Gateway Configuration", style = mobileTitle2, color = mobileText)
Text(
"Manage capabilities, connection mode, permissions, and diagnostics.",
style = mobileCallout,
color = mobileTextSecondary,
)
}
}
item { HorizontalDivider(color = mobileBorder) }
// Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen. // Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen.
item { Text("Node", style = MaterialTheme.typography.titleSmall) } item {
Text(
"NODE",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item { item {
OutlinedTextField( OutlinedTextField(
value = displayName, value = displayName,
onValueChange = viewModel::setDisplayName, onValueChange = viewModel::setDisplayName,
label = { Text("Name") }, label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
) )
} }
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) } item { Text("Instance ID: $instanceId", style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileTextSecondary) }
item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) } item { Text("Device: $deviceModel", style = mobileCallout, color = mobileTextSecondary) }
item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) } item { Text("Version: $appVersion", style = mobileCallout, color = mobileTextSecondary) }
item { HorizontalDivider() } item { HorizontalDivider(color = mobileBorder) }
// Gateway // Gateway
item { Text("Gateway", style = MaterialTheme.typography.titleSmall) } item {
item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) } Text(
"GATEWAY",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item { ListItem(modifier = settingsRowModifier(), colors = listItemColors, headlineContent = { Text("Status", style = mobileHeadline) }, supportingContent = { Text(statusText, style = mobileCallout) }) }
if (serverName != null) { if (serverName != null) {
item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) } item { ListItem(modifier = settingsRowModifier(), colors = listItemColors, headlineContent = { Text("Server", style = mobileHeadline) }, supportingContent = { Text(serverName!!, style = mobileCallout) }) }
} }
if (remoteAddress != null) { if (remoteAddress != null) {
item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) } item { ListItem(modifier = settingsRowModifier(), colors = listItemColors, headlineContent = { Text("Address", style = mobileHeadline) }, supportingContent = { Text(remoteAddress!!, style = mobileCallout.copy(fontFamily = FontFamily.Monospace)) }) }
} }
item { item {
// UI sanity: "Disconnect" only when we have an active remote. // UI sanity: "Disconnect" only when we have an active remote.
@ -328,23 +386,26 @@ fun SettingsSheet(viewModel: MainViewModel) {
viewModel.disconnect() viewModel.disconnect()
NodeForegroundService.stop(context) NodeForegroundService.stop(context)
}, },
colors = settingsDangerButtonColors(),
shape = RoundedCornerShape(14.dp),
) { ) {
Text("Disconnect") Text("Disconnect", style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
} }
} }
} }
item { HorizontalDivider() } item { HorizontalDivider(color = mobileBorder) }
if (!isConnected || visibleGateways.isNotEmpty()) { if (!isConnected || visibleGateways.isNotEmpty()) {
item { item {
Text( Text(
if (isConnected) "Other Gateways" else "Discovered Gateways", if (isConnected) "Other Gateways" else "Discovered Gateways",
style = MaterialTheme.typography.titleSmall, style = mobileHeadline,
color = mobileText,
) )
} }
if (!isConnected && visibleGateways.isEmpty()) { if (!isConnected && visibleGateways.isEmpty()) {
item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) } item { Text("No gateways found yet.", style = mobileCallout, color = mobileTextSecondary) }
} else { } else {
items(items = visibleGateways, key = { it.stableId }) { gateway -> items(items = visibleGateways, key = { it.stableId }) { gateway ->
val detailLines = val detailLines =
@ -359,11 +420,13 @@ fun SettingsSheet(viewModel: MainViewModel) {
} }
} }
ListItem( ListItem(
headlineContent = { Text(gateway.name) }, modifier = settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text(gateway.name, style = mobileHeadline) },
supportingContent = { supportingContent = {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
detailLines.forEach { line -> detailLines.forEach { line ->
Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(line, style = mobileCallout, color = mobileTextSecondary)
} }
} }
}, },
@ -373,8 +436,10 @@ fun SettingsSheet(viewModel: MainViewModel) {
NodeForegroundService.start(context) NodeForegroundService.start(context)
viewModel.connect(gateway) viewModel.connect(gateway)
}, },
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) { ) {
Text("Connect") Text("Connect", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
} }
}, },
) )
@ -385,66 +450,82 @@ fun SettingsSheet(viewModel: MainViewModel) {
gatewayDiscoveryFooterText, gatewayDiscoveryFooterText,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium, style = mobileCaption1,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = mobileTextSecondary,
) )
} }
} }
item { HorizontalDivider() } item { HorizontalDivider(color = mobileBorder) }
item { item {
ListItem( ListItem(
headlineContent = { Text("Advanced") }, modifier = settingsRowModifier().then(Modifier.clickable { setAdvancedExpanded(!advancedExpanded) }),
supportingContent = { Text("Manual gateway connection") }, colors = listItemColors,
headlineContent = { Text("Advanced", style = mobileHeadline) },
supportingContent = { Text("Manual gateway connection", style = mobileCallout) },
trailingContent = { trailingContent = {
Icon( Icon(
imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (advancedExpanded) "Collapse" else "Expand", contentDescription = if (advancedExpanded) "Collapse" else "Expand",
tint = mobileTextSecondary,
) )
}, },
modifier =
Modifier.clickable {
setAdvancedExpanded(!advancedExpanded)
},
) )
} }
item { item {
AnimatedVisibility(visible = advancedExpanded) { AnimatedVisibility(visible = advancedExpanded) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier =
Modifier
.fillMaxWidth()
.border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp))
.background(mobileSurface, RoundedCornerShape(14.dp))
.padding(12.dp),
) {
ListItem( ListItem(
headlineContent = { Text("Use Manual Gateway") }, modifier = settingsRowModifier(),
supportingContent = { Text("Use this when discovery is blocked.") }, colors = listItemColors,
headlineContent = { Text("Use Manual Gateway", style = mobileHeadline) },
supportingContent = { Text("Use this when discovery is blocked.", style = mobileCallout) },
trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) }, trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) },
) )
OutlinedTextField( OutlinedTextField(
value = manualHost, value = manualHost,
onValueChange = viewModel::setManualHost, onValueChange = viewModel::setManualHost,
label = { Text("Host") }, label = { Text("Host", style = mobileCaption1, color = mobileTextSecondary) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled, enabled = manualEnabled,
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
) )
OutlinedTextField( OutlinedTextField(
value = manualPort.toString(), value = manualPort.toString(),
onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) }, onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) },
label = { Text("Port") }, label = { Text("Port", style = mobileCaption1, color = mobileTextSecondary) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled, enabled = manualEnabled,
textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText),
colors = settingsTextFieldColors(),
) )
OutlinedTextField( OutlinedTextField(
value = gatewayToken, value = gatewayToken,
onValueChange = viewModel::setGatewayToken, onValueChange = viewModel::setGatewayToken,
label = { Text("Gateway Token") }, label = { Text("Gateway Token", style = mobileCaption1, color = mobileTextSecondary) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled, enabled = manualEnabled,
singleLine = true, singleLine = true,
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
) )
ListItem( ListItem(
headlineContent = { Text("Require TLS") }, modifier = settingsRowModifier().alpha(if (manualEnabled) 1f else 0.5f),
supportingContent = { Text("Pin the gateway certificate on first connect.") }, colors = listItemColors,
headlineContent = { Text("Require TLS", style = mobileHeadline) },
supportingContent = { Text("Pin the gateway certificate on first connect.", style = mobileCallout) },
trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) }, trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) },
modifier = Modifier.alpha(if (manualEnabled) 1f else 0.5f),
) )
val hostOk = manualHost.trim().isNotEmpty() val hostOk = manualHost.trim().isNotEmpty()
@ -455,26 +536,36 @@ fun SettingsSheet(viewModel: MainViewModel) {
viewModel.connectManual() viewModel.connectManual()
}, },
enabled = manualEnabled && hostOk && portOk, enabled = manualEnabled && hostOk && portOk,
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) { ) {
Text("Connect (Manual)") Text("Connect (Manual)", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
} }
TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) { TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) {
Text("Run onboarding again") Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
} }
} }
} }
} }
item { HorizontalDivider() } item { HorizontalDivider(color = mobileBorder) }
// Voice // Voice
item { Text("Voice", style = MaterialTheme.typography.titleSmall) } item {
Text(
"VOICE",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item { item {
val enabled = voiceWakeMode != VoiceWakeMode.Off val enabled = voiceWakeMode != VoiceWakeMode.Off
ListItem( ListItem(
headlineContent = { Text("Voice Wake") }, modifier = settingsRowModifier(),
supportingContent = { Text(voiceWakeStatusText) }, colors = listItemColors,
headlineContent = { Text("Voice Wake", style = mobileHeadline) },
supportingContent = { Text(voiceWakeStatusText, style = mobileCallout) },
trailingContent = { trailingContent = {
Switch( Switch(
checked = enabled, checked = enabled,
@ -497,8 +588,10 @@ fun SettingsSheet(viewModel: MainViewModel) {
AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) { AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
ListItem( ListItem(
headlineContent = { Text("Foreground Only") }, modifier = settingsRowModifier(),
supportingContent = { Text("Listens only while OpenClaw is open.") }, colors = listItemColors,
headlineContent = { Text("Foreground Only", style = mobileHeadline) },
supportingContent = { Text("Listens only while OpenClaw is open.", style = mobileCallout) },
trailingContent = { trailingContent = {
RadioButton( RadioButton(
selected = voiceWakeMode == VoiceWakeMode.Foreground, selected = voiceWakeMode == VoiceWakeMode.Foreground,
@ -513,8 +606,10 @@ fun SettingsSheet(viewModel: MainViewModel) {
}, },
) )
ListItem( ListItem(
headlineContent = { Text("Always") }, modifier = settingsRowModifier(),
supportingContent = { Text("Keeps listening in the background (shows a persistent notification).") }, colors = listItemColors,
headlineContent = { Text("Always", style = mobileHeadline) },
supportingContent = { Text("Keeps listening in the background (shows a persistent notification).", style = mobileCallout) },
trailingContent = { trailingContent = {
RadioButton( RadioButton(
selected = voiceWakeMode == VoiceWakeMode.Always, selected = voiceWakeMode == VoiceWakeMode.Always,
@ -535,7 +630,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
OutlinedTextField( OutlinedTextField(
value = wakeWordsText, value = wakeWordsText,
onValueChange = setWakeWordsText, onValueChange = setWakeWordsText,
label = { Text("Wake Words (comma-separated)") }, label = { Text("Wake Words (comma-separated)", style = mobileCaption1, color = mobileTextSecondary) },
modifier = modifier =
Modifier.fillMaxWidth().onFocusChanged { focusState -> Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (focusState.isFocused) { if (focusState.isFocused) {
@ -554,9 +649,19 @@ fun SettingsSheet(viewModel: MainViewModel) {
focusManager.clearFocus() focusManager.clearFocus()
}, },
), ),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
) )
} }
item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } } item {
Button(
onClick = viewModel::resetWakeWordsDefaults,
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text("Reset defaults", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
}
}
item { item {
Text( Text(
if (isConnected) { if (isConnected) {
@ -564,32 +669,48 @@ 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, style = mobileCallout,
color = mobileTextSecondary,
) )
} }
item { HorizontalDivider() } item { HorizontalDivider(color = mobileBorder) }
// Camera // Camera
item { Text("Camera", style = MaterialTheme.typography.titleSmall) } item {
Text(
"CAMERA",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item { item {
ListItem( ListItem(
headlineContent = { Text("Allow Camera") }, modifier = settingsRowModifier(),
supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).") }, colors = listItemColors,
headlineContent = { Text("Allow Camera", style = mobileHeadline) },
supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).", style = mobileCallout) },
trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
) )
} }
item { item {
Text( Text(
"Tip: grant Microphone permission for video clips with audio.", "Tip: grant Microphone permission for video clips with audio.",
color = MaterialTheme.colorScheme.onSurfaceVariant, style = mobileCallout,
color = mobileTextSecondary,
) )
} }
item { HorizontalDivider() } item { HorizontalDivider(color = mobileBorder) }
// Messaging // Messaging
item { Text("Messaging", style = MaterialTheme.typography.titleSmall) } item {
Text(
"MESSAGING",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item { item {
val buttonLabel = val buttonLabel =
when { when {
@ -598,7 +719,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
else -> "Grant" else -> "Grant"
} }
ListItem( ListItem(
headlineContent = { Text("SMS Permission") }, modifier = settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("SMS Permission", style = mobileHeadline) },
supportingContent = { supportingContent = {
Text( Text(
if (smsPermissionAvailable) { if (smsPermissionAvailable) {
@ -606,6 +729,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
} else { } else {
"SMS requires a device with telephony hardware." "SMS requires a device with telephony hardware."
}, },
style = mobileCallout,
) )
}, },
trailingContent = { trailingContent = {
@ -619,91 +743,125 @@ fun SettingsSheet(viewModel: MainViewModel) {
} }
}, },
enabled = smsPermissionAvailable, enabled = smsPermissionAvailable,
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) { ) {
Text(buttonLabel) Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold))
} }
}, },
) )
} }
item { HorizontalDivider() } item { HorizontalDivider(color = mobileBorder) }
// Location // Location
item { Text("Location", style = MaterialTheme.typography.titleSmall) } item {
item { Text(
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { "LOCATION",
ListItem( style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
headlineContent = { Text("Off") }, color = mobileAccent,
supportingContent = { Text("Disable location sharing.") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Off,
onClick = { viewModel.setLocationMode(LocationMode.Off) },
)
},
)
ListItem(
headlineContent = { Text("While Using") },
supportingContent = { Text("Only while OpenClaw is open.") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.WhileUsing,
onClick = { requestLocationPermissions(LocationMode.WhileUsing) },
)
},
)
ListItem(
headlineContent = { Text("Always") },
supportingContent = { Text("Allow background location (requires system permission).") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Always,
onClick = { requestLocationPermissions(LocationMode.Always) },
)
},
) )
} }
} item {
item { Column(modifier = settingsRowModifier(), verticalArrangement = Arrangement.spacedBy(0.dp)) {
ListItem( ListItem(
headlineContent = { Text("Precise Location") }, modifier = Modifier.fillMaxWidth(),
supportingContent = { Text("Use precise GPS when available.") }, colors = listItemColors,
trailingContent = { headlineContent = { Text("Off", style = mobileHeadline) },
Switch( supportingContent = { Text("Disable location sharing.", style = mobileCallout) },
checked = locationPreciseEnabled, trailingContent = {
onCheckedChange = ::setPreciseLocationChecked, RadioButton(
enabled = locationMode != LocationMode.Off, selected = locationMode == LocationMode.Off,
onClick = { viewModel.setLocationMode(LocationMode.Off) },
)
},
) )
}, HorizontalDivider(color = mobileBorder)
) ListItem(
} modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("While Using", style = mobileHeadline) },
supportingContent = { Text("Only while OpenClaw is open.", style = mobileCallout) },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.WhileUsing,
onClick = { requestLocationPermissions(LocationMode.WhileUsing) },
)
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Always", style = mobileHeadline) },
supportingContent = { Text("Allow background location (requires system permission).", style = mobileCallout) },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Always,
onClick = { requestLocationPermissions(LocationMode.Always) },
)
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Precise Location", style = mobileHeadline) },
supportingContent = { Text("Use precise GPS when available.", style = mobileCallout) },
trailingContent = {
Switch(
checked = locationPreciseEnabled,
onCheckedChange = ::setPreciseLocationChecked,
enabled = locationMode != LocationMode.Off,
)
},
)
}
}
item { item {
Text( Text(
"Always may require Android Settings to allow background location.", "Always may require Android Settings to allow background location.",
color = MaterialTheme.colorScheme.onSurfaceVariant, style = mobileCallout,
color = mobileTextSecondary,
) )
} }
item { HorizontalDivider() } item { HorizontalDivider(color = mobileBorder) }
// Screen // Screen
item { Text("Screen", style = MaterialTheme.typography.titleSmall) } item {
Text(
"SCREEN",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item { item {
ListItem( ListItem(
headlineContent = { Text("Prevent Sleep") }, modifier = settingsRowModifier(),
supportingContent = { Text("Keeps the screen awake while OpenClaw is open.") }, colors = listItemColors,
headlineContent = { Text("Prevent Sleep", style = mobileHeadline) },
supportingContent = { Text("Keeps the screen awake while OpenClaw is open.", style = mobileCallout) },
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
) )
} }
item { HorizontalDivider() } item { HorizontalDivider(color = mobileBorder) }
// Debug // Debug
item { Text("Debug", style = MaterialTheme.typography.titleSmall) } item {
Text(
"DEBUG",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item { item {
ListItem( ListItem(
headlineContent = { Text("Debug Canvas Status") }, modifier = settingsRowModifier(),
supportingContent = { Text("Show status text in the canvas when debug is enabled.") }, colors = listItemColors,
headlineContent = { Text("Debug Canvas Status", style = mobileHeadline) },
supportingContent = { Text("Show status text in the canvas when debug is enabled.", style = mobileCallout) },
trailingContent = { trailingContent = {
Switch( Switch(
checked = canvasDebugStatusEnabled, checked = canvasDebugStatusEnabled,
@ -713,10 +871,47 @@ fun SettingsSheet(viewModel: MainViewModel) {
) )
} }
item { Spacer(modifier = Modifier.height(20.dp)) } item { Spacer(modifier = Modifier.height(24.dp)) }
}
} }
} }
@Composable
private fun settingsTextFieldColors() =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = mobileSurface,
unfocusedContainerColor = mobileSurface,
focusedBorderColor = mobileAccent,
unfocusedBorderColor = mobileBorder,
focusedTextColor = mobileText,
unfocusedTextColor = mobileText,
cursorColor = mobileAccent,
)
private fun settingsRowModifier() =
Modifier
.fillMaxWidth()
.border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp))
.background(Color.White, RoundedCornerShape(14.dp))
@Composable
private fun settingsPrimaryButtonColors() =
ButtonDefaults.buttonColors(
containerColor = mobileAccent,
contentColor = Color.White,
disabledContainerColor = mobileAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
@Composable
private fun settingsDangerButtonColors() =
ButtonDefaults.buttonColors(
containerColor = mobileDanger,
contentColor = Color.White,
disabledContainerColor = mobileDanger.copy(alpha = 0.45f),
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
private fun openAppSettings(context: Context) { private fun openAppSettings(context: Context) {
val intent = val intent =
Intent( Intent(