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
+86 -9
@@ -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,8 +324,40 @@ fun BotsScreen(
) )
} }
when (state.selectedTab) { HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { page ->
when (BotsTab.entries.getOrElse(page) { BotsTab.BOTS }) {
BotsTab.BOTS -> { BotsTab.BOTS -> {
Box(
modifier =
Modifier
.fillMaxSize()
.pointerInput(pagerState.currentPage, onOpenDrawer) {
if (pagerState.currentPage == 0) {
awaitEachGesture {
awaitFirstDown(pass = PointerEventPass.Initial)
var totalDragX = 0f
while (true) {
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
val change = event.changes.firstOrNull() ?: break
if (!change.pressed) break
val dx = change.position.x - change.previousPosition.x
if (dx > 0) {
totalDragX += dx
if (totalDragX > 75f) {
onOpenDrawer?.invoke()
break
}
} else if (dx < -10f) {
break
}
}
}
}
},
) {
when { when {
state.isLoading && state.profiles.isEmpty() -> { state.isLoading && state.profiles.isEmpty() -> {
SkeletonListState() SkeletonListState()
@@ -320,7 +381,13 @@ fun BotsScreen(
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 =
PaddingValues(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 24.dp,
),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
if (state.activeNowBots.isNotEmpty() && !isSearchActive) { if (state.activeNowBots.isNotEmpty() && !isSearchActive) {
@@ -362,7 +429,9 @@ fun BotsScreen(
if (!canonicalId.isNullOrBlank()) { if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId) NavigationController.openChatSession(canonicalId)
} else { } else {
NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen) NavigationController.navigateTo(
com.m57.hermescontrol.ChatScreen,
)
} }
} }
}, },
@@ -375,6 +444,7 @@ fun BotsScreen(
} }
} }
} }
}
BotsTab.GROUPS -> { BotsTab.GROUPS -> {
when { when {
@@ -389,7 +459,13 @@ fun BotsScreen(
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 =
PaddingValues(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 24.dp,
),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
items( items(
@@ -418,6 +494,7 @@ fun BotsScreen(
} }
} }
} }
}
@Composable @Composable
private fun ActiveNowStrip( private fun ActiveNowStrip(