fix(android): localize command and overview models

This commit is contained in:
Vincent Koc
2026-07-12 10:09:33 +02:00
parent d0fc18fba8
commit 71db13d4c3
6 changed files with 180 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, ReadonlySet<string>>([
["CommandItem", new Set(["subtitle", "title"])],
["OverviewMetricCardSpec", new Set(["subtitle", "title", "value"])],
["SettingsToggleRow", new Set(["subtitle", "title"])],
]);
const KOTLIN_STRING_LITERAL_RE = /"((?:\\.|[^"\\])+)"/gu;

View File

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