feat(#369): rewrite channels tab with full backend integration (#417)

* 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:
M57
2026-06-29 08:56:39 +03:00
committed by GitHub
co-authored by M57
parent 2ae6cce929
commit c64b025a54
7 changed files with 538 additions and 158 deletions
@@ -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) }
} }
+4
View File
@@ -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),
), ),
) )