refactor(#78): extract UI strings for chat, settings, connect, kanban, and skills screens

This commit is contained in:
Hy4ri committed 2026-06-21 11:38:33 +03:00
1 parent acd6cbf751
commit cdad31b555
9 files changed
+348 -149

No files matched your search

@@ -54,6 +54,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -64,6 +65,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.AssistantBubble
import com.m57.hermescontrol.theme.AssistantBubbleLight
import com.m57.hermescontrol.theme.StatusGreen
@@ -232,7 +234,7 @@ private fun UserBubble(
) {
Icon(
Icons.Filled.ContentCopy,
contentDescription = "Copy",
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
@@ -347,7 +349,7 @@ private fun AssistantBubble(
) {
Icon(
Icons.Filled.ContentCopy,
contentDescription = "Copy",
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
@@ -489,7 +491,7 @@ private fun ParsedToolContent(
}
parsed.stderr?.let {
Text(
text = "Error Output (stderr):\n$it",
text = stringResource(R.string.chat_tool_stderr, it),
style =
MaterialTheme.typography.bodySmall.copy(
color = StatusRed.copy(alpha = 0.9f),
@@ -500,7 +502,7 @@ private fun ParsedToolContent(
}
parsed.error?.let {
Text(
text = "Execution Error: $it",
text = stringResource(R.string.chat_tool_execution_error, it),
style =
MaterialTheme.typography.bodySmall.copy(
color = StatusRed,
@@ -513,7 +515,7 @@ private fun ParsedToolContent(
parsed.exitCode?.let { code ->
if (code != 0) {
Text(
text = "Exit Code: $code",
text = stringResource(R.string.chat_tool_exit_code, code),
style =
MaterialTheme.typography.labelSmall.copy(
color = StatusRed,
@@ -619,7 +621,7 @@ private fun ToolBubble(
}
Text(
text = message.toolName ?: "Tool",
text = message.toolName ?: stringResource(R.string.chat_tool_fallback),
style =
MaterialTheme.typography.labelMedium.copy(
color = contentColor,
@@ -635,7 +637,7 @@ private fun ToolBubble(
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Show Raw JSON",
text = stringResource(R.string.chat_tool_show_raw),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
@@ -660,7 +662,7 @@ private fun ToolBubble(
if (parsedOutput != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Show Parsed Output",
text = stringResource(R.string.chat_tool_show_parsed),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
@@ -293,14 +293,20 @@ fun ChatScreen(
imageVector =
if (state.isSearchActive) Icons.Filled.Close else Icons.Filled.Search,
contentDescription =
if (state.isSearchActive) "Close search" else "Search",
if (state.isSearchActive) {
stringResource(
R.string.chat_action_close_search,
)
} else {
stringResource(R.string.chat_action_search)
},
)
}
IconButton(onClick = { viewModel.createNewSession() }) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "New Chat",
contentDescription = stringResource(R.string.content_desc_new_chat),
)
}
},
@@ -335,9 +341,9 @@ fun ChatScreen(
Text(
text =
if (state.connectionStatus == ConnectionStatus.RECONNECTING) {
"Reconnecting to Hermes…"
stringResource(R.string.chat_status_reconnecting)
} else {
"Disconnected from Hermes"
stringResource(R.string.chat_status_disconnected)
},
style = MaterialTheme.typography.bodyMedium,
)
@@ -349,7 +355,7 @@ fun ChatScreen(
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text("Reconnect")
Text(stringResource(R.string.chat_action_reconnect))
}
}
}
@@ -393,8 +399,8 @@ fun ChatScreen(
) {
if (state.messages.isEmpty() && !state.isLoading) {
EmptyState(
title = "Ready to chat",
subtitle = "Send a message to start a conversation",
title = stringResource(R.string.chat_empty_title),
subtitle = stringResource(R.string.chat_empty_subtitle),
)
}
@@ -482,7 +488,7 @@ fun ChatScreen(
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Connecting to Hermes…",
text = stringResource(R.string.chat_status_connecting),
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -512,7 +518,7 @@ fun ChatScreen(
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = "Scroll to bottom",
contentDescription = stringResource(R.string.content_desc_scroll_to_bottom),
)
}
}
@@ -584,9 +590,9 @@ private fun ThinkingIndicator(thinkingText: String) {
text =
if (thinkingText.isNotBlank()) {
val display = thinkingText.takeLast(100)
"Thinking: $display…"
stringResource(R.string.chat_thinking_param, display)
} else {
"Thinking…"
stringResource(R.string.chat_thinking)
},
style =
MaterialTheme.typography.bodySmall.copy(
@@ -671,14 +677,14 @@ private fun ChatInputBar(
items(filteredCommands) { cmd ->
val description =
when (cmd) {
"/help" -> "Show help menu"
"/status" -> "Check gateway and platform status"
"/sessions" -> "List all chat sessions"
"/stats" -> "Check system resource usage (stats)"
"/system" -> "Check system resource usage (system)"
"/new" -> "Create a new chat session"
"/stop" -> "Interrupt the active run"
"/interrupt" -> "Interrupt the active run"
"/help" -> stringResource(R.string.command_help_desc)
"/status" -> stringResource(R.string.command_status_desc)
"/sessions" -> stringResource(R.string.command_sessions_desc)
"/stats" -> stringResource(R.string.command_stats_desc)
"/system" -> stringResource(R.string.command_system_desc)
"/new" -> stringResource(R.string.command_new_desc)
"/stop" -> stringResource(R.string.command_stop_desc)
"/interrupt" -> stringResource(R.string.command_stop_desc)
else -> ""
}
DropdownMenuItem(
@@ -725,11 +731,11 @@ private fun ChatInputBar(
placeholder = {
Text(
if (!isConnected) {
"Not connected…"
stringResource(R.string.chat_input_placeholder_not_connected)
} else if (isAgentTyping) {
"Waiting for response…"
stringResource(R.string.chat_input_placeholder_waiting)
} else {
"Type a message…"
stringResource(R.string.chat_input_placeholder_type_message)
},
)
},
@@ -759,7 +765,7 @@ private fun ChatInputBar(
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = "Interrupt",
contentDescription = stringResource(R.string.content_desc_interrupt),
tint = MaterialTheme.colorScheme.error,
)
}
@@ -250,7 +250,15 @@ fun FilterChipRow(
FilterChip(
selected = chip == selectedChip,
onClick = { onChipSelected(chip) },
label = { Text(chip) },
label = {
Text(
if (chip == "All") {
stringResource(R.string.skills_category_all)
} else {
chip
},
)
},
)
}
}
@@ -45,6 +45,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -53,6 +54,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
@Composable
fun ConnectScreen(
@@ -106,7 +108,7 @@ fun ConnectScreen(
}
Text(
text = "Hermes",
text = stringResource(R.string.notif_title),
style =
MaterialTheme.typography.displayMedium.copy(
fontWeight = FontWeight.Bold,
@@ -114,7 +116,7 @@ fun ConnectScreen(
),
)
Text(
text = "AI Agent Control",
text = stringResource(R.string.nav_drawer_subtitle),
style =
MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -134,9 +136,9 @@ fun ConnectScreen(
Text(
text =
if (state.selectedProfile != null) {
"Profile: ${state.selectedProfile!!.name}"
stringResource(R.string.connect_selected_profile, state.selectedProfile!!.name)
} else {
"Select Saved Profile"
stringResource(R.string.connect_action_select_profile)
},
)
}
@@ -146,7 +148,7 @@ fun ConnectScreen(
modifier = Modifier.fillMaxWidth(0.85f),
) {
DropdownMenuItem(
text = { Text("None (Standalone)") },
text = { Text(stringResource(R.string.connect_profile_none)) },
onClick = {
viewModel.selectProfile(null)
profilesExpanded = false
@@ -171,7 +173,7 @@ fun ConnectScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Pairing string",
text = stringResource(R.string.connect_pairing_string),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
)
@@ -182,7 +184,7 @@ fun ConnectScreen(
pairingString = it
if (it.isNotBlank()) viewModel.onPairingString(it)
},
placeholder = { Text("Paste hermes://… or Base64 config") },
placeholder = { Text(stringResource(R.string.connect_placeholder_pairing)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -215,7 +217,7 @@ fun ConnectScreen(
onCheckedChange = viewModel::onSaveProfileChange,
)
Text(
text = "Save connection profile",
text = stringResource(R.string.connect_save_profile),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
)
@@ -225,8 +227,8 @@ fun ConnectScreen(
OutlinedTextField(
value = state.profileName,
onValueChange = viewModel::onProfileNameChange,
label = { Text("Profile Name") },
placeholder = { Text("e.g. Local Hermit, Remote Server") },
label = { Text(stringResource(R.string.connect_profile_name)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_profile_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -247,7 +249,7 @@ fun ConnectScreen(
strokeWidth = 2.dp,
)
} else {
Text("Connect", style = MaterialTheme.typography.labelLarge)
Text(stringResource(R.string.action_connect), style = MaterialTheme.typography.labelLarge)
}
}
@@ -256,7 +258,12 @@ fun ConnectScreen(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = if (showManualFields) "Hide manual entry" else "Enter manually instead",
text =
if (showManualFields) {
stringResource(R.string.connect_action_hide_manual)
} else {
stringResource(R.string.connect_action_show_manual)
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
@@ -271,8 +278,8 @@ fun ConnectScreen(
OutlinedTextField(
value = state.token,
onValueChange = viewModel::onTokenChange,
label = { Text("Auth Token") },
placeholder = { Text("API token") },
label = { Text(stringResource(R.string.settings_field_token)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_token)) },
singleLine = true,
visualTransformation =
if (passwordVisible) {
@@ -290,7 +297,11 @@ fun ConnectScreen(
Icons.Filled.VisibilityOff
},
contentDescription =
if (passwordVisible) "Hide token" else "Show token",
if (passwordVisible) {
stringResource(R.string.content_desc_hide_token)
} else {
stringResource(R.string.content_desc_show_token)
},
)
}
},
@@ -299,16 +310,16 @@ fun ConnectScreen(
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text("Host") },
placeholder = { Text("127.0.0.1") },
label = { Text(stringResource(R.string.settings_field_host)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_host)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text("Port") },
placeholder = { Text("9119") },
label = { Text(stringResource(R.string.settings_field_port)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
@@ -39,10 +39,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -73,7 +75,7 @@ fun KanbanScreen(
}
HermesScaffold(
title = { Text("Kanban Board") },
title = { Text(stringResource(R.string.kanban_board_title)) },
onOpenDrawer = onOpenDrawer,
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadBoards() },
@@ -99,10 +101,12 @@ fun KanbanScreen(
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
modifier = Modifier.padding(paddingValues),
)
} else {
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().padding(paddingValues),
) {
// Board selector tab row
if (state.boards.isNotEmpty()) {
PrimaryScrollableTabRow(
@@ -121,11 +125,11 @@ fun KanbanScreen(
if (state.selectedBoard == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No boards found.")
Text(stringResource(R.string.kanban_no_boards))
}
} else if (state.columns.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No columns found for this board.")
Text(stringResource(R.string.kanban_no_columns))
}
} else {
LazyRow(
@@ -219,7 +223,10 @@ fun TaskCard(
) {
if (onMoveLeft != null) {
IconButton(onClick = onMoveLeft) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Move Left")
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_desc_move_left),
)
}
} else {
Spacer(modifier = Modifier.size(48.dp))
@@ -227,7 +234,10 @@ fun TaskCard(
if (onMoveRight != null) {
IconButton(onClick = onMoveRight) {
Icon(imageVector = Icons.Filled.ArrowForward, contentDescription = "Move Right")
Icon(
imageVector = Icons.Filled.ArrowForward,
contentDescription = stringResource(R.string.content_desc_move_right),
)
}
} else {
Spacer(modifier = Modifier.size(48.dp))
@@ -248,20 +258,20 @@ fun AddTaskDialog(
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add New Task") },
title = { Text(stringResource(R.string.kanban_add_new_task)) },
text = {
Column {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Task Title") },
label = { Text(stringResource(R.string.kanban_task_title)) },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = desc,
onValueChange = { desc = it },
label = { Text("Task Description") },
label = { Text(stringResource(R.string.kanban_task_desc)) },
modifier = Modifier.fillMaxWidth(),
)
}
@@ -271,12 +281,12 @@ fun AddTaskDialog(
onClick = { if (title.isNotBlank()) onConfirm(title, desc.ifBlank { null }) },
enabled = title.isNotBlank(),
) {
Text("Add")
Text(stringResource(R.string.action_add))
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text("Cancel")
Text(stringResource(R.string.action_cancel))
}
},
)
@@ -53,6 +53,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -61,6 +62,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -77,7 +79,7 @@ fun SettingsScreen(
BackHandler(onBack = onBack)
HermesScaffold(
title = { Text("Settings") },
title = { Text(stringResource(R.string.screen_settings)) },
showBack = true,
onBack = onBack,
) {
@@ -90,12 +92,12 @@ fun SettingsScreen(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Connection section
SectionCard(title = "Connection") {
SectionCard(title = stringResource(R.string.settings_sec_connection)) {
// Connection Profiles management in Settings
if (state.profiles.isNotEmpty()) {
var profilesExpanded by remember { mutableStateOf(false) }
Text(
text = "Connection Profile",
text = stringResource(R.string.settings_item_profile),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -105,7 +107,7 @@ fun SettingsScreen(
modifier = Modifier.fillMaxWidth(),
) {
val activeProfile = state.profiles.firstOrNull { it.id == state.selectedProfileId }
Text(text = activeProfile?.name ?: "Standalone / Default")
Text(text = activeProfile?.name ?: stringResource(R.string.settings_profile_default))
}
DropdownMenu(
expanded = profilesExpanded,
@@ -113,7 +115,7 @@ fun SettingsScreen(
modifier = Modifier.fillMaxWidth(0.85f),
) {
DropdownMenuItem(
text = { Text("None (Standalone / Default)") },
text = { Text(stringResource(R.string.settings_profile_none)) },
onClick = {
viewModel.selectProfile(null)
profilesExpanded = false
@@ -136,7 +138,10 @@ fun SettingsScreen(
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Delete Profile",
contentDescription =
stringResource(
R.string.content_desc_delete_profile,
),
tint = MaterialTheme.colorScheme.error,
)
}
@@ -155,7 +160,7 @@ fun SettingsScreen(
OutlinedTextField(
value = state.renameProfileName,
onValueChange = viewModel::onRenameProfileNameChange,
label = { Text("Rename Profile") },
label = { Text(stringResource(R.string.settings_action_rename_profile)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
@@ -163,7 +168,7 @@ fun SettingsScreen(
onClick = viewModel::renameProfile,
modifier = Modifier.padding(end = 4.dp),
) {
Text("Rename")
Text(stringResource(R.string.action_rename))
}
},
)
@@ -174,7 +179,7 @@ fun SettingsScreen(
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text("Host") },
label = { Text(stringResource(R.string.settings_field_host)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors =
@@ -189,7 +194,7 @@ fun SettingsScreen(
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text("Port") },
label = { Text(stringResource(R.string.settings_field_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
@@ -205,7 +210,7 @@ fun SettingsScreen(
OutlinedTextField(
value = state.token,
onValueChange = viewModel::onTokenChange,
label = { Text("Auth Token") },
label = { Text(stringResource(R.string.settings_field_token)) },
singleLine = true,
visualTransformation =
if (passwordVisible) {
@@ -224,9 +229,9 @@ fun SettingsScreen(
},
contentDescription =
if (passwordVisible) {
"Hide token"
stringResource(R.string.content_desc_hide_token)
} else {
"Show token"
stringResource(R.string.content_desc_show_token)
},
)
}
@@ -253,12 +258,12 @@ fun SettingsScreen(
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Clear Token")
Text(stringResource(R.string.settings_action_clear_token))
}
}
// Behavior section
SectionCard(title = "Behavior") {
SectionCard(title = stringResource(R.string.settings_sec_behavior)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -266,11 +271,11 @@ fun SettingsScreen(
) {
Column {
Text(
text = "Auto-Reconnect",
text = stringResource(R.string.settings_item_auto_reconnect),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = "Automatically reconnect on disconnect",
text = stringResource(R.string.settings_desc_auto_reconnect),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -290,9 +295,9 @@ fun SettingsScreen(
}
// Appearance section
SectionCard(title = "Appearance") {
SectionCard(title = stringResource(R.string.settings_sec_appearance)) {
Text(
text = "Theme",
text = stringResource(R.string.settings_item_theme),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
@@ -308,9 +313,11 @@ fun SettingsScreen(
),
) {
Text(
pref.name
.lowercase()
.replaceFirstChar { it.uppercase() },
when (pref) {
ThemePreference.SYSTEM -> stringResource(R.string.theme_system)
ThemePreference.LIGHT -> stringResource(R.string.theme_light)
ThemePreference.DARK -> stringResource(R.string.theme_dark)
},
)
}
}
@@ -318,7 +325,7 @@ fun SettingsScreen(
}
// Chat section
SectionCard(title = "Chat") {
SectionCard(title = stringResource(R.string.settings_sec_chat)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -326,11 +333,11 @@ fun SettingsScreen(
) {
Column {
Text(
text = "Typing Effect",
text = stringResource(R.string.settings_item_typing_effect),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = "Animate new messages word by word",
text = stringResource(R.string.settings_desc_typing_effect),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -352,7 +359,7 @@ fun SettingsScreen(
AnimatedVisibility(visible = state.typingEffectEnabled) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
text = "Delay: ${state.typingEffectDelayMs} ms",
text = stringResource(R.string.settings_item_typing_delay, state.typingEffectDelayMs),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(4.dp))
@@ -368,14 +375,14 @@ fun SettingsScreen(
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "10 ms",
text = stringResource(R.string.settings_delay_10ms),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Text(
text = "100 ms",
text = stringResource(R.string.settings_delay_100ms),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -387,15 +394,16 @@ fun SettingsScreen(
}
// Navigation Bar section
SectionCard(title = "Navigation Bar") {
SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) {
Text(
text = "Bottom bar items (${state.selectedNavItems.size}/5)",
text = stringResource(R.string.settings_item_nav_items, state.selectedNavItems.size),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
state.selectedNavItems.forEachIndexed { index, name ->
val label = viewModel.availableNavItems.firstOrNull { it.first == name }?.second ?: name
val labelRes = viewModel.availableNavItems.firstOrNull { it.first == name }?.second
val label = if (labelRes != null) stringResource(labelRes) else name
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -417,7 +425,10 @@ fun SettingsScreen(
},
enabled = index > 0,
) {
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = "Move up")
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(R.string.content_desc_move_up),
)
}
IconButton(
onClick = {
@@ -429,10 +440,16 @@ fun SettingsScreen(
},
enabled = index < state.selectedNavItems.lastIndex,
) {
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = "Move down")
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_move_down),
)
}
IconButton(onClick = { viewModel.removeNavItem(name) }) {
Icon(Icons.Filled.Close, contentDescription = "Remove $label")
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.content_desc_remove_item, label),
)
}
}
}
@@ -446,12 +463,12 @@ fun SettingsScreen(
OutlinedButton(onClick = { expanded = true }) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.size(4.dp))
Text("Add Item")
Text(stringResource(R.string.settings_action_add_item))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
available.forEach { (name, label) ->
available.forEach { (name, labelRes) ->
DropdownMenuItem(
text = { Text(label) },
text = { Text(stringResource(labelRes)) },
onClick = {
viewModel.addNavItem(name)
expanded = false
@@ -463,7 +480,7 @@ fun SettingsScreen(
}
} else {
Text(
text = "Maximum of 5 items",
text = stringResource(R.string.settings_nav_max_reached),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -505,7 +522,7 @@ fun SettingsScreen(
exit = fadeOut(),
) {
Text(
text = "✅ Settings saved",
text = stringResource(R.string.settings_save_success),
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.primary,
@@ -529,7 +546,7 @@ fun SettingsScreen(
strokeWidth = 2.dp,
)
} else {
Text("Test Connection")
Text(stringResource(R.string.settings_action_test_connection))
}
}
@@ -541,17 +558,17 @@ fun SettingsScreen(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text("Save")
Text(stringResource(R.string.action_save))
}
}
Spacer(modifier = Modifier.height(16.dp))
// ── About section ─────────────────────────────────────────
SectionCard(title = "About") {
SectionCard(title = stringResource(R.string.settings_sec_about)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "⚡ Hermes Control",
text = stringResource(R.string.settings_about_app_name),
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
@@ -563,13 +580,21 @@ fun SettingsScreen(
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(8.dp))
InfoRow(label = "Version", value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
InfoRow(
label = "Build",
value = if (BuildConfig.DEBUG) "Debug" else "Release",
label = stringResource(R.string.settings_about_version),
value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
)
InfoRow(
label = stringResource(R.string.settings_about_build),
value =
if (BuildConfig.DEBUG) {
stringResource(R.string.settings_about_debug)
} else {
stringResource(R.string.settings_about_release)
},
)
if (BuildConfig.GIT_SHA.isNotBlank()) {
InfoRow(label = "Commit", value = BuildConfig.GIT_SHA)
InfoRow(label = stringResource(R.string.settings_about_commit), value = BuildConfig.GIT_SHA)
}
Spacer(modifier = Modifier.height(12.dp))
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -129,28 +130,28 @@ class SettingsViewModel : ViewModel() {
}
/** All screens available for bottom-nav selection (name → display label). */
val availableNavItems: List<Pair<String, String>> =
val availableNavItems: List<Pair<String, Int>> =
listOf(
"ChatScreen" to "Chat",
"SkillsScreen" to "Skills",
"CronJobsScreen" to "Cron Jobs",
"SystemScreen" to "System",
"SettingsScreen" to "Settings",
"ProfilesScreen" to "Profiles",
"WebhooksScreen" to "Webhooks",
"GatewayScreen" to "Gateway",
"HistoryScreen" to "History",
"ToolsetsScreen" to "Toolsets",
"PluginsScreen" to "Plugins",
"ConfigScreen" to "Config",
"McpServersScreen" to "MCP Servers",
"ModelScreen" to "Models",
"PairingScreen" to "Pairing",
"KeysScreen" to "Keys",
"ChannelsScreen" to "Channels",
"LogsScreen" to "Logs",
"KanbanScreen" to "Kanban",
"AchievementsScreen" to "Achievements",
"ChatScreen" to R.string.screen_chat,
"SkillsScreen" to R.string.screen_skills,
"CronJobsScreen" to R.string.screen_cron,
"SystemScreen" to R.string.screen_system,
"SettingsScreen" to R.string.screen_settings,
"ProfilesScreen" to R.string.screen_profiles,
"WebhooksScreen" to R.string.screen_webhooks,
"GatewayScreen" to R.string.screen_gateway,
"HistoryScreen" to R.string.screen_history,
"ToolsetsScreen" to R.string.screen_toolsets,
"PluginsScreen" to R.string.screen_plugins,
"ConfigScreen" to R.string.screen_config,
"McpServersScreen" to R.string.screen_mcp_servers,
"ModelScreen" to R.string.screen_models,
"PairingScreen" to R.string.screen_pairing,
"KeysScreen" to R.string.screen_keys,
"ChannelsScreen" to R.string.screen_channels,
"LogsScreen" to R.string.screen_logs,
"KanbanScreen" to R.string.screen_kanban,
"AchievementsScreen" to R.string.screen_achievements,
)
/** Add a nav item to the bottom bar (max 5). Auto-saves. */
@@ -35,6 +35,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -43,6 +44,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
@@ -117,7 +119,7 @@ fun SkillsScreen(
}
HermesScaffold(
title = { Text("Skills") },
title = { Text(stringResource(R.string.screen_skills)) },
onOpenDrawer = onOpenDrawer,
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadSkills() },
@@ -129,15 +131,15 @@ fun SkillsScreen(
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "Unknown error",
message = state.errorMessage ?: stringResource(R.string.error_unknown),
onRetry = { viewModel.loadSkills() },
)
}
state.skills.isEmpty() -> {
EmptyState(
title = "No skills found",
subtitle = "Skills loaded from Hermes will appear here.",
title = stringResource(R.string.skills_no_skills),
subtitle = stringResource(R.string.skills_no_skills_desc),
icon = Icons.Filled.Extension,
)
}
@@ -152,7 +154,7 @@ fun SkillsScreen(
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search skills…",
placeholder = stringResource(R.string.skills_search_placeholder),
)
}
item {
@@ -164,7 +166,7 @@ fun SkillsScreen(
verticalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Text(
text = "Status",
text = stringResource(R.string.skills_filter_status),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -175,7 +177,16 @@ fun SkillsScreen(
FilterChip(
selected = selectedStatus == status,
onClick = { selectedStatus = status },
label = { Text(status) },
label = {
Text(
when (status) {
"All Statuses" -> stringResource(R.string.skills_status_all)
"Enabled" -> stringResource(R.string.skills_status_enabled)
"Disabled" -> stringResource(R.string.skills_status_disabled)
else -> status
},
)
},
)
}
}
@@ -191,7 +202,7 @@ fun SkillsScreen(
verticalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Text(
text = "Category",
text = stringResource(R.string.skills_filter_category),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = spacing.md),
@@ -218,14 +229,14 @@ fun SkillsScreen(
verticalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Text(
text = "No matching skills",
text = stringResource(R.string.skills_no_matching),
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
),
)
Text(
text = "Try adjusting your search or filters.",
text = stringResource(R.string.skills_no_matching_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -293,7 +304,7 @@ fun SkillsScreen(
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = "Edit Skill File",
contentDescription = stringResource(R.string.content_desc_edit_skill),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -373,7 +384,7 @@ fun SkillEditorDialog(
),
) {
HermesScaffold(
title = { Text("Edit: $skillName") },
title = { Text(stringResource(R.string.skills_edit_title, skillName)) },
onBack = {
if (hasChanges) {
showDiscardConfirm = true
@@ -397,7 +408,7 @@ fun SkillEditorDialog(
} else {
Icon(
imageVector = Icons.Filled.Save,
contentDescription = "Save Changes",
contentDescription = stringResource(R.string.content_desc_save_changes),
)
}
}
@@ -429,7 +440,7 @@ fun SkillEditorDialog(
color = MaterialTheme.colorScheme.onSurface,
),
placeholder = {
Text("Enter skill content...")
Text(stringResource(R.string.skills_content_placeholder))
},
)
}
@@ -442,8 +453,8 @@ fun SkillEditorDialog(
if (showDiscardConfirm) {
AlertDialog(
onDismissRequest = { showDiscardConfirm = false },
title = { Text("Discard changes?") },
text = { Text("You have unsaved changes. Are you sure you want to discard them?") },
title = { Text(stringResource(R.string.skills_discard_title)) },
text = { Text(stringResource(R.string.skills_discard_message)) },
confirmButton = {
TextButton(
onClick = {
@@ -451,12 +462,12 @@ fun SkillEditorDialog(
onDismiss()
},
) {
Text("Discard")
Text(stringResource(R.string.action_discard))
}
},
dismissButton = {
TextButton(onClick = { showDiscardConfirm = false }) {
Text("Cancel")
Text(stringResource(R.string.action_cancel))
}
},
)