From f3c50557de1e1ef731c54ef2033a5583e7788e73 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 26 Jul 2026 19:27:55 +0300 Subject: [PATCH] Improve chat UI --- .../main/kotlin/ru/fromchat/desktop/Main.kt | 3 + .../commonMain/kotlin/ru/fromchat/ui/App.kt | 339 ++++++++++-------- .../kotlin/ru/fromchat/ui/chat/ChatInput.kt | 6 +- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 11 +- .../kotlin/ru/fromchat/ui/chat/ChatTopBar.kt | 18 +- .../ru/fromchat/ui/chat/MessageContextMenu.kt | 240 ++++++------- .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 4 +- .../ru/fromchat/ui/chat/panels/dm/DmNav.kt | 4 +- .../chat/panels/publicchat/PublicChatNav.kt | 4 +- .../ru/fromchat/ui/components/AppPanel.kt | 15 +- .../ui/main/ConversationListDetailShell.kt | 202 +++-------- .../fromchat/ui/main/DesktopTabDetailHosts.kt | 62 ++++ .../ru/fromchat/ui/main/MainDetailNav.kt | 29 +- .../kotlin/ru/fromchat/ui/main/MainScreen.kt | 144 ++------ .../fromchat/ui/main/chats/ChatContextMenu.kt | 15 +- .../fromchat/ui/main/chats/ChatListShared.kt | 79 ++-- .../fromchat/ui/main/settings/SettingsTab.kt | 23 +- .../ru/fromchat/ui/profile/ProfileScreen.kt | 46 ++- .../pr0gramm3r101/components/Components.kt | 9 +- 19 files changed, 636 insertions(+), 617 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt index 1de229e..8e8daca 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -213,6 +213,9 @@ private object DesktopProtocolRegistration { } fun main(args: Array) { + // Separate AWT windows for Popup/Dialog so menus can draw past the app window edge + // (closer to Android Popup behavior). Must be set before any Compose UI runs. + System.setProperty("compose.layers.type", "WINDOW") if (isMacOs()) { val appName = runBlocking { getString(Res.string.app_name) } System.setProperty("apple.awt.application.name", appName) 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 954131a..d8d4588 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -13,7 +13,6 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState @@ -103,10 +102,11 @@ import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat import ru.fromchat.ui.main.ConversationListDetailShell -import ru.fromchat.ui.main.ConversationProfileThirdPaneMinWidth +import ru.fromchat.ui.main.DesktopTabDetailHosts import ru.fromchat.ui.main.EmptyContactsPlaceholder import ru.fromchat.ui.main.EmptyConversationPlaceholder import ru.fromchat.ui.main.EmptySettingsPlaceholder +import ru.fromchat.ui.main.LocalDesktopMainTab import ru.fromchat.ui.main.MAIN_PAGE_CHATS import ru.fromchat.ui.main.MAIN_PAGE_CONTACTS import ru.fromchat.ui.main.MAIN_PAGE_SETTINGS @@ -132,8 +132,10 @@ import ru.fromchat.ui.profile.EditProfileFocusField import ru.fromchat.ui.profile.EditProfileScreen import ru.fromchat.ui.profile.ProfileRoutes import ru.fromchat.ui.profile.ProfileScreen +import ru.fromchat.ui.components.AppPanel import ru.fromchat.ui.components.LocalPaneHazeState import ru.fromchat.ui.components.ScreenSurface +import androidx.compose.foundation.shape.RoundedCornerShape import dev.chrisbanes.haze.rememberHazeState import ru.fromchat.utils.NetworkConnectivity @@ -514,49 +516,49 @@ fun App( ScreenSurface { Box(Modifier.fillMaxSize()) { - BoxWithConstraints(Modifier.fillMaxSize()) { - val canFitProfileThirdPane = - maxWidth >= ConversationProfileThirdPaneMinWidth - val showProfileThirdPane = - showConversationListDetail && - canFitProfileThirdPane && - ( - isConversationProfileRoute(currentRoute) || - ( - isStandaloneProfileRoute(currentRoute) && - isConversationChatRoute(previousRoute) - ) - ) - val settingsProfileSplit = - showConversationListDetail && - isStandaloneProfileRoute(currentRoute) && - !showProfileThirdPane && - profileUserId != null && - profileUserId == ownUserId - val listPaneWidth = - if (widthSizeClass == WindowWidthSizeClass.EXPANDED) { - 448.dp - } else { - 360.dp - } - val forceSettingsListTab = - settingsProfileSplit || - ( - showConversationListDetail && - isSettingsDetailRoute(currentRoute) - ) + // Own profile from the Profile tab uses the settings detail host. + // Profile opened from a chat stays in the chats detail (push/slide). + val settingsProfileSplit = + showConversationListDetail && + isStandaloneProfileRoute(currentRoute) && + profileUserId != null && + profileUserId == ownUserId && + !isConversationChatRoute(previousRoute) + val listPaneWidth = + if (widthSizeClass == WindowWidthSizeClass.EXPANDED) { + 448.dp + } else { + 360.dp + } + // Only nudge the list pager when opening own profile from the + // Profile tab — do not yank the user back when they switch to Chats + // while a settings destination is still on the back stack. + val forceSettingsListTab = settingsProfileSplit - @Composable - fun AppNavHost(modifier: Modifier = Modifier) { - NavHost( - navController = navController, - startDestination = startDestination!!, - modifier = modifier, - enterTransition = { rootNavEnterTransition() }, - exitTransition = { rootNavExitTransition() }, - popEnterTransition = { rootNavPopEnterTransition() }, - popExitTransition = { rootNavPopExitTransition() }, - ) { + var retainedChatsRoute by remember { mutableStateOf(null) } + LaunchedEffect(currentRoute, pendingMainTab) { + when { + isConversationChatRoute(currentRoute) -> { + retainedChatsRoute = currentRoute + } + currentRoute == "chat" && + pendingMainTab == MAIN_PAGE_CHATS -> { + retainedChatsRoute = null + } + } + } + + @Composable + fun AppNavHost(modifier: Modifier = Modifier) { + NavHost( + navController = navController, + startDestination = startDestination!!, + modifier = modifier, + enterTransition = { rootNavEnterTransition() }, + exitTransition = { rootNavExitTransition() }, + popEnterTransition = { rootNavPopEnterTransition() }, + popExitTransition = { rootNavPopExitTransition() }, + ) { composable("serverConfig") { ServerConfigScreen() } @@ -872,113 +874,166 @@ fun App( ApiClient.onAuthError = null } } - } + } - if (showConversationListDetail) { - val detailPaneState = when { - showProfileThirdPane -> "split" - currentRoute == "chat" -> "empty-$pendingMainTab" - else -> "nav" - } - val detailEdgeToEdge = - showProfileThirdPane || isConversationChatRoute(currentRoute) - // Empty settings placeholder stays transparent (no AppPanel fill). - val detailInPanel = - settingsProfileSplit || isSettingsDetailRoute(currentRoute) - val detailPaneAnim = tween( - durationMillis = 280, - easing = FastOutSlowInEasing, - ) - val listPaneHazeState = rememberHazeState() - CompositionLocalProvider( - LocalPaneHazeState provides listPaneHazeState, - ) { - ConversationListDetailShell( - showProfilePane = showProfileThirdPane, - listPaneWidth = listPaneWidth, - detailInPanel = detailInPanel, - detailEdgeToEdge = detailEdgeToEdge, - listPane = { - MainScreen( - sharedTransitionScope = this@SharedTransitionLayout, - snackbarHostState = profileLookupSnackbarHostState, - embeddedInListDetail = true, - initialPage = pendingMainTab, - forceSettingsTab = forceSettingsListTab, - onPageChanged = { pendingMainTab = it }, - ) - }, + if (showConversationListDetail) { + val detailPaneAnim = tween( + durationMillis = 280, + easing = FastOutSlowInEasing, + ) + val chatsDetailKey = when { + isConversationChatRoute(currentRoute) || + isConversationProfileRoute(currentRoute) || + ( + isStandaloneProfileRoute(currentRoute) && + isConversationChatRoute(previousRoute) + ) -> "nav" + retainedChatsRoute != null -> "retained:$retainedChatsRoute" + else -> "empty" + } + val settingsDetailKey = when { + settingsProfileSplit || isSettingsDetailRoute(currentRoute) -> "nav" + else -> "empty" + } + // Chats / contacts stay edge-to-edge; settings host wraps its + // own AppPanel so shell structure stays stable across tabs. + val detailEdgeToEdge = + pendingMainTab == MAIN_PAGE_CHATS || + pendingMainTab == MAIN_PAGE_CONTACTS + val listPaneHazeState = rememberHazeState() + CompositionLocalProvider( + LocalPaneHazeState provides listPaneHazeState, + LocalDesktopMainTab provides pendingMainTab, + ) { + ConversationListDetailShell( + listPaneWidth = listPaneWidth, + detailInPanel = false, + detailEdgeToEdge = detailEdgeToEdge, + listPane = { + MainScreen( + sharedTransitionScope = this@SharedTransitionLayout, + snackbarHostState = profileLookupSnackbarHostState, + embeddedInListDetail = true, + initialPage = pendingMainTab, + forceSettingsTab = forceSettingsListTab, + onPageChanged = { pendingMainTab = it }, + ) + }, detailPane = { - AnimatedContent( - targetState = detailPaneState, - transitionSpec = { - ( - fadeIn(animationSpec = detailPaneAnim) + - scaleIn( - initialScale = 0.98f, - animationSpec = detailPaneAnim, - ) - ) togetherWith fadeOut( - animationSpec = tween( - durationMillis = 180, - easing = FastOutSlowInEasing, - ), - ) - }, - label = "detailPane", - modifier = Modifier.fillMaxSize(), - ) { state -> - when { - state == "split" -> { - val chatRouteForDetail = - when { - isConversationProfileRoute(currentRoute) -> - currentRoute - isConversationChatRoute(previousRoute) -> - previousRoute - else -> null - } - val dmUserId = dmUserIdFromRoute(chatRouteForDetail) - ?: dmUserIdFromRoute(currentRoute) - when { - dmUserId != null -> DmChatRoute( - otherUserId = dmUserId, - scrollToMessageId = null, - navController = navController, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - ) - chatRouteForDetail == PublicChatNav.PROFILE_ROUTE || - chatRouteForDetail == PublicChatNav.CHAT_ROUTE || - currentRoute == PublicChatNav.PROFILE_ROUTE -> { - PublicChatChatRoute( - scrollToMessageId = scrollToMessageId, - navController = navController, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, + DesktopTabDetailHosts( + selectedTab = pendingMainTab, + chatsDetail = { + // SizeTransform uses Lookahead under SharedTransitionLayout; + // nesting that with always-composed tab hosts crashes with + // "Parents state is LookaheadMeasuring". Disable size morph. + AnimatedContent( + targetState = chatsDetailKey, + transitionSpec = { + val navHostSwap = + ( + initialState == "nav" && + targetState.startsWith("retained:") + ) || + ( + initialState.startsWith("retained:") && + targetState == "nav" + ) + val transform = if (navHostSwap) { + EnterTransition.None togetherWith + ExitTransition.None + } else { + ( + fadeIn(animationSpec = detailPaneAnim) + + scaleIn( + initialScale = 0.98f, + animationSpec = detailPaneAnim, + ) + ) togetherWith fadeOut( + animationSpec = tween( + durationMillis = 180, + easing = FastOutSlowInEasing, + ), ) } - } - } - state.startsWith("empty-") -> { - when (pendingMainTab) { - MAIN_PAGE_CONTACTS -> EmptyContactsPlaceholder() - MAIN_PAGE_SETTINGS -> EmptySettingsPlaceholder() + transform.using(null) + }, + label = "chatsDetailPane", + modifier = Modifier.fillMaxSize(), + ) { state -> + when { + state == "nav" -> { + AppNavHost(Modifier.fillMaxSize()) + } + state.startsWith("retained:") -> { + val route = + state.removePrefix("retained:") + val dmUserId = dmUserIdFromRoute(route) + when { + dmUserId != null -> DmChatRoute( + otherUserId = dmUserId, + scrollToMessageId = null, + navController = navController, + sharedTransitionScope = null, + animatedVisibilityScope = null, + ) + route == PublicChatNav.CHAT_ROUTE -> { + PublicChatChatRoute( + scrollToMessageId = + scrollToMessageId, + navController = navController, + sharedTransitionScope = null, + animatedVisibilityScope = null, + ) + } + } + } else -> EmptyConversationPlaceholder() } } - else -> AppNavHost(Modifier.fillMaxSize()) - } - } + }, + settingsDetail = { + AnimatedContent( + targetState = settingsDetailKey, + transitionSpec = { + ( + ( + fadeIn(animationSpec = detailPaneAnim) + + scaleIn( + initialScale = 0.98f, + animationSpec = detailPaneAnim, + ) + ) togetherWith fadeOut( + animationSpec = tween( + durationMillis = 180, + easing = FastOutSlowInEasing, + ), + ) + ).using(null) + }, + label = "settingsDetailPane", + modifier = Modifier.fillMaxSize(), + ) { state -> + if (state == "nav") { + AppPanel( + Modifier.fillMaxSize(), + shape = RoundedCornerShape(24.dp), + ) { + AppNavHost(Modifier.fillMaxSize()) + } + } else { + EmptySettingsPlaceholder() + } + } + }, + contactsDetail = { + EmptyContactsPlaceholder() + }, + ) }, - profilePane = { - AppNavHost(Modifier.fillMaxSize()) - }, - ) - } - } else { - AppNavHost(Modifier.fillMaxSize()) + ) } + } else { + AppNavHost(Modifier.fillMaxSize()) } CallOverlay(Modifier.fillMaxSize()) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt index 522a5c5..f180c0f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt @@ -98,6 +98,8 @@ import ru.fromchat.cd_send import ru.fromchat.config.Settings import ru.fromchat.message_corrupted_short import ru.fromchat.message_editing_title +import ru.fromchat.ui.main.ConversationDetailContentPadding +import ru.fromchat.ui.main.LocalConversationListDetailActive import ru.fromchat.message_placeholder import ru.fromchat.message_replying_to import ru.fromchat.suspend_chat_banner_message @@ -322,12 +324,14 @@ fun ChatInput( typingHandler.stopTyping() } + val chromeHorizontalPad = + if (LocalConversationListDetailActive.current) ConversationDetailContentPadding else 8.dp Box( modifier = Modifier .fillMaxWidth() .background(Color.Transparent) .windowInsetsPadding(WindowInsets.navigationBars) - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), + .padding(start = chromeHorizontalPad, end = chromeHorizontalPad, bottom = 12.dp), ) { val pillShape = RoundedCornerShape(28.dp) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index be3f349..76dfbb5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -839,12 +839,11 @@ fun ChatScreen( } } ) { - val inputPad = Modifier.padding(horizontal = detailContentPad) if (peerDeleted && dmRecipientId != null) { Box( modifier = Modifier .fillMaxWidth() - .then(inputPad) + .padding(horizontal = detailContentPad) .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), ) { Button( @@ -871,7 +870,6 @@ fun ChatScreen( } } } else { - Column(modifier = inputPad) { ChatInput( text = inputText, onTextChange = { inputText = it }, @@ -1067,7 +1065,6 @@ fun ChatScreen( } ) } - } } } ) { innerPadding -> @@ -1153,7 +1150,7 @@ fun ChatScreen( } is ChatListItem.MessageRow -> { val message = item.message - var tapPositionInRoot by remember { + var tapPositionInWindow by remember { mutableStateOf(IntOffset(0, 0)) } val messageKey = timestampGroupKey(message) @@ -1291,11 +1288,11 @@ fun ChatScreen( contextMenuState = ContextMenuState( isOpen = true, message = message, - position = tapPositionInRoot + position = tapPositionInWindow, ) }, onTapPosition = { offset -> - tapPositionInRoot = + tapPositionInWindow = IntOffset(offset.x.toInt(), offset.y.toInt()) }, onUsernameClick = diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt index 418e8b6..cc19090 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt @@ -393,7 +393,7 @@ fun ChatTopBar( val layoutHeight = topBarPlaceable.height + arcExtentPx val bgPlaceable = subcompose("background") { - val hazeStyle = rememberChatSurfaceContainerHazeStyle() + val hazeStyle = HazeMaterials.thin() Box( modifier = Modifier .fillMaxSize() @@ -402,11 +402,15 @@ fun ChatTopBar( BottomInsetTopBarShape(bottomCornerRadius) } ) - .background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f)) + .background(MaterialTheme.colorScheme.surfaceContainer) .hazeEffect(state = hazeState) { blurEffect { blurEnabled = hazeBlurEnabled style = hazeStyle + progressive = HazeProgressive.verticalGradient( + startIntensity = 1f, + endIntensity = 0f, + ) } }, ) @@ -422,8 +426,8 @@ fun ChatTopBar( /** * Two-pane chat chrome: separate back pill + title pill. Matches the chat input bottom bar * ([HazeMaterials.thin] + [androidx.compose.material3.ColorScheme.surfaceContainer] + progressive - * blur). The haze strip is full-bleed to the window top (under traffic lights); pills sit in - * inset content so blur breathes at the edges. + * blur). The haze strip is full-bleed; pills sit in [ConversationDetailContentPadding] so blur + * breathes at the edges (same horizontal inset as [ChatInput]). */ @Composable private fun ChatTopBarPill( @@ -440,8 +444,6 @@ private fun ChatTopBarPill( val hazeStyle = HazeMaterials.thin() val chromeColor = MaterialTheme.colorScheme.surfaceContainer val pillShape = RoundedCornerShape(28.dp) - // Soft chip over the frosted strip — not an opaque solid bar. - val pillBg = chromeColor.copy(alpha = 0.45f) Box(modifier = modifier.fillMaxWidth()) { Box( @@ -471,7 +473,7 @@ private fun ChatTopBarPill( modifier = Modifier .size(48.dp) .clip(CircleShape) - .background(pillBg), + .background(chromeColor), contentAlignment = Alignment.Center, ) { IconButton(onClick = onBack) { @@ -485,7 +487,7 @@ private fun ChatTopBarPill( modifier = Modifier .weight(1f) .clip(pillShape) - .background(pillBg) + .background(chromeColor) .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt index 2e98e99..c5f4907 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt @@ -37,17 +37,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res @@ -147,151 +148,148 @@ fun MessageContextMenu( if (state.isOpen && state.message != null) { shouldShowPopup = true animationProgress.floatValue = 0f + animate( + initialValue = 0f, + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) { value, _ -> + animationProgress.floatValue = value + } } } if (shouldShowPopup && state.message != null) { - var measuredSize by remember(state.message) { mutableStateOf(IntSize.Zero) } - - SubcomposeLayout(Modifier.size(0.dp)) { _ -> - val looseConstraints = Constraints( - minWidth = 0, - minHeight = 0, - maxWidth = screenWidthPx, - maxHeight = screenHeightPx - ) - val placeables = subcompose("measure") { - ContextMenuContent( - message = state.message, - isAuthor = isAuthor, - canDelete = canDelete, - onReply = {}, - onEdit = {}, - onDelete = {}, - onCopy = {}, - onSave = {}, - onCancelSend = {}, - onRetrySend = {}, - modifier = modifier.graphicsLayer(alpha = 0f), - animated = false, - withShadow = false, - isReadOnly = isReadOnly, - ) - }.map { it.measure(looseConstraints) } - val p = placeables.firstOrNull() - if (p != null && measuredSize == IntSize.Zero) { - measuredSize = IntSize(p.width, p.height) - } - layout(0, 0) { - placeables.forEach { it.placeRelative(-10000, -10000) } - } - } - val density = LocalDensity.current val paddingPx = with(density) { 16.dp.toPx().toInt() } val allowOutsideWindow = supportsMouseMessageInteraction() - val rightEdge = screenWidthPx - paddingPx - val bottomEdge = screenHeightPx - paddingPx + var popupSize by remember(state.message) { mutableStateOf(IntSize.Zero) } - val adjustedOffset = remember( - measuredSize, + // Clamp with popupContentSize — do not SubcomposeLayout-measure under + // SharedTransitionLayout (writes state during LookaheadMeasuring). + val positionProvider = remember( state.position, - rightEdge, - bottomEdge, + screenWidthPx, + screenHeightPx, paddingPx, allowOutsideWindow, ) { - if (allowOutsideWindow || measuredSize == IntSize.Zero) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + if (allowOutsideWindow) return state.position + var x = state.position.x + var y = state.position.y + val rightEdge = screenWidthPx - paddingPx + val bottomEdge = screenHeightPx - paddingPx + if (x + popupContentSize.width > rightEdge) { + x = rightEdge - popupContentSize.width + } + if (y + popupContentSize.height > bottomEdge) { + y = bottomEdge - popupContentSize.height + } + if (x < paddingPx) x = paddingPx + if (y < paddingPx) y = paddingPx + return IntOffset(x, y) + } + } + } + + val adjustedOffset = remember( + popupSize, + state.position, + screenWidthPx, + screenHeightPx, + paddingPx, + allowOutsideWindow, + ) { + if (allowOutsideWindow || popupSize == IntSize.Zero) { state.position } else { var x = state.position.x var y = state.position.y - if (x + measuredSize.width > rightEdge) x = rightEdge - measuredSize.width - if (y + measuredSize.height > bottomEdge) y = bottomEdge - measuredSize.height + val rightEdge = screenWidthPx - paddingPx + val bottomEdge = screenHeightPx - paddingPx + if (x + popupSize.width > rightEdge) x = rightEdge - popupSize.width + if (y + popupSize.height > bottomEdge) y = bottomEdge - popupSize.height if (x < paddingPx) x = paddingPx if (y < paddingPx) y = paddingPx IntOffset(x, y) } } - val sizeF = Offset(measuredSize.width.toFloat(), measuredSize.height.toFloat()) - val transformOriginX = if (sizeF.x > 0f) { - ((state.position.x - adjustedOffset.x) / sizeF.x).coerceIn(0f, 1f) - } else 0f - val transformOriginY = if (sizeF.y > 0f) { - ((state.position.y - adjustedOffset.y) / sizeF.y).coerceIn(0f, 1f) - } else 0f - - LaunchedEffect(measuredSize) { - if (measuredSize != IntSize.Zero) { - animate( - initialValue = 0f, - targetValue = 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessMediumLow - ) - ) { value, _ -> - animationProgress.floatValue = value - } - } + val transformOriginX = if (popupSize.width > 0) { + ((state.position.x - adjustedOffset.x).toFloat() / popupSize.width).coerceIn(0f, 1f) + } else { + 0f + } + val transformOriginY = if (popupSize.height > 0) { + ((state.position.y - adjustedOffset.y).toFloat() / popupSize.height).coerceIn(0f, 1f) + } else { + 0f } val scale = 0.5f + 0.5f * animationProgress.floatValue val alpha = animationProgress.floatValue - if (measuredSize != IntSize.Zero) { - Popup( - onDismissRequest = onDismiss, - alignment = Alignment.TopStart, - offset = adjustedOffset, - properties = PopupProperties( - dismissOnBackPress = true, - dismissOnClickOutside = true, - clippingEnabled = false - ) - ) { - ContextMenuContent( - message = state.message, - isAuthor = isAuthor, - canDelete = canDelete, - onReply = { - onReply(it) - onDismiss() - }, - onEdit = { - onEdit(it) - onDismiss() - }, - onDelete = { - onDelete(it) - onDismiss() - }, - onCopy = { - onCopy(it) - onDismiss() - }, - onSave = { - onSave(it) - onDismiss() - }, - onCancelSend = { - onCancelSend(it) - onDismiss() - }, - onRetrySend = { - onRetrySend(it) - onDismiss() - }, - modifier = modifier, - animated = true, - scale = scale, - alpha = alpha, - transformOriginX = transformOriginX, - transformOriginY = transformOriginY, - isReadOnly = isReadOnly - ) - } + // [state.position] is window coordinates (see MessageItem localToWindow). + // Alignment+offset would add the popup parent’s window origin again (double offset + // in list–detail panes). Place with an absolute provider instead. + Popup( + onDismissRequest = onDismiss, + popupPositionProvider = positionProvider, + properties = PopupProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + clippingEnabled = false, + ), + ) { + ContextMenuContent( + message = state.message, + isAuthor = isAuthor, + canDelete = canDelete, + onReply = { + onReply(it) + onDismiss() + }, + onEdit = { + onEdit(it) + onDismiss() + }, + onDelete = { + onDelete(it) + onDismiss() + }, + onCopy = { + onCopy(it) + onDismiss() + }, + onSave = { + onSave(it) + onDismiss() + }, + onCancelSend = { + onCancelSend(it) + onDismiss() + }, + onRetrySend = { + onRetrySend(it) + onDismiss() + }, + modifier = modifier.onSizeChanged { popupSize = it }, + animated = true, + scale = scale, + alpha = alpha, + transformOriginX = transformOriginX, + transformOriginY = transformOriginY, + isReadOnly = isReadOnly, + ) } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 2e1bfd7..676e195 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -503,7 +503,7 @@ fun MessageItem( val openMessageMenu: (LayoutCoordinates?, Offset) -> Unit = { coords, localOffset -> if (coords != null && coords.isAttached) { - onTapPosition(coords.localToRoot(localOffset)) + onTapPosition(coords.localToWindow(localOffset)) } isPressed = true onLongPress() @@ -773,7 +773,7 @@ fun MessageItem( coords.size.height / 2f, ) onTapPosition( - coords.localToRoot(center), + coords.localToWindow(center), ) } onLongPress() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt index 6ec9ac3..a898bd3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt @@ -57,8 +57,8 @@ fun DmChatRoute( otherUserId: Int, scrollToMessageId: Int? = null, navController: NavController, - sharedTransitionScope: SharedTransitionScope, - animatedVisibilityScope: AnimatedVisibilityScope, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, modifier: Modifier = Modifier, ) { val activeInstanceId by CacheContext.activeInstanceId.collectAsState() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt index cf7b333..7b68b64 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt @@ -41,8 +41,8 @@ fun NavController.navigateToPublicChat( fun PublicChatChatRoute( scrollToMessageId: Int? = null, navController: NavController, - sharedTransitionScope: SharedTransitionScope, - animatedVisibilityScope: AnimatedVisibilityScope, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, modifier: Modifier = Modifier, ) { val haptic = rememberHapticFeedback() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/AppPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/AppPanel.kt index 5373007..3448f29 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/AppPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/AppPanel.kt @@ -13,11 +13,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.hazeSource /** - * When non-null, [AppPanel] is a haze source so overlays (chat context-menu blur) include - * panel chrome / rounded outlines. Provide above the panel (list–detail); null in compact. + * Shared [HazeState] for the list pane’s context-menu blur. Provide above the list–detail shell; + * [ru.fromchat.ui.main.MainScreen] applies [dev.chrisbanes.haze.hazeSource] to list content only + * so the blur layer and sharp overlay stay outside the source. Null in compact layouts. */ val LocalPaneHazeState = staticCompositionLocalOf { null } @@ -32,15 +32,8 @@ fun AppPanel( shape: Shape = RoundedCornerShape(24.dp), content: @Composable BoxScope.() -> Unit, ) { - val paneHazeState = LocalPaneHazeState.current Surface( - modifier = modifier.then( - if (paneHazeState != null) { - Modifier.hazeSource(paneHazeState) - } else { - Modifier - }, - ), + modifier = modifier, shape = shape, color = color, ) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt index 5dde297..112ed35 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt @@ -1,15 +1,8 @@ package ru.fromchat.ui.main -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight @@ -18,193 +11,104 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import ru.fromchat.ui.components.AppPanel import ru.fromchat.ui.extraStatusBars -/** Minimum width to show chat + profile side-by-side beside the list. */ -val ConversationProfileThirdPaneMinWidth = 1100.dp - /** * Horizontal inset for chat detail content (messages, top-bar pills, input) in list–detail - * so progressive chrome blur can breathe at the pane edges while outer panes stay flush. + * so progressive chrome blur can breathe at the pane edges. */ val ConversationDetailContentPadding = 12.dp -private val PaneCornerRadius = 24.dp - -/** Round only the outer (leading) corners — adjacent edge stays square so panes sit flush. */ -private val ListPaneShape: Shape = RoundedCornerShape( - topStart = PaneCornerRadius, - bottomStart = PaneCornerRadius, -) - -/** Round only the outer (trailing) corners for settings / profile detail panels. */ -private val DetailPaneShape: Shape = RoundedCornerShape( - topEnd = PaneCornerRadius, - bottomEnd = PaneCornerRadius, -) - /** * True while the conversation list–detail shell is showing. Chat chrome uses this for * pill-shaped top bars and edge-to-edge detail layout. */ val LocalConversationListDetailActive = staticCompositionLocalOf { false } -private const val ProfilePaneAnimMs = 280 - /** - * List–detail (optional profile) shell. The list pane always uses [AppPanel]. - * Settings detail uses [AppPanel] too ([detailInPanel]); chat detail is edge-to-edge - * ([detailEdgeToEdge]) so floating chrome can blur past its inner padding toward the window. + * List–detail shell. The list pane always uses [AppPanel] with full rounded corners + * (including the edge facing the detail pane). Settings detail uses [AppPanel] too + * ([detailInPanel]); chat detail is edge-to-edge ([detailEdgeToEdge]) so floating chrome + * can blur past its inner padding toward the window. * - * Panes sit flush (no gutter) so the window background does not show as a vertical strip. + * A small gutter separates panes so list-panel round corners stay visible. * * Pads with [WindowInsets.safeDrawing] ∪ [WindowInsets.extraStatusBars] so panes clear the * macOS title bar / system bars, then consumes those insets so child top bars do not double-pad * — except when [detailEdgeToEdge], where the detail column omits outer padding and keeps * insets available for its own chrome. + * + * Layout structure is stable regardless of [detailEdgeToEdge] / [detailInPanel] so detail + * [androidx.compose.animation.AnimatedContent] is not remounted when empty ↔ chat. */ @Composable fun ConversationListDetailShell( - showProfilePane: Boolean, listPaneWidth: Dp, listPane: @Composable () -> Unit, detailPane: @Composable () -> Unit, - profilePane: @Composable () -> Unit, modifier: Modifier = Modifier, detailInPanel: Boolean = false, detailEdgeToEdge: Boolean = false, ) { - BoxWithConstraints(modifier.fillMaxSize()) { - val profileVisible = showProfilePane && maxWidth >= ConversationProfileThirdPaneMinWidth - val paneInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars) + val paneInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars) - CompositionLocalProvider(LocalConversationListDetailActive provides true) { - if (detailEdgeToEdge) { - Row(Modifier.fillMaxSize()) { + CompositionLocalProvider(LocalConversationListDetailActive provides true) { + Row(modifier.fillMaxSize()) { + AppPanel( + Modifier + .windowInsetsPadding(paneInsets) + .consumeWindowInsets(paneInsets) + .padding( + start = 8.dp, + top = 8.dp, + bottom = 8.dp, + ) + .width(listPaneWidth) + .fillMaxHeight(), + shape = RoundedCornerShape(24.dp), + ) { + listPane() + } + Spacer(Modifier.width(8.dp)) + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .then( + if (detailEdgeToEdge) { + Modifier + } else { + Modifier + .windowInsetsPadding(paneInsets) + .consumeWindowInsets(paneInsets) + .padding( + top = 8.dp, + end = 8.dp, + bottom = 8.dp, + ) + }, + ), + ) { + if (detailInPanel) { AppPanel( - Modifier - .windowInsetsPadding(paneInsets) - .consumeWindowInsets(paneInsets) - .padding(start = 8.dp, top = 8.dp, bottom = 8.dp) - .width(listPaneWidth) - .fillMaxHeight(), - shape = ListPaneShape, - ) { - listPane() - } - Box( - Modifier - .weight(1f) - .fillMaxHeight(), + Modifier.fillMaxSize(), + shape = RoundedCornerShape(24.dp), ) { detailPane() } - ProfilePaneSlot( - visible = profileVisible, - modifier = Modifier - .windowInsetsPadding(paneInsets) - .consumeWindowInsets(paneInsets) - .padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), - content = profilePane, - ) - } - } else { - Row( - Modifier - .fillMaxSize() - .windowInsetsPadding(paneInsets) - .consumeWindowInsets(paneInsets) - .padding(8.dp), - ) { - AppPanel( - Modifier - .width(listPaneWidth) - .fillMaxHeight(), - shape = ListPaneShape, - ) { - listPane() - } - if (detailInPanel) { - AppPanel( - Modifier - .weight(1f) - .fillMaxHeight(), - shape = if (profileVisible) RectangleShape else DetailPaneShape, - ) { - detailPane() - } - } else { - Box( - Modifier - .weight(1f) - .fillMaxHeight(), - ) { - detailPane() - } - } - ProfilePaneSlot( - visible = profileVisible, - content = profilePane, - ) + } else { + detailPane() } } } } } - -@Composable -private fun ProfilePaneSlot( - visible: Boolean, - content: @Composable () -> Unit, - modifier: Modifier = Modifier, -) { - val fadeAnim = tween( - durationMillis = ProfilePaneAnimMs, - easing = FastOutSlowInEasing, - ) - val sizeAnim = tween( - durationMillis = ProfilePaneAnimMs, - easing = FastOutSlowInEasing, - ) - AnimatedVisibility( - visible = visible, - enter = fadeIn(animationSpec = fadeAnim) + expandHorizontally( - animationSpec = sizeAnim, - expandFrom = Alignment.End, - clip = false, - ), - exit = fadeOut( - animationSpec = tween( - durationMillis = 220, - easing = FastOutSlowInEasing, - ), - ) + shrinkHorizontally( - animationSpec = sizeAnim, - shrinkTowards = Alignment.End, - clip = false, - ), - ) { - Box( - modifier - .widthIn(min = 320.dp, max = 420.dp) - .width(380.dp) - .fillMaxHeight(), - ) { - content() - } - } -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt new file mode 100644 index 0000000..4118a02 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt @@ -0,0 +1,62 @@ +package ru.fromchat.ui.main + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.zIndex + +/** + * Which main bottom-nav tab owns the list–detail detail pane on large screens. + * Inactive tab hosts stay composed (alpha 0) so their detail state is retained. + */ +val LocalDesktopMainTab = compositionLocalOf { MAIN_PAGE_CHATS } + +/** + * Per-tab detail pane hosts for desktop list–detail. + * + * Each tab supplies its own detail content. Only the [selectedTab] host is visible + * (alpha 1 + highest z-index); the others stay composed at alpha 0 underneath so opening + * Settings does not dispose an open Chats detail, and returning to Chats shows it again + * without clearing the chat route. + */ +@Composable +fun DesktopTabDetailHosts( + selectedTab: Int, + chatsDetail: @Composable () -> Unit, + settingsDetail: @Composable () -> Unit, + contactsDetail: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier.fillMaxSize()) { + TabDetailHost( + active = selectedTab == MAIN_PAGE_CHATS, + content = chatsDetail, + ) + TabDetailHost( + active = selectedTab == MAIN_PAGE_SETTINGS || selectedTab == MAIN_PAGE_PROFILE, + content = settingsDetail, + ) + TabDetailHost( + active = selectedTab == MAIN_PAGE_CONTACTS, + content = contactsDetail, + ) + } +} + +@Composable +private fun TabDetailHost( + active: Boolean, + content: @Composable () -> Unit, +) { + Box( + Modifier + .fillMaxSize() + .zIndex(if (active) 1f else 0f) + .graphicsLayer { alpha = if (active) 1f else 0f }, + ) { + content() + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailNav.kt index 4e1faff..3487521 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailNav.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailNav.kt @@ -2,19 +2,40 @@ package ru.fromchat.ui.main import androidx.navigation.NavController import androidx.navigation.NavOptionsBuilder +import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav /** * Opens a main-hub detail destination, replacing any prior conversation/settings/profile * above the main `chat` root so large-screen switches do not stack previous panes. * Matches [ru.fromchat.ui.chat.panels.dm.navigateToDmChat] / public chat. + * + * When [preserveConversationDetail] is true (desktop list–detail), keeps an open chat under + * the new settings/profile destination so tab hosts can retain the Chats detail. */ fun NavController.navigateReplacingMainDetail( route: String, + preserveConversationDetail: Boolean = false, builder: NavOptionsBuilder.() -> Unit = {}, ) { - navigate(route) { - popUpTo("chat") { saveState = true } - launchSingleTop = true - builder() + if (preserveConversationDetail) { + while (true) { + val current = currentBackStackEntry?.destination?.route ?: break + if (current == "chat" || isConversationChatRoute(current)) break + if (!popBackStack()) break + } + navigate(route) { + launchSingleTop = true + builder() + } + } else { + navigate(route) { + popUpTo("chat") { saveState = true } + launchSingleTop = true + builder() + } } } + +private fun isConversationChatRoute(route: String?): Boolean = + route == PublicChatNav.CHAT_ROUTE || + (route != null && route.startsWith("dm/") && "/chat" in route) 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 ea464f5..9e01191 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 @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons @@ -40,18 +39,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.zIndex import com.pr0gramm3r101.utils.WindowWidthSizeClass import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo @@ -82,8 +72,6 @@ import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost import ru.fromchat.ui.main.chats.ChatsTab import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.profile.ProfileScreen -import kotlin.math.roundToInt - const val MAIN_PAGE_CHATS = 0 const val MAIN_PAGE_CONTACTS = 1 const val MAIN_PAGE_SETTINGS = 2 @@ -126,9 +114,7 @@ fun MainScreen( val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle() val paneHazeState = LocalPaneHazeState.current val contextMenuHazeState = paneHazeState ?: rememberHazeState() - val paneProvidesHazeSource = paneHazeState != null val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } - var contextMenuBlurPaneBoundsInWindow by remember { mutableStateOf(IntRect.Zero) } val widthClass = currentWindowAdaptiveInfo().widthSizeClass @@ -167,15 +153,12 @@ fun MainScreen( val settingsLabel = stringResource(Res.string.settings) val profileLabel = stringResource(Res.string.profile) - fun clearConversationStack() { - // Pop above chat only — inclusive pop remounts the chat destination and briefly - // drops the shell route, which remounts list–detail ↔ compact and blinks forever. - navController.popBackStack("chat", inclusive = false) - } - fun openOwnProfileInDetailPane() { val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return - navController.navigateReplacingMainDetail("profile/$userId") + navController.navigateReplacingMainDetail( + route = "profile/$userId", + preserveConversationDetail = embeddedInListDetail, + ) } fun selectMainPage(page: Int) { @@ -184,17 +167,10 @@ fun MainScreen( scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } openOwnProfileInDetailPane() } - page == PAGE_CHATS -> { - scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) } - if (embeddedInListDetail && selectedPage != PAGE_CHATS) { - clearConversationStack() - } - } else -> { + // Desktop list–detail: do not pop the chat (or settings) stack — each tab + // keeps its own detail host, so switching tabs must not wipe the other tab. scope.launch { pagerState.animateScrollToPage(page) } - if (embeddedInListDetail) { - clearConversationStack() - } } } } @@ -210,28 +186,12 @@ fun MainScreen( } BoxWithConstraints( - Modifier - .fillMaxSize() - .onGloballyPositioned { coords -> - val bounds = coords.boundsInWindow() - contextMenuBlurPaneBoundsInWindow = IntRect( - left = bounds.left.roundToInt(), - top = bounds.top.roundToInt(), - right = bounds.right.roundToInt(), - bottom = bounds.bottom.roundToInt(), - ) - }, + Modifier.fillMaxSize(), ) { Box( modifier = Modifier .fillMaxSize() - .then( - if (paneProvidesHazeSource) { - Modifier - } else { - Modifier.hazeSource(contextMenuHazeState) - }, - ), + .hazeSource(contextMenuHazeState), ) { Scaffold( snackbarHost = { @@ -360,8 +320,6 @@ fun MainScreen( ChatContextMenuBlurLayer( hazeState = contextMenuHazeState, blurProgress = chatMenuBlurProgress, - paneBoundsInWindow = contextMenuBlurPaneBoundsInWindow, - expandBeyondPane = paneProvidesHazeSource, modifier = Modifier.zIndex(2f), ) } @@ -381,78 +339,26 @@ fun MainScreen( private fun ChatContextMenuBlurLayer( hazeState: HazeState, blurProgress: Float, - paneBoundsInWindow: IntRect, - expandBeyondPane: Boolean, modifier: Modifier = Modifier, ) { val blurRadius = 12.dp * blurProgress if (blurRadius <= 0.dp) return - val density = LocalDensity.current - val expandPx = if (expandBeyondPane) { - with(density) { (24.dp + blurRadius).roundToPx() } - } else { - 0 - } - val blurBounds = if (paneBoundsInWindow.width > 0 && paneBoundsInWindow.height > 0) { - IntRect( - left = (paneBoundsInWindow.left - expandPx).coerceAtLeast(0), - top = (paneBoundsInWindow.top - expandPx).coerceAtLeast(0), - right = paneBoundsInWindow.right + expandPx, - bottom = paneBoundsInWindow.bottom + expandPx, - ) - } else { - null - } - val blurModifier = Modifier.hazeEffect(state = hazeState) { - blurEffect { - style = HazeBlurStyle( - blurRadius = blurRadius, - colorEffects = emptyList(), - backgroundColor = Color.Transparent, - noiseFactor = 0f, - fallbackColorEffect = HazeColorEffect.tint(Color.Transparent), - ) - } - } - - if (blurBounds == null || !expandBeyondPane) { - Box( - modifier = modifier - .fillMaxSize() - .then(blurModifier), - ) - return - } - - // Escape AppPanel clip so blurred panel chrome / rounded outlines are covered. - Popup( - popupPositionProvider = object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset = IntOffset(blurBounds.left, blurBounds.top) - }, - properties = PopupProperties( - focusable = false, - dismissOnBackPress = false, - dismissOnClickOutside = false, - clippingEnabled = false, - ), - ) { - Box( - modifier = modifier - .then( - with(density) { - Modifier.size( - width = blurBounds.width.toDp(), - height = blurBounds.height.toDp(), - ) - }, - ) - .then(blurModifier), - ) - } + // In-tree under the overlay (zIndex). Never use a Popup here: platform popups stack above + // the sharp row/menu, blur them, and intercept dismiss taps. + Box( + modifier = modifier + .fillMaxSize() + .hazeEffect(state = hazeState) { + blurEffect { + style = HazeBlurStyle( + blurRadius = blurRadius, + colorEffects = emptyList(), + backgroundColor = Color.Transparent, + noiseFactor = 0f, + fallbackColorEffect = HazeColorEffect.tint(Color.Transparent), + ) + } + }, + ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatContextMenu.kt index 260eecf..bedec38 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatContextMenu.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatContextMenu.kt @@ -22,6 +22,9 @@ import androidx.compose.material.icons.rounded.MarkEmailRead import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -34,6 +37,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.pr0gramm3r101.utils.scaleOnPress +import kotlinx.coroutines.channels.Channel import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.action_delete @@ -57,6 +61,14 @@ internal fun ChatContextMenuMeasurer( screenHeightPx: Int, onMeasured: (IntSize) -> Unit, ) { + val latestOnMeasured = rememberUpdatedState(onMeasured) + val measuredSizes = remember { Channel(Channel.CONFLATED) } + LaunchedEffect(Unit) { + for (size in measuredSizes) { + latestOnMeasured.value(size) + } + } + SubcomposeLayout(Modifier.size(0.dp)) { _ -> val looseConstraints = Constraints( minWidth = 0, @@ -84,8 +96,9 @@ internal fun ChatContextMenuMeasurer( ) }.map { it.measure(looseConstraints) } val measured = placeables.firstOrNull()?.let { IntSize(it.width, it.height) } ?: IntSize.Zero + // Do not write Compose state during measure (LookaheadMeasuring under SharedTransition). if (measured != IntSize.Zero) { - onMeasured(measured) + measuredSizes.trySend(measured) } layout(0, 0) { placeables.forEach { it.placeRelative(-10000, -10000) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt index 17338ad..5c8d898 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException import androidx.compose.ui.input.pointer.PointerType @@ -182,6 +183,52 @@ private fun Modifier.chatRowOpenAndSelectGestures( } } +/** + * Touch/stylus: press feedback + long-press opens the menu. + * Mouse: ignore avatar presses (row right-click handles the menu). + */ +private fun Modifier.chatRowAvatarGestures( + enabled: Boolean, + onPressStart: () -> Unit, + onPressEnd: () -> Unit, + onLongPress: (Offset) -> Unit, +): Modifier = + pointerInput(enabled) { + if (!enabled) return@pointerInput + awaitEachGesture { + handleChatRowAvatarGesture( + onPressStart = onPressStart, + onPressEnd = onPressEnd, + onLongPress = onLongPress, + ) + } + } + +private suspend fun AwaitPointerEventScope.handleChatRowAvatarGesture( + onPressStart: () -> Unit, + onPressEnd: () -> Unit, + onLongPress: (Offset) -> Unit, +) { + var downEvent: PointerEvent + do { + downEvent = awaitPointerEvent() + } while (!downEvent.changes.fastAll { it.changedToDown() }) + val down = downEvent.changes.firstOrNull() ?: return + if (down.type == PointerType.Mouse) return + if (down.type != PointerType.Touch && down.type != PointerType.Stylus) return + onPressStart() + try { + withTimeout(viewConfiguration.longPressTimeoutMillis) { + waitForUpOrCancellation() + } + } catch (_: PointerEventTimeoutCancellationException) { + onLongPress(down.position) + waitForUpOrCancellation() + } finally { + onPressEnd() + } +} + @Composable internal fun ChatListHeadlineWithBadge( title: String, @@ -692,32 +739,12 @@ internal fun ChatRowAvatar( Box( modifier .size(40.dp) - .pointerInput(enabled) { - if (!enabled) return@pointerInput - awaitEachGesture { - var downEvent: PointerEvent - do { - downEvent = awaitPointerEvent() - } while (!downEvent.changes.fastAll { it.changedToDown() }) - val down = downEvent.changes.firstOrNull() ?: return@awaitEachGesture - // Mouse: avatar press/click is disabled; use row right-click for the menu. - if (down.type == PointerType.Mouse) return@awaitEachGesture - if (down.type != PointerType.Touch && down.type != PointerType.Stylus) { - return@awaitEachGesture - } - onPressStart() - try { - withTimeout(viewConfiguration.longPressTimeoutMillis) { - waitForUpOrCancellation() - } - } catch (_: PointerEventTimeoutCancellationException) { - onLongPress(down.position) - waitForUpOrCancellation() - } finally { - onPressEnd() - } - } - }, + .chatRowAvatarGestures( + enabled = enabled, + onPressStart = onPressStart, + onPressEnd = onPressEnd, + onLongPress = onLongPress, + ), ) { Avatar( profilePictureUrl = profilePictureUrl, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt index 85a6e9a..a52c823 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt @@ -68,6 +68,13 @@ fun SettingsTab() { val navController = LocalNavController.current val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT + fun openDetail(route: String) { + navController.navigateReplacingMainDetail( + route = route, + preserveConversationDetail = isTwoPane, + ) + } + Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), containerColor = Color.Transparent, @@ -104,7 +111,7 @@ fun SettingsTab() { supportingText = stringResource(Res.string.settings_hub_profile_sub), onClick = { val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return@ListItem - navController.navigateReplacingMainDetail("profile/$userId") + openDetail("profile/$userId") }, leadingContent = { Icon(Icons.Filled.Person, null) }, ) @@ -115,7 +122,7 @@ fun SettingsTab() { ListItem( headline = stringResource(Res.string.settings_category_account), supportingText = stringResource(Res.string.settings_category_account_d), - onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Account) }, + onClick = { openDetail(SettingsRoutes.Account) }, leadingContent = { Icon(Icons.Filled.AccountCircle, null) }, divider = true ) @@ -123,7 +130,7 @@ fun SettingsTab() { ListItem( headline = stringResource(Res.string.settings_category_devices), supportingText = stringResource(Res.string.settings_category_devices_d), - onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Devices) }, + onClick = { openDetail(SettingsRoutes.Devices) }, leadingContent = { Icon(Icons.Filled.Devices, null) }, divider = true ) @@ -131,7 +138,7 @@ fun SettingsTab() { ListItem( headline = stringResource(Res.string.settings_category_appearance), supportingText = stringResource(Res.string.settings_category_appearance_d), - onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Appearance) }, + onClick = { openDetail(SettingsRoutes.Appearance) }, leadingContent = { Icon(Icons.Filled.Palette, null) }, divider = true ) @@ -139,7 +146,7 @@ fun SettingsTab() { ListItem( headline = stringResource(Res.string.settings_category_notifications), supportingText = stringResource(Res.string.settings_category_notifications_d), - onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Notifications) }, + onClick = { openDetail(SettingsRoutes.Notifications) }, leadingContent = { Icon(Icons.Filled.Notifications, null) }, divider = true ) @@ -147,7 +154,7 @@ fun SettingsTab() { ListItem( headline = stringResource(Res.string.change_server), supportingText = stringResource(Res.string.change_server_d), - onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.ServerConfig) }, + onClick = { openDetail(SettingsRoutes.ServerConfig) }, leadingContent = { Icon(Icons.Filled.Storage, null) }, divider = true ) @@ -155,7 +162,7 @@ fun SettingsTab() { ListItem( headline = stringResource(Res.string.about), supportingText = stringResource(Res.string.settings_hub_about_sub), - onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.About) }, + onClick = { openDetail(SettingsRoutes.About) }, leadingContent = { Icon(Icons.Filled.Info, null) }, divider = true ) @@ -163,7 +170,7 @@ fun SettingsTab() { ListItem( headline = stringResource(Res.string.logs_title), supportingText = stringResource(Res.string.settings_hub_logs_sub), - onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Logs) }, + onClick = { openDetail(SettingsRoutes.Logs) }, leadingContent = { Icon(Icons.Outlined.BugReport, null) } ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index a5ae0dd..d81820a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -63,6 +63,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester @@ -698,17 +699,25 @@ fun ProfileScreen( animatedVisibilityScope != null && sharedAvatarKey != null - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .hazeSource(hazeState), - ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, + .widthIn(max = 600.dp), ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .hazeSource(hazeState), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { when { showAvatarSkeleton && !hideAvatar -> { item { @@ -862,9 +871,11 @@ fun ProfileScreen( .padding(bottom = 16.dp) .fillMaxWidth(), ) + } } } } + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PublicChatProfileScreen( @@ -985,17 +996,25 @@ fun PublicChatProfileScreen( sharedAvatarKey != null ScreenSurface(modifier = modifier) { - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .hazeSource(hazeState), + .widthIn(max = 600.dp), ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .hazeSource(hazeState), ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { when { useSharedAvatar && displayName.isNotBlank() -> { item { @@ -1071,6 +1090,7 @@ fun PublicChatProfileScreen( .padding(bottom = 16.dp) .fillMaxWidth(), ) + } } } } diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt index 8404afd..28d55ae 100644 --- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt @@ -118,6 +118,7 @@ import com.pr0gramm3r101.utils.plus import com.pr0gramm3r101.utils.right import com.pr0gramm3r101.utils.scaleOnPress import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import tech.annexflow.constraintlayout.compose.ConstraintLayout import tech.annexflow.constraintlayout.compose.ConstraintLayoutScope @@ -251,6 +252,12 @@ private fun ListItemContextMenuPopup( } var measuredSize by remember { mutableStateOf(IntSize.Zero) } + val measuredSizes = remember { Channel(Channel.CONFLATED) } + LaunchedEffect(Unit) { + for (size in measuredSizes) { + if (measuredSize == IntSize.Zero) measuredSize = size + } + } SubcomposeLayout(Modifier.size(0.dp)) { _ -> val looseConstraints = Constraints( @@ -269,7 +276,7 @@ private fun ListItemContextMenuPopup( }.map { it.measure(looseConstraints) } val p = placeables.firstOrNull() if (p != null && measuredSize == IntSize.Zero) { - measuredSize = IntSize(p.width, p.height) + measuredSizes.trySend(IntSize(p.width, p.height)) } layout(0, 0) { placeables.forEach { it.placeRelative(-10000, -10000) }