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 StatusYellowContainer = Color(0xFF3D2F0F)
|
||||||
val StatusBlue = Color(0xFF4DA8FF)
|
val StatusBlue = Color(0xFF4DA8FF)
|
||||||
val StatusBlueContainer = Color(0xFF0F2A3D)
|
val StatusBlueContainer = Color(0xFF0F2A3D)
|
||||||
|
val StatusGrey = Color(0xFF9E9E9E)
|
||||||
|
|
||||||
// ── Chat-specific colours (always brand-defined for consistency) ───────
|
// ── Chat-specific colours (always brand-defined for consistency) ───────
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ data class HermesStatusColors(
|
|||||||
val errorContainer: Color,
|
val errorContainer: Color,
|
||||||
val info: Color,
|
val info: Color,
|
||||||
val infoContainer: Color,
|
val infoContainer: Color,
|
||||||
|
val neutral: Color = StatusGrey,
|
||||||
)
|
)
|
||||||
|
|
||||||
val LocalHermesStatusColors =
|
val LocalHermesStatusColors =
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.channels
|
package com.m57.hermescontrol.ui.channels
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.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.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -47,8 +34,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
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.MessagingPlatform
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
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.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
@@ -101,42 +88,6 @@ private fun validateEnvField(
|
|||||||
|
|
||||||
// ── State → badge colour / label ──────────────────────────────────────
|
// ── 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 ──────────────────────────────────────────────────────
|
// ── Main screen ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -352,203 +303,10 @@ private fun GatewayOfflineBanner(gatewayCommand: String) {
|
|||||||
|
|
||||||
// ── Platform card ────────────────────────────────────────────────────
|
// ── 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 ───────────────────────────────────────────────────
|
// ── Configure form ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConfigureForm(
|
internal fun ConfigureForm(
|
||||||
platform: MessagingPlatform,
|
platform: MessagingPlatform,
|
||||||
onSave: (MessagingPlatformUpdate) -> Unit,
|
onSave: (MessagingPlatformUpdate) -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
@@ -692,176 +450,6 @@ private fun AdminSection(envPath: String) {
|
|||||||
|
|
||||||
// ── Telegram Onboarding Dialog ────────────────────────────────────────
|
// ── 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 =
|
private val isoTimestampParser =
|
||||||
java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
|
java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
|
||||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Build
|
import androidx.compose.material.icons.filled.Build
|
||||||
import androidx.compose.material.icons.filled.Check
|
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.Delete
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
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.PushPin
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.outlined.Add
|
|
||||||
import androidx.compose.material.icons.outlined.PushPin
|
import androidx.compose.material.icons.outlined.PushPin
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -59,8 +55,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
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.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
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.NavIcon
|
||||||
import com.m57.hermescontrol.ui.common.SearchBar
|
import com.m57.hermescontrol.ui.common.SearchBar
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
|
import com.m57.hermescontrol.ui.model.components.MoaConfigDialog
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -543,450 +538,6 @@ private fun AuxTasksDialog(
|
|||||||
// MOA Config Dialog
|
// 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
|
// 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
|
package com.m57.hermescontrol.ui.settings
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.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.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -60,21 +31,22 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.BuildConfig
|
import com.m57.hermescontrol.BuildConfig
|
||||||
import com.m57.hermescontrol.R
|
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.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
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 {
|
enum class SettingsTab {
|
||||||
CONNECTION,
|
CONNECTION,
|
||||||
@@ -277,7 +249,7 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InfoRow(
|
internal fun InfoRow(
|
||||||
label: String,
|
label: String,
|
||||||
value: String,
|
value: String,
|
||||||
) {
|
) {
|
||||||
@@ -299,7 +271,7 @@ private fun InfoRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionCard(
|
internal fun SectionCard(
|
||||||
title: String,
|
title: String,
|
||||||
content: @Composable () -> Unit,
|
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.listContentPadding
|
||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
|
|
||||||
private const val CATEGORY_ALL = "All"
|
internal const val CATEGORY_ALL = "All"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SkillsScreen(
|
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.Speed
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material.icons.filled.Storage
|
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.Update
|
||||||
import androidx.compose.material.icons.filled.VerifiedUser
|
import androidx.compose.material.icons.filled.VerifiedUser
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -70,8 +69,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
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.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.LocalSpacing
|
import com.m57.hermescontrol.theme.LocalSpacing
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
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.StatusBadge
|
||||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
|
import com.m57.hermescontrol.ui.system.components.CredentialEntryRow
|
||||||
|
import com.m57.hermescontrol.ui.system.components.HookCard
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
// ── 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(
|
private fun LazyListScope.operationsSection(
|
||||||
state: SystemUiState,
|
state: SystemUiState,
|
||||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
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(
|
private fun LazyListScope.actionLogSection(
|
||||||
state: SystemUiState,
|
state: SystemUiState,
|
||||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
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