feat: richer Webhooks tab — create, toggle, delete subscriptions (#454)

* feat: richer Webhooks tab — create, toggle, delete subscriptions

Adds full webhook subscription management matching the dashboard UI:
- Create subscription dialog (name, description, events, deliver, secret)
- Per-subscription enable/disable toggle via PUT endpoint
- Delete with confirmation dialog
- Enhanced subscription cards: description, event chips, delivery badge,
  secret indicator, URL display
- Search filtering across name, description, and events
- Updated Webhook.kt model with full API shape
- New HermesApiService endpoints for CRUD operations

Closes #447

* fix: remove double top padding on webhooks screen LazyColumn

* fix: use string resources in webhooks screen (sweep hardcoded strings)

* fix: resolve unresolved merge conflict in AchievementsScreen

Leftover <<<<<<< markers from a main merge broke compilation on the
webhooks-full-parity branch, cascading through ktlint/lint/tests.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-03 00:52:49 +03:00
1 parent f887146056
commit d875c8a040
6 files changed
+660 -161

No files matched your search

@@ -8,10 +8,38 @@ data class WebhooksResponse(
data class WebhookSubscription(
val name: String,
val url: String,
val description: String?,
val events: List<String>?,
val deliver: String?,
val deliver_only: Boolean?,
val prompt: String?,
val skills: List<String>?,
val created_at: String?,
val url: String,
val secret_set: Boolean?,
val enabled: Boolean?,
)
data class CreateWebhookRequest(
val name: String,
val description: String? = null,
val events: List<String>? = null,
val prompt: String? = null,
val skills: List<String>? = null,
val deliver: String? = null,
val deliver_only: Boolean? = null,
val deliver_chat_id: String? = null,
val secret: String? = null,
)
data class WebhooksToggleRequest(
val enabled: Boolean,
)
data class WebhookToggleSubscriptionRequest(
val enabled: Boolean,
)
data class DeleteWebhookResponse(
val ok: Boolean,
)
@@ -5,7 +5,9 @@ import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateWebhookRequest
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.DeleteWebhookResponse
import com.m57.hermescontrol.data.model.DoctorResponse
import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.data.model.EnvVarRevealRequest
@@ -45,6 +47,8 @@ import com.m57.hermescontrol.data.model.UpdateCronJobRequest
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.WebhookSubscription
import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest
import com.m57.hermescontrol.data.model.WebhooksResponse
import com.m57.hermescontrol.data.model.WebhooksToggleRequest
import retrofit2.Response
@@ -245,6 +249,22 @@ interface HermesApiService {
@Body body: WebhooksToggleRequest,
): Response<Unit>
@POST("api/webhooks")
suspend fun createWebhook(
@Body body: CreateWebhookRequest,
): Response<WebhookSubscription>
@DELETE("api/webhooks/{name}")
suspend fun deleteWebhook(
@Path("name") name: String,
): Response<DeleteWebhookResponse>
@PUT("api/webhooks/{name}/enabled")
suspend fun setWebhookEnabled(
@Path("name") name: String,
@Body body: WebhookToggleSubscriptionRequest,
): Response<Unit>
@GET("api/model/options")
suspend fun getModelOptions(): Response<ModelOptionsResponse>
@@ -191,9 +191,7 @@ fun AchievementsScreen(
else -> {
LazyColumn(
modifier =
Modifier
.fillMaxSize(),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -1,26 +1,36 @@
package com.m57.hermescontrol.ui.webhooks
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.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.size
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.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.OutlinedTextField
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -32,15 +42,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.WebhookSubscription
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@@ -60,7 +75,8 @@ fun WebhooksScreen(
remember(query, state.subscriptions) {
state.subscriptions.filter { sub ->
sub.name.contains(query, ignoreCase = true) ||
sub.url.contains(query, ignoreCase = true)
sub.description?.contains(query, ignoreCase = true) == true ||
(sub.events?.any { it.contains(query, ignoreCase = true) } == true)
}
}
@@ -70,18 +86,164 @@ fun WebhooksScreen(
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
// ── Delete Confirmation Dialog ──
state.deleteTarget?.let { target ->
AlertDialog(
onDismissRequest = { viewModel.dismissDeleteDialog() },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.webhooks_dialog_delete_title))
}
},
text = {
Text(stringResource(R.string.webhooks_dialog_delete_message, target.name))
},
confirmButton = {
Button(
onClick = { viewModel.confirmDeleteSubscription() },
enabled = !state.isDeleting,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
if (state.isDeleting) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onError,
)
Spacer(modifier = Modifier.width(6.dp))
}
Text(stringResource(R.string.webhooks_action_delete))
}
},
dismissButton = {
TextButton(
onClick = { viewModel.dismissDeleteDialog() },
enabled = !state.isDeleting,
) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
// ── Create Subscription Dialog ──
if (state.showCreateDialog) {
AlertDialog(
onDismissRequest = { viewModel.dismissCreateDialog() },
title = { Text(stringResource(R.string.webhooks_dialog_create_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = state.createFormName,
onValueChange = viewModel::updateCreateFormName,
label = { Text(stringResource(R.string.webhooks_field_name)) },
placeholder = { Text(stringResource(R.string.webhooks_hint_name)) },
singleLine = true,
isError = state.createFormError != null,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.createFormDescription,
onValueChange = viewModel::updateCreateFormDescription,
label = { Text(stringResource(R.string.webhooks_field_description)) },
placeholder = { Text("What this webhook does...") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.createFormEvents,
onValueChange = viewModel::updateCreateFormEvents,
label = { Text(stringResource(R.string.webhooks_field_events)) },
placeholder = { Text(stringResource(R.string.webhooks_hint_events)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.createFormDeliver,
onValueChange = viewModel::updateCreateFormDeliver,
label = { Text(stringResource(R.string.webhooks_field_deliver)) },
placeholder = { Text(stringResource(R.string.webhooks_hint_deliver)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.createFormSecret,
onValueChange = viewModel::updateCreateFormSecret,
label = { Text(stringResource(R.string.webhooks_field_secret)) },
placeholder = { Text(stringResource(R.string.webhooks_hint_secret)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
state.createFormError?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
Button(
onClick = { viewModel.createSubscription() },
enabled = !state.isActionRunning,
) {
if (state.isActionRunning) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(6.dp))
}
Text(stringResource(R.string.webhooks_action_create))
}
},
dismissButton = {
TextButton(
onClick = { viewModel.dismissCreateDialog() },
enabled = !state.isActionRunning,
) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
HermesScaffold(
title = { Text(stringResource(R.string.screen_webhooks)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadWebhooks() },
actions = {
IconButton(onClick = { viewModel.showCreateDialog() }) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(R.string.webhooks_action_add),
)
}
},
) { paddingValues ->
when {
state.isLoading && state.baseUrl == null -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
state.errorMessage != null && state.baseUrl == null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadWebhooks() },
@@ -90,168 +252,111 @@ fun WebhooksScreen(
}
else -> {
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.baseUrl == null) {
CircularProgressIndicator()
} else if (state.errorMessage != null && state.baseUrl == null) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
// 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
},
),
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadWebhooks() }) {
Text(stringResource(R.string.action_retry))
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
// 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 = stringResource(R.string.webhooks_sec_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) },
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.webhooks_sec_status),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
state.baseUrl?.let {
Text(
text = "Receiver URL: $it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
// Subscriptions List Header
item {
Text(
text = stringResource(R.string.webhooks_sec_subscriptions),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
Spacer(modifier = Modifier.width(12.dp))
Switch(
checked = state.enabled,
onCheckedChange = { viewModel.toggleWebhooks(it) },
)
}
}
}
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search subscriptions...",
)
}
// Subscriptions header
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.webhooks_sec_subscriptions),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = "(${state.subscriptions.size})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (filteredSubscriptions.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.webhooks_empty_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else {
items(filteredSubscriptions, key = { it.name }) { 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 = stringResource(R.string.webhooks_label_target),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Text(
text = sub.url,
style =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
),
)
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.webhooks_search_placeholder),
)
}
sub.events?.let { events ->
if (events.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.webhooks_label_events),
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,
)
},
)
}
}
}
}
}
}
}
}
if (filteredSubscriptions.isEmpty()) {
item {
EmptyState(
title =
if (state.subscriptions.isEmpty()) {
"No subscriptions"
} else {
stringResource(R.string.webhooks_no_matching)
},
subtitle =
if (state.subscriptions.isEmpty()) {
stringResource(R.string.webhooks_empty_desc)
} else {
stringResource(R.string.webhooks_no_matching_desc)
},
)
}
} else {
items(filteredSubscriptions, key = { it.name }) { sub ->
SubscriptionCard(
sub = sub,
isToggling = state.togglingName == sub.name,
onToggle = { enabled -> viewModel.toggleSubscription(sub.name, enabled) },
onDelete = { viewModel.promptDeleteSubscription(sub) },
)
}
}
}
@@ -259,3 +364,150 @@ fun WebhooksScreen(
}
}
}
@Composable
private fun SubscriptionCard(
sub: WebhookSubscription,
isToggling: Boolean,
onToggle: (Boolean) -> Unit,
onDelete: () -> Unit,
) {
val isEnabled = sub.enabled ?: true
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (isEnabled) {
MaterialTheme.colorScheme.surface
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)
},
),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Header row: Name + Toggle + Delete
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
Text(
text = sub.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color =
if (isEnabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (sub.secret_set == true) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = "Secret configured",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
// Delivery target badge
sub.deliver?.let { deliver ->
if (deliver.isNotBlank() && deliver != "log") {
Spacer(modifier = Modifier.width(6.dp))
StatusBadge(
text = deliver,
status = StatusBadgeType.INFO,
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (isToggling) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
} else {
Switch(
checked = isEnabled,
onCheckedChange = onToggle,
)
}
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = onDelete,
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Delete subscription",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
)
}
}
}
// Description
sub.description?.let { desc ->
if (desc.isNotBlank()) {
Text(
text = desc,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Event chips
sub.events?.let { events ->
if (events.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
events.forEach { event ->
SuggestionChip(
onClick = {},
label = {
Text(
text = event,
style = MaterialTheme.typography.labelSmall,
)
},
)
}
}
}
}
// URL
Text(
text = sub.url,
style =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -2,7 +2,9 @@ package com.m57.hermescontrol.ui.webhooks
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.CreateWebhookRequest
import com.m57.hermescontrol.data.model.WebhookSubscription
import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest
import com.m57.hermescontrol.data.model.WebhooksToggleRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -25,6 +27,19 @@ data class WebhooksUiState(
val subscriptions: List<WebhookSubscription> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
// Create form state
val showCreateDialog: Boolean = false,
val createFormName: String = "",
val createFormDescription: String = "",
val createFormEvents: String = "",
val createFormDeliver: String = "log",
val createFormSecret: String = "",
val createFormError: String? = null,
// Delete confirmation state
val deleteTarget: WebhookSubscription? = null,
val isDeleting: Boolean = false,
// Toggle tracking
val togglingName: String? = null,
)
class WebhooksViewModel : ViewModel(), ToastHost {
@@ -88,6 +103,173 @@ class WebhooksViewModel : ViewModel(), ToastHost {
}
}
// ── Create dialog ────────────────────────────────────────────────
fun showCreateDialog() {
_uiState.update {
it.copy(
showCreateDialog = true,
createFormName = "",
createFormDescription = "",
createFormEvents = "",
createFormDeliver = "log",
createFormSecret = "",
createFormError = null,
)
}
}
fun dismissCreateDialog() {
_uiState.update { it.copy(showCreateDialog = false, createFormError = null) }
}
fun updateCreateFormName(value: String) {
_uiState.update { it.copy(createFormName = value, createFormError = null) }
}
fun updateCreateFormDescription(value: String) {
_uiState.update { it.copy(createFormDescription = value) }
}
fun updateCreateFormEvents(value: String) {
_uiState.update { it.copy(createFormEvents = value) }
}
fun updateCreateFormDeliver(value: String) {
_uiState.update { it.copy(createFormDeliver = value) }
}
fun updateCreateFormSecret(value: String) {
_uiState.update { it.copy(createFormSecret = value) }
}
fun createSubscription() {
val state = _uiState.value
val name = state.createFormName.trim()
if (name.isBlank()) {
_uiState.update { it.copy(createFormError = "Name is required") }
return
}
val events =
state.createFormEvents
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
_uiState.update { it.copy(isActionRunning = true, createFormError = null) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.createWebhook(
CreateWebhookRequest(
name = name,
description = state.createFormDescription.trim().ifEmpty { null },
events = events.ifEmpty { null },
deliver = state.createFormDeliver.ifBlank { null },
secret = state.createFormSecret.trim().ifEmpty { null },
),
)
}
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isActionRunning = false,
showCreateDialog = false,
toastMessage = "Subscription \"$name\" created",
)
}
loadWebhooks()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isActionRunning = false,
createFormError = result.error.message,
)
}
}
}
}
}
// ── Per-subscription toggle ──────────────────────────────────────
fun toggleSubscription(
name: String,
enabled: Boolean,
) {
_uiState.update { it.copy(togglingName = name) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.setWebhookEnabled(
name = name,
body = WebhookToggleSubscriptionRequest(enabled = enabled),
)
}
}
_uiState.update { it.copy(togglingName = null) }
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
toastMessage = "Subscription \"$name\" ${if (enabled) "enabled" else "disabled"}",
)
}
loadWebhooks()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to toggle \"$name\": ${result.error.message}")
}
}
}
}
}
// ── Delete confirmation ─────────────────────────────────────────
fun promptDeleteSubscription(sub: WebhookSubscription) {
_uiState.update { it.copy(deleteTarget = sub) }
}
fun dismissDeleteDialog() {
_uiState.update { it.copy(deleteTarget = null) }
}
fun confirmDeleteSubscription() {
val target = _uiState.value.deleteTarget ?: return
_uiState.update { it.copy(isDeleting = true) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.deleteWebhook(target.name) }
}
_uiState.update { it.copy(isDeleting = false, deleteTarget = null) }
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(toastMessage = "Subscription \"${target.name}\" deleted")
}
loadWebhooks()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to delete \"${target.name}\": ${result.error.message}")
}
}
}
}
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}