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