From 6a0c3eaf78362ed1e5a705b95a94e232d01aeea0 Mon Sep 17 00:00:00 2001 From: Voscko Date: Fri, 19 Jun 2026 06:15:19 +0200 Subject: [PATCH] fix(android): group settings by intent (#94539) Summary: - The PR reorganizes the Android Settings home rows into titled intent sections and adds ShellScreen logic tests for section title mapping and section ordering. - PR surface: Other +106. Total +106 across 2 files. - Reproducibility: not applicable. this is a UI organization cleanup rather than a bug report. The relevant ve ... ion path is the before/after Android emulator screenshot proof plus source comparison against current main. Automerge notes: - No ClawSweeper repair was needed after automerge opt-in. Validation: - ClawSweeper review passed for head da9bf5c5b591c72555510feb1d7931073d203f0a. - Required merge gates passed before the squash merge. Prepared head SHA: da9bf5c5b591c72555510feb1d7931073d203f0a Review: https://github.com/openclaw/openclaw/pull/94539#issuecomment-4741795253 Co-authored-by: Tosko4 --- .../java/ai/openclaw/app/ui/ShellScreen.kt | 113 ++++++++++++++---- .../openclaw/app/ui/ShellScreenLogicTest.kt | 41 +++++++ 2 files changed, 130 insertions(+), 24 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt index 0b60267d6586..c3213e1da977 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt @@ -898,32 +898,38 @@ private fun SettingsShellScreen( ProfilePanel(displayName = displayName.ifBlank { "OpenClaw" }, onClick = { onRouteChange(SettingsRoute.Profile) }) } - item { - SettingsGroup( - rows = - listOf( - SettingsRow("Profile", displayName.ifBlank { "Local device" }, Icons.Default.Person, route = SettingsRoute.Profile), - SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice), - SettingsRow("Agents", if (agents.isEmpty()) "Load from gateway" else "${agents.size} available", Icons.Default.Person, status = agents.isNotEmpty(), route = SettingsRoute.Agents), - SettingsRow("Approvals", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, status = approvalsStatus(pendingToolCalls.size), route = SettingsRoute.Approvals), - SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs), - SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage), - SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills), - SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = SettingsRoute.NodesDevices), - SettingsRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, status = channelsStatus(channelsSummary), route = SettingsRoute.Channels), - SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming), - SettingsRow("Canvas", "Screen surface", Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas), - SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications), - SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities), - SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway), - SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance), - SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health), - SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About), - ), - onOpen = onRouteChange, + val settingsRows = + listOf( + SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway), + SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = SettingsRoute.NodesDevices), + SettingsRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, status = channelsStatus(channelsSummary), route = SettingsRoute.Channels), + SettingsRow("Agents", if (agents.isEmpty()) "Load from gateway" else "${agents.size} available", Icons.Default.Person, status = agents.isNotEmpty(), route = SettingsRoute.Agents), + SettingsRow("Approvals", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, status = approvalsStatus(pendingToolCalls.size), route = SettingsRoute.Approvals), + SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs), + SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage), + SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills), + SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming), + SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice), + SettingsRow("Canvas", "Screen surface", Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas), + SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications), + SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities), + SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance), + SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About), + SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health), ) + + settingsSections(settingsRows).forEach { section -> + item { + SettingsSectionTitle(section.title) + } + item { + SettingsGroup(rows = section.rows, onOpen = onRouteChange) + } } + item { + SettingsSectionTitle("Account") + } item { SettingsGroup( rows = listOf(SettingsRow("Sign Out", "Disconnect", Icons.AutoMirrored.Filled.ExitToApp)), @@ -1057,7 +1063,7 @@ private fun dreamingStatus(summary: GatewayDreamingSummary): Boolean? = else -> null } -private data class SettingsRow( +internal data class SettingsRow( val title: String, val value: String, val icon: ImageVector, @@ -1065,6 +1071,65 @@ private data class SettingsRow( val route: SettingsRoute? = null, ) +internal data class SettingsSection( + val title: String, + val rows: List, +) + +internal fun settingsSections(rows: List): List = + settingsSectionOrder.mapNotNull { title -> + val sectionRows = rows.filter { row -> row.route?.let(::settingsSectionTitleForRoute) == title } + if (sectionRows.isEmpty()) null else SettingsSection(title = title, rows = sectionRows) + } + +private val settingsSectionOrder = + listOf( + "Connection", + "Agents & automation", + "Phone context & privacy", + "Profile & device", + "Diagnostics", + ) + +internal fun settingsSectionTitleForRoute(route: SettingsRoute): String = + when (route) { + SettingsRoute.Gateway, + SettingsRoute.NodesDevices, + SettingsRoute.Channels, + -> "Connection" + + SettingsRoute.Agents, + SettingsRoute.Approvals, + SettingsRoute.CronJobs, + SettingsRoute.Usage, + SettingsRoute.Skills, + SettingsRoute.Dreaming, + -> "Agents & automation" + + SettingsRoute.Voice, + SettingsRoute.Canvas, + SettingsRoute.Notifications, + SettingsRoute.PhoneCapabilities, + -> "Phone context & privacy" + + SettingsRoute.Profile, + SettingsRoute.Appearance, + SettingsRoute.About, + -> "Profile & device" + + SettingsRoute.Health -> "Diagnostics" + SettingsRoute.Home -> "Diagnostics" + } + +@Composable +private fun SettingsSectionTitle(title: String) { + Text( + text = title.uppercase(), + style = ClawTheme.type.caption.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = ClawTheme.colors.textMuted, + ) +} + @Composable private fun ProfilePanel( displayName: String, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt index 57c73b0880ca..5e602d303d03 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt @@ -7,6 +7,8 @@ import ai.openclaw.app.GatewayNodeApprovalState import ai.openclaw.app.GatewayNodeSummary import ai.openclaw.app.GatewayNodesDevicesSummary import ai.openclaw.app.GatewayPendingDeviceSummary +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -155,7 +157,46 @@ class ShellScreenLogicTest { assertEquals("Node approval pending", rows.single().subtitle) } + @Test + fun settingsSectionTitlesGroupPowerSettingsByMeaning() { + assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.Gateway)) + assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.NodesDevices)) + assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.Approvals)) + assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.CronJobs)) + assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.PhoneCapabilities)) + assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.Notifications)) + assertEquals("Profile & device", settingsSectionTitleForRoute(SettingsRoute.Appearance)) + assertEquals("Diagnostics", settingsSectionTitleForRoute(SettingsRoute.Health)) + } + + @Test + fun settingsSectionsPreserveMeaningfulOrder() { + val sections = + settingsSections( + listOf( + settingsRow(SettingsRoute.Voice), + settingsRow(SettingsRoute.Agents), + settingsRow(SettingsRoute.Gateway), + settingsRow(SettingsRoute.Appearance), + settingsRow(SettingsRoute.Health), + ), + ) + + assertEquals( + listOf( + "Connection", + "Agents & automation", + "Phone context & privacy", + "Profile & device", + "Diagnostics", + ), + sections.map { it.title }, + ) + } + private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList()) private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList()) + + private fun settingsRow(route: SettingsRoute): SettingsRow = SettingsRow(route.name, "Value", Icons.Default.Settings, route = route) }