refactor(ui): split monolithic screen files into focused components (#485)

* refactor(ui): extract SkillsStep to components package

Move the 252-LOC SkillsStep composable out of ProfilesScreen.kt into
ui/profiles/components/SkillsStep.kt. Body is verbatim (behavior-preserving);
only package + imports changed. Parent now imports and calls it directly.

Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
Note: sibling screens resolve Modifier.weight() without an explicit import;
matching that convention (no spurious weight import) to avoid the
internal RowColumnParentData.weight shadowing error.

* refactor(ui): extract ProfileBuilderView cluster to components package

Move the Profile Builder wizard (ProfileBuilderView + validateProfileName +
IdentityStep/ModelStep/McpStep/AddMcpDialog/ReviewStep) out of ProfilesScreen.kt
into ui/profiles/components/ProfileBuilderView.kt. Body is verbatim
(behavior-preserving); only package + imports changed.

Notable wiring required:
- validateProfileName kept as internal fun: it is also used by the parent's
  clone dialog, so both the screen and the builder share it via import.
- ProfilesUiState/ProfilesViewModel imported from the ui.profiles package.
- SkillsStep call resolves to the already-extracted sibling file (same package).

Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).

* refactor(ui): extract MoaConfigDialog cluster to components package

Move the Mixture-of-Agents config dialog (MoaConfigDialog + MoaConfigEditor +
MoaSlotPicker + MoaPickerTarget sealed class) out of ModelScreen.kt into
ui/model/components/MoaConfigDialog.kt. Body is verbatim (behavior-preserving);
only package + imports + entry visibility changed.

- Entry MoaConfigDialog is internal (called by ModelScreen root); the editor,
  slot picker, and sealed class stay private to the new file.
- The @Composable annotation preceding the declaration is moved with the
  cluster (earlier attempt left it orphaned, causing a duplicate-annotation
  compile error at the next declaration).

Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).

* refactor(ui): extract PlatformCard + TelegramOnboardingDialog clusters

Move two self-contained clusters out of ChannelsScreen.kt:
- PlatformCard.kt: StateStyle (private data class) + platformStateStyle +
  StateBadge + PlatformCard. Entry PlatformCard is internal (called by
  ChannelsScreen root); StateStyle/StateBadge/platformStateStyle stay private.
- TelegramOnboardingDialog.kt: TelegramOnboardingDialog. Entry internal.

Wiring required:
- ConfigureForm kept in ChannelsScreen as internal (shared by PlatformCard
  and the screen root) and imported into PlatformCard.kt.
- ChannelsUiState/OnboardingPhase imported from the ui.channels package.
- formatTimestamp left in the screen (used by ConfigureForm).

Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).

* refactor(ui): extract SkillsScreen sub-composables to components package

Split the 914-line SkillsScreen.kt into the screen root plus 4 cohesive
component files under ui/skills/components/:
- SourceBadge.kt: shared SourceBadge (internal, used by both tabs)
- InstalledSkillsTab.kt: InstalledSkillsView + SkillCard + SkillPreviewDialog
- HubSkillsTab.kt: HubBrowseView + HubSkillCard
- SkillEditorDialog.kt: SkillEditorDialog

Wiring required:
- CATEGORY_ALL promoted private -> internal in SkillsScreen (shared by the
  screen root and InstalledSkillsView) and imported where needed.
- SkillFilter / SkillsUiState imported from the ui.skills package.
- HubBrowseView now reads isHubSearching/hubResults/hubSearchError off its
  SkillsUiState param (fields, not locals) - no behavior change.

Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).

* refactor(ui): extract SettingsScreen + SystemScreen sub-composables

SettingsScreen.kt (1238 -> ~340): move self-contained sections into
ui/settings/components/ - ConnectionSection (+TestResultCard/SaveIndicator/
TestConnectionButton), BehaviorSection, AppearanceSection (+ChatSection/
NavBarSection), AboutSection, ProfileEditorDialog (+DeleteProfileConfirmDialog).
Shared leaves SectionCard/InfoRow kept in the screen as internal and imported
by the extracted files.

SystemScreen.kt (1755 -> 1619): move CredentialEntryRow + HookCard into
ui/system/components/ (both internal). formatBytes/formatDuration helpers stay
in the screen (used by the root and remaining content).

Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).

* fix(#485): use LocalHermesStatusColors for PlatformCard state colors

Map platform connection-status badges to semantic LocalHermesStatusColors
slots (success/warning/error) + add neutral slot for not_configured/disabled.
Removes hardcoded hex (Color(0xFF...)) and MaterialTheme.colorScheme usage
flagged by Sourcery; neutral defaults to StatusGrey so existing theme
providers are untouched.
This commit is contained in:
M57 authored and GitHub committed 2026-07-07 23:00:24 +03:00
1 parent d178d7f69e
commit ca32e37e2f
24 files changed
+4153 -2840

No files matched your search

@@ -67,6 +67,7 @@ val StatusYellow = Color(0xFFFFB627)
val StatusYellowContainer = Color(0xFF3D2F0F)
val StatusBlue = Color(0xFF4DA8FF)
val StatusBlueContainer = Color(0xFF0F2A3D)
val StatusGrey = Color(0xFF9E9E9E)
// ── Chat-specific colours (always brand-defined for consistency) ───────
@@ -22,6 +22,7 @@ data class HermesStatusColors(
val errorContainer: Color,
val info: Color,
val infoContainer: Color,
val neutral: Color = StatusGrey,
)
val LocalHermesStatusColors =
@@ -1,7 +1,6 @@
package com.m57.hermescontrol.ui.channels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -15,29 +14,17 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -47,8 +34,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -60,6 +45,8 @@ import com.m57.hermescontrol.data.model.EnvVarField
import com.m57.hermescontrol.data.model.MessagingPlatform
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.channels.components.PlatformCard
import com.m57.hermescontrol.ui.channels.components.TelegramOnboardingDialog
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -101,42 +88,6 @@ private fun validateEnvField(
// ── State → badge colour / label ──────────────────────────────────────
private data class StateStyle(
val label: String,
val color: Color,
val icon: ImageVector,
)
@Composable
private fun platformStateStyle(state: String): StateStyle =
when (state) {
"connected" -> StateStyle("Connected", Color(0xFF4CAF50), Icons.Filled.CheckCircle)
"pending_restart" -> StateStyle("Restart to apply", Color(0xFFFFC107), Icons.Filled.Refresh)
"gateway_stopped" -> StateStyle("Gateway stopped", Color(0xFFFF9800), Icons.Filled.RadioButtonChecked)
"startup_failed" -> StateStyle("Start failed", Color(0xFFF44336), Icons.Filled.Warning)
"disconnected" -> StateStyle("Disconnected", Color(0xFFFF9800), Icons.Filled.RadioButtonChecked)
"not_configured" -> StateStyle("Not configured", Color(0xFF9E9E9E), Icons.Filled.RadioButtonChecked)
"disabled" -> StateStyle("Disabled", Color(0xFF9E9E9E), Icons.Filled.RadioButtonChecked)
"fatal" -> StateStyle("Error", Color(0xFFF44336), Icons.Filled.Warning)
else -> StateStyle(state, MaterialTheme.colorScheme.onSurfaceVariant, Icons.Filled.RadioButtonChecked)
}
@Composable
private fun StateBadge(state: String) {
val style = platformStateStyle(state)
Surface(
shape = RoundedCornerShape(4.dp),
color = style.color.copy(alpha = 0.15f),
) {
Text(
text = style.label,
color = style.color,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
// ── Main screen ──────────────────────────────────────────────────────
@Composable
@@ -352,203 +303,10 @@ private fun GatewayOfflineBanner(gatewayCommand: String) {
// ── Platform card ────────────────────────────────────────────────────
@Composable
private fun PlatformCard(
platform: MessagingPlatform,
isToggling: Boolean,
isTesting: Boolean,
isRemoving: Boolean,
onToggle: (Boolean) -> Unit,
onTest: () -> Unit,
onSave: (MessagingPlatformUpdate) -> Unit,
onRemove: () -> Unit,
onStartOnboarding: () -> Unit,
) {
var showConfigureForm by remember { mutableStateOf(false) }
var showRemoveConfirm by remember { mutableStateOf(false) }
val style = platformStateStyle(platform.state)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
// ── Top row: icon + name + badge + switch ──
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
Icon(
imageVector = style.icon,
contentDescription = null,
tint = style.color,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = platform.name,
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(2.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
StateBadge(state = platform.state)
}
}
}
if (isToggling) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
} else {
Switch(
checked = platform.enabled,
onCheckedChange = onToggle,
)
}
}
// ── Description ──
if (!platform.description.isNullOrBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = platform.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Error message ──
if (!platform.errorMessage.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = platform.errorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
// ── Action buttons ──
Spacer(modifier = Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onTest,
enabled = !isTesting,
) {
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = stringResource(R.string.channels_action_test),
style = MaterialTheme.typography.labelSmall,
)
}
Button(
onClick = { showConfigureForm = !showConfigureForm },
) {
Text(
text =
if (showConfigureForm) {
stringResource(R.string.action_cancel)
} else {
stringResource(R.string.channels_action_configure)
},
style = MaterialTheme.typography.labelSmall,
)
}
// Remove button
IconButton(
onClick = { showRemoveConfirm = true },
enabled = !isRemoving,
) {
if (isRemoving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Remove platform",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
// ── Telegram onboarding (only for unconfigured telegram) ──
if (platform.id == "telegram" && !platform.configured) {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onStartOnboarding,
) {
Icon(
imageVector = Icons.Filled.QrCode,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Set up with QR",
style = MaterialTheme.typography.labelSmall,
)
}
}
// ── Configure form ──
if (showConfigureForm) {
ConfigureForm(
platform = platform,
onSave = onSave,
onClose = { showConfigureForm = false },
)
}
}
}
// Remove confirmation
if (showRemoveConfirm) {
AlertDialog(
onDismissRequest = { showRemoveConfirm = false },
title = { Text("Remove ${platform.name}?") },
text = { Text("This will permanently remove this platform's configuration.") },
confirmButton = {
TextButton(
onClick = {
showRemoveConfirm = false
onRemove()
},
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text("Remove")
}
},
dismissButton = {
TextButton(onClick = { showRemoveConfirm = false }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
// ── Configure form ───────────────────────────────────────────────────
@Composable
private fun ConfigureForm(
internal fun ConfigureForm(
platform: MessagingPlatform,
onSave: (MessagingPlatformUpdate) -> Unit,
onClose: () -> Unit,
@@ -692,176 +450,6 @@ private fun AdminSection(envPath: String) {
// ── Telegram Onboarding Dialog ────────────────────────────────────────
@Composable
private fun TelegramOnboardingDialog(
state: ChannelsUiState,
onDismiss: () -> Unit,
onAddAllowedId: () -> Unit,
onRemoveAllowedId: (String) -> Unit,
onNewAllowedIdChange: (String) -> Unit,
onApply: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text =
when (state.onboardingPhase) {
OnboardingPhase.STARTING -> "Starting…"
OnboardingPhase.WAITING -> "Scan QR code"
OnboardingPhase.READY -> "Telegram connected"
OnboardingPhase.APPLYING -> "Saving…"
OnboardingPhase.IDLE -> "Telegram Setup"
},
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
state.onboardingError?.let { error ->
Surface(
color = LocalHermesStatusColors.current.errorContainer,
shape = RoundedCornerShape(8.dp),
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
}
when (state.onboardingPhase) {
OnboardingPhase.STARTING -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
OnboardingPhase.WAITING -> {
Text(
text = "Open Telegram and scan the QR code displayed in the app.",
style = MaterialTheme.typography.bodyMedium,
)
if (state.onboardingExpiresIn.isNotEmpty()) {
Text(
text = "QR code expires in: ${state.onboardingExpiresIn}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OnboardingPhase.READY -> {
state.onboardingBotUsername?.let { username ->
Text(
text = "Connected as @$username",
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Allowed users:",
style = MaterialTheme.typography.titleSmall,
)
state.onboardingDetectedOwnerId?.let { ownerId ->
if (state.onboardingAllowedIds.contains(ownerId)) {
Text(
text = "$ownerId (owner detected)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
state.onboardingAllowedIds.forEach { id ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = id,
style = MaterialTheme.typography.bodyMedium,
)
IconButton(onClick = { onRemoveAllowedId(id) }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Remove",
modifier = Modifier.size(18.dp),
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = state.onboardingNewAllowedId,
onValueChange = onNewAllowedIdChange,
placeholder = { Text("Telegram user ID") },
singleLine = true,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onAddAllowedId) {
Icon(Icons.Filled.Add, contentDescription = "Add user ID")
}
}
}
OnboardingPhase.APPLYING -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Saving configuration…",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
OnboardingPhase.IDLE -> { }
}
}
},
confirmButton = {
when (state.onboardingPhase) {
OnboardingPhase.READY -> {
Button(
onClick = onApply,
enabled = state.onboardingAllowedIds.isNotEmpty(),
) {
Text("Save and restart")
}
}
else -> {}
}
},
dismissButton = {
when (state.onboardingPhase) {
OnboardingPhase.READY -> {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
OnboardingPhase.WAITING -> {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
OnboardingPhase.IDLE, OnboardingPhase.STARTING, OnboardingPhase.APPLYING -> {}
}
},
)
}
private val isoTimestampParser =
java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
@@ -0,0 +1,290 @@
package com.m57.hermescontrol.ui.channels.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.MessagingPlatform
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.channels.ConfigureForm
private enum class StatusRole { SUCCESS, WARNING, ERROR, NEUTRAL }
private data class StateStyle(
val label: String,
val color: Color,
val icon: ImageVector,
)
@Composable
private fun platformStateStyle(state: String): StateStyle {
val statusColors = LocalHermesStatusColors.current
val (label, role, icon) =
when (state) {
"connected" -> Triple("Connected", StatusRole.SUCCESS, Icons.Filled.CheckCircle)
"pending_restart" -> Triple("Restart to apply", StatusRole.WARNING, Icons.Filled.Refresh)
"gateway_stopped" -> Triple("Gateway stopped", StatusRole.WARNING, Icons.Filled.RadioButtonChecked)
"startup_failed" -> Triple("Start failed", StatusRole.ERROR, Icons.Filled.Warning)
"disconnected" -> Triple("Disconnected", StatusRole.WARNING, Icons.Filled.RadioButtonChecked)
"not_configured" -> Triple("Not configured", StatusRole.NEUTRAL, Icons.Filled.RadioButtonChecked)
"disabled" -> Triple("Disabled", StatusRole.NEUTRAL, Icons.Filled.RadioButtonChecked)
"fatal" -> Triple("Error", StatusRole.ERROR, Icons.Filled.Warning)
else -> Triple(state, StatusRole.NEUTRAL, Icons.Filled.RadioButtonChecked)
}
val color =
when (role) {
StatusRole.SUCCESS -> statusColors.success
StatusRole.WARNING -> statusColors.warning
StatusRole.ERROR -> statusColors.error
StatusRole.NEUTRAL -> statusColors.neutral
}
return StateStyle(label, color, icon)
}
@Composable
private fun StateBadge(state: String) {
val style = platformStateStyle(state)
Surface(
shape = RoundedCornerShape(4.dp),
color = style.color.copy(alpha = 0.15f),
) {
Text(
text = style.label,
color = style.color,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
@Composable
internal fun PlatformCard(
platform: MessagingPlatform,
isToggling: Boolean,
isTesting: Boolean,
isRemoving: Boolean,
onToggle: (Boolean) -> Unit,
onTest: () -> Unit,
onSave: (MessagingPlatformUpdate) -> Unit,
onRemove: () -> Unit,
onStartOnboarding: () -> Unit,
) {
var showConfigureForm by remember { mutableStateOf(false) }
var showRemoveConfirm by remember { mutableStateOf(false) }
val style = platformStateStyle(platform.state)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
// ── Top row: icon + name + badge + switch ──
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
Icon(
imageVector = style.icon,
contentDescription = null,
tint = style.color,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = platform.name,
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(2.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
StateBadge(state = platform.state)
}
}
}
if (isToggling) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
} else {
Switch(
checked = platform.enabled,
onCheckedChange = onToggle,
)
}
}
// ── Description ──
if (!platform.description.isNullOrBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = platform.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Error message ──
if (!platform.errorMessage.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = platform.errorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
// ── Action buttons ──
Spacer(modifier = Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onTest,
enabled = !isTesting,
) {
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = stringResource(R.string.channels_action_test),
style = MaterialTheme.typography.labelSmall,
)
}
Button(
onClick = { showConfigureForm = !showConfigureForm },
) {
Text(
text =
if (showConfigureForm) {
stringResource(R.string.action_cancel)
} else {
stringResource(R.string.channels_action_configure)
},
style = MaterialTheme.typography.labelSmall,
)
}
// Remove button
IconButton(
onClick = { showRemoveConfirm = true },
enabled = !isRemoving,
) {
if (isRemoving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Remove platform",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
// ── Telegram onboarding (only for unconfigured telegram) ──
if (platform.id == "telegram" && !platform.configured) {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onStartOnboarding,
) {
Icon(
imageVector = Icons.Filled.QrCode,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Set up with QR",
style = MaterialTheme.typography.labelSmall,
)
}
}
// ── Configure form ──
if (showConfigureForm) {
ConfigureForm(
platform = platform,
onSave = onSave,
onClose = { showConfigureForm = false },
)
}
}
}
// Remove confirmation
if (showRemoveConfirm) {
AlertDialog(
onDismissRequest = { showRemoveConfirm = false },
title = { Text("Remove ${platform.name}?") },
text = { Text("This will permanently remove this platform's configuration.") },
confirmButton = {
TextButton(
onClick = {
showRemoveConfirm = false
onRemove()
},
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text("Remove")
}
},
dismissButton = {
TextButton(onClick = { showRemoveConfirm = false }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
@@ -0,0 +1,204 @@
package com.m57.hermescontrol.ui.channels.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.channels.ChannelsUiState
import com.m57.hermescontrol.ui.channels.OnboardingPhase
@Composable
internal fun TelegramOnboardingDialog(
state: ChannelsUiState,
onDismiss: () -> Unit,
onAddAllowedId: () -> Unit,
onRemoveAllowedId: (String) -> Unit,
onNewAllowedIdChange: (String) -> Unit,
onApply: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text =
when (state.onboardingPhase) {
OnboardingPhase.STARTING -> "Starting…"
OnboardingPhase.WAITING -> "Scan QR code"
OnboardingPhase.READY -> "Telegram connected"
OnboardingPhase.APPLYING -> "Saving…"
OnboardingPhase.IDLE -> "Telegram Setup"
},
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
state.onboardingError?.let { error ->
Surface(
color = LocalHermesStatusColors.current.errorContainer,
shape = RoundedCornerShape(8.dp),
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
}
when (state.onboardingPhase) {
OnboardingPhase.STARTING -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
OnboardingPhase.WAITING -> {
Text(
text = "Open Telegram and scan the QR code displayed in the app.",
style = MaterialTheme.typography.bodyMedium,
)
if (state.onboardingExpiresIn.isNotEmpty()) {
Text(
text = "QR code expires in: ${state.onboardingExpiresIn}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OnboardingPhase.READY -> {
state.onboardingBotUsername?.let { username ->
Text(
text = "Connected as @$username",
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Allowed users:",
style = MaterialTheme.typography.titleSmall,
)
state.onboardingDetectedOwnerId?.let { ownerId ->
if (state.onboardingAllowedIds.contains(ownerId)) {
Text(
text = "$ownerId (owner detected)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
state.onboardingAllowedIds.forEach { id ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = id,
style = MaterialTheme.typography.bodyMedium,
)
IconButton(onClick = { onRemoveAllowedId(id) }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Remove",
modifier = Modifier.size(18.dp),
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = state.onboardingNewAllowedId,
onValueChange = onNewAllowedIdChange,
placeholder = { Text("Telegram user ID") },
singleLine = true,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onAddAllowedId) {
Icon(Icons.Filled.Add, contentDescription = "Add user ID")
}
}
}
OnboardingPhase.APPLYING -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Saving configuration…",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
OnboardingPhase.IDLE -> { }
}
}
},
confirmButton = {
when (state.onboardingPhase) {
OnboardingPhase.READY -> {
Button(
onClick = onApply,
enabled = state.onboardingAllowedIds.isNotEmpty(),
) {
Text("Save and restart")
}
}
else -> {}
}
},
dismissButton = {
when (state.onboardingPhase) {
OnboardingPhase.READY -> {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
OnboardingPhase.WAITING -> {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
OnboardingPhase.IDLE, OnboardingPhase.STARTING, OnboardingPhase.APPLYING -> {}
}
},
)
}
@@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
@@ -27,21 +26,18 @@ import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -59,8 +55,6 @@ 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.MoaConfigPreset
import com.m57.hermescontrol.data.model.MoaModelSlot
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -68,6 +62,7 @@ import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.model.components.MoaConfigDialog
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -543,450 +538,6 @@ private fun AuxTasksDialog(
// MOA Config Dialog
// ────────────────────────────────────────────────────────────────────
@Composable
private fun MoaConfigDialog(
config: com.m57.hermescontrol.data.model.MoaConfigResponse,
providers: List<com.m57.hermescontrol.data.model.ModelProvider>,
busy: Boolean,
onSave: (com.m57.hermescontrol.data.model.MoaConfigResponse) -> Unit,
onDismiss: () -> Unit,
) {
var draft by remember(config) { mutableStateOf(config) }
var selectedPreset by remember { mutableStateOf(config.default_preset.ifBlank { "default" }) }
var newPresetName by remember { mutableStateOf("") }
var pickerTarget by remember { mutableStateOf<MoaPickerTarget?>(null) }
val presetNames = draft.presets.keys.toList()
val effectivePreset = draft.presets[selectedPreset]
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Mixture of Agents") },
text = {
if (pickerTarget != null && effectivePreset != null) {
// Inline model picker within the dialog
MoaSlotPicker(
providers = providers,
draft = draft,
selectedPreset = selectedPreset,
pickerTarget = pickerTarget!!,
onSlotSelected = { updatedDraft ->
draft = updatedDraft
pickerTarget = null
},
onCancel = { pickerTarget = null },
)
} else {
MoaConfigEditor(
draft = draft,
presetNames = presetNames,
selectedPreset = selectedPreset,
effectivePreset = effectivePreset,
newPresetName = newPresetName,
busy = busy,
onSelectPreset = { selectedPreset = it },
onNewPresetNameChange = { newPresetName = it },
onAddPreset = {
val name = newPresetName.trim()
if (name.isNotBlank() && !draft.presets.containsKey(name)) {
val seed =
effectivePreset ?: MoaConfigPreset(
reference_models = draft.reference_models,
aggregator = draft.aggregator,
reference_temperature = draft.reference_temperature,
aggregator_temperature = draft.aggregator_temperature,
max_tokens = draft.max_tokens,
)
draft =
draft.copy(
default_preset = draft.default_preset.ifBlank { name },
presets = draft.presets + (name to seed),
)
selectedPreset = name
newPresetName = ""
}
},
onDeletePreset = {
if (presetNames.size > 1) {
val remaining = presetNames - selectedPreset
val nextSelected = remaining.first()
draft =
draft.copy(
presets = draft.presets - selectedPreset,
default_preset =
if (draft.default_preset == selectedPreset) {
nextSelected
} else {
draft.default_preset
},
)
selectedPreset = nextSelected
}
},
onUpdatePreset = { updater ->
if (effectivePreset != null) {
draft =
draft.copy(
presets = draft.presets + (selectedPreset to updater(effectivePreset)),
)
}
},
onPickReference = { index ->
pickerTarget = MoaPickerTarget.Reference(index)
},
onPickAggregator = {
pickerTarget = MoaPickerTarget.Aggregator
},
onAddReference = {
if (effectivePreset != null) {
draft =
draft.copy(
presets =
draft.presets + (
selectedPreset to
effectivePreset.copy(
reference_models =
effectivePreset.reference_models +
effectivePreset.aggregator,
)
),
)
}
},
onRemoveReference = { index ->
if (effectivePreset != null && effectivePreset.reference_models.size > 1) {
draft =
draft.copy(
presets =
draft.presets + (
selectedPreset to
effectivePreset.copy(
reference_models =
effectivePreset.reference_models.toMutableList().also {
it.removeAt(
index,
)
},
)
),
)
}
},
onSetDefault = {
draft = draft.copy(default_preset = selectedPreset)
},
)
}
},
confirmButton = {
if (pickerTarget == null) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") }
Button(
onClick = { onSave(draft) },
enabled = !busy,
) {
if (busy) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text("Save")
}
}
}
}
},
)
}
private sealed class MoaPickerTarget {
data class Reference(
val index: Int,
) : MoaPickerTarget()
data object Aggregator : MoaPickerTarget()
}
@Composable
private fun MoaConfigEditor(
draft: com.m57.hermescontrol.data.model.MoaConfigResponse,
presetNames: List<String>,
selectedPreset: String,
effectivePreset: com.m57.hermescontrol.data.model.MoaConfigPreset?,
newPresetName: String,
busy: Boolean,
onSelectPreset: (String) -> Unit,
onNewPresetNameChange: (String) -> Unit,
onAddPreset: () -> Unit,
onDeletePreset: () -> Unit,
onUpdatePreset: (
updater: (com.m57.hermescontrol.data.model.MoaConfigPreset) -> com.m57.hermescontrol.data.model.MoaConfigPreset,
) -> Unit,
onPickReference: (Int) -> Unit,
onPickAggregator: () -> Unit,
onAddReference: () -> Unit,
onRemoveReference: (Int) -> Unit,
onSetDefault: () -> Unit,
) {
Column {
Text(
text = "Presets appear as models under the Mixture of Agents provider.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (effectivePreset == null) {
Text("No presets configured", style = MaterialTheme.typography.bodyMedium)
return
}
Spacer(modifier = Modifier.height(12.dp))
// Preset selector
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
// Simplified preset dropdown with buttons
if (presetNames.isNotEmpty()) {
Text("Preset:", style = MaterialTheme.typography.bodySmall)
presetNames.forEach { name ->
TextButton(
onClick = { onSelectPreset(name) },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
name,
fontWeight = if (name == selectedPreset) FontWeight.Bold else FontWeight.Normal,
color =
if (name == selectedPreset) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
// Default preset indicator
Text(
text = "Default: ${draft.default_preset.ifBlank { "(none)" }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
// Preset actions
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = onSetDefault,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
enabled = presetNames.size > 1,
) {
Text("Set default", style = MaterialTheme.typography.labelSmall)
}
OutlinedButton(
onClick = onDeletePreset,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
enabled = presetNames.size > 1,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text("Delete", style = MaterialTheme.typography.labelSmall)
}
}
// New preset
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = newPresetName,
onValueChange = onNewPresetNameChange,
placeholder = { Text("new preset name", style = MaterialTheme.typography.bodySmall) },
singleLine = true,
modifier = Modifier.weight(1f).height(48.dp),
textStyle = MaterialTheme.typography.bodySmall,
)
IconButton(onClick = onAddPreset, enabled = newPresetName.isNotBlank()) {
Icon(Icons.Outlined.Add, contentDescription = "Add preset")
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// Reference models
Text(
text = "Reference models",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(4.dp))
effectivePreset.reference_models.forEachIndexed { index, slot ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "${slot.provider} · ${slot.model}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
OutlinedButton(
onClick = { onPickReference(index) },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
) { Text("Change", style = MaterialTheme.typography.labelSmall) }
if (effectivePreset.reference_models.size > 1) {
IconButton(onClick = { onRemoveReference(index) }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp))
}
}
}
}
OutlinedButton(
onClick = onAddReference,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Add reference model", style = MaterialTheme.typography.labelSmall)
}
Spacer(modifier = Modifier.height(8.dp))
// Aggregator
Text(
text = "Aggregator",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "${effectivePreset.aggregator.provider} · ${effectivePreset.aggregator.model}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
OutlinedButton(
onClick = onPickAggregator,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
) { Text("Change", style = MaterialTheme.typography.labelSmall) }
}
}
}
@Composable
private fun MoaSlotPicker(
providers: List<com.m57.hermescontrol.data.model.ModelProvider>,
draft: com.m57.hermescontrol.data.model.MoaConfigResponse,
selectedPreset: String,
pickerTarget: MoaPickerTarget,
onSlotSelected: (com.m57.hermescontrol.data.model.MoaConfigResponse) -> Unit,
onCancel: () -> Unit,
) {
var pickerQuery by remember { mutableStateOf("") }
Column {
Text(
text =
"Select model for ${
if (pickerTarget is MoaPickerTarget.Aggregator) {
"aggregator"
} else {
"reference model"
}
}",
style = MaterialTheme.typography.titleSmall,
)
Spacer(modifier = Modifier.height(8.dp))
SearchBar(
query = pickerQuery,
onQueryChange = { pickerQuery = it },
placeholder = "Search providers...",
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(modifier = Modifier.height(300.dp)) {
items(
providers.filter {
pickerQuery.isBlank() ||
it.name.contains(pickerQuery, ignoreCase = true) ||
it.slug.contains(pickerQuery, ignoreCase = true)
},
key = { it.slug },
) { provider ->
val models =
provider.models.orEmpty().filter {
pickerQuery.isBlank() ||
it.contains(pickerQuery, ignoreCase = true)
}
if (models.isNotEmpty()) {
Text(
text = provider.name,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 4.dp),
)
models.forEach { model ->
OutlinedButton(
onClick = {
val slot = MoaModelSlot(provider.slug, model)
val update: (
com.m57.hermescontrol.data.model.MoaConfigPreset,
) -> com.m57.hermescontrol.data.model.MoaConfigPreset = { preset ->
when (pickerTarget) {
is MoaPickerTarget.Aggregator -> {
preset.copy(aggregator = slot)
}
is MoaPickerTarget.Reference -> {
preset.copy(
reference_models =
preset.reference_models.toMutableList().also {
it[pickerTarget.index] = slot
},
)
}
}
}
val preset = draft.presets[selectedPreset]
if (preset != null) {
onSlotSelected(
draft.copy(presets = draft.presets + (selectedPreset to update(preset))),
)
}
},
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Text(text = model, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
TextButton(onClick = onCancel) {
Text("Back")
}
}
}
// ────────────────────────────────────────────────────────────────────
// Model Picker Dialog
// ────────────────────────────────────────────────────────────────────
@@ -0,0 +1,485 @@
package com.m57.hermescontrol.ui.model.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.data.model.MoaConfigPreset
import com.m57.hermescontrol.data.model.MoaModelSlot
import com.m57.hermescontrol.ui.common.SearchBar
@Composable
internal fun MoaConfigDialog(
config: com.m57.hermescontrol.data.model.MoaConfigResponse,
providers: List<com.m57.hermescontrol.data.model.ModelProvider>,
busy: Boolean,
onSave: (com.m57.hermescontrol.data.model.MoaConfigResponse) -> Unit,
onDismiss: () -> Unit,
) {
var draft by remember(config) { mutableStateOf(config) }
var selectedPreset by remember { mutableStateOf(config.default_preset.ifBlank { "default" }) }
var newPresetName by remember { mutableStateOf("") }
var pickerTarget by remember { mutableStateOf<MoaPickerTarget?>(null) }
val presetNames = draft.presets.keys.toList()
val effectivePreset = draft.presets[selectedPreset]
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Mixture of Agents") },
text = {
if (pickerTarget != null && effectivePreset != null) {
// Inline model picker within the dialog
MoaSlotPicker(
providers = providers,
draft = draft,
selectedPreset = selectedPreset,
pickerTarget = pickerTarget!!,
onSlotSelected = { updatedDraft ->
draft = updatedDraft
pickerTarget = null
},
onCancel = { pickerTarget = null },
)
} else {
MoaConfigEditor(
draft = draft,
presetNames = presetNames,
selectedPreset = selectedPreset,
effectivePreset = effectivePreset,
newPresetName = newPresetName,
busy = busy,
onSelectPreset = { selectedPreset = it },
onNewPresetNameChange = { newPresetName = it },
onAddPreset = {
val name = newPresetName.trim()
if (name.isNotBlank() && !draft.presets.containsKey(name)) {
val seed =
effectivePreset ?: MoaConfigPreset(
reference_models = draft.reference_models,
aggregator = draft.aggregator,
reference_temperature = draft.reference_temperature,
aggregator_temperature = draft.aggregator_temperature,
max_tokens = draft.max_tokens,
)
draft =
draft.copy(
default_preset = draft.default_preset.ifBlank { name },
presets = draft.presets + (name to seed),
)
selectedPreset = name
newPresetName = ""
}
},
onDeletePreset = {
if (presetNames.size > 1) {
val remaining = presetNames - selectedPreset
val nextSelected = remaining.first()
draft =
draft.copy(
presets = draft.presets - selectedPreset,
default_preset =
if (draft.default_preset == selectedPreset) {
nextSelected
} else {
draft.default_preset
},
)
selectedPreset = nextSelected
}
},
onUpdatePreset = { updater ->
if (effectivePreset != null) {
draft =
draft.copy(
presets = draft.presets + (selectedPreset to updater(effectivePreset)),
)
}
},
onPickReference = { index ->
pickerTarget = MoaPickerTarget.Reference(index)
},
onPickAggregator = {
pickerTarget = MoaPickerTarget.Aggregator
},
onAddReference = {
if (effectivePreset != null) {
draft =
draft.copy(
presets =
draft.presets + (
selectedPreset to
effectivePreset.copy(
reference_models =
effectivePreset.reference_models +
effectivePreset.aggregator,
)
),
)
}
},
onRemoveReference = { index ->
if (effectivePreset != null && effectivePreset.reference_models.size > 1) {
draft =
draft.copy(
presets =
draft.presets + (
selectedPreset to
effectivePreset.copy(
reference_models =
effectivePreset.reference_models.toMutableList().also {
it.removeAt(
index,
)
},
)
),
)
}
},
onSetDefault = {
draft = draft.copy(default_preset = selectedPreset)
},
)
}
},
confirmButton = {
if (pickerTarget == null) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") }
Button(
onClick = { onSave(draft) },
enabled = !busy,
) {
if (busy) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text("Save")
}
}
}
}
},
)
}
private sealed class MoaPickerTarget {
data class Reference(
val index: Int,
) : MoaPickerTarget()
data object Aggregator : MoaPickerTarget()
}
@Composable
private fun MoaConfigEditor(
draft: com.m57.hermescontrol.data.model.MoaConfigResponse,
presetNames: List<String>,
selectedPreset: String,
effectivePreset: com.m57.hermescontrol.data.model.MoaConfigPreset?,
newPresetName: String,
busy: Boolean,
onSelectPreset: (String) -> Unit,
onNewPresetNameChange: (String) -> Unit,
onAddPreset: () -> Unit,
onDeletePreset: () -> Unit,
onUpdatePreset: (
updater: (com.m57.hermescontrol.data.model.MoaConfigPreset) -> com.m57.hermescontrol.data.model.MoaConfigPreset,
) -> Unit,
onPickReference: (Int) -> Unit,
onPickAggregator: () -> Unit,
onAddReference: () -> Unit,
onRemoveReference: (Int) -> Unit,
onSetDefault: () -> Unit,
) {
Column {
Text(
text = "Presets appear as models under the Mixture of Agents provider.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (effectivePreset == null) {
Text("No presets configured", style = MaterialTheme.typography.bodyMedium)
return
}
Spacer(modifier = Modifier.height(12.dp))
// Preset selector
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
// Simplified preset dropdown with buttons
if (presetNames.isNotEmpty()) {
Text("Preset:", style = MaterialTheme.typography.bodySmall)
presetNames.forEach { name ->
TextButton(
onClick = { onSelectPreset(name) },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
name,
fontWeight = if (name == selectedPreset) FontWeight.Bold else FontWeight.Normal,
color =
if (name == selectedPreset) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
// Default preset indicator
Text(
text = "Default: ${draft.default_preset.ifBlank { "(none)" }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
// Preset actions
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = onSetDefault,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
enabled = presetNames.size > 1,
) {
Text("Set default", style = MaterialTheme.typography.labelSmall)
}
OutlinedButton(
onClick = onDeletePreset,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
enabled = presetNames.size > 1,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text("Delete", style = MaterialTheme.typography.labelSmall)
}
}
// New preset
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = newPresetName,
onValueChange = onNewPresetNameChange,
placeholder = { Text("new preset name", style = MaterialTheme.typography.bodySmall) },
singleLine = true,
modifier = Modifier.weight(1f).height(48.dp),
textStyle = MaterialTheme.typography.bodySmall,
)
IconButton(onClick = onAddPreset, enabled = newPresetName.isNotBlank()) {
Icon(Icons.Outlined.Add, contentDescription = "Add preset")
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// Reference models
Text(
text = "Reference models",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(4.dp))
effectivePreset.reference_models.forEachIndexed { index, slot ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "${slot.provider} · ${slot.model}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
OutlinedButton(
onClick = { onPickReference(index) },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
) { Text("Change", style = MaterialTheme.typography.labelSmall) }
if (effectivePreset.reference_models.size > 1) {
IconButton(onClick = { onRemoveReference(index) }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp))
}
}
}
}
OutlinedButton(
onClick = onAddReference,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Add reference model", style = MaterialTheme.typography.labelSmall)
}
Spacer(modifier = Modifier.height(8.dp))
// Aggregator
Text(
text = "Aggregator",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "${effectivePreset.aggregator.provider} · ${effectivePreset.aggregator.model}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
OutlinedButton(
onClick = onPickAggregator,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
) { Text("Change", style = MaterialTheme.typography.labelSmall) }
}
}
}
@Composable
private fun MoaSlotPicker(
providers: List<com.m57.hermescontrol.data.model.ModelProvider>,
draft: com.m57.hermescontrol.data.model.MoaConfigResponse,
selectedPreset: String,
pickerTarget: MoaPickerTarget,
onSlotSelected: (com.m57.hermescontrol.data.model.MoaConfigResponse) -> Unit,
onCancel: () -> Unit,
) {
var pickerQuery by remember { mutableStateOf("") }
Column {
Text(
text =
"Select model for ${
if (pickerTarget is MoaPickerTarget.Aggregator) {
"aggregator"
} else {
"reference model"
}
}",
style = MaterialTheme.typography.titleSmall,
)
Spacer(modifier = Modifier.height(8.dp))
SearchBar(
query = pickerQuery,
onQueryChange = { pickerQuery = it },
placeholder = "Search providers...",
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(modifier = Modifier.height(300.dp)) {
items(
providers.filter {
pickerQuery.isBlank() ||
it.name.contains(pickerQuery, ignoreCase = true) ||
it.slug.contains(pickerQuery, ignoreCase = true)
},
key = { it.slug },
) { provider ->
val models =
provider.models.orEmpty().filter {
pickerQuery.isBlank() ||
it.contains(pickerQuery, ignoreCase = true)
}
if (models.isNotEmpty()) {
Text(
text = provider.name,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 4.dp),
)
models.forEach { model ->
OutlinedButton(
onClick = {
val slot = MoaModelSlot(provider.slug, model)
val update: (
com.m57.hermescontrol.data.model.MoaConfigPreset,
) -> com.m57.hermescontrol.data.model.MoaConfigPreset = { preset ->
when (pickerTarget) {
is MoaPickerTarget.Aggregator -> {
preset.copy(aggregator = slot)
}
is MoaPickerTarget.Reference -> {
preset.copy(
reference_models =
preset.reference_models.toMutableList().also {
it[pickerTarget.index] = slot
},
)
}
}
}
val preset = draft.presets[selectedPreset]
if (preset != null) {
onSlotSelected(
draft.copy(presets = draft.presets + (selectedPreset to update(preset))),
)
}
},
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Text(text = model, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
TextButton(onClick = onCancel) {
Text("Back")
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,771 @@
package com.m57.hermescontrol.ui.profiles.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.CreateProfileRequest
import com.m57.hermescontrol.data.model.McpServerConfigInput
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.ui.profiles.ProfilesUiState
import com.m57.hermescontrol.ui.profiles.ProfilesViewModel
@Composable
fun ProfileBuilderView(
state: ProfilesUiState,
viewModel: ProfilesViewModel,
onCancel: () -> Unit,
) {
var step by remember { mutableStateOf(1) }
// Step 1: Identity
var name by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
// Step 2: Model Config
var selectedProvider by remember { mutableStateOf("") }
var selectedModel by remember { mutableStateOf("") }
// Step 3: Skills
var useDefaultSkills by remember { mutableStateOf(true) }
var selectedSkills by remember { mutableStateOf<Set<String>>(emptySet()) }
var addedHubSkills by remember { mutableStateOf<List<String>>(emptyList()) }
// Step 4: MCP Servers
var mcpServers by remember { mutableStateOf<List<McpServerConfigInput>>(emptyList()) }
val nameError = if (name.isNotBlank()) validateProfileName(name) else null
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Step Indicator / Stepper Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.profiles_builder_step_indicator, step),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text =
when (step) {
1 -> stringResource(R.string.profiles_builder_step_identity)
2 -> stringResource(R.string.profiles_builder_step_model)
3 -> stringResource(R.string.profiles_builder_step_skills)
4 -> stringResource(R.string.profiles_builder_step_mcp)
else -> stringResource(R.string.profiles_builder_step_review)
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
}
// Horizontal Progress indicator
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
for (i in 1..5) {
Box(
modifier =
Modifier
.weight(1f)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(
if (i <= step) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
)
}
}
Box(
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
) {
if (state.isLoadingBuilderData) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
when (step) {
1 -> {
IdentityStep(
name = name,
onNameChange = { name = it },
nameError = nameError,
description = description,
onDescriptionChange = { description = it },
)
}
2 -> {
ModelStep(
providers = state.modelProviders,
selectedProvider = selectedProvider,
onProviderChange = {
selectedProvider = it
selectedModel = ""
},
selectedModel = selectedModel,
onModelChange = { selectedModel = it },
)
}
3 -> {
SkillsStep(
availableSkills = state.availableSkills,
useDefaultSkills = useDefaultSkills,
onUseDefaultSkillsChange = { useDefaultSkills = it },
selectedSkills = selectedSkills,
onSelectedSkillsChange = { selectedSkills = it },
addedHubSkills = addedHubSkills,
onAddedHubSkillsChange = { addedHubSkills = it },
hubSearchResults = state.hubSearchResults,
isSearchingHub = state.isSearchingHub,
onSearchHub = viewModel::searchHub,
)
}
4 -> {
McpStep(
mcpServers = mcpServers,
onMcpServersChange = { mcpServers = it },
)
}
5 -> {
ReviewStep(
name = name,
description = description,
provider = selectedProvider,
model = selectedModel,
useDefaultSkills = useDefaultSkills,
selectedSkills = selectedSkills,
addedHubSkills = addedHubSkills,
mcpServers = mcpServers,
)
}
}
}
}
// Stepper Navigation Footer
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (step > 1) {
OutlinedButton(onClick = { step-- }) {
Text(stringResource(R.string.profiles_builder_action_back))
}
} else {
OutlinedButton(onClick = onCancel) {
Text(stringResource(R.string.action_cancel))
}
}
if (step < 5) {
Button(
onClick = { step++ },
enabled =
when (step) {
1 -> name.isNotBlank() && nameError == null
else -> true
},
) {
Text(stringResource(R.string.profiles_builder_action_next))
}
} else {
Button(
onClick = {
val req =
CreateProfileRequest(
name = name,
description = description.ifBlank { null },
provider = selectedProvider.ifBlank { null },
model = selectedModel.ifBlank { null },
mcp_servers = mcpServers.ifEmpty { null },
keep_skills = if (useDefaultSkills) null else false,
hub_skills = addedHubSkills.ifEmpty { null },
)
viewModel.createProfile(req, onSuccess = onCancel)
},
enabled = !state.isLoading,
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp,
)
} else {
Text(stringResource(R.string.profiles_builder_action_create))
}
}
}
}
}
}
internal fun validateProfileName(name: String): String? {
if (!name.matches(Regex("^[a-zA-Z0-9_-]+$"))) {
return "Only letters, numbers, hyphens, and underscores allowed"
}
return null
}
@Composable
private fun IdentityStep(
name: String,
onNameChange: (String) -> Unit,
nameError: String?,
description: String,
onDescriptionChange: (String) -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text(stringResource(R.string.profiles_builder_label_profile_name)) },
placeholder = { Text("my-agent-profile") },
isError = nameError != null,
supportingText = nameError?.let { { Text(it) } },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = description,
onValueChange = onDescriptionChange,
label = { Text(stringResource(R.string.profiles_builder_label_description)) },
placeholder = { Text("Describe the purpose of this profile") },
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
)
}
}
@Composable
private fun ModelStep(
providers: List<ModelProvider>,
selectedProvider: String,
onProviderChange: (String) -> Unit,
selectedModel: String,
onModelChange: (String) -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.profiles_builder_title_provider),
style = MaterialTheme.typography.titleSmall,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
providers.forEach { provider ->
val isSelected = selectedProvider == provider.slug
OutlinedButton(
onClick = { onProviderChange(provider.slug) },
modifier = Modifier.weight(1f),
colors =
if (isSelected) {
ButtonDefaults.outlinedButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
)
} else {
ButtonDefaults.outlinedButtonColors()
},
) {
Text(provider.name.uppercase())
}
}
}
if (selectedProvider.isNotBlank()) {
val providerObj = providers.find { it.slug == selectedProvider }
providerObj?.models?.let { models ->
Text(
text = stringResource(R.string.profiles_builder_title_model),
style = MaterialTheme.typography.titleSmall,
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
models.forEach { modelName ->
val isSelected = selectedModel == modelName
Card(
modifier =
Modifier
.fillMaxWidth()
.clickable { onModelChange(modelName) },
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = modelName,
style = MaterialTheme.typography.bodyLarge,
)
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.secondary,
)
}
}
}
}
}
}
}
}
}
@Composable
private fun McpStep(
mcpServers: List<McpServerConfigInput>,
onMcpServersChange: (List<McpServerConfigInput>) -> Unit,
) {
var showAddDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.profiles_builder_mcp_title),
style = MaterialTheme.typography.titleSmall,
)
Button(onClick = { showAddDialog = true }) {
Text(stringResource(R.string.profiles_builder_mcp_add))
}
}
if (mcpServers.isEmpty()) {
Box(
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.profiles_builder_mcp_none),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(mcpServers) { server ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = server.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
)
Text(
text = "Transport: ${server.transport.uppercase()}",
style = MaterialTheme.typography.bodySmall,
)
if (server.transport == "sse") {
Text(
text = "URL: ${server.url}",
style = MaterialTheme.typography.labelSmall,
)
} else {
Text(
text = "Command: ${server.command}",
style = MaterialTheme.typography.labelSmall,
)
}
}
IconButton(
onClick = { onMcpServersChange(mcpServers - server) },
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove Server",
)
}
}
}
}
}
}
}
if (showAddDialog) {
AddMcpDialog(
onDismiss = { showAddDialog = false },
onAdd = { newServer ->
onMcpServersChange(mcpServers + newServer)
showAddDialog = false
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddMcpDialog(
onDismiss: () -> Unit,
onAdd: (McpServerConfigInput) -> Unit,
) {
var name by remember { mutableStateOf("") }
var transport by remember { mutableStateOf("sse") }
var url by remember { mutableStateOf("") }
var command by remember { mutableStateOf("") }
var argsInput by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.profiles_builder_mcp_dialog_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.profiles_builder_mcp_name)) },
placeholder = { Text("postgres-mcp") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
text = stringResource(R.string.profiles_builder_mcp_transport),
style = MaterialTheme.typography.labelMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = { transport = "sse" },
modifier = Modifier.weight(1f),
colors =
if (transport == "sse") {
ButtonDefaults.outlinedButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
)
} else {
ButtonDefaults.outlinedButtonColors()
},
) {
Text(stringResource(R.string.profiles_builder_mcp_transport_sse))
}
OutlinedButton(
onClick = { transport = "stdio" },
modifier = Modifier.weight(1f),
colors =
if (transport == "stdio") {
ButtonDefaults.outlinedButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
)
} else {
ButtonDefaults.outlinedButtonColors()
},
) {
Text(stringResource(R.string.profiles_builder_mcp_transport_stdio))
}
}
if (transport == "sse") {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text(stringResource(R.string.profiles_builder_mcp_sse_url)) },
placeholder = { Text("http://localhost:8000/sse") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
} else {
OutlinedTextField(
value = command,
onValueChange = { command = it },
label = { Text(stringResource(R.string.profiles_builder_mcp_command)) },
placeholder = { Text("npx") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = argsInput,
onValueChange = { argsInput = it },
label = { Text(stringResource(R.string.profiles_builder_mcp_args)) },
placeholder = {
Text("-y, @modelcontextprotocol/server-postgres")
},
modifier = Modifier.fillMaxWidth(),
)
}
}
},
confirmButton = {
TextButton(
onClick = {
val argsList =
if (argsInput.isNotBlank()) {
argsInput.split(",").map { it.trim() }
} else {
null
}
onAdd(
McpServerConfigInput(
name = name,
transport = transport,
url = if (transport == "sse") url else null,
command = if (transport == "stdio") command else null,
args = if (transport == "stdio") argsList else null,
),
)
},
enabled =
name.isNotBlank() && (
(transport == "sse" && url.isNotBlank()) ||
(transport == "stdio" && command.isNotBlank())
),
) {
Text(stringResource(R.string.profiles_builder_action_add))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@Composable
private fun ReviewStep(
name: String,
description: String,
provider: String,
model: String,
useDefaultSkills: Boolean,
selectedSkills: Set<String>,
addedHubSkills: List<String>,
mcpServers: List<McpServerConfigInput>,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.profiles_builder_review_name),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
if (description.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.profiles_builder_review_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.profiles_builder_review_model_settings),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (provider.isNotBlank() && model.isNotBlank()) {
Text(
text = stringResource(R.string.profiles_builder_review_provider, provider),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.profiles_builder_review_model, model),
style = MaterialTheme.typography.bodyMedium,
)
} else {
Text(
text = stringResource(R.string.profiles_builder_review_system_defaults),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.profiles_builder_review_skills_title),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (useDefaultSkills) {
Text(
text = stringResource(R.string.profiles_builder_review_skills_default),
style = MaterialTheme.typography.bodyMedium,
)
} else {
Text(
text = stringResource(R.string.profiles_builder_review_skills_local, selectedSkills.size),
style = MaterialTheme.typography.bodyMedium,
)
}
if (addedHubSkills.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.profiles_builder_review_skills_hub, addedHubSkills.size),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.profiles_builder_review_mcp_title),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (mcpServers.isNotEmpty()) {
mcpServers.forEach { server ->
Text(
text = "- ${server.name} (${server.transport.uppercase()})",
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
Text(
text = stringResource(R.string.profiles_builder_review_mcp_none),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
@@ -0,0 +1,301 @@
package com.m57.hermescontrol.ui.profiles.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.Skill
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SkillsStep(
availableSkills: List<Skill>,
useDefaultSkills: Boolean,
onUseDefaultSkillsChange: (Boolean) -> Unit,
selectedSkills: Set<String>,
onSelectedSkillsChange: (Set<String>) -> Unit,
addedHubSkills: List<String>,
onAddedHubSkillsChange: (List<String>) -> Unit,
hubSearchResults: List<HubSkill>,
isSearchingHub: Boolean,
onSearchHub: (String) -> Unit,
) {
var hubSearchQuery by remember { mutableStateOf("") }
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = useDefaultSkills,
onCheckedChange = onUseDefaultSkillsChange,
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = stringResource(R.string.profiles_builder_skills_default),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.profiles_builder_skills_default_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
HorizontalDivider()
if (!useDefaultSkills) {
Text(
text = stringResource(R.string.profiles_builder_skills_local),
style = MaterialTheme.typography.titleSmall,
)
LazyColumn(
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(availableSkills) { skill ->
val isChecked = selectedSkills.contains(skill.name)
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
if (isChecked) {
onSelectedSkillsChange(selectedSkills - skill.name)
} else {
onSelectedSkillsChange(selectedSkills + skill.name)
}
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = isChecked,
onCheckedChange = { checked ->
if (checked == true) {
onSelectedSkillsChange(selectedSkills + skill.name)
} else {
onSelectedSkillsChange(selectedSkills - skill.name)
}
},
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = skill.name,
style = MaterialTheme.typography.bodyLarge,
)
skill.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
} else {
// Browse and add Skills from Hermes Hub
Text(
text = stringResource(R.string.profiles_builder_skills_hub_title),
style = MaterialTheme.typography.titleSmall,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = hubSearchQuery,
onValueChange = { hubSearchQuery = it },
label = { Text(stringResource(R.string.profiles_builder_skills_search_hub)) },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = { onSearchHub(hubSearchQuery) },
enabled = hubSearchQuery.isNotBlank() && !isSearchingHub,
) {
if (isSearchingHub) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
)
}
}
}
if (addedHubSkills.isNotEmpty()) {
Text(
text = stringResource(R.string.profiles_builder_skills_added),
style = MaterialTheme.typography.labelMedium,
)
LazyColumn(
modifier =
Modifier
.height(80.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(addedHubSkills) { skillName ->
Row(
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(4.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = skillName,
style = MaterialTheme.typography.bodyMedium,
)
IconButton(
onClick = { onAddedHubSkillsChange(addedHubSkills - skillName) },
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(16.dp),
)
}
}
}
}
}
if (hubSearchResults.isNotEmpty()) {
Text(
text = stringResource(R.string.profiles_builder_skills_search_results),
style = MaterialTheme.typography.labelMedium,
)
LazyColumn(
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(hubSearchResults) { skill ->
val isAdded = addedHubSkills.contains(skill.name)
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = skill.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
)
skill.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!skill.source.isNullOrBlank()) {
Text(
text = "Source: ${skill.source}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontStyle = FontStyle.Italic,
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (isAdded) {
onAddedHubSkillsChange(addedHubSkills - skill.name)
} else {
onAddedHubSkillsChange(addedHubSkills + skill.name)
}
},
colors =
if (isAdded) {
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
)
} else {
ButtonDefaults.buttonColors()
},
) {
if (isAdded) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove",
modifier = Modifier.size(16.dp),
)
} else {
Text("Add")
}
}
}
}
}
}
}
}
}
@@ -1,12 +1,7 @@
package com.m57.hermescontrol.ui.settings
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -14,40 +9,16 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -60,21 +31,22 @@ 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.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
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.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.settings.components.AppearanceSection
import com.m57.hermescontrol.ui.settings.components.BehaviorSection
import com.m57.hermescontrol.ui.settings.components.ChatSection
import com.m57.hermescontrol.ui.settings.components.ConnectionSection
import com.m57.hermescontrol.ui.settings.components.NavBarSection
import com.m57.hermescontrol.ui.settings.components.SaveIndicator
import com.m57.hermescontrol.ui.settings.components.TestConnectionButton
import com.m57.hermescontrol.ui.settings.components.TestResultCard
enum class SettingsTab {
CONNECTION,
@@ -277,7 +249,7 @@ fun SettingsScreen(
}
@Composable
private fun InfoRow(
internal fun InfoRow(
label: String,
value: String,
) {
@@ -299,7 +271,7 @@ private fun InfoRow(
}
@Composable
private fun SectionCard(
internal fun SectionCard(
title: String,
content: @Composable () -> Unit,
) {
@@ -325,810 +297,3 @@ private fun SectionCard(
}
}
}
@Composable
private fun ConnectionSection(
state: SettingsUiState,
viewModel: SettingsViewModel,
passwordVisible: Boolean,
onPasswordVisibilityToggle: () -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_connection)) {
if (state.profiles.isNotEmpty()) {
// ── Saved profiles list ──────────────────────────────────
Text(
text = stringResource(R.string.settings_saved_profiles, state.profiles.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
state.profiles.forEach { profile ->
val isActive = profile.id == state.selectedProfileId
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.clickable(role = Role.Button) { viewModel.selectProfile(profile.id) },
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
},
),
elevation = CardDefaults.cardElevation(0.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile.name,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
)
Text(
text = "${profile.host}:${profile.port}",
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
IconButton(
onClick = { viewModel.openEditProfile(profile.id) },
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.settings_action_edit_profile),
modifier = Modifier.size(18.dp),
)
}
IconButton(
onClick = { viewModel.requestDeleteProfile(profile.id) },
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.content_desc_delete_profile),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
}
// ── Add profile button ───────────────────────────────────────
OutlinedButton(
onClick = viewModel::openAddProfile,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.size(4.dp))
Text(stringResource(R.string.settings_action_add_profile))
}
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text(stringResource(R.string.settings_field_host)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text(stringResource(R.string.settings_field_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.token,
onValueChange = viewModel::onTokenChange,
label = { Text(stringResource(R.string.settings_field_token)) },
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = onPasswordVisibilityToggle) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription =
if (passwordVisible) {
stringResource(R.string.content_desc_hide_token)
} else {
stringResource(R.string.content_desc_show_token)
},
)
}
},
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(8.dp))
// B7 (Jun 18 2026): one-tap escape hatch for when the stored
// token is corrupted / rejected. Clears the token field and
// persists the empty value immediately so a stress-free
// re-pair is possible without manually selecting-and-deleting
// the masked password field character by character.
OutlinedButton(
onClick = {
viewModel.onTokenChange("")
viewModel.save()
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.settings_action_clear_token))
}
}
// ── Dialogs ──────────────────────────────────────────────────────────
if (state.showProfileDialog) {
ProfileEditorDialog(
isEditing = state.editingProfileId != null,
name = state.dialogProfileName,
host = state.dialogProfileHost,
port = state.dialogProfilePort,
token = state.dialogProfileToken,
onNameChange = viewModel::onDialogProfileNameChange,
onHostChange = viewModel::onDialogProfileHostChange,
onPortChange = viewModel::onDialogProfilePortChange,
onTokenChange = viewModel::onDialogProfileTokenChange,
onSave = viewModel::saveProfileFromDialog,
onDismiss = viewModel::closeProfileDialog,
)
}
if (state.showDeleteConfirm) {
DeleteProfileConfirmDialog(
profileName = state.profileToDeleteName,
onConfirm = viewModel::confirmDeleteProfile,
onDismiss = viewModel::cancelDeleteProfile,
)
}
}
@Composable
private fun ProfileEditorDialog(
isEditing: Boolean,
name: String,
host: String,
port: String,
token: String,
onNameChange: (String) -> Unit,
onHostChange: (String) -> Unit,
onPortChange: (String) -> Unit,
onTokenChange: (String) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
) {
val title =
if (isEditing) {
stringResource(R.string.settings_title_edit_profile)
} else {
stringResource(R.string.settings_title_add_profile)
}
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text(stringResource(R.string.settings_field_profile_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = host,
onValueChange = onHostChange,
label = { Text(stringResource(R.string.settings_field_host)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = port,
onValueChange = onPortChange,
label = { Text(stringResource(R.string.settings_field_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
if (isEditing) {
OutlinedTextField(
value = token,
onValueChange = onTokenChange,
label = { Text(stringResource(R.string.settings_field_token)) },
singleLine = true,
visualTransformation =
if (token.isNotEmpty()) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
modifier = Modifier.fillMaxWidth(),
)
}
}
},
confirmButton = {
Button(onClick = onSave, enabled = name.isNotBlank() && host.isNotBlank()) {
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@Composable
private fun DeleteProfileConfirmDialog(
profileName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_delete_confirm_title)) },
text = {
Text(stringResource(R.string.settings_delete_confirm_message, profileName))
},
confirmButton = {
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.action_delete))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@Composable
private fun TestResultCard(testResult: String?) {
AnimatedVisibility(
visible = testResult != null,
enter = fadeIn(),
exit = fadeOut(),
) {
testResult?.let { result ->
Card(
colors =
CardDefaults.cardColors(
containerColor =
if (result.startsWith("✅")) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = result,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
@Composable
private fun SaveIndicator(isSaved: Boolean) {
AnimatedVisibility(
visible = isSaved,
enter = fadeIn(),
exit = fadeOut(),
) {
Text(
text = stringResource(R.string.settings_save_success),
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.primary,
),
)
}
}
@Composable
private fun TestConnectionButton(
isTesting: Boolean,
onTest: () -> Unit,
) {
OutlinedButton(
onClick = onTest,
enabled = !isTesting,
modifier = Modifier.fillMaxWidth(),
) {
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Text(stringResource(R.string.settings_action_test_connection))
}
}
}
@Composable
private fun BehaviorSection(
autoReconnect: Boolean,
onAutoReconnectChange: (Boolean) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_behavior)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = stringResource(R.string.settings_item_auto_reconnect),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.settings_desc_auto_reconnect),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
Switch(
checked = autoReconnect,
onCheckedChange = onAutoReconnectChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
)
}
}
}
@Composable
private fun AppearanceSection(
themePreference: ThemePreference,
onThemeChange: (ThemePreference) -> Unit,
useDynamicColors: Boolean,
onUseDynamicColorsChange: (Boolean) -> Unit,
themePreset: ThemePreset,
onThemePresetChange: (ThemePreset) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_appearance)) {
Text(
text = stringResource(R.string.settings_item_theme),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ThemePreference.entries.forEachIndexed { index, pref ->
SegmentedButton(
selected = themePreference == pref,
onClick = { onThemeChange(pref) },
shape =
SegmentedButtonDefaults.itemShape(
index = index,
count = ThemePreference.entries.size,
),
) {
Text(
when (pref) {
ThemePreference.SYSTEM -> stringResource(R.string.theme_system)
ThemePreference.LIGHT -> stringResource(R.string.theme_light)
ThemePreference.DARK -> stringResource(R.string.theme_dark)
},
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.settings_item_use_dynamic_colors),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.settings_desc_use_dynamic_colors),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
Switch(
checked = useDynamicColors,
onCheckedChange = onUseDynamicColorsChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.settings_item_theme_preset),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
var presetsExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { presetsExpanded = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(
when (themePreset) {
ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default)
ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome)
ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox)
ThemePreset.CATPPUCCIN -> stringResource(R.string.theme_preset_catppuccin)
ThemePreset.AMOLED -> stringResource(R.string.theme_preset_amoled)
ThemePreset.NEON_NOIR -> stringResource(R.string.theme_preset_neon_noir)
},
)
}
DropdownMenu(
expanded = presetsExpanded,
onDismissRequest = { presetsExpanded = false },
modifier = Modifier.fillMaxWidth(0.85f),
) {
ThemePreset.entries.forEach { preset ->
DropdownMenuItem(
text = {
Text(
when (preset) {
ThemePreset.DEFAULT -> {
stringResource(
R.string.theme_preset_default,
)
}
ThemePreset.MONOCHROME -> {
stringResource(
R.string.theme_preset_monochrome,
)
}
ThemePreset.GRUVBOX -> {
stringResource(
R.string.theme_preset_gruvbox,
)
}
ThemePreset.CATPPUCCIN -> {
stringResource(
R.string.theme_preset_catppuccin,
)
}
ThemePreset.AMOLED -> {
stringResource(
R.string.theme_preset_amoled,
)
}
ThemePreset.NEON_NOIR -> {
stringResource(
R.string.theme_preset_neon_noir,
)
}
},
)
},
onClick = {
onThemePresetChange(preset)
presetsExpanded = false
},
)
}
}
}
}
}
@Composable
private fun ChatSection(
typingEffectEnabled: Boolean,
onTypingEffectEnabledChange: (Boolean) -> Unit,
typingEffectDelayMs: Int,
onTypingEffectDelayMsChange: (Int) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_chat)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = stringResource(R.string.settings_item_typing_effect),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.settings_desc_typing_effect),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
Switch(
checked = typingEffectEnabled,
onCheckedChange = onTypingEffectEnabledChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
)
}
// Delay slider — only visible when effect is enabled
AnimatedVisibility(visible = typingEffectEnabled) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
text =
stringResource(
R.string.settings_item_typing_delay,
typingEffectDelayMs,
),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Slider(
value = typingEffectDelayMs.toFloat(),
onValueChange = { onTypingEffectDelayMsChange(it.toInt()) },
valueRange = 10f..100f,
steps = 8, // 10, 20, 30, 40, 50, 60, 70, 80, 90, 100
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.settings_delay_10ms),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Text(
text = stringResource(R.string.settings_delay_100ms),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
}
}
}
}
@Composable
private fun NavBarSection(
bottomNavDisplayMode: BottomNavDisplayMode,
onBottomNavDisplayModeChange: (BottomNavDisplayMode) -> Unit,
selectedNavItems: List<String>,
availableNavItems: List<Pair<String, Int>>,
onReorderNavItems: (List<String>) -> Unit,
onRemoveNavItem: (String) -> Unit,
onAddNavItem: (String) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) {
Text(
text = stringResource(R.string.settings_item_bottom_nav_display_mode),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
BottomNavDisplayMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = bottomNavDisplayMode == mode,
onClick = { onBottomNavDisplayModeChange(mode) },
shape =
SegmentedButtonDefaults.itemShape(
index = index,
count = BottomNavDisplayMode.entries.size,
),
) {
Text(
when (mode) {
BottomNavDisplayMode.ICON_AND_TEXT -> {
stringResource(
R.string.settings_display_mode_icon_and_text,
)
}
BottomNavDisplayMode.ICON_ONLY -> {
stringResource(
R.string.settings_display_mode_icon_only,
)
}
BottomNavDisplayMode.TEXT_ONLY -> {
stringResource(
R.string.settings_display_mode_text_only,
)
}
},
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.settings_item_nav_items, selectedNavItems.size),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
selectedNavItems.forEachIndexed { index, name ->
val labelRes = 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,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(
onClick = {
val list = selectedNavItems.toMutableList()
val temp = list[index]
list[index] = list[index - 1]
list[index - 1] = temp
onReorderNavItems(list)
},
enabled = index > 0,
) {
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(R.string.content_desc_move_up),
)
}
IconButton(
onClick = {
val list = selectedNavItems.toMutableList()
val temp = list[index]
list[index] = list[index + 1]
list[index + 1] = temp
onReorderNavItems(list)
},
enabled = index < selectedNavItems.lastIndex,
) {
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_move_down),
)
}
IconButton(onClick = { onRemoveNavItem(name) }) {
Icon(
Icons.Filled.Close,
contentDescription =
stringResource(
R.string.content_desc_remove_item,
label,
),
)
}
}
}
}
if (selectedNavItems.size < 5) {
val available =
availableNavItems.filter { it.first !in selectedNavItems }
if (available.isNotEmpty()) {
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { expanded = true }) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.size(4.dp))
Text(stringResource(R.string.settings_action_add_item))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
available.forEach { (name, labelRes) ->
DropdownMenuItem(
text = { Text(stringResource(labelRes)) },
onClick = {
onAddNavItem(name)
expanded = false
},
)
}
}
}
}
} else {
Text(
text = stringResource(R.string.settings_nav_max_reached),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,86 @@
package com.m57.hermescontrol.ui.settings.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.unit.dp
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.settings.InfoRow
import com.m57.hermescontrol.ui.settings.SectionCard
@Composable
internal fun AboutSection(onLogout: () -> Unit) {
SectionCard(title = stringResource(R.string.settings_sec_about)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.settings_about_app_name),
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
),
)
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(8.dp))
InfoRow(
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 = stringResource(R.string.settings_about_commit),
value = BuildConfig.GIT_SHA,
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "https://github.com/Hy4ri/hermes-mobile",
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.settings_logout))
}
}
}
@@ -0,0 +1,431 @@
package com.m57.hermescontrol.ui.settings.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.ui.settings.SectionCard
@Composable
internal fun AppearanceSection(
themePreference: ThemePreference,
onThemeChange: (ThemePreference) -> Unit,
useDynamicColors: Boolean,
onUseDynamicColorsChange: (Boolean) -> Unit,
themePreset: ThemePreset,
onThemePresetChange: (ThemePreset) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_appearance)) {
Text(
text = stringResource(R.string.settings_item_theme),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ThemePreference.entries.forEachIndexed { index, pref ->
SegmentedButton(
selected = themePreference == pref,
onClick = { onThemeChange(pref) },
shape =
SegmentedButtonDefaults.itemShape(
index = index,
count = ThemePreference.entries.size,
),
) {
Text(
when (pref) {
ThemePreference.SYSTEM -> stringResource(R.string.theme_system)
ThemePreference.LIGHT -> stringResource(R.string.theme_light)
ThemePreference.DARK -> stringResource(R.string.theme_dark)
},
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.settings_item_use_dynamic_colors),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.settings_desc_use_dynamic_colors),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
Switch(
checked = useDynamicColors,
onCheckedChange = onUseDynamicColorsChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.settings_item_theme_preset),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
var presetsExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { presetsExpanded = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(
when (themePreset) {
ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default)
ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome)
ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox)
ThemePreset.CATPPUCCIN -> stringResource(R.string.theme_preset_catppuccin)
ThemePreset.AMOLED -> stringResource(R.string.theme_preset_amoled)
ThemePreset.NEON_NOIR -> stringResource(R.string.theme_preset_neon_noir)
},
)
}
DropdownMenu(
expanded = presetsExpanded,
onDismissRequest = { presetsExpanded = false },
modifier = Modifier.fillMaxWidth(0.85f),
) {
ThemePreset.entries.forEach { preset ->
DropdownMenuItem(
text = {
Text(
when (preset) {
ThemePreset.DEFAULT -> {
stringResource(
R.string.theme_preset_default,
)
}
ThemePreset.MONOCHROME -> {
stringResource(
R.string.theme_preset_monochrome,
)
}
ThemePreset.GRUVBOX -> {
stringResource(
R.string.theme_preset_gruvbox,
)
}
ThemePreset.CATPPUCCIN -> {
stringResource(
R.string.theme_preset_catppuccin,
)
}
ThemePreset.AMOLED -> {
stringResource(
R.string.theme_preset_amoled,
)
}
ThemePreset.NEON_NOIR -> {
stringResource(
R.string.theme_preset_neon_noir,
)
}
},
)
},
onClick = {
onThemePresetChange(preset)
presetsExpanded = false
},
)
}
}
}
}
}
@Composable
internal fun ChatSection(
typingEffectEnabled: Boolean,
onTypingEffectEnabledChange: (Boolean) -> Unit,
typingEffectDelayMs: Int,
onTypingEffectDelayMsChange: (Int) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_chat)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = stringResource(R.string.settings_item_typing_effect),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.settings_desc_typing_effect),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
Switch(
checked = typingEffectEnabled,
onCheckedChange = onTypingEffectEnabledChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
)
}
// Delay slider — only visible when effect is enabled
AnimatedVisibility(visible = typingEffectEnabled) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
text =
stringResource(
R.string.settings_item_typing_delay,
typingEffectDelayMs,
),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Slider(
value = typingEffectDelayMs.toFloat(),
onValueChange = { onTypingEffectDelayMsChange(it.toInt()) },
valueRange = 10f..100f,
steps = 8, // 10, 20, 30, 40, 50, 60, 70, 80, 90, 100
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.settings_delay_10ms),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Text(
text = stringResource(R.string.settings_delay_100ms),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
}
}
}
}
@Composable
internal fun NavBarSection(
bottomNavDisplayMode: BottomNavDisplayMode,
onBottomNavDisplayModeChange: (BottomNavDisplayMode) -> Unit,
selectedNavItems: List<String>,
availableNavItems: List<Pair<String, Int>>,
onReorderNavItems: (List<String>) -> Unit,
onRemoveNavItem: (String) -> Unit,
onAddNavItem: (String) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) {
Text(
text = stringResource(R.string.settings_item_bottom_nav_display_mode),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
BottomNavDisplayMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = bottomNavDisplayMode == mode,
onClick = { onBottomNavDisplayModeChange(mode) },
shape =
SegmentedButtonDefaults.itemShape(
index = index,
count = BottomNavDisplayMode.entries.size,
),
) {
Text(
when (mode) {
BottomNavDisplayMode.ICON_AND_TEXT -> {
stringResource(
R.string.settings_display_mode_icon_and_text,
)
}
BottomNavDisplayMode.ICON_ONLY -> {
stringResource(
R.string.settings_display_mode_icon_only,
)
}
BottomNavDisplayMode.TEXT_ONLY -> {
stringResource(
R.string.settings_display_mode_text_only,
)
}
},
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.settings_item_nav_items, selectedNavItems.size),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
selectedNavItems.forEachIndexed { index, name ->
val labelRes = 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,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(
onClick = {
val list = selectedNavItems.toMutableList()
val temp = list[index]
list[index] = list[index - 1]
list[index - 1] = temp
onReorderNavItems(list)
},
enabled = index > 0,
) {
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(R.string.content_desc_move_up),
)
}
IconButton(
onClick = {
val list = selectedNavItems.toMutableList()
val temp = list[index]
list[index] = list[index + 1]
list[index + 1] = temp
onReorderNavItems(list)
},
enabled = index < selectedNavItems.lastIndex,
) {
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_move_down),
)
}
IconButton(onClick = { onRemoveNavItem(name) }) {
Icon(
Icons.Filled.Close,
contentDescription =
stringResource(
R.string.content_desc_remove_item,
label,
),
)
}
}
}
}
if (selectedNavItems.size < 5) {
val available =
availableNavItems.filter { it.first !in selectedNavItems }
if (available.isNotEmpty()) {
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { expanded = true }) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.size(4.dp))
Text(stringResource(R.string.settings_action_add_item))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
available.forEach { (name, labelRes) ->
DropdownMenuItem(
text = { Text(stringResource(labelRes)) },
onClick = {
onAddNavItem(name)
expanded = false
},
)
}
}
}
}
} else {
Text(
text = stringResource(R.string.settings_nav_max_reached),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,55 @@
package com.m57.hermescontrol.ui.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.settings.SectionCard
@Composable
internal fun BehaviorSection(
autoReconnect: Boolean,
onAutoReconnectChange: (Boolean) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_behavior)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = stringResource(R.string.settings_item_auto_reconnect),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.settings_desc_auto_reconnect),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
Switch(
checked = autoReconnect,
onCheckedChange = onAutoReconnectChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
)
}
}
}
@@ -0,0 +1,327 @@
package com.m57.hermescontrol.ui.settings.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.settings.SectionCard
import com.m57.hermescontrol.ui.settings.SettingsUiState
import com.m57.hermescontrol.ui.settings.SettingsViewModel
@Composable
internal fun ConnectionSection(
state: SettingsUiState,
viewModel: SettingsViewModel,
passwordVisible: Boolean,
onPasswordVisibilityToggle: () -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_connection)) {
// ── Saved profiles list ──────────────────────────────────
if (state.profiles.isNotEmpty()) {
Text(
text = stringResource(R.string.settings_saved_profiles, state.profiles.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
state.profiles.forEach { profile ->
val isActive = profile.id == state.selectedProfileId
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.clickable(role = Role.Button) { viewModel.selectProfile(profile.id) },
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
},
),
elevation = CardDefaults.cardElevation(0.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile.name,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
)
Text(
text = "${profile.host}:${profile.port}",
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
IconButton(
onClick = { viewModel.openEditProfile(profile.id) },
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.settings_action_edit_profile),
modifier = Modifier.size(18.dp),
)
}
IconButton(
onClick = { viewModel.requestDeleteProfile(profile.id) },
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.content_desc_delete_profile),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
}
// ── Add profile button ───────────────────────────────────────
OutlinedButton(
onClick = viewModel::openAddProfile,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.size(4.dp))
Text(stringResource(R.string.settings_action_add_profile))
}
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text(stringResource(R.string.settings_field_host)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text(stringResource(R.string.settings_field_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.token,
onValueChange = viewModel::onTokenChange,
label = { Text(stringResource(R.string.settings_field_token)) },
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = onPasswordVisibilityToggle) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription =
if (passwordVisible) {
stringResource(R.string.content_desc_hide_token)
} else {
stringResource(R.string.content_desc_show_token)
},
)
}
},
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(8.dp))
// B7 (Jun 18 2026): one-tap escape hatch for when the stored
// token is corrupted / rejected. Clears the token field and
// persists the empty value immediately so a stress-free
// re-pair is possible without manually selecting-and-deleting
// the masked password field character by character.
OutlinedButton(
onClick = {
viewModel.onTokenChange("")
viewModel.save()
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.settings_action_clear_token))
}
}
// ── Dialogs ──────────────────────────────────────────────────────────
if (state.showProfileDialog) {
ProfileEditorDialog(
isEditing = state.editingProfileId != null,
name = state.dialogProfileName,
host = state.dialogProfileHost,
port = state.dialogProfilePort,
token = state.dialogProfileToken,
onNameChange = viewModel::onDialogProfileNameChange,
onHostChange = viewModel::onDialogProfileHostChange,
onPortChange = viewModel::onDialogProfilePortChange,
onTokenChange = viewModel::onDialogProfileTokenChange,
onSave = viewModel::saveProfileFromDialog,
onDismiss = viewModel::closeProfileDialog,
)
}
if (state.showDeleteConfirm) {
DeleteProfileConfirmDialog(
profileName = state.profileToDeleteName,
onConfirm = viewModel::confirmDeleteProfile,
onDismiss = viewModel::cancelDeleteProfile,
)
}
}
@Composable
internal fun TestResultCard(testResult: String?) {
AnimatedVisibility(
visible = testResult != null,
enter = fadeIn(),
exit = fadeOut(),
) {
testResult?.let { result ->
Card(
colors =
CardDefaults.cardColors(
containerColor =
if (result.startsWith("✅")) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = result,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
@Composable
internal fun SaveIndicator(isSaved: Boolean) {
AnimatedVisibility(
visible = isSaved,
enter = fadeIn(),
exit = fadeOut(),
) {
Text(
text = stringResource(R.string.settings_save_success),
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.primary,
),
)
}
}
@Composable
internal fun TestConnectionButton(
isTesting: Boolean,
onTest: () -> Unit,
) {
OutlinedButton(
onClick = onTest,
enabled = !isTesting,
modifier = Modifier.fillMaxWidth(),
) {
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Text(stringResource(R.string.settings_action_test_connection))
}
}
}
@@ -0,0 +1,128 @@
package com.m57.hermescontrol.ui.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
@Composable
internal fun ProfileEditorDialog(
isEditing: Boolean,
name: String,
host: String,
port: String,
token: String,
onNameChange: (String) -> Unit,
onHostChange: (String) -> Unit,
onPortChange: (String) -> Unit,
onTokenChange: (String) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
) {
val title =
if (isEditing) {
stringResource(R.string.settings_title_edit_profile)
} else {
stringResource(R.string.settings_title_add_profile)
}
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text(stringResource(R.string.settings_field_profile_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = host,
onValueChange = onHostChange,
label = { Text(stringResource(R.string.settings_field_host)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = port,
onValueChange = onPortChange,
label = { Text(stringResource(R.string.settings_field_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
if (isEditing) {
OutlinedTextField(
value = token,
onValueChange = onTokenChange,
label = { Text(stringResource(R.string.settings_field_token)) },
singleLine = true,
visualTransformation =
if (token.isNotEmpty()) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
modifier = Modifier.fillMaxWidth(),
)
}
}
},
confirmButton = {
Button(onClick = onSave, enabled = name.isNotBlank() && host.isNotBlank()) {
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@Composable
internal fun DeleteProfileConfirmDialog(
profileName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_delete_confirm_title)) },
text = {
Text(stringResource(R.string.settings_delete_confirm_message, profileName))
},
confirmButton = {
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.action_delete))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@@ -75,7 +75,7 @@ import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
private const val CATEGORY_ALL = "All"
internal const val CATEGORY_ALL = "All"
@Composable
fun SkillsScreen(
@@ -0,0 +1,245 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.skills.SkillsUiState
@Composable
internal fun HubBrowseView(
state: SkillsUiState,
hubQuery: String,
onHubQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
onClearSearch: () -> Unit,
onInstall: (String) -> Unit,
isInstalling: Boolean,
installingSkillName: String?,
) {
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = hubQuery,
onValueChange = onHubQueryChange,
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
placeholder = { Text(stringResource(R.string.skills_hub_search_placeholder)) },
trailingIcon = {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (hubQuery.isNotEmpty()) {
IconButton(onClick = {
onHubQueryChange("")
onClearSearch()
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.action_clear),
)
}
}
IconButton(
onClick = { onSearch(hubQuery) },
enabled = hubQuery.isNotBlank(),
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.skills_hub_search_button),
)
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions =
KeyboardActions(
onSearch = {
if (hubQuery.isNotBlank()) {
onSearch(hubQuery)
}
},
),
)
when {
state.isHubSearching -> {
LoadingState()
}
state.hubSearchError != null -> {
ErrorState(
message = state.hubSearchError,
onRetry = { onSearch(hubQuery) },
)
}
state.hubResults.isEmpty() && hubQuery.isNotBlank() && !state.isHubSearching -> {
EmptyState(
icon = Icons.Filled.Extension,
title = stringResource(R.string.skills_hub_empty_results),
)
}
state.hubResults.isNotEmpty() -> {
LazyColumn(
modifier = Modifier.weight(1f).padding(listContentPadding),
verticalArrangement = listItemSpacing,
) {
itemsIndexed(
state.hubResults,
key = { index, hubSkill -> "${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" },
) { _, hubSkill ->
HubSkillCard(
hubSkill = hubSkill,
onInstall = { onInstall(hubSkill.name) },
isInstalling = isInstalling && installingSkillName == hubSkill.name,
)
}
}
}
else -> {
EmptyState(
icon = Icons.Filled.Search,
title = stringResource(R.string.skills_hub_search_hint),
)
}
}
}
}
@Composable
private fun HubSkillCard(
hubSkill: HubSkill,
onInstall: () -> Unit,
isInstalling: Boolean,
) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
// ── Top row: name + source badge ──
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = hubSkill.name,
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
hubSkill.source?.let { source ->
Spacer(modifier = Modifier.width(6.dp))
SourceBadge(source = source)
}
}
// ── Category ──
hubSkill.category?.let { cat ->
Spacer(modifier = Modifier.height(2.dp))
Text(
text = cat,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Description ──
hubSkill.description?.let { desc ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = desc,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
// ── Install button ──
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Button(
onClick = onInstall,
enabled = !isInstalling,
) {
if (isInstalling) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Icon(
imageVector = Icons.Filled.CloudDownload,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.skills_hub_install))
}
}
}
}
}
}
@@ -0,0 +1,407 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.FilterChipRow
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.skills.CATEGORY_ALL
import com.m57.hermescontrol.ui.skills.SkillFilter
import com.m57.hermescontrol.ui.skills.SkillsUiState
@Composable
internal fun InstalledSkillsView(
state: SkillsUiState,
query: String,
onQueryChange: (String) -> Unit,
selectedStatus: SkillFilter,
onStatusChange: (SkillFilter) -> Unit,
selectedCategory: String?,
onCategoryChange: (String) -> Unit,
onToggle: (Skill) -> Unit,
onEdit: (String) -> Unit,
onUninstall: (String) -> Unit,
onSetSourceFilter: (String?) -> Unit,
sourceFilter: String?,
isUninstalling: Boolean,
uninstallingSkillName: String?,
onRefresh: () -> Unit,
) {
val categories =
remember(state.skills) {
state.skills
.mapNotNull { it.category }
.distinct()
.sorted()
}
val sources =
remember(state.skills) {
state.skills
.mapNotNull { it.source }
.distinct()
.sorted()
}
val filteredSkills =
remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) {
state.skills.filter { skill ->
(
query.isBlank() || skill.name.contains(query, ignoreCase = true) ||
skill.description?.contains(query, ignoreCase = true) == true
) &&
(
selectedStatus == SkillFilter.ALL_STATUSES ||
(selectedStatus == SkillFilter.ENABLED && skill.enabled) ||
(selectedStatus == SkillFilter.DISABLED && !skill.enabled)
) &&
(selectedCategory == CATEGORY_ALL || skill.category == selectedCategory) &&
(sourceFilter == null || skill.source == sourceFilter)
}
}
Column(modifier = Modifier.fillMaxSize()) {
SearchBar(
query = query,
onQueryChange = onQueryChange,
placeholder = stringResource(R.string.skills_search_placeholder),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChipRow(
chips =
listOf(
SkillFilter.ALL_STATUSES,
SkillFilter.ENABLED,
SkillFilter.DISABLED,
),
selectedChip = selectedStatus,
onChipSelected = onStatusChange,
chipLabel = { chip -> Text(stringResource(chip.labelRes)) },
)
}
if (categories.isNotEmpty() || sources.isNotEmpty()) {
FilterChipRow(
chips = listOf(stringResource(R.string.skills_category_all)) + categories,
selectedChip = selectedCategory,
onChipSelected = onCategoryChange,
)
if (sources.isNotEmpty()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
FilterChip(
selected = sourceFilter == null,
onClick = { onSetSourceFilter(null) },
label = {
Text(
stringResource(R.string.skills_source_all),
style = MaterialTheme.typography.labelSmall,
)
},
)
sources.forEach { source ->
FilterChip(
selected = sourceFilter == source,
onClick = { onSetSourceFilter(if (sourceFilter == source) null else source) },
label = { Text(source, style = MaterialTheme.typography.labelSmall) },
)
}
}
}
}
when {
state.isLoading -> {
LoadingState()
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage,
onRetry = onRefresh,
)
}
filteredSkills.isEmpty() -> {
EmptyState(
icon = Icons.Filled.Extension,
title = stringResource(R.string.skills_empty_message),
)
}
else -> {
LazyColumn(
modifier = Modifier.padding(listContentPadding),
verticalArrangement = listItemSpacing,
) {
itemsIndexed(
filteredSkills,
key = { index, skill -> "${skill.name}:${skill.source ?: "unknown"}:$index" },
) { _, skill ->
SkillCard(
skill = skill,
onToggle = { onToggle(skill) },
onAction = { onEdit(skill.name) },
onUninstall =
if (skill.source == "hub") {
{ onUninstall(skill.name) }
} else {
null
},
isUninstalling = isUninstalling && uninstallingSkillName == skill.name,
)
}
}
}
}
}
}
@Composable
private fun SkillCard(
skill: Skill,
onToggle: () -> Unit,
onAction: () -> Unit,
onUninstall: (() -> Unit)?,
isUninstalling: Boolean,
) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f).padding(16.dp),
) {
// ── Top row: name + source badge + toggle ──
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = skill.name,
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
skill.source?.let { source ->
Spacer(modifier = Modifier.width(6.dp))
SourceBadge(source = source)
}
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = skill.enabled,
onCheckedChange = { onToggle() },
)
}
// ── Category ──
skill.category?.let { cat ->
Spacer(modifier = Modifier.height(2.dp))
Text(
text = cat,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Description ──
skill.description?.let { desc ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = desc,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
// ── Action buttons row ──
if (onUninstall != null) {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onAction) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.content_desc_edit_skill),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(
onClick = onUninstall,
enabled = !isUninstalling,
) {
if (isUninstalling) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
val statusColors = LocalHermesStatusColors.current
Icon(
imageVector = Icons.Filled.CloudOff,
contentDescription = stringResource(R.string.content_desc_uninstall_skill),
tint = statusColors.error,
)
}
}
}
} else {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = onAction) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.content_desc_edit_skill),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
@Composable
fun SkillPreviewDialog(
skillName: String,
isLoading: Boolean,
content: String?,
onDismiss: () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Card(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
HermesScaffold(
title = { Text(stringResource(R.string.skills_preview_title, skillName)) },
navigationIcon = NavIcon.Back(onBack = onDismiss),
) { padding ->
Box(
modifier =
Modifier
.fillMaxSize(),
) {
when {
isLoading -> {
LoadingState()
}
content != null -> {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
Text(
text = content,
style =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
),
)
}
}
else -> {
EmptyState(
icon = Icons.Filled.Extension,
title = stringResource(R.string.skills_preview_empty),
)
}
}
}
}
}
}
}
@@ -0,0 +1,173 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
@Composable
internal fun SkillEditorDialog(
skillName: String,
isLoading: Boolean,
initialContent: String?,
isSaving: Boolean,
saveSuccess: Boolean,
onSave: (String) -> Unit,
onDismiss: () -> Unit,
onClearSaveSuccess: () -> Unit,
) {
var contentText by remember(initialContent) { mutableStateOf(initialContent.orEmpty()) }
var showDiscardConfirm by remember { mutableStateOf(false) }
val hasChanges =
remember(initialContent, contentText) {
val original = initialContent.orEmpty()
original != contentText
}
LaunchedEffect(saveSuccess) {
if (saveSuccess) {
onClearSaveSuccess()
onDismiss()
}
}
Dialog(
onDismissRequest = {
if (hasChanges) {
showDiscardConfirm = true
} else {
onDismiss()
}
},
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Card(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
HermesScaffold(
title = { Text(stringResource(R.string.skills_edit_title, skillName)) },
navigationIcon =
NavIcon.Back(
onBack = {
if (hasChanges) {
showDiscardConfirm = true
} else {
onDismiss()
}
},
),
actions = {
if (!isLoading) {
IconButton(
onClick = { onSave(contentText) },
enabled = !isSaving,
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.fillMaxSize(0.6f),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurface,
)
} else {
Icon(
imageVector = Icons.Filled.Save,
contentDescription = stringResource(R.string.content_desc_save_changes),
)
}
}
}
},
) { padding ->
Box(
modifier =
Modifier
.fillMaxSize(),
) {
when {
isLoading -> {
LoadingState()
}
else -> {
OutlinedTextField(
value = contentText,
onValueChange = { contentText = it },
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
textStyle =
TextStyle(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
),
placeholder = {
Text(stringResource(R.string.skills_content_placeholder))
},
)
}
}
}
}
}
}
if (showDiscardConfirm) {
AlertDialog(
onDismissRequest = { showDiscardConfirm = false },
title = { Text(stringResource(R.string.skills_discard_title)) },
text = { Text(stringResource(R.string.skills_discard_message)) },
confirmButton = {
TextButton(
onClick = {
showDiscardConfirm = false
onDismiss()
},
) {
Text(stringResource(R.string.action_discard))
}
},
dismissButton = {
TextButton(onClick = { showDiscardConfirm = false }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
@@ -0,0 +1,33 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
internal fun SourceBadge(source: String) {
val (color, label) =
when (source) {
"hub" -> MaterialTheme.colorScheme.tertiary to "hub"
"built-in" -> MaterialTheme.colorScheme.secondary to "built-in"
"optional" -> MaterialTheme.colorScheme.outline to "opt"
else -> MaterialTheme.colorScheme.outline to source
}
Surface(
color = color.copy(alpha = 0.15f),
shape = MaterialTheme.shapes.small,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = color,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
@@ -35,7 +35,6 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Update
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material3.AlertDialog
@@ -70,8 +69,6 @@ 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.CredentialPoolEntry
import com.m57.hermescontrol.data.model.HookEntry
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState
@@ -85,6 +82,8 @@ import com.m57.hermescontrol.ui.common.StatCard
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.system.components.CredentialEntryRow
import com.m57.hermescontrol.ui.system.components.HookCard
// ── Helpers ─────────────────────────────────────────────────────────────
@@ -1069,55 +1068,6 @@ private fun LazyListScope.credentialsSection(
}
}
@Composable
private fun CredentialEntryRow(
entry: CredentialPoolEntry,
providerName: String,
onRemove: () -> Unit,
spacing: com.m57.hermescontrol.theme.Spacing,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.label ?: "(${stringResource(R.string.system_credentials_api_key)} ${entry.index ?: 0})",
style = MaterialTheme.typography.bodyMedium,
)
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
entry.token_preview?.let { preview ->
Text(
text = preview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
entry.last_status?.let { status ->
StatusBadge(
text = status,
status =
when (status.lowercase()) {
"ok", "valid", "active" -> StatusBadgeType.SUCCESS
"error", "invalid", "expired" -> StatusBadgeType.ERROR
else -> StatusBadgeType.NEUTRAL
},
)
}
}
}
IconButton(onClick = onRemove) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
}
}
}
private fun LazyListScope.operationsSection(
state: SystemUiState,
spacing: com.m57.hermescontrol.theme.Spacing,
@@ -1560,97 +1510,6 @@ private fun LazyListScope.shellHooksSection(
}
}
@Composable
private fun HookCard(
hook: HookEntry,
spacing: com.m57.hermescontrol.theme.Spacing,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(spacing.md),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Icon(
Icons.Filled.Terminal,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = hook.command ?: "?",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
}
Spacer(modifier = Modifier.height(spacing.xs))
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
Text(
text = hook.event ?: "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
hook.matcher?.let { matcher ->
Text(
text = stringResource(R.string.system_hooks_matcher, matcher),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(spacing.xs))
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
StatusBadge(
text =
if (hook.executable != false) {
stringResource(R.string.system_hooks_allowed)
} else {
stringResource(R.string.system_hooks_not_executable)
},
status =
if (hook.executable != false) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR,
)
StatusBadge(
text =
if (hook.allowed == true) {
stringResource(R.string.system_hooks_allowed)
} else {
stringResource(R.string.system_hooks_not_approved)
},
status =
if (hook.allowed == true) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING,
)
hook.timeout?.let { t ->
StatusBadge(
text = "${t}s",
status = StatusBadgeType.NEUTRAL,
)
}
}
}
IconButton(onClick = onDelete) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
}
}
}
}
private fun LazyListScope.actionLogSection(
state: SystemUiState,
spacing: com.m57.hermescontrol.theme.Spacing,
@@ -0,0 +1,74 @@
package com.m57.hermescontrol.ui.system.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.CredentialPoolEntry
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
@Composable
internal fun CredentialEntryRow(
entry: CredentialPoolEntry,
providerName: String,
onRemove: () -> Unit,
spacing: com.m57.hermescontrol.theme.Spacing,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.label ?: "(${stringResource(R.string.system_credentials_api_key)} ${entry.index ?: 0})",
style = MaterialTheme.typography.bodyMedium,
)
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
entry.token_preview?.let { preview ->
Text(
text = preview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
entry.last_status?.let { status ->
StatusBadge(
text = status,
status =
when (status.lowercase()) {
"ok", "valid", "active" -> StatusBadgeType.SUCCESS
"error", "invalid", "expired" -> StatusBadgeType.ERROR
else -> StatusBadgeType.NEUTRAL
},
)
}
}
}
IconButton(onClick = onRemove) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
}
}
}
@@ -0,0 +1,122 @@
package com.m57.hermescontrol.ui.system.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HookEntry
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
@Composable
internal fun HookCard(
hook: HookEntry,
spacing: com.m57.hermescontrol.theme.Spacing,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(spacing.md),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Icon(
Icons.Filled.Terminal,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = hook.command ?: "?",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
}
Spacer(modifier = Modifier.height(spacing.xs))
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
Text(
text = hook.event ?: "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
hook.matcher?.let { matcher ->
Text(
text = stringResource(R.string.system_hooks_matcher, matcher),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(spacing.xs))
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
StatusBadge(
text =
if (hook.executable != false) {
stringResource(R.string.system_hooks_allowed)
} else {
stringResource(R.string.system_hooks_not_executable)
},
status =
if (hook.executable != false) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR,
)
StatusBadge(
text =
if (hook.allowed == true) {
stringResource(R.string.system_hooks_allowed)
} else {
stringResource(R.string.system_hooks_not_approved)
},
status =
if (hook.allowed == true) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING,
)
hook.timeout?.let { t ->
StatusBadge(
text = "${t}s",
status = StatusBadgeType.NEUTRAL,
)
}
}
}
IconButton(onClick = onDelete) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
}
}
}
}