mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 06:51:38 +00:00
fix(android): localize command and overview models
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user