diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Webhook.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Webhook.kt index fb282ec4..9d2eb781 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Webhook.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Webhook.kt @@ -8,10 +8,38 @@ data class WebhooksResponse( data class WebhookSubscription( val name: String, - val url: String, + val description: String?, val events: List?, + val deliver: String?, + val deliver_only: Boolean?, + val prompt: String?, + val skills: List?, + 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? = null, + val prompt: String? = null, + val skills: List? = 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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 85954db4..2c737025 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + @POST("api/webhooks") + suspend fun createWebhook( + @Body body: CreateWebhookRequest, + ): Response + + @DELETE("api/webhooks/{name}") + suspend fun deleteWebhook( + @Path("name") name: String, + ): Response + + @PUT("api/webhooks/{name}/enabled") + suspend fun setWebhookEnabled( + @Path("name") name: String, + @Body body: WebhookToggleSubscriptionRequest, + ): Response + @GET("api/model/options") suspend fun getModelOptions(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt index e39083bf..7d4f3cff 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt @@ -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), ) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt index cf6541ca..7f2ecb4e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt index e7fde637..d76fbea8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt @@ -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 = 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) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d20a604..2b5d5897 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -300,8 +300,27 @@ Global Webhooks Status Subscriptions No webhook subscriptions configured. + Search subscriptions… Target URL: Events Subscribed: + Add subscription + Create + Delete + Create Subscription + Delete Subscription + Are you sure you want to delete \"%1$s\"? This action cannot be undone. All events sent to this URL will be rejected. + Name * + Description + Events + Deliver To + Secret + e.g. github-push + comma separated (e.g. push, pull_request) + log, telegram, discord, origin, all… + auto-generated if empty + Name is required + No matching subscriptions + Try adjusting your search. Device Pairing