feat(ui): add EditBotBottomSheet with avatar editor and bot deletion (#983)

This commit is contained in:
M57 authored and GitHub committed 2026-08-27 01:53:15 +03:00
1 parent 274120cc86
commit 6ca248d28b
7 files changed
+411

No files matched your search

@@ -21,6 +21,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.GroupAdd
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Visibility
@@ -73,8 +74,26 @@ fun BotsScreen(
var isSearchActive by remember { mutableStateOf(false) }
var showCreateDialog by remember { mutableStateOf(false) }
var showCreateGroupDialog by remember { mutableStateOf(false) }
var editingBot by remember { mutableStateOf<ProfileInfo?>(null) }
val scope = rememberCoroutineScope()
editingBot?.let { bot ->
EditBotBottomSheet(
bot = bot,
onDismiss = { editingBot = null },
onSave = { title, description, shape, color ->
viewModel.updateBotMeta(bot.name, title, description, shape, color) {
editingBot = null
}
},
onDelete = {
viewModel.deleteBot(bot.name) {
editingBot = null
}
},
)
}
if (showCreateDialog) {
CreateBotDialog(
onDismiss = { showCreateDialog = false },
@@ -264,6 +283,9 @@ fun BotsScreen(
}
}
},
onEditClick = {
editingBot = profile
},
)
}
}
@@ -333,6 +355,7 @@ private fun BotCard(
profile: ProfileInfo,
isActiveProfile: Boolean,
onClick: () -> Unit,
onEditClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val botMeta = profile.botMeta()
@@ -428,6 +451,16 @@ private fun BotCard(
)
}
}
IconButton(
onClick = onEditClick,
modifier = Modifier.testTag("bot_edit_button_${profile.name}"),
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.bots_edit_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -188,6 +188,51 @@ class BotsViewModel(
}
}
fun updateBotMeta(
name: String,
title: String,
description: String,
shape: String,
color: String,
onSuccess: () -> Unit,
) {
viewModelScope.launch(ioDispatcher) {
val bot = _uiState.value.profiles.find { it.name == name }
val existingMeta = bot?.botMeta() ?: BotRosterMeta()
val updatedMeta =
existingMeta.copy(
title = title.ifBlank { null },
description = description.ifBlank { null },
avatar =
BotAvatarMeta(
shape = shape,
color = color,
),
)
wsClientConfigureBot(name, updatedMeta)
loadBots()
onSuccess()
}
}
fun deleteBot(
name: String,
onSuccess: () -> Unit,
) {
viewModelScope.launch(ioDispatcher) {
val result = safeApiCall { ApiClient.hermesApi.deleteProfile(name) }
if (result is NetworkResult.Success) {
loadBots()
onSuccess()
} else {
val err =
(result as? NetworkResult.Failure)?.error?.message
?: "Failed to delete bot"
_uiState.update { it.copy(errorMessage = err) }
}
}
}
fun createGroupChat(
groupName: String,
botNames: List<String>,
@@ -0,0 +1,277 @@
package com.m57.hermescontrol.ui.bots
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.Spacer
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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.theme.parseHexColor
import com.m57.hermescontrol.ui.common.BotAvatar
private val AVAILABLE_SHAPES = listOf("circle", "square", "rounded", "hexagon")
private val PALETTE_COLORS =
listOf(
"#4F46E5", // Indigo
"#2563EB", // Blue
"#0D9488", // Teal
"#16A34A", // Green
"#D97706", // Amber
"#EA580C", // Orange
"#DC2626", // Red
"#DB2777", // Pink
"#9333EA", // Purple
"#4B5563", // Gray
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditBotBottomSheet(
bot: ProfileInfo,
onDismiss: () -> Unit,
onSave: (title: String, description: String, shape: String, color: String) -> Unit,
onDelete: () -> Unit,
) {
val meta = bot.botMeta()
var title by remember { mutableStateOf(meta?.title ?: bot.effectiveTitle) }
var description by remember { mutableStateOf(meta?.description ?: bot.effectiveDescription) }
var selectedShape by remember { mutableStateOf(meta?.avatar?.shape ?: "circle") }
var selectedColor by remember { mutableStateOf(meta?.avatar?.color ?: PALETTE_COLORS[0]) }
var showDeleteConfirm by remember { mutableStateOf(false) }
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = {
Text(
text = stringResource(R.string.bots_edit_delete_confirm_title, bot.effectiveTitle),
fontWeight = FontWeight.Bold,
)
},
text = {
Text(stringResource(R.string.bots_edit_delete_confirm_msg))
},
confirmButton = {
Button(
onClick = {
showDeleteConfirm = false
onDelete()
},
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Text(stringResource(R.string.bots_edit_delete))
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState()),
) {
Text(
text = stringResource(R.string.bots_edit_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
// Live Avatar Preview Header
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
BotAvatar(
name = bot.name,
avatar =
BotAvatarMeta(
shape = selectedShape,
color = selectedColor,
),
size = 64.dp,
showPresence = false,
)
}
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text(stringResource(R.string.bots_create_title_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(R.string.bots_create_desc_label)) },
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.bots_create_avatar_shape),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
AVAILABLE_SHAPES.forEach { shape ->
FilterChip(
selected = selectedShape == shape,
onClick = { selectedShape = shape },
label = { Text(shape.replaceFirstChar { it.uppercase() }) },
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.bots_create_avatar_color),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PALETTE_COLORS.take(5).forEach { hex ->
val color = parseHexColor(hex, Color.Gray)
Box(
modifier =
Modifier
.size(32.dp)
.clip(CircleShape)
.background(color)
.clickable { selectedColor = hex }
.then(
if (selectedColor == hex) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
} else {
Modifier
},
),
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PALETTE_COLORS.drop(5).forEach { hex ->
val color = parseHexColor(hex, Color.Gray)
Box(
modifier =
Modifier
.size(32.dp)
.clip(CircleShape)
.background(color)
.clickable { selectedColor = hex }
.then(
if (selectedColor == hex) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
} else {
Modifier
},
),
)
}
}
Spacer(modifier = Modifier.height(20.dp))
Button(
onClick = {
onSave(title.trim(), description.trim(), selectedShape, selectedColor)
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.bots_edit_save))
}
if (bot.name != "default") {
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { showDeleteConfirm = true },
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.bots_edit_delete))
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}