Implement MCP Servers, Webhooks, and Models screens
This commit is contained in:
1 parent
ce71aa3e68
commit
43dfbc5470
12 files changed
+1173
-1
No files matched your search
@@ -26,11 +26,14 @@ import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
|
||||
import com.m57.hermescontrol.ui.connect.ConnectScreen as ConnectScreenContent
|
||||
import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent
|
||||
import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent
|
||||
import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent
|
||||
import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent
|
||||
import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent
|
||||
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
|
||||
import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent
|
||||
import com.m57.hermescontrol.ui.toolsets.ToolsetsScreen as ToolsetsScreenContent
|
||||
import com.m57.hermescontrol.ui.webhooks.WebhooksScreen as WebhooksScreenContent
|
||||
|
||||
@Composable
|
||||
fun MainNavigation() {
|
||||
@@ -48,7 +51,9 @@ fun MainNavigation() {
|
||||
currentScreen == ChatScreen || currentScreen == SkillsScreen || currentScreen == CronJobsScreen ||
|
||||
currentScreen == GatewayScreen || currentScreen == ProfilesScreen ||
|
||||
currentScreen == ToolsetsScreen || currentScreen == AchievementsScreen ||
|
||||
currentScreen == PairingScreen || currentScreen == ConfigScreen
|
||||
currentScreen == PairingScreen || currentScreen == ConfigScreen ||
|
||||
currentScreen == McpServersScreen || currentScreen == WebhooksScreen ||
|
||||
currentScreen == ModelScreen
|
||||
val openDrawer = { scope.launch { drawerState.open() } }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
@@ -135,6 +140,33 @@ fun MainNavigation() {
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("MCP Servers") },
|
||||
selected = currentScreen == McpServersScreen,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
NavigationController.navigateTo(McpServersScreen)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Webhooks") },
|
||||
selected = currentScreen == WebhooksScreen,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
NavigationController.navigateTo(WebhooksScreen)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Models") },
|
||||
selected = currentScreen == ModelScreen,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
NavigationController.navigateTo(ModelScreen)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Gateway") },
|
||||
selected = currentScreen == GatewayScreen,
|
||||
@@ -222,6 +254,24 @@ fun MainNavigation() {
|
||||
)
|
||||
}
|
||||
|
||||
entry<McpServersScreen> {
|
||||
McpServersScreenContent(
|
||||
onOpenDrawer = { openDrawer() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<WebhooksScreen> {
|
||||
WebhooksScreenContent(
|
||||
onOpenDrawer = { openDrawer() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<ModelScreen> {
|
||||
ModelScreenContent(
|
||||
onOpenDrawer = { openDrawer() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<GatewayScreen> {
|
||||
GatewayScreenContent(
|
||||
onOpenDrawer = { openDrawer() },
|
||||
|
||||
@@ -24,3 +24,9 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable data object PairingScreen : NavKey
|
||||
|
||||
@Serializable data object ConfigScreen : NavKey
|
||||
|
||||
@Serializable data object McpServersScreen : NavKey
|
||||
|
||||
@Serializable data object WebhooksScreen : NavKey
|
||||
|
||||
@Serializable data object ModelScreen : NavKey
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class McpServersResponse(
|
||||
val servers: List<McpServer>,
|
||||
)
|
||||
|
||||
data class McpServer(
|
||||
val name: String,
|
||||
val transport: String?,
|
||||
val url: String?,
|
||||
val command: String?,
|
||||
val args: List<String>?,
|
||||
val env: Map<String, String>?,
|
||||
val enabled: Boolean,
|
||||
)
|
||||
|
||||
data class McpServerToggleRequest(
|
||||
val enabled: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class ModelOptionsResponse(
|
||||
val providers: List<ModelProvider>,
|
||||
)
|
||||
|
||||
data class ModelProvider(
|
||||
val slug: String,
|
||||
val name: String,
|
||||
val is_current: Boolean?,
|
||||
val is_user_defined: Boolean?,
|
||||
val models: List<String>?,
|
||||
val total_models: Int?,
|
||||
val source: String?,
|
||||
val authenticated: Boolean?,
|
||||
val auth_type: String?,
|
||||
val warning: String?,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class WebhooksResponse(
|
||||
val enabled: Boolean,
|
||||
val base_url: String?,
|
||||
val subscriptions: List<WebhookSubscription>?,
|
||||
)
|
||||
|
||||
data class WebhookSubscription(
|
||||
val name: String,
|
||||
val url: String,
|
||||
val events: List<String>?,
|
||||
)
|
||||
|
||||
data class WebhooksToggleRequest(
|
||||
val enabled: Boolean,
|
||||
)
|
||||
@@ -3,6 +3,9 @@ package com.m57.hermescontrol.data.remote
|
||||
import com.m57.hermescontrol.data.model.AchievementsResponse
|
||||
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||
import com.m57.hermescontrol.data.model.PairingApproveRequest
|
||||
import com.m57.hermescontrol.data.model.PairingResponse
|
||||
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
||||
@@ -22,6 +25,8 @@ import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
|
||||
import com.m57.hermescontrol.data.model.WebhooksResponse
|
||||
import com.m57.hermescontrol.data.model.WebhooksToggleRequest
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
@@ -157,4 +162,34 @@ interface HermesApiService {
|
||||
suspend fun updateRawConfig(
|
||||
@Body body: UpdateRawConfigRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/mcp/servers")
|
||||
suspend fun getMcpServers(): Response<McpServersResponse>
|
||||
|
||||
@PUT("api/mcp/servers/{name}/enabled")
|
||||
suspend fun toggleMcpServer(
|
||||
@Path("name") name: String,
|
||||
@Body body: McpServerToggleRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/mcp/servers/{name}/test")
|
||||
suspend fun testMcpServer(
|
||||
@Path("name") name: String,
|
||||
): Response<Unit>
|
||||
|
||||
@DELETE("api/mcp/servers/{name}")
|
||||
suspend fun deleteMcpServer(
|
||||
@Path("name") name: String,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/webhooks")
|
||||
suspend fun getWebhooks(): Response<WebhooksResponse>
|
||||
|
||||
@POST("api/webhooks/enable")
|
||||
suspend fun toggleWebhooks(
|
||||
@Body body: WebhooksToggleRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/model/options")
|
||||
suspend fun getModelOptions(): Response<ModelOptionsResponse>
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.m57.hermescontrol.ui.mcp
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun McpServersScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: McpServersViewModel = viewModel { McpServersViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadServers()
|
||||
}
|
||||
|
||||
LaunchedEffect(state.toastMessage) {
|
||||
state.toastMessage?.let { msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
viewModel.clearToast()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (onOpenDrawer != null) {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
contentDescription = "Open Drawer",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { Text("MCP Servers") },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.loadServers() }) {
|
||||
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
if (state.isLoading && state.servers.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else if (state.errorMessage != null && state.servers.isEmpty()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = { viewModel.loadServers() }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(state.servers) { server ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (server.enabled) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = server.name.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "Transport: ${server.transport ?: "stdio"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = server.enabled,
|
||||
onCheckedChange = { viewModel.toggleServer(server) },
|
||||
)
|
||||
}
|
||||
|
||||
if (server.command != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val fullCmd = "${server.command} ${server.args.orEmpty().joinToString(" ")}"
|
||||
Text(
|
||||
text = "Command:",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = fullCmd,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.testServer(server.name) },
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
) {
|
||||
Text("Test")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.deleteServer(server.name) },
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.m57.hermescontrol.ui.mcp
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class McpServersUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isActionRunning: Boolean = false,
|
||||
val servers: List<McpServer> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
|
||||
class McpServersViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(McpServersUiState())
|
||||
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadServers() {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.getMcpServers()
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
servers = response.body()?.servers.orEmpty(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load MCP servers: HTTP ${response.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load MCP servers: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleServer(server: McpServer) {
|
||||
val originalEnabled = server.enabled
|
||||
val targetEnabled = !originalEnabled
|
||||
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
servers =
|
||||
state.servers.map {
|
||||
if (it.name == server.name) it.copy(enabled = targetEnabled) else it
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.toggleMcpServer(server.name, McpServerToggleRequest(targetEnabled))
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
revertToggle(server.name, originalEnabled, "Failed to toggle server: HTTP ${response.code()}")
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Server '${server.name}' ${if (targetEnabled) "enabled" else "disabled"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun testServer(name: String) {
|
||||
_uiState.update { it.copy(isActionRunning = true) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.testMcpServer(name)
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActionRunning = false,
|
||||
toastMessage = "Server '$name' tested successfully",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActionRunning = false,
|
||||
toastMessage = "Server '$name' test failed: HTTP ${response.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActionRunning = false,
|
||||
toastMessage = "Server '$name' test failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteServer(name: String) {
|
||||
_uiState.update { it.copy(isActionRunning = true) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.deleteMcpServer(name)
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActionRunning = false,
|
||||
toastMessage = "Server '$name' deleted successfully",
|
||||
)
|
||||
}
|
||||
loadServers()
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActionRunning = false,
|
||||
toastMessage = "Failed to delete server: HTTP ${response.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActionRunning = false,
|
||||
toastMessage = "Failed to delete server: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun revertToggle(
|
||||
name: String,
|
||||
originalEnabled: Boolean,
|
||||
errorMsg: String,
|
||||
) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
servers =
|
||||
state.servers.map {
|
||||
if (it.name == name) it.copy(enabled = originalEnabled) else it
|
||||
},
|
||||
toastMessage = errorMsg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.m57.hermescontrol.ui.model
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ModelScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: ModelViewModel = viewModel { ModelViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
var expandedProviderSlug by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadModelOptions()
|
||||
}
|
||||
|
||||
LaunchedEffect(state.toastMessage) {
|
||||
state.toastMessage?.let { msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
viewModel.clearToast()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (onOpenDrawer != null) {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
contentDescription = "Open Drawer",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { Text("Model Providers") },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.loadModelOptions() }) {
|
||||
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
if (state.isLoading && state.providers.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else if (state.errorMessage != null && state.providers.isEmpty()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IconButton(onClick = { viewModel.loadModelOptions() }) {
|
||||
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(state.providers) { provider ->
|
||||
val isExpanded = expandedProviderSlug == provider.slug
|
||||
val isCurrent = provider.is_current == true
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
expandedProviderSlug = if (isExpanded) null else provider.slug
|
||||
},
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isCurrent) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = provider.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "Slug: ${provider.slug}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (isCurrent) {
|
||||
Text(
|
||||
text = "CURRENT",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
provider.warning?.let {
|
||||
if (it.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = isExpanded) {
|
||||
Column(modifier = Modifier.padding(top = 16.dp)) {
|
||||
Text(
|
||||
text = "Available Models:",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
val models = provider.models.orEmpty()
|
||||
if (models.isEmpty()) {
|
||||
Text(
|
||||
text = "No models reported or provider needs authentication.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
models.forEach { model ->
|
||||
Text(
|
||||
text = "• $model",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.m57.hermescontrol.ui.model
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class ModelUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val providers: List<ModelProvider> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
|
||||
class ModelViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ModelUiState())
|
||||
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadModelOptions() {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.getModelOptions()
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
providers = response.body()?.providers.orEmpty(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load model options: HTTP ${response.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load model options: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.m57.hermescontrol.ui.webhooks
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WebhooksScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: WebhooksViewModel = viewModel { WebhooksViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadWebhooks()
|
||||
}
|
||||
|
||||
LaunchedEffect(state.toastMessage) {
|
||||
state.toastMessage?.let { msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
viewModel.clearToast()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (onOpenDrawer != null) {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
contentDescription = "Open Drawer",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { Text("Webhooks Dispatcher") },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.loadWebhooks() }) {
|
||||
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
if (state.isLoading && state.baseUrl == null) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else if (state.errorMessage != null && state.baseUrl == null) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = { viewModel.loadWebhooks() }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Global Status Switch
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (state.enabled) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Global Webhooks Status",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
state.baseUrl?.let {
|
||||
Text(
|
||||
text = "Local Server URL: $it",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = state.enabled,
|
||||
onCheckedChange = { viewModel.toggleWebhooks(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscriptions List Header
|
||||
item {
|
||||
Text(
|
||||
text = "Subscriptions",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.subscriptions.isEmpty()) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No webhook subscriptions configured.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(state.subscriptions) { sub ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = sub.name.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "Target URL:",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = sub.url,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
)
|
||||
|
||||
sub.events?.let { events ->
|
||||
if (events.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Events Subscribed:",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
events.forEach { event ->
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
text = event,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.m57.hermescontrol.ui.webhooks
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.WebhookSubscription
|
||||
import com.m57.hermescontrol.data.model.WebhooksResponse
|
||||
import com.m57.hermescontrol.data.model.WebhooksToggleRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class WebhooksUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isActionRunning: Boolean = false,
|
||||
val enabled: Boolean = false,
|
||||
val baseUrl: String? = null,
|
||||
val subscriptions: List<WebhookSubscription> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
|
||||
class WebhooksViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(WebhooksUiState())
|
||||
val uiState: StateFlow<WebhooksUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadWebhooks() {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.getWebhooks()
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
enabled = body?.enabled ?: false,
|
||||
baseUrl = body?.base_url,
|
||||
subscriptions = body?.subscriptions.orEmpty(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load webhooks: HTTP ${response.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load webhooks: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleWebhooks(targetEnabled: Boolean) {
|
||||
val originalEnabled = _uiState.value.enabled
|
||||
_uiState.update { it.copy(enabled = targetEnabled) }
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.toggleWebhooks(WebhooksToggleRequest(targetEnabled))
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Webhooks global status ${if (targetEnabled) "enabled" else "disabled"}",
|
||||
)
|
||||
}
|
||||
loadWebhooks()
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
enabled = originalEnabled,
|
||||
toastMessage = "Failed to toggle webhooks: HTTP ${response.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
enabled = originalEnabled,
|
||||
toastMessage = "Failed to toggle webhooks: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user