* 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(
|
data class MessagingPlatform(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val description: String? = null,
|
||||||
|
@SerializedName("docs_url") val docsUrl: String? = null,
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
val configured: 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,
|
@SerializedName("env_vars") val envVars: List<EnvVarField>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,9 +20,19 @@ data class EnvVarField(
|
|||||||
val key: String,
|
val key: String,
|
||||||
val required: Boolean,
|
val required: Boolean,
|
||||||
@SerializedName("is_set") val isSet: Boolean,
|
@SerializedName("is_set") val isSet: Boolean,
|
||||||
val description: String?,
|
@SerializedName("redacted_value") val redactedValue: String? = null,
|
||||||
val prompt: String?,
|
val description: String? = null,
|
||||||
|
val prompt: String? = null,
|
||||||
|
val help: String? = null,
|
||||||
@SerializedName("is_password") val isPassword: Boolean,
|
@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(
|
data class MessagingPlatformUpdate(
|
||||||
@@ -25,3 +41,9 @@ data class MessagingPlatformUpdate(
|
|||||||
@SerializedName("clear_env") val clearEnv: List<String> = emptyList(),
|
@SerializedName("clear_env") val clearEnv: List<String> = emptyList(),
|
||||||
val profile: String? = null,
|
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.McpServerToggleRequest
|
||||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
|
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.MessagingPlatformUpdate
|
||||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||||
import com.m57.hermescontrol.data.model.PairingApproveRequest
|
import com.m57.hermescontrol.data.model.PairingApproveRequest
|
||||||
@@ -260,6 +261,11 @@ interface HermesApiService {
|
|||||||
@Body config: MessagingPlatformUpdate,
|
@Body config: MessagingPlatformUpdate,
|
||||||
): Response<Unit>
|
): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/messaging/platforms/{platform_id}/test")
|
||||||
|
suspend fun testMessagingPlatform(
|
||||||
|
@Path("platform_id") platformId: String,
|
||||||
|
): Response<MessagingPlatformTestResult>
|
||||||
|
|
||||||
@GET("api/env")
|
@GET("api/env")
|
||||||
suspend fun getEnvVars(): Response<Map<String, EnvVarConfig>>
|
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.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.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.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.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -26,11 +37,15 @@ 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.input.PasswordVisualTransformation
|
||||||
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.R
|
import com.m57.hermescontrol.R
|
||||||
|
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.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
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.listContentPadding
|
||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
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
|
@Composable
|
||||||
fun ChannelsScreen(
|
fun ChannelsScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -49,7 +100,6 @@ fun ChannelsScreen(
|
|||||||
viewModel: ChannelsViewModel = viewModel { ChannelsViewModel() },
|
viewModel: ChannelsViewModel = viewModel { ChannelsViewModel() },
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val filteredPlatforms =
|
val filteredPlatforms =
|
||||||
@@ -79,7 +129,7 @@ fun ChannelsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.errorMessage != null -> {
|
state.errorMessage != null && state.platforms.isEmpty() -> {
|
||||||
ErrorState(
|
ErrorState(
|
||||||
message = state.errorMessage ?: "",
|
message = state.errorMessage ?: "",
|
||||||
onRetry = { viewModel.loadPlatforms() },
|
onRetry = { viewModel.loadPlatforms() },
|
||||||
@@ -96,157 +146,331 @@ fun ChannelsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
val gatewayRunning = state.platforms.firstOrNull()?.gatewayRunning ?: false
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = listContentPadding,
|
contentPadding = listContentPadding,
|
||||||
verticalArrangement = listItemSpacing,
|
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(
|
SearchBar(
|
||||||
query = query,
|
query = query,
|
||||||
onQueryChange = { query = it },
|
onQueryChange = { query = it },
|
||||||
placeholder = "Search channels...",
|
placeholder = "Search channels…",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Channel cards ──
|
||||||
items(filteredPlatforms, key = { it.id }) { platform ->
|
items(filteredPlatforms, key = { it.id }) { platform ->
|
||||||
var showConfigureForm by remember { mutableStateOf(false) }
|
PlatformCard(
|
||||||
|
platform = platform,
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
isToggling = state.togglingId == platform.id,
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
isTesting = state.testingId == platform.id,
|
||||||
Row(
|
onToggle = { enabled -> viewModel.togglePlatform(platform.id, enabled) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onTest = { viewModel.testPlatform(platform.id) },
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
onSave = { env -> viewModel.configurePlatform(platform.id, env) },
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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
|
package com.m57.hermescontrol.ui.channels
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
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.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class ChannelsUiState(
|
data class ChannelsUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val platforms: List<MessagingPlatform> = emptyList(),
|
val platforms: List<MessagingPlatform> = emptyList(),
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val toastMessage: 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 {
|
class ChannelsViewModel : ViewModel(), ToastHost {
|
||||||
@@ -26,9 +33,15 @@ class ChannelsViewModel : ViewModel(), ToastHost {
|
|||||||
fun loadPlatforms() {
|
fun loadPlatforms() {
|
||||||
safeLaunchLoad(
|
safeLaunchLoad(
|
||||||
apiCall = { safeApiCall { ApiClient.hermesApi.getMessagingPlatforms() } },
|
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 ->
|
onSuccess = { data ->
|
||||||
_uiState.update { it.copy(isLoading = false, platforms = data?.platforms.orEmpty()) }
|
_uiState.update {
|
||||||
|
it.copy(isLoading = false, platforms = data?.platforms.orEmpty())
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError = { errorMsg ->
|
onError = { errorMsg ->
|
||||||
_uiState.update {
|
_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(
|
fun configurePlatform(
|
||||||
platformId: String,
|
platformId: String,
|
||||||
update: MessagingPlatformUpdate,
|
update: MessagingPlatformUpdate,
|
||||||
) {
|
) {
|
||||||
safeLaunchLoad(
|
safeLaunchLoad(
|
||||||
apiCall = { safeApiCall { ApiClient.hermesApi.configurePlatform(platformId, update) } },
|
apiCall = {
|
||||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
safeApiCall {
|
||||||
onSuccess = { data ->
|
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"
|
val message = "$platformId configured successfully — restart the gateway for changes to take effect"
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
restartNeeded = true,
|
||||||
toastMessage = message,
|
toastMessage = message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -62,14 +133,14 @@ class ChannelsViewModel : ViewModel(), ToastHost {
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Failed to configure platform: $errorMsg",
|
toastMessage = "Failed to configure: $errorMsg",
|
||||||
toastMessage = "Failed to configure platform: $errorMsg",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convenience: configure a platform's env vars via a Map<String,String>. */
|
||||||
fun configurePlatform(
|
fun configurePlatform(
|
||||||
platformId: String,
|
platformId: String,
|
||||||
config: Map<String, String>,
|
config: Map<String, String>,
|
||||||
@@ -77,6 +148,59 @@ class ChannelsViewModel : ViewModel(), ToastHost {
|
|||||||
configurePlatform(platformId, MessagingPlatformUpdate(env = config))
|
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() {
|
override fun clearToast() {
|
||||||
_uiState.update { it.copy(toastMessage = null) }
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -375,6 +375,10 @@
|
|||||||
<string name="channels_sec_keys">Configured Keys:</string>
|
<string name="channels_sec_keys">Configured Keys:</string>
|
||||||
<string name="channels_status_configured">Configured</string>
|
<string name="channels_status_configured">Configured</string>
|
||||||
<string name="channels_status_not_set">Not set</string>
|
<string name="channels_status_not_set">Not set</string>
|
||||||
|
<string name="channels_action_test">Test</string>
|
||||||
|
<string name="channels_action_restart_now">Restart now</string>
|
||||||
|
<string name="channels_restart_banner">Changes saved. Restart the gateway for them to take effect.</string>
|
||||||
|
<string name="channels_gateway_offline">The gateway is not running. Configure channels here, then restart it.</string>
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<string name="settings_action_delete_profile">Delete</string>
|
<string name="settings_action_delete_profile">Delete</string>
|
||||||
|
|||||||
@@ -904,12 +904,19 @@ class E2eIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testChannelsConfiguration_success() =
|
fun testChannelsConfiguration_success() =
|
||||||
runTest {
|
runTest {
|
||||||
val platform = MessagingPlatform("telegram", "Telegram", false, false, emptyList())
|
val platform =
|
||||||
|
MessagingPlatform(
|
||||||
|
id = "telegram",
|
||||||
|
name = "Telegram",
|
||||||
|
enabled = false,
|
||||||
|
configured = false,
|
||||||
|
envVars = emptyList(),
|
||||||
|
)
|
||||||
coEvery { mockApiService.getMessagingPlatforms() } returns
|
coEvery { mockApiService.getMessagingPlatforms() } returns
|
||||||
Response.success(
|
Response.success(
|
||||||
MessagingPlatformResponse(
|
MessagingPlatformResponse(
|
||||||
env_path = "/tmp/.env",
|
envPath = "/tmp/.env",
|
||||||
gateway_start_command = "hermes gateway start",
|
gatewayStartCommand = "hermes gateway start",
|
||||||
platforms = listOf(platform),
|
platforms = listOf(platform),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user