* feat(#369): rewrite channels tab with full backend integration - Add state, error, gateway_running, description to model - Add test endpoint to API service - Rewrite ViewModel with toggle, restart, test actions - Rewrite screen with state badges, restart banner, test button - Fix string resources and E2E test * fix(#417): add missing import and fix ButtonSize API - Add MessagingPlatformTestResult import to HermesApiService.kt - Replace ButtonDefaults.ButtonSize.SMALL with ButtonDefaults.SmallButtonSize (correct M3 API) * fix(#417): mark platformStateStyle as @Composable, remove Button size param - @Composable annotation needed for MaterialTheme.colorScheme access - Button/OutlinedButton size parameter not available in project's M3 version - ktlint removed unused ButtonDefaults import --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
2ae6cce929
commit
c64b025a54
7 files changed
+538
-158
No files matched your search
@@ -5,8 +5,14 @@ import com.google.gson.annotations.SerializedName
|
||||
data class MessagingPlatform(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
@SerializedName("docs_url") val docsUrl: String? = null,
|
||||
val enabled: Boolean,
|
||||
val configured: Boolean,
|
||||
@SerializedName("gateway_running") val gatewayRunning: Boolean = false,
|
||||
val state: String = "disabled",
|
||||
@SerializedName("error_code") val errorCode: String? = null,
|
||||
@SerializedName("error_message") val errorMessage: String? = null,
|
||||
@SerializedName("env_vars") val envVars: List<EnvVarField>? = null,
|
||||
)
|
||||
|
||||
@@ -14,9 +20,19 @@ data class EnvVarField(
|
||||
val key: String,
|
||||
val required: Boolean,
|
||||
@SerializedName("is_set") val isSet: Boolean,
|
||||
val description: String?,
|
||||
val prompt: String?,
|
||||
@SerializedName("redacted_value") val redactedValue: String? = null,
|
||||
val description: String? = null,
|
||||
val prompt: String? = null,
|
||||
val help: String? = null,
|
||||
@SerializedName("is_password") val isPassword: Boolean,
|
||||
val advanced: Boolean = false,
|
||||
val url: String? = null,
|
||||
)
|
||||
|
||||
data class MessagingPlatformResponse(
|
||||
@SerializedName("env_path") val envPath: String,
|
||||
@SerializedName("gateway_start_command") val gatewayStartCommand: String,
|
||||
val platforms: List<MessagingPlatform>,
|
||||
)
|
||||
|
||||
data class MessagingPlatformUpdate(
|
||||
@@ -25,3 +41,9 @@ data class MessagingPlatformUpdate(
|
||||
@SerializedName("clear_env") val clearEnv: List<String> = emptyList(),
|
||||
val profile: String? = null,
|
||||
)
|
||||
|
||||
data class MessagingPlatformTestResult(
|
||||
val ok: Boolean,
|
||||
val state: String,
|
||||
val message: String,
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class MessagingPlatformResponse(
|
||||
val env_path: String,
|
||||
val gateway_start_command: String,
|
||||
val platforms: List<MessagingPlatform>,
|
||||
)
|
||||
@@ -17,6 +17,7 @@ import com.m57.hermescontrol.data.model.LogResponse
|
||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatformTestResult
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||
import com.m57.hermescontrol.data.model.PairingApproveRequest
|
||||
@@ -260,6 +261,11 @@ interface HermesApiService {
|
||||
@Body config: MessagingPlatformUpdate,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/messaging/platforms/{platform_id}/test")
|
||||
suspend fun testMessagingPlatform(
|
||||
@Path("platform_id") platformId: String,
|
||||
): Response<MessagingPlatformTestResult>
|
||||
|
||||
@GET("api/env")
|
||||
suspend fun getEnvVars(): Response<Map<String, EnvVarConfig>>
|
||||
|
||||
|
||||
@@ -8,13 +8,24 @@ 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.CheckCircle
|
||||
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.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
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.runtime.Composable
|
||||
@@ -26,11 +37,15 @@ 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.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatform
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
@@ -42,6 +57,42 @@ import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
|
||||
// ── 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
|
||||
fun ChannelsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -49,7 +100,6 @@ fun ChannelsScreen(
|
||||
viewModel: ChannelsViewModel = viewModel { ChannelsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
val filteredPlatforms =
|
||||
@@ -79,7 +129,7 @@ fun ChannelsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
state.errorMessage != null -> {
|
||||
state.errorMessage != null && state.platforms.isEmpty() -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadPlatforms() },
|
||||
@@ -96,157 +146,331 @@ fun ChannelsScreen(
|
||||
}
|
||||
|
||||
else -> {
|
||||
val gatewayRunning = state.platforms.firstOrNull()?.gatewayRunning ?: false
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = listContentPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
item {
|
||||
// ── Restart banner ──
|
||||
if (state.restartNeeded) {
|
||||
item(key = "restart_banner") {
|
||||
RestartBanner(
|
||||
isRestarting = state.isRestarting,
|
||||
onRestart = viewModel::restartGateway,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway-not-running hint ──
|
||||
if (!gatewayRunning && !state.restartNeeded) {
|
||||
item(key = "gateway_offline") {
|
||||
GatewayOfflineBanner()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search bar ──
|
||||
item(key = "search") {
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
placeholder = "Search channels...",
|
||||
placeholder = "Search channels…",
|
||||
)
|
||||
}
|
||||
|
||||
// ── Channel cards ──
|
||||
items(filteredPlatforms, key = { it.id }) { platform ->
|
||||
var showConfigureForm by remember { mutableStateOf(false) }
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(text = platform.name, style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
text =
|
||||
if (platform.enabled) {
|
||||
stringResource(
|
||||
R.string.channels_status_active,
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.channels_status_disabled)
|
||||
},
|
||||
color =
|
||||
if (platform.enabled) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = platform.enabled,
|
||||
onCheckedChange = { isChecked ->
|
||||
viewModel.configurePlatform(
|
||||
platform.id,
|
||||
MessagingPlatformUpdate(enabled = isChecked),
|
||||
)
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(onClick = { showConfigureForm = !showConfigureForm }) {
|
||||
Text(
|
||||
if (showConfigureForm) {
|
||||
stringResource(
|
||||
R.string.action_cancel,
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.channels_action_configure)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showConfigureForm) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.channels_sec_settings),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
val envFields = platform.envVars.orEmpty()
|
||||
val inputValues = remember { mutableStateMapOf<String, String>() }
|
||||
|
||||
envFields.forEach { field ->
|
||||
OutlinedTextField(
|
||||
value = inputValues[field.key].orEmpty(),
|
||||
onValueChange = { inputValues[field.key] = it },
|
||||
label = { Text(field.prompt ?: field.key) },
|
||||
placeholder = {
|
||||
if (field.isSet) {
|
||||
Text(stringResource(R.string.channels_placeholder_configured))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.configurePlatform(
|
||||
platform.id,
|
||||
MessagingPlatformUpdate(
|
||||
enabled = platform.enabled,
|
||||
env = inputValues.toMap(),
|
||||
),
|
||||
)
|
||||
showConfigureForm = false
|
||||
},
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Text(stringResource(R.string.channels_action_save_settings))
|
||||
}
|
||||
} else {
|
||||
val envFields = platform.envVars.orEmpty()
|
||||
if (envFields.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.channels_sec_keys),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
envFields.forEach { field ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = field.key,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (field.isSet) {
|
||||
stringResource(
|
||||
R.string.channels_status_configured,
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.channels_status_not_set)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
if (field.isSet) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PlatformCard(
|
||||
platform = platform,
|
||||
isToggling = state.togglingId == platform.id,
|
||||
isTesting = state.testingId == platform.id,
|
||||
onToggle = { enabled -> viewModel.togglePlatform(platform.id, enabled) },
|
||||
onTest = { viewModel.testPlatform(platform.id) },
|
||||
onSave = { env -> viewModel.configurePlatform(platform.id, env) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Restart banner ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun RestartBanner(
|
||||
isRestarting: Boolean,
|
||||
onRestart: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.channels_restart_banner),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Button(
|
||||
onClick = onRestart,
|
||||
enabled = !isRestarting,
|
||||
) {
|
||||
if (isRestarting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.channels_action_restart_now),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway offline banner ───────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun GatewayOfflineBanner() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.channels_gateway_offline),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Platform card ────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun PlatformCard(
|
||||
platform: MessagingPlatform,
|
||||
isToggling: Boolean,
|
||||
isTesting: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onTest: () -> Unit,
|
||||
onSave: (MessagingPlatformUpdate) -> Unit,
|
||||
) {
|
||||
var showConfigureForm 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 (platform.id == "telegram") {
|
||||
// reserved for onboarding badge later
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle switch
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Configure form ──
|
||||
if (showConfigureForm) {
|
||||
ConfigureForm(platform = platform, onSave = onSave, onClose = { showConfigureForm = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Configure form ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ConfigureForm(
|
||||
platform: MessagingPlatform,
|
||||
onSave: (MessagingPlatformUpdate) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val envFields = platform.envVars.orEmpty()
|
||||
val inputValues = remember { mutableStateMapOf<String, String>() }
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.channels_sec_settings),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
envFields.forEach { field ->
|
||||
OutlinedTextField(
|
||||
value = inputValues[field.key].orEmpty(),
|
||||
onValueChange = { inputValues[field.key] = it },
|
||||
label = { Text(field.prompt ?: field.key) },
|
||||
placeholder = {
|
||||
if (field.isSet) {
|
||||
Text(field.redactedValue ?: "******** (Already Configured)")
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
if (field.isPassword) {
|
||||
PasswordVisualTransformation()
|
||||
} else {
|
||||
androidx.compose.ui.text.input.VisualTransformation.None
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClose,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.action_cancel),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val filledEnv = inputValues.filterValues { it.isNotBlank() }
|
||||
val update =
|
||||
MessagingPlatformUpdate(
|
||||
enabled = platform.enabled,
|
||||
env = filledEnv,
|
||||
)
|
||||
onSave(update)
|
||||
onClose()
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.channels_action_save_settings),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,29 @@
|
||||
package com.m57.hermescontrol.ui.channels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatform
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ChannelsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val platforms: List<MessagingPlatform> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
val restartNeeded: Boolean = false,
|
||||
val isRestarting: Boolean = false,
|
||||
val togglingId: String? = null,
|
||||
val testingId: String? = null,
|
||||
)
|
||||
|
||||
class ChannelsViewModel : ViewModel(), ToastHost {
|
||||
@@ -26,9 +33,15 @@ class ChannelsViewModel : ViewModel(), ToastHost {
|
||||
fun loadPlatforms() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getMessagingPlatforms() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onStart = {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = true, errorMessage = null)
|
||||
}
|
||||
},
|
||||
onSuccess = { data ->
|
||||
_uiState.update { it.copy(isLoading = false, platforms = data?.platforms.orEmpty()) }
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, platforms = data?.platforms.orEmpty())
|
||||
}
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
@@ -41,18 +54,76 @@ class ChannelsViewModel : ViewModel(), ToastHost {
|
||||
)
|
||||
}
|
||||
|
||||
/** Toggle a platform's enabled state without a full reload.
|
||||
* On success the local state is updated optimistically and
|
||||
* restartNeeded is set. */
|
||||
fun togglePlatform(
|
||||
platformId: String,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
_uiState.update { it.copy(togglingId = platformId) }
|
||||
safeLaunchLoad(
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.configurePlatform(
|
||||
platformId,
|
||||
MessagingPlatformUpdate(enabled = enabled),
|
||||
)
|
||||
}
|
||||
},
|
||||
onStart = {},
|
||||
onSuccess = {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
platforms =
|
||||
state.platforms.map { p ->
|
||||
if (p.id == platformId) {
|
||||
p.copy(
|
||||
enabled = enabled,
|
||||
state = if (enabled) "pending_restart" else "disabled",
|
||||
)
|
||||
} else {
|
||||
p
|
||||
}
|
||||
},
|
||||
restartNeeded = true,
|
||||
togglingId = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { error ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
togglingId = null,
|
||||
toastMessage = "Toggle failed: $error",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Configure a platform's env vars and/or settings. Reloads on success. */
|
||||
fun configurePlatform(
|
||||
platformId: String,
|
||||
update: MessagingPlatformUpdate,
|
||||
) {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.configurePlatform(platformId, update) } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.configurePlatform(platformId, update)
|
||||
}
|
||||
},
|
||||
onStart = {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = true, errorMessage = null)
|
||||
}
|
||||
},
|
||||
onSuccess = {
|
||||
val message = "$platformId configured successfully — restart the gateway for changes to take effect"
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
restartNeeded = true,
|
||||
toastMessage = message,
|
||||
)
|
||||
}
|
||||
@@ -62,14 +133,14 @@ class ChannelsViewModel : ViewModel(), ToastHost {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to configure platform: $errorMsg",
|
||||
toastMessage = "Failed to configure platform: $errorMsg",
|
||||
toastMessage = "Failed to configure: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Convenience: configure a platform's env vars via a Map<String,String>. */
|
||||
fun configurePlatform(
|
||||
platformId: String,
|
||||
config: Map<String, String>,
|
||||
@@ -77,6 +148,59 @@ class ChannelsViewModel : ViewModel(), ToastHost {
|
||||
configurePlatform(platformId, MessagingPlatformUpdate(env = config))
|
||||
}
|
||||
|
||||
/** Test connectivity for a platform. */
|
||||
fun testPlatform(platformId: String) {
|
||||
_uiState.update { it.copy(testingId = platformId) }
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.testMessagingPlatform(platformId) } },
|
||||
onStart = {},
|
||||
onSuccess = { result ->
|
||||
val msg = result?.message ?: "Test complete"
|
||||
_uiState.update {
|
||||
it.copy(testingId = null, toastMessage = msg)
|
||||
}
|
||||
},
|
||||
onError = { error ->
|
||||
_uiState.update {
|
||||
it.copy(testingId = null, toastMessage = "Test failed: $error")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Restart the whole gateway. Reloads platforms after a 4s delay. */
|
||||
fun restartGateway() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.restartGateway() } },
|
||||
onStart = {
|
||||
_uiState.update {
|
||||
it.copy(isRestarting = true, errorMessage = null)
|
||||
}
|
||||
},
|
||||
onSuccess = {
|
||||
_uiState.update {
|
||||
it.copy(isRestarting = false, restartNeeded = false)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
delay(4000)
|
||||
loadPlatforms()
|
||||
}
|
||||
},
|
||||
onError = { error ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isRestarting = false,
|
||||
toastMessage = "Restart failed: $error",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun dismissRestartNeeded() {
|
||||
_uiState.update { it.copy(restartNeeded = false) }
|
||||
}
|
||||
|
||||
override fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user