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:
M57andM57 authored and GitHub committed 2026-06-29 08:56:39 +03:00
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) }
}