mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement full predictive back support and replace tabs with HorizontalPager
This commit is contained in:
@@ -47,6 +47,10 @@ import ru.fromchat.app_name
|
||||
import ru.fromchat.back
|
||||
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_MAX = "https://maxgate.io/fromchat_ch"
|
||||
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.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import coil3.ImageLoader
|
||||
import coil3.compose.setSingletonImageLoaderFactory
|
||||
import coil3.svg.SvgDecoder
|
||||
@@ -38,7 +40,9 @@ import ru.fromchat.ui.auth.LoginScreen
|
||||
import ru.fromchat.ui.auth.RegisterScreen
|
||||
import ru.fromchat.ui.chat.PublicChatScreen
|
||||
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.profile.ProfileScreen
|
||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||
@@ -237,7 +241,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
||||
ProfileScreen(
|
||||
userId = userId,
|
||||
onBack = { navController.navigateUp() },
|
||||
onChat = { navController.navigate("dm/$it") },
|
||||
onChat = { navController.navigate(DmNav.chatRoute(it)) },
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this@composable,
|
||||
useSharedElementFromNavigation = useSharedElement,
|
||||
@@ -245,11 +249,61 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
||||
)
|
||||
}
|
||||
|
||||
composable("dm/{otherUserId}") { backStackEntry ->
|
||||
val otherUserId = backStackEntry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
|
||||
DmContainerScreen(
|
||||
val dmChatProfileFade = tween<Float>(durationMillis = 280)
|
||||
|
||||
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,
|
||||
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.transport.TransportCrypto
|
||||
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
|
||||
@@ -170,7 +171,7 @@ fun DebugApiScreenContent() {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Button(
|
||||
onClick = { navController.navigate("dm/2") },
|
||||
onClick = { navController.navigate(DmNav.chatRoute(2)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
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.ui.ConnectingEllipsis
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.dm.DmNav
|
||||
import ru.fromchat.ui.branding.FromChatBrandTitle
|
||||
import ru.fromchat.ui.chat.Avatar
|
||||
|
||||
@@ -287,7 +288,7 @@ fun ChatsTab() {
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
if (conv.otherUserId != 0) {
|
||||
navController.navigate("dm/${conv.otherUserId}")
|
||||
navController.navigate(DmNav.chatRoute(conv.otherUserId))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package ru.fromchat.ui.main
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.automirrored.filled.Chat
|
||||
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.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.*
|
||||
@@ -30,35 +29,56 @@ import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.utils.exclude
|
||||
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")
|
||||
@Composable
|
||||
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(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == "chats",
|
||||
onClick = { selectedTab = "chats" },
|
||||
selected = selectedPage == PAGE_CHATS,
|
||||
onClick = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
|
||||
},
|
||||
label = { Text(stringResource(Res.string.chats)) },
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == "contacts",
|
||||
onClick = { selectedTab = "contacts" },
|
||||
selected = selectedPage == PAGE_CONTACTS,
|
||||
onClick = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
|
||||
},
|
||||
label = { Text(stringResource(Res.string.contacts)) },
|
||||
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == "settings",
|
||||
onClick = { selectedTab = "settings" },
|
||||
selected = selectedPage == PAGE_SETTINGS,
|
||||
onClick = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
||||
},
|
||||
label = { Text(stringResource(Res.string.settings)) },
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = null) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == "profile",
|
||||
onClick = { selectedTab = "profile" },
|
||||
selected = selectedPage == PAGE_PROFILE,
|
||||
onClick = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
|
||||
},
|
||||
label = { Text(stringResource(Res.string.profile)) },
|
||||
icon = { Icon(Icons.Filled.Person, contentDescription = null) }
|
||||
)
|
||||
@@ -67,29 +87,30 @@ fun MainScreen(onLogout: () -> Unit = {}) {
|
||||
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
|
||||
modifier = Modifier.imePadding()
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when (selectedTab) {
|
||||
"chats" -> ChatsTab()
|
||||
"contacts" -> {
|
||||
Text(stringResource(Res.string.coming_soon))
|
||||
}
|
||||
"settings" -> {
|
||||
SettingsTab(onLogout = onLogout)
|
||||
}
|
||||
"profile" -> {
|
||||
.padding(innerPadding),
|
||||
beyondViewportPageCount = 1,
|
||||
) { page ->
|
||||
when (page) {
|
||||
PAGE_CHATS -> ChatsTab()
|
||||
PAGE_CONTACTS -> Text(stringResource(Res.string.coming_soon))
|
||||
PAGE_SETTINGS -> SettingsTab(onLogout = onLogout)
|
||||
PAGE_PROFILE -> {
|
||||
val currentUserId = ApiClient.user?.id
|
||||
ProfileScreen(
|
||||
userId = currentUserId,
|
||||
onBack = {},
|
||||
onChat = { _ -> },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onOpenSettings = { selectedTab = "settings" }
|
||||
onOpenSettings = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
||||
}
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user