diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SaveSkillContentRequest.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SaveSkillContentRequest.kt new file mode 100644 index 00000000..4738760d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SaveSkillContentRequest.kt @@ -0,0 +1,5 @@ +package com.m57.hermescontrol.data.model + +data class SaveSkillContentRequest( + val content: String, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt index 836a75d9..ed0a461a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt @@ -5,4 +5,5 @@ data class Skill( val description: String?, val category: String?, val enabled: Boolean, + val content: String? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SkillContentResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SkillContentResponse.kt new file mode 100644 index 00000000..31815bf2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SkillContentResponse.kt @@ -0,0 +1,6 @@ +package com.m57.hermescontrol.data.model + +data class SkillContentResponse( + val name: String, + val content: String, +) 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 c7c4c0ee..2913571b 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 @@ -26,10 +26,12 @@ import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.ProfileSoulResponse import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.model.RawConfigResponse +import com.m57.hermescontrol.data.model.SaveSkillContentRequest import com.m57.hermescontrol.data.model.SessionListResponse import com.m57.hermescontrol.data.model.SessionMessagesResponse import com.m57.hermescontrol.data.model.SetActiveProfileRequest import com.m57.hermescontrol.data.model.Skill +import com.m57.hermescontrol.data.model.SkillContentResponse import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.SystemStatsResponse import com.m57.hermescontrol.data.model.ToggleSkillRequest @@ -52,6 +54,17 @@ import retrofit2.http.Path import retrofit2.http.Query interface HermesApiService { + @GET("api/skills/{name}/content") + suspend fun getSkillContent( + @Path("name") name: String, + ): Response + + @PUT("api/skills/{name}/content") + suspend fun saveSkillContent( + @Path("name") name: String, + @Body body: SaveSkillContentRequest, + ): Response + @GET("api/status") suspend fun getStatus(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt index 7155d2cf..dbda34ab 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt @@ -4,19 +4,32 @@ import retrofit2.Response import java.io.IOException sealed interface NetworkResult { - data class Success(val data: T) : NetworkResult + data class Success( + val data: T, + ) : NetworkResult - data class Failure(val error: NetworkError) : NetworkResult + data class Failure( + val error: NetworkError, + ) : NetworkResult } sealed interface NetworkError { val message: String - data class Http(val code: Int, override val message: String) : NetworkError + data class Http( + val code: Int, + override val message: String, + ) : NetworkError - data class Connection(override val message: String, val cause: IOException) : NetworkError + data class Connection( + override val message: String, + val cause: IOException, + ) : NetworkError - data class Unknown(override val message: String, val cause: Throwable) : NetworkError + data class Unknown( + override val message: String, + val cause: Throwable, + ) : NetworkError } fun mapHttpError(code: Int): NetworkError { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt index 6ea52e97..0e709858 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt @@ -40,6 +40,7 @@ class AchievementsViewModel : ViewModel() { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt index 58b6c114..0dd68eed 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt @@ -37,6 +37,7 @@ class ChannelsViewModel : ViewModel() { is NetworkResult.Success -> { _uiState.update { it.copy(isLoading = false, platforms = result.data.orEmpty()) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -63,6 +64,7 @@ class ChannelsViewModel : ViewModel() { _uiState.update { it.copy(toastMessage = "$platformId configured successfully") } loadPlatforms() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to configure platform: ${result.error.message}") } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index bf86135e..9695f9b3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -301,7 +301,9 @@ private fun parseToolOutput(content: String): ParsedToolOutput? { val trimmed = content.trim() if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null return try { - val element = com.google.gson.JsonParser.parseString(trimmed) + val element = + com.google.gson.JsonParser + .parseString(trimmed) if (element.isJsonObject) { val obj = element.asJsonObject @@ -310,10 +312,25 @@ private fun parseToolOutput(content: String): ParsedToolOutput? { val hasExitCode = obj.has("exit_code") || obj.has("exitCode") if (hasStdout || hasStderr || hasExitCode) { - val stdout = obj.get("stdout")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() } - val stderr = obj.get("stderr")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() } + val stdout = + obj + .get("stdout") + ?.takeIf { !it.isJsonNull } + ?.asString + ?.takeIf { it.isNotEmpty() } + val stderr = + obj + .get("stderr") + ?.takeIf { !it.isJsonNull } + ?.asString + ?.takeIf { it.isNotEmpty() } val exitCode = (obj.get("exit_code") ?: obj.get("exitCode"))?.takeIf { !it.isJsonNull }?.asInt - val error = obj.get("error")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() } + val error = + obj + .get("error") + ?.takeIf { !it.isJsonNull } + ?.asString + ?.takeIf { it.isNotEmpty() } ParsedToolOutput( isTerminal = true, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 4a7dc694..63633add 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -450,6 +450,7 @@ class ChatViewModel( """.trimIndent() addAssistantMessage(statusText) } + is NetworkResult.Failure -> { addAssistantMessage("Failed to retrieve status: ${result.error.message}") } @@ -472,6 +473,7 @@ class ChatViewModel( } addAssistantMessage("**Sessions List:**\n$sessionsStr") } + is NetworkResult.Failure -> { addAssistantMessage("Failed to list sessions: ${result.error.message}") } @@ -500,6 +502,7 @@ class ChatViewModel( """.trimIndent() addAssistantMessage(statsText) } + is NetworkResult.Failure -> { addAssistantMessage("Failed to retrieve system stats: ${result.error.message}") } @@ -627,6 +630,7 @@ class ChatViewModel( ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt index 6f9be40a..d18f5744 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt @@ -44,6 +44,7 @@ class ConfigViewModel : ViewModel() { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -73,6 +74,7 @@ class ConfigViewModel : ViewModel() { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index aba86e87..c0d474b0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -89,6 +89,7 @@ class ConnectViewModel : ViewModel() { it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null) } } + is NetworkResult.Failure -> { val msg = when (val err = result.error) { @@ -98,10 +99,17 @@ class ConnectViewModel : ViewModel() { AuthManager.setToken(null) "Invalid token (401 Unauthorized)" } - 403 -> "Access denied (403 Forbidden)" - else -> "Server returned HTTP ${err.code}" + + 403 -> { + "Access denied (403 Forbidden)" + } + + else -> { + "Server returned HTTP ${err.code}" + } } } + is NetworkError.Connection -> { val causeMessage = err.cause.message ?: "" when { @@ -111,6 +119,7 @@ class ConnectViewModel : ViewModel() { else -> "Connection failed: ${err.cause.message}" } } + is NetworkError.Unknown -> { val causeMessage = err.cause.message ?: "" when { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index 96e7622f..7fa43f7b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -36,6 +36,7 @@ class CronJobsViewModel : ViewModel() { is NetworkResult.Success -> { _uiState.update { it.copy(isLoading = false, jobs = result.data.orEmpty()) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -102,6 +103,7 @@ class CronJobsViewModel : ViewModel() { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Job triggered successfully") } } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${result.error.message}") } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt index eb1a269b..f3da7fc1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt @@ -37,6 +37,7 @@ class GatewayViewModel : ViewModel() { is NetworkResult.Success -> { _uiState.update { it.copy(isLoading = false, status = result.data) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -78,6 +79,7 @@ class GatewayViewModel : ViewModel() { } loadStatus() } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index 9f822d6c..2bf9908e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -47,6 +47,7 @@ class KanbanViewModel : ViewModel() { selectBoard(currentBoard) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -92,6 +93,7 @@ class KanbanViewModel : ViewModel() { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -129,6 +131,7 @@ class KanbanViewModel : ViewModel() { _uiState.update { it.copy(toastMessage = "Task created successfully") } selectBoard(board) } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to create task: ${result.error.message}") } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt index b94255b7..839a94f2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt @@ -44,6 +44,7 @@ class KeysViewModel : ViewModel() { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -71,6 +72,7 @@ class KeysViewModel : ViewModel() { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to reveal key: ${result.error.message}") } } @@ -100,6 +102,7 @@ class KeysViewModel : ViewModel() { _uiState.update { it.copy(toastMessage = "Key updated successfully") } loadKeys() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to update key: ${result.error.message}") } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt index 936348f4..f538ce0e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt @@ -37,6 +37,7 @@ class LogsViewModel : ViewModel() { val logsList = body.lines ?: body.logs ?: emptyList() _uiState.update { it.copy(isLoading = false, logs = logsList) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 26a12926..25374a95 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -43,6 +43,7 @@ class McpServersViewModel : ViewModel() { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -83,6 +84,7 @@ class McpServersViewModel : ViewModel() { ) } } + is NetworkResult.Failure -> { revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}") } @@ -106,6 +108,7 @@ class McpServersViewModel : ViewModel() { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -135,6 +138,7 @@ class McpServersViewModel : ViewModel() { } loadServers() } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt index b33a701d..3a2c2801 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -67,6 +67,7 @@ class ModelViewModel : ViewModel() { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -98,6 +99,7 @@ class ModelViewModel : ViewModel() { } return@launch } + is NetworkResult.Success -> { val activeProfileName = activeProfileNameResResult.data.active val updateResResult = @@ -114,6 +116,7 @@ class ModelViewModel : ViewModel() { _uiState.update { it.copy(toastMessage = "Successfully set model to $modelName") } loadModelOptions() } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt index 4169a5f4..12ebf6e8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt @@ -39,6 +39,7 @@ class PairingViewModel : ViewModel() { is NetworkResult.Success -> { _uiState.update { it.copy(isLoading = false, pairing = result.data) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -92,6 +93,7 @@ class PairingViewModel : ViewModel() { } loadPairing() } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index 1969a1ba..85fa039c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -37,6 +37,7 @@ class PluginsViewModel : ViewModel() { val plugins = result.data.plugins.orEmpty() _uiState.update { it.copy(isLoading = false, plugins = plugins) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -98,6 +99,7 @@ class PluginsViewModel : ViewModel() { _uiState.update { it.copy(toastMessage = "Plugin installed successfully") } loadPlugins() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to install plugin: ${result.error.message}") } } @@ -116,6 +118,7 @@ class PluginsViewModel : ViewModel() { _uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") } loadPlugins() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") } } @@ -134,6 +137,7 @@ class PluginsViewModel : ViewModel() { _uiState.update { it.copy(toastMessage = "Plugin updated successfully") } loadPlugins() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt index f4ea35a9..fe38a430 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt @@ -79,6 +79,7 @@ class ProfilesViewModel : ViewModel() { _uiState.update { it.copy(toastMessage = "Switched to profile $name") } loadProfiles() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -107,6 +108,7 @@ class ProfilesViewModel : ViewModel() { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -143,6 +145,7 @@ class ProfilesViewModel : ViewModel() { } loadProfiles() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -174,6 +177,7 @@ class ProfilesViewModel : ViewModel() { _uiState.update { it.copy(toastMessage = "Model settings updated") } loadProfiles() } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 9c07d5a5..65c3a04f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -156,6 +156,7 @@ class SettingsViewModel : ViewModel() { it.copy(isTesting = false, testResult = "✅ Connected successfully") } } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index 599d3902..6f55ff28 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -2,20 +2,29 @@ package com.m57.hermescontrol.ui.skills 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.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.Edit import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField 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 @@ -25,8 +34,12 @@ 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.TextStyle +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.theme.LocalSpacing @@ -161,10 +174,24 @@ fun SkillsScreen( } } } - Switch( - checked = skill.enabled, - onCheckedChange = { viewModel.toggleSkill(skill) }, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + IconButton( + onClick = { viewModel.loadSkillContent(skill.name) }, + ) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = "Edit Skill File", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = skill.enabled, + onCheckedChange = { viewModel.toggleSkill(skill) }, + ) + } } } } @@ -172,4 +199,155 @@ fun SkillsScreen( } } } + + state.editingSkillName?.let { skillName -> + SkillEditorDialog( + skillName = skillName, + isLoading = state.isLoadingContent, + initialContent = state.skillContent, + isSaving = state.isSavingContent, + saveSuccess = state.saveContentSuccess, + onSave = { content -> viewModel.saveSkillContent(skillName, content) }, + onDismiss = { viewModel.clearEditor() }, + onClearSaveSuccess = { viewModel.clearSaveSuccess() }, + ) + } +} + +@Composable +fun SkillEditorDialog( + skillName: String, + isLoading: Boolean, + initialContent: String?, + isSaving: Boolean, + saveSuccess: Boolean, + onSave: (String) -> Unit, + onDismiss: () -> Unit, + onClearSaveSuccess: () -> Unit, +) { + var contentText by remember(initialContent) { mutableStateOf(initialContent.orEmpty()) } + var showDiscardConfirm by remember { mutableStateOf(false) } + + val hasChanges = + remember(initialContent, contentText) { + val original = initialContent.orEmpty() + original != contentText + } + + LaunchedEffect(saveSuccess) { + if (saveSuccess) { + onClearSaveSuccess() + onDismiss() + } + } + + Dialog( + onDismissRequest = { + if (hasChanges) { + showDiscardConfirm = true + } else { + onDismiss() + } + }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + HermesScaffold( + title = { Text("Edit: $skillName") }, + onBack = { + if (hasChanges) { + showDiscardConfirm = true + } else { + onDismiss() + } + }, + showBack = true, + actions = { + if (!isLoading) { + IconButton( + onClick = { onSave(contentText) }, + enabled = !isSaving, + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.fillMaxSize(0.6f), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurface, + ) + } else { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = "Save Changes", + ) + } + } + } + }, + ) { padding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(padding), + ) { + when { + isLoading -> { + LoadingState() + } + + else -> { + OutlinedTextField( + value = contentText, + onValueChange = { contentText = it }, + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + textStyle = + TextStyle( + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + ), + placeholder = { + Text("Enter skill content...") + }, + ) + } + } + } + } + } + } + + if (showDiscardConfirm) { + AlertDialog( + onDismissRequest = { showDiscardConfirm = false }, + title = { Text("Discard changes?") }, + text = { Text("You have unsaved changes. Are you sure you want to discard them?") }, + confirmButton = { + TextButton( + onClick = { + showDiscardConfirm = false + onDismiss() + }, + ) { + Text("Discard") + } + }, + dismissButton = { + TextButton(onClick = { showDiscardConfirm = false }) { + Text("Cancel") + } + }, + ) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt index 199c7945..09049185 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt @@ -20,6 +20,11 @@ data class SkillsUiState( val skills: List = emptyList(), val errorMessage: String? = null, val toastMessage: String? = null, + val isLoadingContent: Boolean = false, + val editingSkillName: String? = null, + val skillContent: String? = null, + val isSavingContent: Boolean = false, + val saveContentSuccess: Boolean = false, ) class SkillsViewModel : ViewModel() { @@ -37,6 +42,7 @@ class SkillsViewModel : ViewModel() { is NetworkResult.Success -> { _uiState.update { it.copy(isLoading = false, skills = result.data.orEmpty()) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -90,6 +96,99 @@ class SkillsViewModel : ViewModel() { } } + fun loadSkillContent(skillName: String) { + _uiState.update { + it.copy( + isLoadingContent = true, + editingSkillName = skillName, + skillContent = null, + saveContentSuccess = false, + ) + } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getSkillContent(skillName) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isLoadingContent = false, + skillContent = result.data?.content.orEmpty(), + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoadingContent = false, + editingSkillName = null, + toastMessage = "Failed to load skill content: ${result.error.message}", + ) + } + } + } + } + } + + fun saveSkillContent( + skillName: String, + content: String, + ) { + _uiState.update { it.copy(isSavingContent = true, saveContentSuccess = false) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.saveSkillContent( + skillName, + com.m57.hermescontrol.data.model + .SaveSkillContentRequest(content), + ) + } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isSavingContent = false, + saveContentSuccess = true, + skillContent = content, + toastMessage = "Skill saved successfully", + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isSavingContent = false, + toastMessage = "Failed to save skill content: ${result.error.message}", + ) + } + } + } + } + } + + fun clearEditor() { + _uiState.update { + it.copy( + editingSkillName = null, + skillContent = null, + isLoadingContent = false, + isSavingContent = false, + saveContentSuccess = false, + ) + } + } + + fun clearSaveSuccess() { + _uiState.update { it.copy(saveContentSuccess = false) } + } + fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index 8b68e5c4..da45b5cf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -51,6 +51,7 @@ class SystemViewModel : ViewModel() { // Doctor is optional; many servers do not expose it. loadDoctorReport() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -87,6 +88,7 @@ class SystemViewModel : ViewModel() { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Backup triggered successfully") } } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to trigger backup: ${result.error.message}") } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt index 4455f5ea..76d24ca7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt @@ -37,6 +37,7 @@ class ToolsetsViewModel : ViewModel() { is NetworkResult.Success -> { _uiState.update { it.copy(isLoading = false, toolsets = result.data.orEmpty()) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( 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 1d51111e..238002d3 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 @@ -48,6 +48,7 @@ class WebhooksViewModel : ViewModel() { ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -78,6 +79,7 @@ class WebhooksViewModel : ViewModel() { } loadWebhooks() } + is NetworkResult.Failure -> { _uiState.update { it.copy(