diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt index 44273d86698b..5a19ecf0a6ad 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt @@ -70,14 +70,14 @@ internal fun CommandPalette( val providers by viewModel.modelAuthProviders.collectAsState() val pendingRunCount by viewModel.pendingRunCount.collectAsState() var query by rememberSaveable { mutableStateOf("") } - val normalizedQuery = query.trim().lowercase() + val normalizedQuery = query.trim() val quickActions = listOf( - CommandItem("Open Chat", "Start or continue a conversation", Icons.Outlined.ChatBubbleOutline, onOpenChat), - CommandItem("Start Voice", "Talk or dictate with OpenClaw", Icons.Outlined.MicNone, onOpenVoice), - CommandItem("Browse Sessions", "Find previous conversations", Icons.Outlined.AccessTime, onOpenSessions), - CommandItem("Providers & Models", providerCommandSubtitle(isConnected, providers, models), Icons.Outlined.Inventory2, onOpenProviders), - CommandItem("Settings", "Gateway, voice, notifications, privacy", Icons.Outlined.Settings, onOpenSettings), + CommandItem(CommandAction.Chat, nativeString("Open Chat"), nativeString("Start or continue a conversation"), Icons.Outlined.ChatBubbleOutline, onOpenChat), + CommandItem(CommandAction.Voice, nativeString("Start Voice"), nativeString("Talk or dictate with OpenClaw"), Icons.Outlined.MicNone, onOpenVoice), + CommandItem(CommandAction.Sessions, nativeString("Browse Sessions"), nativeString("Find previous conversations"), Icons.Outlined.AccessTime, onOpenSessions), + CommandItem(CommandAction.Providers, nativeString("Providers & Models"), providerCommandSubtitle(isConnected, providers, models), Icons.Outlined.Inventory2, onOpenProviders), + CommandItem(CommandAction.Settings, nativeString("Settings"), nativeString("Gateway, voice, notifications, privacy"), Icons.Outlined.Settings, onOpenSettings), ) val actionRows = quickActions.filter { it.matches(normalizedQuery) } val sessionRows = @@ -159,16 +159,40 @@ internal fun CommandPalette( } } -private data class CommandItem( +internal enum class CommandAction { + Chat, + Voice, + Sessions, + Providers, + Settings, +} + +internal data class CommandItem( + val action: CommandAction, val title: String, val subtitle: String, val icon: ImageVector, val onClick: () -> Unit, ) { /** Matches palette queries against both action title and explanatory subtitle. */ - fun matches(query: String): Boolean = query.isEmpty() || title.lowercase().contains(query) || subtitle.lowercase().contains(query) + fun matches(query: String): Boolean = query.isEmpty() || title.contains(query, ignoreCase = true) || subtitle.contains(query, ignoreCase = true) } +internal fun commandActionAccessibilityDescription( + action: CommandAction, + title: String, + resolve: (String, String) -> String = { source, argument -> nativeString(source, argument) }, +): String = + when (action) { + CommandAction.Chat, + CommandAction.Voice, + CommandAction.Sessions, + -> title + CommandAction.Providers, + CommandAction.Settings, + -> resolve("Open \${row.title}", title) + } + private data class CommandSessionRow( val key: String, val title: String, @@ -194,17 +218,17 @@ private fun CommandActionRow(row: CommandItem) { .fillMaxWidth() .heightIn(min = 52.dp) .clip(RoundedCornerShape(ClawTheme.radii.row)) - .clickable(onClick = row.onClick) + .clickable(onClickLabel = commandActionAccessibilityDescription(row.action, row.title), onClick = row.onClick) .padding(horizontal = 2.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp), ) { CommandRowIcon(icon = row.icon) Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { - Text(text = nativeString(row.title), style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(text = nativeString(row.subtitle), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = row.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) } - CommandRowChevron(contentDescription = nativeString("Open \${row.title}", row.title)) + CommandRowChevron(contentDescription = null) } } } @@ -264,7 +288,7 @@ private fun CommandRowIcon(icon: ImageVector) { } @Composable -private fun CommandRowChevron(contentDescription: String) { +private fun CommandRowChevron(contentDescription: String?) { Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, 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 417873b28b57..41603f321f45 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 @@ -117,6 +117,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import java.util.Locale internal enum class Tab( val key: String, @@ -822,11 +823,11 @@ private fun OverviewMetricTile( Column(modifier = Modifier.padding(ClawTheme.spacing.xs), verticalArrangement = Arrangement.spacedBy(6.dp)) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(imageVector = card.icon, contentDescription = null, modifier = Modifier.size(17.dp), tint = card.tint) - Text(text = nativeString(card.title).uppercase(), style = ClawTheme.type.caption.copy(fontSize = 10.5.sp, lineHeight = 13.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Text(text = localizedUppercase(card.title, currentAppLanguage().languageTag), style = ClawTheme.type.caption.copy(fontSize = 10.5.sp, lineHeight = 13.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = nativeString("Open \${card.title}", card.title), modifier = Modifier.size(15.dp), tint = ClawTheme.colors.textMuted) } - Text(text = nativeString(card.value), style = ClawTheme.type.title.copy(fontSize = 22.sp, lineHeight = 25.sp), color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(text = nativeString(card.subtitle), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textSubtle, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(text = card.value, style = ClawTheme.type.title.copy(fontSize = 22.sp, lineHeight = 25.sp), color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = card.subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textSubtle, maxLines = 2, overflow = TextOverflow.Ellipsis) card.progressFraction?.let { progress -> OverviewProgressBar(progress = progress, tint = card.tint) } @@ -834,6 +835,12 @@ private fun OverviewMetricTile( } } +internal fun localizedUppercase( + value: String, + languageTag: String?, + fallbackLocale: Locale = Locale.getDefault(), +): String = value.uppercase(languageTag?.let(Locale::forLanguageTag) ?: fallbackLocale) + @Composable private fun OverviewProgressBar( progress: Float, @@ -1030,14 +1037,14 @@ internal fun overviewMetricCardSpecs( value = when { !isConnected -> nativeString("Offline") - hasAttention -> "Online" - else -> "Healthy" + hasAttention -> nativeString("Online") + else -> nativeString("Healthy") }, subtitle = when { - !isConnected -> "Reconnect to continue" - hasAttention -> "Review highlighted items" - else -> "All systems nominal" + !isConnected -> nativeString("Reconnect to continue") + hasAttention -> nativeString("Review highlighted items") + else -> nativeString("All systems nominal") }, icon = Icons.Default.Favorite, status = @@ -1054,9 +1061,12 @@ internal fun overviewMetricCardSpecs( value = if (nodeCount == 0) nativeString("None") else nativeString("\$onlineNodes/\$nodeCount", onlineNodes, nodeCount), subtitle = if (nodesDevicesSummary.hasNodeCapabilityApprovalPending()) { - "Review node access" + nativeString("Review node access") } else if (nodeCount > 0) { - "${nodeOnlinePercent(onlineNodes = onlineNodes, nodeCount = nodeCount)}% online" + nativeString( + "\${nodeOnlinePercent(onlineNodes = onlineNodes, nodeCount = nodeCount)}% online", + nodeOnlinePercent(onlineNodes = onlineNodes, nodeCount = nodeCount), + ) } else { nodesDevicesSummaryText(nodesDevicesSummary) }, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/CommandPaletteLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/CommandPaletteLogicTest.kt new file mode 100644 index 000000000000..fe75c86124e2 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/CommandPaletteLogicTest.kt @@ -0,0 +1,65 @@ +package ai.openclaw.app.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CommandPaletteLogicTest { + @Test + fun localizedCopyDrivesRenderingAndSearchWithoutChangingActionIdentity() { + val item = + CommandItem( + action = CommandAction.Chat, + title = "Ouvrir le chat", + subtitle = "Démarrer ou poursuivre une conversation", + icon = Icons.Outlined.ChatBubbleOutline, + onClick = {}, + ) + + assertEquals("Ouvrir le chat", item.title) + assertEquals("Démarrer ou poursuivre une conversation", item.subtitle) + assertTrue(item.matches("ouvrir")) + assertTrue(item.matches("OUVRIR")) + assertTrue(item.matches("conversation")) + assertFalse(item.matches("open chat")) + assertTrue(item.copy(title = "İletişim").matches("iletişim")) + assertEquals(CommandAction.Chat, item.action) + } + + @Test + fun accessibilityDescriptionUsesLocalizedActionCopyWithoutDuplicateVerbs() { + val chatDescription = + commandActionAccessibilityDescription(CommandAction.Chat, "Ouvrir le chat") { _, _ -> + error("verb-led commands should use their localized title directly") + } + val settingsDescription = + commandActionAccessibilityDescription(CommandAction.Settings, "Paramètres") { source, title -> + assertEquals("Open \${row.title}", source) + "Ouvrir $title" + } + + assertEquals("Ouvrir le chat", chatDescription) + assertEquals("Ouvrir Paramètres", settingsDescription) + } + + @Test + fun stableActionDispatchDoesNotDependOnLocalizedCopy() { + val calls = mutableListOf() + val item = + CommandItem( + action = CommandAction.Voice, + title = "Démarrer la voix", + subtitle = "Parler avec OpenClaw", + icon = Icons.Outlined.ChatBubbleOutline, + onClick = { calls += CommandAction.Voice }, + ) + + item.onClick() + + assertEquals(CommandAction.Voice, item.action) + assertEquals(listOf(CommandAction.Voice), calls) + } +} 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 047824cfacc8..ebb8ee3db667 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 @@ -22,6 +22,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.util.Locale class ShellScreenLogicTest { @Test @@ -31,6 +32,11 @@ class ShellScreenLogicTest { assertFalse(shellBottomNavVisible(keyboardVisible = false, commandOpen = true)) } + @Test + fun localizedUppercaseUsesTheSelectedAppLocale() { + assertEquals("İLETİŞİM", localizedUppercase("iletişim", languageTag = "tr", fallbackLocale = Locale.US)) + } + @Test fun appearanceThemeModeDefaultsToDarkForExistingInstalls() { assertEquals(AppearanceThemeMode.Dark, AppearanceThemeMode.fromRawValue(null)) diff --git a/scripts/android-app-i18n.ts b/scripts/android-app-i18n.ts index cd519c33d8c4..b11f8e93e2ec 100644 --- a/scripts/android-app-i18n.ts +++ b/scripts/android-app-i18n.ts @@ -308,6 +308,8 @@ const UI_IF_BRANCH_LITERAL_RE = /(?:\bif\s*\([^)]*\)|\belse)\s*\{\s*"((?:\\.|[^" const UI_MODEL_STRING_NAME_RE = /^(?:contentDescription|errorText|helperText|onClickLabel|statusText)$/u; const UI_MODEL_CLASS_FIELDS = new Map>([ + ["CommandItem", new Set(["subtitle", "title"])], + ["OverviewMetricCardSpec", new Set(["subtitle", "title", "value"])], ["SettingsToggleRow", new Set(["subtitle", "title"])], ]); const KOTLIN_STRING_LITERAL_RE = /"((?:\\.|[^"\\])+)"/gu; diff --git a/test/scripts/android-app-i18n.test.ts b/test/scripts/android-app-i18n.test.ts index 4f4f48e5020b..2bcbf8f3cc4d 100644 --- a/test/scripts/android-app-i18n.test.ts +++ b/test/scripts/android-app-i18n.test.ts @@ -139,6 +139,56 @@ describe("Android app i18n resources", () => { expect(findings).not.toContain("Internal code"); }); + it("inventories command and overview model display literals", () => { + const source = ` + data class CommandItem( + val key: String, + val title: String, + val subtitle: String, + ) + data class OverviewMetricCardSpec( + val title: String, + val value: String, + val subtitle: String, + ) + + CommandItem("chat", "Open Chat", "Start a conversation") + CommandItem( + key = "voice", + title = nativeString("Start Voice"), + subtitle = nativeString("Talk with OpenClaw"), + ) + OverviewMetricCardSpec( + title = nativeString("Gateway"), + value = if (connected) "Online" else nativeString("Offline"), + subtitle = "All systems nominal", + ) + `; + const findings = findUnlocalizedAndroidUiLiterals( + source, + "apps/android/app/src/main/java/ai/openclaw/app/ui/Example.kt", + ).map((finding) => finding.source); + + expect(findings).toEqual( + expect.arrayContaining([ + "Open Chat", + "Start a conversation", + "Online", + "All systems nominal", + ]), + ); + expect(findings).not.toEqual( + expect.arrayContaining([ + "chat", + "voice", + "Start Voice", + "Talk with OpenClaw", + "Gateway", + "Offline", + ]), + ); + }); + it("requires exact String fields and scans multiline helper expressions", () => { const source = ` data class StringResource(val key: String)