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
@@ -1,7 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.channels
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -15,29 +14,17 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.QrCode
|
||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -47,8 +34,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
@@ -60,6 +45,8 @@ import com.m57.hermescontrol.data.model.EnvVarField
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatform
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.channels.components.PlatformCard
|
||||
import com.m57.hermescontrol.ui.channels.components.TelegramOnboardingDialog
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
@@ -101,42 +88,6 @@ private fun validateEnvField(
|
||||
|
||||
// ── State → badge colour / label ──────────────────────────────────────
|
||||
|
||||
private data class StateStyle(
|
||||
val label: String,
|
||||
val color: Color,
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun platformStateStyle(state: String): StateStyle =
|
||||
when (state) {
|
||||
"connected" -> StateStyle("Connected", Color(0xFF4CAF50), Icons.Filled.CheckCircle)
|
||||
"pending_restart" -> StateStyle("Restart to apply", Color(0xFFFFC107), Icons.Filled.Refresh)
|
||||
"gateway_stopped" -> StateStyle("Gateway stopped", Color(0xFFFF9800), Icons.Filled.RadioButtonChecked)
|
||||
"startup_failed" -> StateStyle("Start failed", Color(0xFFF44336), Icons.Filled.Warning)
|
||||
"disconnected" -> StateStyle("Disconnected", Color(0xFFFF9800), Icons.Filled.RadioButtonChecked)
|
||||
"not_configured" -> StateStyle("Not configured", Color(0xFF9E9E9E), Icons.Filled.RadioButtonChecked)
|
||||
"disabled" -> StateStyle("Disabled", Color(0xFF9E9E9E), Icons.Filled.RadioButtonChecked)
|
||||
"fatal" -> StateStyle("Error", Color(0xFFF44336), Icons.Filled.Warning)
|
||||
else -> StateStyle(state, MaterialTheme.colorScheme.onSurfaceVariant, Icons.Filled.RadioButtonChecked)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StateBadge(state: String) {
|
||||
val style = platformStateStyle(state)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = style.color.copy(alpha = 0.15f),
|
||||
) {
|
||||
Text(
|
||||
text = style.label,
|
||||
color = style.color,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main screen ──────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
@@ -352,203 +303,10 @@ private fun GatewayOfflineBanner(gatewayCommand: String) {
|
||||
|
||||
// ── Platform card ────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun PlatformCard(
|
||||
platform: MessagingPlatform,
|
||||
isToggling: Boolean,
|
||||
isTesting: Boolean,
|
||||
isRemoving: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onTest: () -> Unit,
|
||||
onSave: (MessagingPlatformUpdate) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onStartOnboarding: () -> Unit,
|
||||
) {
|
||||
var showConfigureForm by remember { mutableStateOf(false) }
|
||||
var showRemoveConfirm by remember { mutableStateOf(false) }
|
||||
val style = platformStateStyle(platform.state)
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// ── Top row: icon + name + badge + switch ──
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = style.icon,
|
||||
contentDescription = null,
|
||||
tint = style.color,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = platform.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
StateBadge(state = platform.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isToggling) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Switch(
|
||||
checked = platform.enabled,
|
||||
onCheckedChange = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Description ──
|
||||
if (!platform.description.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = platform.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Error message ──
|
||||
if (!platform.errorMessage.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = platform.errorMessage,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Action buttons ──
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onTest,
|
||||
enabled = !isTesting,
|
||||
) {
|
||||
if (isTesting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.channels_action_test),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { showConfigureForm = !showConfigureForm },
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
if (showConfigureForm) {
|
||||
stringResource(R.string.action_cancel)
|
||||
} else {
|
||||
stringResource(R.string.channels_action_configure)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
|
||||
// Remove button
|
||||
IconButton(
|
||||
onClick = { showRemoveConfirm = true },
|
||||
enabled = !isRemoving,
|
||||
) {
|
||||
if (isRemoving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = "Remove platform",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Telegram onboarding (only for unconfigured telegram) ──
|
||||
if (platform.id == "telegram" && !platform.configured) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = onStartOnboarding,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.QrCode,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Set up with QR",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Configure form ──
|
||||
if (showConfigureForm) {
|
||||
ConfigureForm(
|
||||
platform = platform,
|
||||
onSave = onSave,
|
||||
onClose = { showConfigureForm = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove confirmation
|
||||
if (showRemoveConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRemoveConfirm = false },
|
||||
title = { Text("Remove ${platform.name}?") },
|
||||
text = { Text("This will permanently remove this platform's configuration.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showRemoveConfirm = false
|
||||
onRemove()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text("Remove")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRemoveConfirm = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Configure form ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ConfigureForm(
|
||||
internal fun ConfigureForm(
|
||||
platform: MessagingPlatform,
|
||||
onSave: (MessagingPlatformUpdate) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
@@ -692,176 +450,6 @@ private fun AdminSection(envPath: String) {
|
||||
|
||||
// ── Telegram Onboarding Dialog ────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun TelegramOnboardingDialog(
|
||||
state: ChannelsUiState,
|
||||
onDismiss: () -> Unit,
|
||||
onAddAllowedId: () -> Unit,
|
||||
onRemoveAllowedId: (String) -> Unit,
|
||||
onNewAllowedIdChange: (String) -> Unit,
|
||||
onApply: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text =
|
||||
when (state.onboardingPhase) {
|
||||
OnboardingPhase.STARTING -> "Starting…"
|
||||
OnboardingPhase.WAITING -> "Scan QR code"
|
||||
OnboardingPhase.READY -> "Telegram connected"
|
||||
OnboardingPhase.APPLYING -> "Saving…"
|
||||
OnboardingPhase.IDLE -> "Telegram Setup"
|
||||
},
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
state.onboardingError?.let { error ->
|
||||
Surface(
|
||||
color = LocalHermesStatusColors.current.errorContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (state.onboardingPhase) {
|
||||
OnboardingPhase.STARTING -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
OnboardingPhase.WAITING -> {
|
||||
Text(
|
||||
text = "Open Telegram and scan the QR code displayed in the app.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (state.onboardingExpiresIn.isNotEmpty()) {
|
||||
Text(
|
||||
text = "QR code expires in: ${state.onboardingExpiresIn}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OnboardingPhase.READY -> {
|
||||
state.onboardingBotUsername?.let { username ->
|
||||
Text(
|
||||
text = "Connected as @$username",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Allowed users:",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
state.onboardingDetectedOwnerId?.let { ownerId ->
|
||||
if (state.onboardingAllowedIds.contains(ownerId)) {
|
||||
Text(
|
||||
text = "$ownerId (owner detected)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
state.onboardingAllowedIds.forEach { id ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = id,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
IconButton(onClick = { onRemoveAllowedId(id) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Remove",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.onboardingNewAllowedId,
|
||||
onValueChange = onNewAllowedIdChange,
|
||||
placeholder = { Text("Telegram user ID") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onAddAllowedId) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Add user ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnboardingPhase.APPLYING -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Saving configuration…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnboardingPhase.IDLE -> { }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
when (state.onboardingPhase) {
|
||||
OnboardingPhase.READY -> {
|
||||
Button(
|
||||
onClick = onApply,
|
||||
enabled = state.onboardingAllowedIds.isNotEmpty(),
|
||||
) {
|
||||
Text("Save and restart")
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
when (state.onboardingPhase) {
|
||||
OnboardingPhase.READY -> {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
OnboardingPhase.WAITING -> {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
OnboardingPhase.IDLE, OnboardingPhase.STARTING, OnboardingPhase.APPLYING -> {}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val isoTimestampParser =
|
||||
java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package com.m57.hermescontrol.ui.channels.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.QrCode
|
||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatform
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.channels.ConfigureForm
|
||||
|
||||
private enum class StatusRole { SUCCESS, WARNING, ERROR, NEUTRAL }
|
||||
|
||||
private data class StateStyle(
|
||||
val label: String,
|
||||
val color: Color,
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun platformStateStyle(state: String): StateStyle {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val (label, role, icon) =
|
||||
when (state) {
|
||||
"connected" -> Triple("Connected", StatusRole.SUCCESS, Icons.Filled.CheckCircle)
|
||||
"pending_restart" -> Triple("Restart to apply", StatusRole.WARNING, Icons.Filled.Refresh)
|
||||
"gateway_stopped" -> Triple("Gateway stopped", StatusRole.WARNING, Icons.Filled.RadioButtonChecked)
|
||||
"startup_failed" -> Triple("Start failed", StatusRole.ERROR, Icons.Filled.Warning)
|
||||
"disconnected" -> Triple("Disconnected", StatusRole.WARNING, Icons.Filled.RadioButtonChecked)
|
||||
"not_configured" -> Triple("Not configured", StatusRole.NEUTRAL, Icons.Filled.RadioButtonChecked)
|
||||
"disabled" -> Triple("Disabled", StatusRole.NEUTRAL, Icons.Filled.RadioButtonChecked)
|
||||
"fatal" -> Triple("Error", StatusRole.ERROR, Icons.Filled.Warning)
|
||||
else -> Triple(state, StatusRole.NEUTRAL, Icons.Filled.RadioButtonChecked)
|
||||
}
|
||||
val color =
|
||||
when (role) {
|
||||
StatusRole.SUCCESS -> statusColors.success
|
||||
StatusRole.WARNING -> statusColors.warning
|
||||
StatusRole.ERROR -> statusColors.error
|
||||
StatusRole.NEUTRAL -> statusColors.neutral
|
||||
}
|
||||
return StateStyle(label, color, icon)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StateBadge(state: String) {
|
||||
val style = platformStateStyle(state)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = style.color.copy(alpha = 0.15f),
|
||||
) {
|
||||
Text(
|
||||
text = style.label,
|
||||
color = style.color,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PlatformCard(
|
||||
platform: MessagingPlatform,
|
||||
isToggling: Boolean,
|
||||
isTesting: Boolean,
|
||||
isRemoving: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onTest: () -> Unit,
|
||||
onSave: (MessagingPlatformUpdate) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onStartOnboarding: () -> Unit,
|
||||
) {
|
||||
var showConfigureForm by remember { mutableStateOf(false) }
|
||||
var showRemoveConfirm by remember { mutableStateOf(false) }
|
||||
val style = platformStateStyle(platform.state)
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// ── Top row: icon + name + badge + switch ──
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = style.icon,
|
||||
contentDescription = null,
|
||||
tint = style.color,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = platform.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
StateBadge(state = platform.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isToggling) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Switch(
|
||||
checked = platform.enabled,
|
||||
onCheckedChange = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Description ──
|
||||
if (!platform.description.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = platform.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Error message ──
|
||||
if (!platform.errorMessage.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = platform.errorMessage,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Action buttons ──
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onTest,
|
||||
enabled = !isTesting,
|
||||
) {
|
||||
if (isTesting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.channels_action_test),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { showConfigureForm = !showConfigureForm },
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
if (showConfigureForm) {
|
||||
stringResource(R.string.action_cancel)
|
||||
} else {
|
||||
stringResource(R.string.channels_action_configure)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
|
||||
// Remove button
|
||||
IconButton(
|
||||
onClick = { showRemoveConfirm = true },
|
||||
enabled = !isRemoving,
|
||||
) {
|
||||
if (isRemoving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = "Remove platform",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Telegram onboarding (only for unconfigured telegram) ──
|
||||
if (platform.id == "telegram" && !platform.configured) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = onStartOnboarding,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.QrCode,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Set up with QR",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Configure form ──
|
||||
if (showConfigureForm) {
|
||||
ConfigureForm(
|
||||
platform = platform,
|
||||
onSave = onSave,
|
||||
onClose = { showConfigureForm = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove confirmation
|
||||
if (showRemoveConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRemoveConfirm = false },
|
||||
title = { Text("Remove ${platform.name}?") },
|
||||
text = { Text("This will permanently remove this platform's configuration.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showRemoveConfirm = false
|
||||
onRemove()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text("Remove")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRemoveConfirm = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package com.m57.hermescontrol.ui.channels.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.channels.ChannelsUiState
|
||||
import com.m57.hermescontrol.ui.channels.OnboardingPhase
|
||||
|
||||
@Composable
|
||||
internal fun TelegramOnboardingDialog(
|
||||
state: ChannelsUiState,
|
||||
onDismiss: () -> Unit,
|
||||
onAddAllowedId: () -> Unit,
|
||||
onRemoveAllowedId: (String) -> Unit,
|
||||
onNewAllowedIdChange: (String) -> Unit,
|
||||
onApply: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text =
|
||||
when (state.onboardingPhase) {
|
||||
OnboardingPhase.STARTING -> "Starting…"
|
||||
OnboardingPhase.WAITING -> "Scan QR code"
|
||||
OnboardingPhase.READY -> "Telegram connected"
|
||||
OnboardingPhase.APPLYING -> "Saving…"
|
||||
OnboardingPhase.IDLE -> "Telegram Setup"
|
||||
},
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
state.onboardingError?.let { error ->
|
||||
Surface(
|
||||
color = LocalHermesStatusColors.current.errorContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (state.onboardingPhase) {
|
||||
OnboardingPhase.STARTING -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
OnboardingPhase.WAITING -> {
|
||||
Text(
|
||||
text = "Open Telegram and scan the QR code displayed in the app.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (state.onboardingExpiresIn.isNotEmpty()) {
|
||||
Text(
|
||||
text = "QR code expires in: ${state.onboardingExpiresIn}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OnboardingPhase.READY -> {
|
||||
state.onboardingBotUsername?.let { username ->
|
||||
Text(
|
||||
text = "Connected as @$username",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Allowed users:",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
state.onboardingDetectedOwnerId?.let { ownerId ->
|
||||
if (state.onboardingAllowedIds.contains(ownerId)) {
|
||||
Text(
|
||||
text = "$ownerId (owner detected)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
state.onboardingAllowedIds.forEach { id ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = id,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
IconButton(onClick = { onRemoveAllowedId(id) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Remove",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.onboardingNewAllowedId,
|
||||
onValueChange = onNewAllowedIdChange,
|
||||
placeholder = { Text("Telegram user ID") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onAddAllowedId) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Add user ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnboardingPhase.APPLYING -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Saving configuration…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnboardingPhase.IDLE -> { }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
when (state.onboardingPhase) {
|
||||
OnboardingPhase.READY -> {
|
||||
Button(
|
||||
onClick = onApply,
|
||||
enabled = state.onboardingAllowedIds.isNotEmpty(),
|
||||
) {
|
||||
Text("Save and restart")
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
when (state.onboardingPhase) {
|
||||
OnboardingPhase.READY -> {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
OnboardingPhase.WAITING -> {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
OnboardingPhase.IDLE, OnboardingPhase.STARTING, OnboardingPhase.APPLYING -> {}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user