Implement full predictive back support and replace tabs with HorizontalPager

This commit is contained in:
2026-04-04 01:13:03 +03:00
Unverified
parent dfda05465b
commit 0b51c9d633
7 changed files with 203 additions and 133 deletions
@@ -47,6 +47,10 @@ import ru.fromchat.app_name
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.ui.branding.FromChatBrandTitle import ru.fromchat.ui.branding.FromChatBrandTitle
/**
* About is a root [androidx.navigation.NavHost] destination; system and predictive back (see
* manifest `enableOnBackInvokedCallback`) pop via the same stack as [LocalNavController.navigateUp].
*/
private const val URL_TELEGRAM = "https://t.me/fromchat_ch" private const val URL_TELEGRAM = "https://t.me/fromchat_ch"
private const val URL_MAX = "https://maxgate.io/fromchat_ch" private const val URL_MAX = "https://maxgate.io/fromchat_ch"
private const val URL_WEBSITE = "https://fromchat.ru" private const val URL_WEBSITE = "https://fromchat.ru"
@@ -4,6 +4,8 @@ import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import coil3.ImageLoader import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory import coil3.compose.setSingletonImageLoaderFactory
import coil3.svg.SvgDecoder import coil3.svg.SvgDecoder
@@ -38,7 +40,9 @@ import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen import ru.fromchat.ui.auth.RegisterScreen
import ru.fromchat.ui.chat.PublicChatScreen import ru.fromchat.ui.chat.PublicChatScreen
import ru.fromchat.ui.debug.DebugApiScreen import ru.fromchat.ui.debug.DebugApiScreen
import ru.fromchat.ui.dm.DmContainerScreen import ru.fromchat.ui.dm.DmChatRoute
import ru.fromchat.ui.dm.DmNav
import ru.fromchat.ui.dm.DmProfileRoute
import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.setup.ServerConfigScreen import ru.fromchat.ui.setup.ServerConfigScreen
@@ -237,7 +241,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
ProfileScreen( ProfileScreen(
userId = userId, userId = userId,
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
onChat = { navController.navigate("dm/$it") }, onChat = { navController.navigate(DmNav.chatRoute(it)) },
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable, animatedVisibilityScope = this@composable,
useSharedElementFromNavigation = useSharedElement, useSharedElementFromNavigation = useSharedElement,
@@ -245,11 +249,61 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
) )
} }
composable("dm/{otherUserId}") { backStackEntry -> val dmChatProfileFade = tween<Float>(durationMillis = 280)
val otherUserId = backStackEntry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
DmContainerScreen( composable(
route = DmNav.CHAT_ROUTE,
arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }),
enterTransition = {
when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
else -> slideIntoContainer(Start, animationSpec = animationSpec)
}
},
exitTransition = {
when (targetState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
else -> slideOutOfContainer(Start, animationSpec = animationSpec)
}
},
popEnterTransition = {
when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
else -> slideIntoContainer(End, animationSpec = animationSpec)
}
},
popExitTransition = {
when (targetState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
else -> slideOutOfContainer(End, animationSpec = animationSpec)
}
},
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
if (otherUserId <= 0) return@composable
DmChatRoute(
otherUserId = otherUserId, otherUserId = otherUserId,
onBack = { navController.navigateUp() } navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
}
composable(
route = DmNav.PROFILE_ROUTE,
arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = dmChatProfileFade) },
exitTransition = { fadeOut(animationSpec = dmChatProfileFade) },
popEnterTransition = { fadeIn(animationSpec = dmChatProfileFade) },
popExitTransition = { fadeOut(animationSpec = dmChatProfileFade) },
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
if (otherUserId <= 0) return@composable
DmProfileRoute(
otherUserId = otherUserId,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
) )
} }
@@ -37,6 +37,7 @@ import ru.fromchat.crypto.IdentityKeyManager
import ru.fromchat.crypto.decryptEnvelope import ru.fromchat.crypto.decryptEnvelope
import ru.fromchat.crypto.transport.TransportCrypto import ru.fromchat.crypto.transport.TransportCrypto
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.dm.DmNav
/** /**
* English-only copy: the Debug API screen is not for end users; strings are not in compose * English-only copy: the Debug API screen is not for end users; strings are not in compose
@@ -170,7 +171,7 @@ fun DebugApiScreenContent() {
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Button( Button(
onClick = { navController.navigate("dm/2") }, onClick = { navController.navigate(DmNav.chatRoute(2)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(text = DebugScreenText.OpenAction) Text(text = DebugScreenText.OpenAction)
@@ -1,96 +0,0 @@
package ru.fromchat.ui.dm
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import ru.fromchat.ui.BackHandler
import ru.fromchat.ui.HapticFeedbackEvent
import ru.fromchat.ui.rememberHapticFeedback
import ru.fromchat.ui.profile.ProfileScreen
private const val TRANSITION_MS = 320
@Composable
fun DmContainerScreen(
otherUserId: Int,
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
var showProfile by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val haptic = rememberHapticFeedback()
val panel = remember(otherUserId) {
DmPanelCache.getOrCreate(otherUserId, scope)
}
var panelState by remember(panel) { mutableStateOf(panel.getState()) }
LaunchedEffect(panel) {
panel.setOnStateChange { panelState = it }
panelState = panel.getState()
}
val initialDisplayName = panelState.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
?: panelState.title.takeIf { it.isNotBlank() }
BackHandler(enabled = showProfile) {
haptic(HapticFeedbackEvent.ProfileClosed)
showProfile = false
}
// One stable key per DM to match the same avatar in chat + profile.
val sharedAvatarKey = remember(otherUserId) { "dm-avatar-$otherUserId" }
SharedTransitionLayout(modifier = modifier.fillMaxSize()) {
AnimatedContent(
targetState = showProfile,
transitionSpec = {
fadeIn(animationSpec = tween(TRANSITION_MS)) togetherWith
fadeOut(animationSpec = tween(TRANSITION_MS))
},
label = "dm_profile"
) { targetState ->
if (!targetState) {
DmScreen(
panel = panel,
modifier = Modifier.fillMaxSize(),
onTitleClick = {
haptic(HapticFeedbackEvent.ProfileOpened)
showProfile = true
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
sharedAvatarKey = sharedAvatarKey
)
} else {
ProfileScreen(
userId = otherUserId,
onBack = {
haptic(HapticFeedbackEvent.ProfileClosed)
showProfile = false
},
onChat = { _ ->
haptic(HapticFeedbackEvent.ProfileClosed)
showProfile = false
},
modifier = Modifier.fillMaxSize(),
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
sharedAvatarKey = sharedAvatarKey,
initialDisplayName = initialDisplayName
)
}
}
}
}
@@ -0,0 +1,85 @@
package ru.fromchat.ui.dm
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import ru.fromchat.ui.HapticFeedbackEvent
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.rememberHapticFeedback
/** Route patterns and builders for DM chat + in-DM profile (stacked for predictive / system back). */
object DmNav {
const val CHAT_ROUTE = "dm/{otherUserId}/chat"
const val PROFILE_ROUTE = "dm/{otherUserId}/profile"
fun chatRoute(otherUserId: Int) = "dm/$otherUserId/chat"
fun profileRoute(otherUserId: Int) = "dm/$otherUserId/profile"
}
private const val DM_AVATAR_KEY_PREFIX = "dm-avatar-"
@Composable
fun DmChatRoute(
otherUserId: Int,
navController: NavController,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val panel = remember(otherUserId, scope) { DmPanelCache.getOrCreate(otherUserId, scope) }
val haptic = rememberHapticFeedback()
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
DmScreen(
panel = panel,
modifier = modifier.fillMaxSize(),
onTitleClick = {
haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(DmNav.profileRoute(otherUserId))
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = sharedAvatarKey
)
}
@Composable
fun DmProfileRoute(
otherUserId: Int,
navController: NavController,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val panel = remember(otherUserId, scope) { DmPanelCache.getOrCreate(otherUserId, scope) }
val haptic = rememberHapticFeedback()
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
val stateSnapshot = panel.getState()
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
?: stateSnapshot.title.takeIf { it.isNotBlank() }
ProfileScreen(
userId = otherUserId,
onBack = {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
},
onChat = {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
},
modifier = modifier.fillMaxSize(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = sharedAvatarKey,
initialDisplayName = initialDisplayName
)
}
@@ -51,6 +51,7 @@ import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.net.NetworkConnectivity import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.ConnectingEllipsis import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.dm.DmNav
import ru.fromchat.ui.branding.FromChatBrandTitle import ru.fromchat.ui.branding.FromChatBrandTitle
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
@@ -287,7 +288,7 @@ fun ChatsTab() {
}, },
modifier = Modifier.clickable { modifier = Modifier.clickable {
if (conv.otherUserId != 0) { if (conv.otherUserId != 0) {
navController.navigate("dm/${conv.otherUserId}") navController.navigate(DmNav.chatRoute(conv.otherUserId))
} }
} }
) )
@@ -1,12 +1,13 @@
package ru.fromchat.ui.main package ru.fromchat.ui.main
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Contacts import androidx.compose.material.icons.filled.Contacts
@@ -18,11 +19,9 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.* import ru.fromchat.*
@@ -30,35 +29,56 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.utils.exclude import ru.fromchat.utils.exclude
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
private const val PAGE_CHATS = 0
private const val PAGE_CONTACTS = 1
private const val PAGE_SETTINGS = 2
private const val PAGE_PROFILE = 3
private const val PAGE_COUNT = 4
@Suppress("AssignedValueIsNeverRead") @Suppress("AssignedValueIsNeverRead")
@Composable @Composable
fun MainScreen(onLogout: () -> Unit = {}) { fun MainScreen(onLogout: () -> Unit = {}) {
var selectedTab by rememberSaveable { mutableStateOf("chats") } val pagerState = rememberPagerState(
initialPage = PAGE_CHATS,
pageCount = { PAGE_COUNT },
)
val scope = rememberCoroutineScope()
// Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop).
val selectedPage = pagerState.currentPage
Scaffold( Scaffold(
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
NavigationBarItem( NavigationBarItem(
selected = selectedTab == "chats", selected = selectedPage == PAGE_CHATS,
onClick = { selectedTab = "chats" }, onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
},
label = { Text(stringResource(Res.string.chats)) }, label = { Text(stringResource(Res.string.chats)) },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) } icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
) )
NavigationBarItem( NavigationBarItem(
selected = selectedTab == "contacts", selected = selectedPage == PAGE_CONTACTS,
onClick = { selectedTab = "contacts" }, onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
},
label = { Text(stringResource(Res.string.contacts)) }, label = { Text(stringResource(Res.string.contacts)) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) } icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
) )
NavigationBarItem( NavigationBarItem(
selected = selectedTab == "settings", selected = selectedPage == PAGE_SETTINGS,
onClick = { selectedTab = "settings" }, onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
},
label = { Text(stringResource(Res.string.settings)) }, label = { Text(stringResource(Res.string.settings)) },
icon = { Icon(Icons.Filled.Settings, contentDescription = null) } icon = { Icon(Icons.Filled.Settings, contentDescription = null) }
) )
NavigationBarItem( NavigationBarItem(
selected = selectedTab == "profile", selected = selectedPage == PAGE_PROFILE,
onClick = { selectedTab = "profile" }, onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
},
label = { Text(stringResource(Res.string.profile)) }, label = { Text(stringResource(Res.string.profile)) },
icon = { Icon(Icons.Filled.Person, contentDescription = null) } icon = { Icon(Icons.Filled.Person, contentDescription = null) }
) )
@@ -67,30 +87,31 @@ fun MainScreen(onLogout: () -> Unit = {}) {
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top), contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
modifier = Modifier.imePadding() modifier = Modifier.imePadding()
) { innerPadding -> ) { innerPadding ->
Column( HorizontalPager(
Modifier state = pagerState,
modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding),
) { beyondViewportPageCount = 1,
when (selectedTab) { ) { page ->
"chats" -> ChatsTab() when (page) {
"contacts" -> { PAGE_CHATS -> ChatsTab()
Text(stringResource(Res.string.coming_soon)) PAGE_CONTACTS -> Text(stringResource(Res.string.coming_soon))
} PAGE_SETTINGS -> SettingsTab(onLogout = onLogout)
"settings" -> { PAGE_PROFILE -> {
SettingsTab(onLogout = onLogout)
}
"profile" -> {
val currentUserId = ApiClient.user?.id val currentUserId = ApiClient.user?.id
ProfileScreen( ProfileScreen(
userId = currentUserId, userId = currentUserId,
onBack = {}, onBack = {},
onChat = { _ -> }, onChat = { _ -> },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onOpenSettings = { selectedTab = "settings" } onOpenSettings = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
}
) )
} }
else -> Unit
} }
} }
} }
} }