feat(bots): horizontal swipe between bots and groups tabs with drawer gesture gating (#1011)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-29 23:48:08 +03:00
1 parent 2f3bf5114d
commit bd4a73dd48
1 file changed
+185 -108
@@ -1,6 +1,8 @@
package com.m57.hermescontrol.ui.bots package com.m57.hermescontrol.ui.bots
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -16,6 +18,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -55,6 +59,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -82,6 +88,7 @@ fun BotsScreen(
viewModel: BotsViewModel = viewModel { BotsViewModel() }, viewModel: BotsViewModel = viewModel { BotsViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val pagerState = rememberPagerState(initialPage = state.selectedTab.ordinal) { BotsTab.entries.size }
var isSearchActive by remember { mutableStateOf(false) } var isSearchActive by remember { mutableStateOf(false) }
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
var showCreateGroupDialog by remember { mutableStateOf(false) } var showCreateGroupDialog by remember { mutableStateOf(false) }
@@ -89,6 +96,19 @@ fun BotsScreen(
var disbandingGroup by remember { mutableStateOf<GroupInfo?>(null) } var disbandingGroup by remember { mutableStateOf<GroupInfo?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(pagerState.currentPage) {
val targetTab = BotsTab.entries.getOrNull(pagerState.currentPage) ?: BotsTab.BOTS
if (state.selectedTab != targetTab) {
viewModel.setSelectedTab(targetTab)
}
}
LaunchedEffect(state.selectedTab) {
if (pagerState.currentPage != state.selectedTab.ordinal) {
pagerState.animateScrollToPage(state.selectedTab.ordinal)
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadBots() viewModel.loadBots()
} }
@@ -176,6 +196,7 @@ fun BotsScreen(
HermesScaffold( HermesScaffold(
modifier = modifier, modifier = modifier,
drawerGesturesEnabled = pagerState.currentPage == 0,
title = { title = {
if (isSearchActive) { if (isSearchActive) {
OutlinedTextField( OutlinedTextField(
@@ -270,18 +291,26 @@ fun BotsScreen(
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow( PrimaryTabRow(
selectedTabIndex = state.selectedTab.ordinal, selectedTabIndex = pagerState.currentPage,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Tab( Tab(
selected = state.selectedTab == BotsTab.BOTS, selected = pagerState.currentPage == BotsTab.BOTS.ordinal,
onClick = { viewModel.setSelectedTab(BotsTab.BOTS) }, onClick = {
scope.launch {
pagerState.animateScrollToPage(BotsTab.BOTS.ordinal)
}
},
text = { Text(stringResource(R.string.bots_tab_all)) }, text = { Text(stringResource(R.string.bots_tab_all)) },
icon = { Icon(Icons.Filled.SmartToy, contentDescription = null) }, icon = { Icon(Icons.Filled.SmartToy, contentDescription = null) },
) )
Tab( Tab(
selected = state.selectedTab == BotsTab.GROUPS, selected = pagerState.currentPage == BotsTab.GROUPS.ordinal,
onClick = { viewModel.setSelectedTab(BotsTab.GROUPS) }, onClick = {
scope.launch {
pagerState.animateScrollToPage(BotsTab.GROUPS.ordinal)
}
},
text = { text = {
Text( Text(
if (state.allGroups.isNotEmpty()) { if (state.allGroups.isNotEmpty()) {
@@ -295,120 +324,168 @@ fun BotsScreen(
) )
} }
when (state.selectedTab) { HorizontalPager(
BotsTab.BOTS -> { state = pagerState,
when { modifier = Modifier.fillMaxSize(),
state.isLoading && state.profiles.isEmpty() -> { ) { page ->
SkeletonListState() when (BotsTab.entries.getOrElse(page) { BotsTab.BOTS }) {
} BotsTab.BOTS -> {
Box(
state.errorMessage != null && state.profiles.isEmpty() -> { modifier =
ErrorState( Modifier
message = state.errorMessage ?: "", .fillMaxSize()
onRetry = { viewModel.loadBots() }, .pointerInput(pagerState.currentPage, onOpenDrawer) {
) if (pagerState.currentPage == 0) {
} awaitEachGesture {
awaitFirstDown(pass = PointerEventPass.Initial)
state.displayProfiles.isEmpty() -> { var totalDragX = 0f
EmptyState( while (true) {
title = stringResource(R.string.bots_empty_title), val event = awaitPointerEvent(pass = PointerEventPass.Initial)
subtitle = stringResource(R.string.bots_empty_description), val change = event.changes.firstOrNull() ?: break
modifier = Modifier.fillMaxSize(), if (!change.pressed) break
) val dx = change.position.x - change.previousPosition.x
} if (dx > 0) {
totalDragX += dx
else -> { if (totalDragX > 75f) {
LazyColumn( onOpenDrawer?.invoke()
modifier = Modifier.fillMaxSize(), break
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), }
verticalArrangement = Arrangement.spacedBy(10.dp), } else if (dx < -10f) {
) { break
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,
)
} }
} }
}, }
) }
} },
) {
when {
state.isLoading && state.profiles.isEmpty() -> {
SkeletonListState()
} }
items( state.errorMessage != null && state.profiles.isEmpty() -> {
items = state.displayProfiles, ErrorState(
key = { it.name }, message = state.errorMessage ?: "",
) { profile -> onRetry = { viewModel.loadBots() },
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)
}
}
},
onEditClick = {
editingBot = profile
},
) )
} }
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,
)
}
}
},
onEditClick = {
editingBot = profile
},
)
}
}
}
} }
} }
} }
}
BotsTab.GROUPS -> { BotsTab.GROUPS -> {
when { when {
state.displayGroups.isEmpty() -> { state.displayGroups.isEmpty() -> {
EmptyState( EmptyState(
title = stringResource(R.string.bots_groups_empty_title), title = stringResource(R.string.bots_groups_empty_title),
subtitle = stringResource(R.string.bots_groups_empty_desc), subtitle = stringResource(R.string.bots_groups_empty_desc),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
else -> { else -> {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), contentPadding =
verticalArrangement = Arrangement.spacedBy(10.dp), PaddingValues(
) { start = 16.dp,
items( end = 16.dp,
items = state.displayGroups, top = 8.dp,
key = { it.name }, bottom = 24.dp,
) { group -> ),
GroupCard( verticalArrangement = Arrangement.spacedBy(10.dp),
group = group, ) {
onClick = { items(
if (group.members.isNotEmpty()) { items = state.displayGroups,
NavigationController.navigateTo( key = { it.name },
com.m57.hermescontrol.GroupChatKey(group.name), ) { group ->
) GroupCard(
} group = group,
}, onClick = {
onDisbandClick = { if (group.members.isNotEmpty()) {
disbandingGroup = group NavigationController.navigateTo(
}, com.m57.hermescontrol.GroupChatKey(group.name),
) )
}
},
onDisbandClick = {
disbandingGroup = group
},
)
}
} }
} }
} }