diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt index 587ecdd..5b90d1a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt @@ -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" diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 3a92415..5ead284 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -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("otherUserId")?.toIntOrNull() ?: 0 - DmContainerScreen( + val dmChatProfileFade = tween(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("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("otherUserId")?.toIntOrNull() ?: 0 + if (otherUserId <= 0) return@composable + DmProfileRoute( + otherUserId = otherUserId, + navController = navController, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt index 8794e76..82e8804 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt @@ -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) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmContainerScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmContainerScreen.kt deleted file mode 100644 index 31a59c3..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmContainerScreen.kt +++ /dev/null @@ -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 - ) - } - } - } -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt new file mode 100644 index 0000000..911db6a --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt @@ -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 + ) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt index a4ce74d..79db48f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt @@ -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)) } } ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index 15e132d..9e62a64 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -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,30 +87,31 @@ 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 } } } -} \ No newline at end of file +}