diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index c36f88df..1de1348a 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -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) ─────── diff --git a/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt b/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt index 7be863a7..306c09bb 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt @@ -22,6 +22,7 @@ data class HermesStatusColors( val errorContainer: Color, val info: Color, val infoContainer: Color, + val neutral: Color = StatusGrey, ) val LocalHermesStatusColors = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt index 12f584f4..9882fe84 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt @@ -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") diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt new file mode 100644 index 00000000..3e55c3a8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt @@ -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)) + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/TelegramOnboardingDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/TelegramOnboardingDialog.kt new file mode 100644 index 00000000..ab69e295 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/TelegramOnboardingDialog.kt @@ -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 -> {} + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index 4ca8f8ae..71b80f37 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -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, - 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(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, - 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, - 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 // ──────────────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/components/MoaConfigDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/components/MoaConfigDialog.kt new file mode 100644 index 00000000..675109cc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/components/MoaConfigDialog.kt @@ -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, + 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(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, + 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, + 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") + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index 890f0df2..365384b6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -1,7 +1,6 @@ package com.m57.hermescontrol.ui.profiles 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 @@ -12,32 +11,23 @@ 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.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add 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.Edit import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Search 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.Checkbox 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 @@ -61,17 +51,14 @@ 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.CreateProfileRequest -import com.m57.hermescontrol.data.model.HubSkill -import com.m57.hermescontrol.data.model.McpServerConfigInput -import com.m57.hermescontrol.data.model.ModelProvider -import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState 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.ToastEffect +import com.m57.hermescontrol.ui.profiles.components.ProfileBuilderView +import com.m57.hermescontrol.ui.profiles.components.validateProfileName @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -651,974 +638,3 @@ fun ProfilesScreen( // --------------------------------------------------------------------- // Profile Builder Wizard // --------------------------------------------------------------------- - -@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>(emptySet()) } - var addedHubSkills by remember { mutableStateOf>(emptyList()) } - - // Step 4: MCP Servers - var mcpServers by remember { mutableStateOf>(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)) - } - } - } - } - } -} - -private 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, - 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 SkillsStep( - availableSkills: List, - useDefaultSkills: Boolean, - onUseDefaultSkillsChange: (Boolean) -> Unit, - selectedSkills: Set, - onSelectedSkillsChange: (Set) -> Unit, - addedHubSkills: List, - onAddedHubSkillsChange: (List) -> Unit, - hubSearchResults: List, - 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") - } - } - } - } - } - } - } - } -} - -@Composable -private fun McpStep( - mcpServers: List, - onMcpServersChange: (List) -> 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, - addedHubSkills: List, - mcpServers: List, -) { - 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, - ) - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/ProfileBuilderView.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/ProfileBuilderView.kt new file mode 100644 index 00000000..f7819dd0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/ProfileBuilderView.kt @@ -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>(emptySet()) } + var addedHubSkills by remember { mutableStateOf>(emptyList()) } + + // Step 4: MCP Servers + var mcpServers by remember { mutableStateOf>(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, + 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, + onMcpServersChange: (List) -> 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, + addedHubSkills: List, + mcpServers: List, +) { + 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, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/SkillsStep.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/SkillsStep.kt new file mode 100644 index 00000000..d49f5362 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/SkillsStep.kt @@ -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, + useDefaultSkills: Boolean, + onUseDefaultSkillsChange: (Boolean) -> Unit, + selectedSkills: Set, + onSelectedSkillsChange: (Set) -> Unit, + addedHubSkills: List, + onAddedHubSkillsChange: (List) -> Unit, + hubSearchResults: List, + 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") + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 6ef3cd65..cc82314a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -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, - availableNavItems: List>, - onReorderNavItems: (List) -> 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, - ) - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt new file mode 100644 index 00000000..97771511 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt new file mode 100644 index 00000000..c3c9738d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt @@ -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, + availableNavItems: List>, + onReorderNavItems: (List) -> 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, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt new file mode 100644 index 00000000..ec4928dd --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt @@ -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, + ), + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt new file mode 100644 index 00000000..0060e8fe --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt new file mode 100644 index 00000000..14979241 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt @@ -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)) + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index 76526081..1b9e33cc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillsTab.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillsTab.kt new file mode 100644 index 00000000..35dda9c1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillsTab.kt @@ -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)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsTab.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsTab.kt new file mode 100644 index 00000000..fbe3d462 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsTab.kt @@ -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), + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillEditorDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillEditorDialog.kt new file mode 100644 index 00000000..f1109a0c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillEditorDialog.kt @@ -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)) + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceBadge.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceBadge.kt new file mode 100644 index 00000000..a9d5f54c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceBadge.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index 4594a8fd..f0178ce0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialEntryRow.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialEntryRow.kt new file mode 100644 index 00000000..8550403b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialEntryRow.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCard.kt new file mode 100644 index 00000000..91188f87 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCard.kt @@ -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), + ) + } + } + } +}