feat(ui): add BotsScreen and BotsViewModel with active presence strip (#977)

This commit is contained in:
M57 authored and GitHub committed 2026-08-27 01:49:31 +03:00
1 parent 6af349cdf2
commit 48c9039e02
8 files changed
+699

No files matched your search

@@ -21,6 +21,8 @@ import kotlinx.serialization.Serializable
@Serializable data object ProfilesScreen : NavKey
@Serializable data object BotsScreen : NavKey
@Serializable data object ToolsetsScreen : NavKey
@Serializable data class ToolsetDetailKey(
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material.icons.filled.ViewKanban
import androidx.compose.material.icons.filled.Webhook
import androidx.compose.runtime.Composable
@@ -34,6 +35,7 @@ import com.m57.hermescontrol.ui.common.NeurologyIcon
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
import com.m57.hermescontrol.ui.analytics.AnalyticsScreen as AnalyticsScreenContent
import com.m57.hermescontrol.ui.billing.BillingScreen as BillingScreenContent
import com.m57.hermescontrol.ui.bots.BotsScreen as BotsScreenContent
import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
@@ -96,6 +98,12 @@ object ScreenRegistry {
Icons.Filled.AccountCircle,
DrawerSection.CONVERSE,
) { sessionId, openDrawer -> ProfilesScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
BotsScreen,
R.string.screen_bots,
Icons.Filled.SmartToy,
DrawerSection.CONVERSE,
) { sessionId, openDrawer -> BotsScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
CronJobsScreen,
R.string.screen_cron,
@@ -0,0 +1,388 @@
package com.m57.hermescontrol.ui.bots
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.testTag
import androidx.compose.ui.res.stringResource
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.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.ui.common.BotAvatar
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.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BotsScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: BotsViewModel = viewModel { BotsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var isSearchActive by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
ToastEffect(
toastMessage = state.toastMessage,
onClearToast = { viewModel.clearToast() },
)
HermesScaffold(
modifier = modifier,
title = {
if (isSearchActive) {
OutlinedTextField(
value = state.searchQuery,
onValueChange = { viewModel.setSearchQuery(it) },
placeholder = { Text(stringResource(R.string.bots_search_placeholder)) },
singleLine = true,
trailingIcon = {
IconButton(
onClick = {
if (state.searchQuery.isNotEmpty()) {
viewModel.setSearchQuery("")
} else {
isSearchActive = false
}
},
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.action_cancel),
)
}
},
modifier =
Modifier
.fillMaxWidth()
.testTag("bots_search_field"),
)
} else {
Text(
text = stringResource(R.string.screen_bots),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
}
},
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
actions = {
if (!isSearchActive) {
IconButton(
onClick = { isSearchActive = true },
modifier = Modifier.testTag("bots_action_search"),
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.bots_search_placeholder),
)
}
}
if (state.hasHiddenBots) {
IconButton(
onClick = { viewModel.toggleShowHidden() },
modifier = Modifier.testTag("bots_action_toggle_hidden"),
) {
Icon(
imageVector =
if (state.showHidden) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription =
if (state.showHidden) {
stringResource(R.string.bots_hide_hidden)
} else {
stringResource(R.string.bots_show_hidden)
},
)
}
}
},
isRefreshing = state.isRefreshing,
onRefresh = { viewModel.loadBots(isRefresh = true) },
) {
when {
state.isLoading && state.profiles.isEmpty() -> {
SkeletonListState()
}
state.errorMessage != null && state.profiles.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadBots() },
)
}
state.displayProfiles.isEmpty() -> {
EmptyState(
title = stringResource(R.string.bots_empty_title),
subtitle = stringResource(R.string.bots_empty_description),
modifier = Modifier.fillMaxSize(),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (state.activeNowBots.isNotEmpty() && !isSearchActive) {
item(key = "active_now_strip") {
ActiveNowStrip(
activeBots = state.activeNowBots,
onSelectBot = { bot ->
scope.launch {
viewModel.selectBot(bot)
val canonicalId =
bot.canonical_session?.resolved_id
?: bot.canonical_session?.id
if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId)
} else {
NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen)
}
}
},
)
}
}
items(
items = state.displayProfiles,
key = { it.name },
) { profile ->
BotCard(
profile = profile,
isActiveProfile = profile.name == state.activeProfileName,
onClick = {
scope.launch {
viewModel.selectBot(profile)
val canonicalId =
profile.canonical_session?.resolved_id
?: profile.canonical_session?.id
if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId)
} else {
NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen)
}
}
},
)
}
}
}
}
}
}
@Composable
private fun ActiveNowStrip(
activeBots: List<ProfileInfo>,
onSelectBot: (ProfileInfo) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.bots_active_now),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 6.dp),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
activeBots.forEach { bot ->
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier =
Modifier
.clip(RoundedCornerShape(20.dp))
.clickable { onSelectBot(bot) }
.testTag("bot_active_chip_${bot.name}"),
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BotAvatar(
name = bot.name,
avatar = bot.botMeta()?.avatar,
size = 24.dp,
isActive = true,
showPresence = true,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = bot.effectiveTitle,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
)
}
}
}
}
Spacer(modifier = Modifier.height(4.dp))
}
}
@Composable
private fun BotCard(
profile: ProfileInfo,
isActiveProfile: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val botMeta = profile.botMeta()
val isHidden = profile.isHidden
val hasWorker = profile.worker_session != null
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (isActiveProfile) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.surface
},
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
modifier =
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.testTag("bot_card_${profile.name}"),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BotAvatar(
name = profile.name,
avatar = botMeta?.avatar,
size = 44.dp,
isActive = isActiveProfile || hasWorker,
showPresence = true,
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = profile.effectiveTitle,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (isHidden) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
text = stringResource(R.string.bots_hidden_badge),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
if (profile.effectiveTitle != profile.name) {
Text(
text = "@${profile.name}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (profile.effectiveDescription.isNotBlank()) {
Text(
text = profile.effectiveDescription,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
val preview = profile.canonical_session?.preview ?: profile.last_session?.preview
if (!preview.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = preview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@@ -0,0 +1,138 @@
package com.m57.hermescontrol.ui.bots
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class BotsUiState(
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val profiles: List<ProfileInfo> = emptyList(),
val activeProfileName: String? = null,
val searchQuery: String = "",
val showHidden: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
) {
val hasHiddenBots: Boolean
get() = profiles.any { it.isHidden }
val activeNowBots: List<ProfileInfo>
get() {
val nowSeconds = System.currentTimeMillis() / 1000
return profiles.filter { profile ->
profile.worker_session != null ||
profile.name == activeProfileName ||
((profile.canonical_session?.last_active ?: 0L) > nowSeconds - 90) ||
((profile.last_session?.last_active ?: 0L) > nowSeconds - 90)
}
}
val displayProfiles: List<ProfileInfo>
get() {
val query = searchQuery.trim().lowercase()
return profiles
.filter { profile ->
if (!showHidden && profile.isHidden) return@filter false
if (query.isBlank()) return@filter true
profile.name.lowercase().contains(query) ||
profile.effectiveTitle.lowercase().contains(query) ||
profile.effectiveDescription.lowercase().contains(query)
}
.sortedWith(
compareByDescending<ProfileInfo> { it.name == activeProfileName }
.thenByDescending {
it.canonical_session?.last_active
?: it.last_session?.last_active
?: 0L
}
.thenBy { it.name },
)
}
}
class BotsViewModel(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
autoLoad: Boolean = true,
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(BotsUiState())
val uiState: StateFlow<BotsUiState> = _uiState.asStateFlow()
init {
if (autoLoad) {
loadBots()
}
}
fun loadBots(isRefresh: Boolean = false) {
_uiState.update {
if (isRefresh) {
it.copy(isRefreshing = true, errorMessage = null)
} else {
it.copy(isLoading = true, errorMessage = null)
}
}
viewModelScope.launch(ioDispatcher) {
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() }
val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() }
if (profilesResult is NetworkResult.Success) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeName ?: it.activeProfileName,
errorMessage = null,
)
}
} else {
val err =
(profilesResult as? NetworkResult.Failure)?.error?.message
?: "Failed to load bots"
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = err,
)
}
}
}
}
fun setSearchQuery(query: String) {
_uiState.update { it.copy(searchQuery = query) }
}
fun toggleShowHidden() {
_uiState.update { it.copy(showHidden = !it.showHidden) }
}
suspend fun selectBot(bot: ProfileInfo): Boolean {
val result = ProfileSwitchCoordinator.switchProfile(bot.name)
return result is NetworkResult.Success
}
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}