feat(#58): implement skill markdown/yaml editor UI (#121)

This commit is contained in:
M57 authored and GitHub committed 2026-06-19 23:58:34 +03:00
1 parent 022e9de195
commit 46b24593f9
27 files changed
+399 -15

No files matched your search

@@ -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")
}
},
)
}
}
@@ -20,6 +20,11 @@ data class SkillsUiState(
val skills: List<Skill> = 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) }
}