mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Improve chat UI
This commit is contained in:
@@ -213,6 +213,9 @@ private object DesktopProtocolRegistration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
// 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()) {
|
if (isMacOs()) {
|
||||||
val appName = runBlocking { getString(Res.string.app_name) }
|
val appName = runBlocking { getString(Res.string.app_name) }
|
||||||
System.setProperty("apple.awt.application.name", appName)
|
System.setProperty("apple.awt.application.name", appName)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import androidx.compose.animation.scaleIn
|
|||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHostState
|
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.PublicChatProfileRoute
|
||||||
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat
|
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat
|
||||||
import ru.fromchat.ui.main.ConversationListDetailShell
|
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.EmptyContactsPlaceholder
|
||||||
import ru.fromchat.ui.main.EmptyConversationPlaceholder
|
import ru.fromchat.ui.main.EmptyConversationPlaceholder
|
||||||
import ru.fromchat.ui.main.EmptySettingsPlaceholder
|
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_CHATS
|
||||||
import ru.fromchat.ui.main.MAIN_PAGE_CONTACTS
|
import ru.fromchat.ui.main.MAIN_PAGE_CONTACTS
|
||||||
import ru.fromchat.ui.main.MAIN_PAGE_SETTINGS
|
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.EditProfileScreen
|
||||||
import ru.fromchat.ui.profile.ProfileRoutes
|
import ru.fromchat.ui.profile.ProfileRoutes
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
|
import ru.fromchat.ui.components.AppPanel
|
||||||
import ru.fromchat.ui.components.LocalPaneHazeState
|
import ru.fromchat.ui.components.LocalPaneHazeState
|
||||||
import ru.fromchat.ui.components.ScreenSurface
|
import ru.fromchat.ui.components.ScreenSurface
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import ru.fromchat.utils.NetworkConnectivity
|
import ru.fromchat.utils.NetworkConnectivity
|
||||||
|
|
||||||
@@ -514,37 +516,37 @@ fun App(
|
|||||||
|
|
||||||
ScreenSurface {
|
ScreenSurface {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
// Own profile from the Profile tab uses the settings detail host.
|
||||||
val canFitProfileThirdPane =
|
// Profile opened from a chat stays in the chats detail (push/slide).
|
||||||
maxWidth >= ConversationProfileThirdPaneMinWidth
|
|
||||||
val showProfileThirdPane =
|
|
||||||
showConversationListDetail &&
|
|
||||||
canFitProfileThirdPane &&
|
|
||||||
(
|
|
||||||
isConversationProfileRoute(currentRoute) ||
|
|
||||||
(
|
|
||||||
isStandaloneProfileRoute(currentRoute) &&
|
|
||||||
isConversationChatRoute(previousRoute)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val settingsProfileSplit =
|
val settingsProfileSplit =
|
||||||
showConversationListDetail &&
|
showConversationListDetail &&
|
||||||
isStandaloneProfileRoute(currentRoute) &&
|
isStandaloneProfileRoute(currentRoute) &&
|
||||||
!showProfileThirdPane &&
|
|
||||||
profileUserId != null &&
|
profileUserId != null &&
|
||||||
profileUserId == ownUserId
|
profileUserId == ownUserId &&
|
||||||
|
!isConversationChatRoute(previousRoute)
|
||||||
val listPaneWidth =
|
val listPaneWidth =
|
||||||
if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
|
if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
|
||||||
448.dp
|
448.dp
|
||||||
} else {
|
} else {
|
||||||
360.dp
|
360.dp
|
||||||
}
|
}
|
||||||
val forceSettingsListTab =
|
// Only nudge the list pager when opening own profile from the
|
||||||
settingsProfileSplit ||
|
// Profile tab — do not yank the user back when they switch to Chats
|
||||||
(
|
// while a settings destination is still on the back stack.
|
||||||
showConversationListDetail &&
|
val forceSettingsListTab = settingsProfileSplit
|
||||||
isSettingsDetailRoute(currentRoute)
|
|
||||||
)
|
var retainedChatsRoute by remember { mutableStateOf<String?>(null) }
|
||||||
|
LaunchedEffect(currentRoute, pendingMainTab) {
|
||||||
|
when {
|
||||||
|
isConversationChatRoute(currentRoute) -> {
|
||||||
|
retainedChatsRoute = currentRoute
|
||||||
|
}
|
||||||
|
currentRoute == "chat" &&
|
||||||
|
pendingMainTab == MAIN_PAGE_CHATS -> {
|
||||||
|
retainedChatsRoute = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost(modifier: Modifier = Modifier) {
|
fun AppNavHost(modifier: Modifier = Modifier) {
|
||||||
@@ -875,28 +877,37 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showConversationListDetail) {
|
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<Float>(
|
val detailPaneAnim = tween<Float>(
|
||||||
durationMillis = 280,
|
durationMillis = 280,
|
||||||
easing = FastOutSlowInEasing,
|
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()
|
val listPaneHazeState = rememberHazeState()
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalPaneHazeState provides listPaneHazeState,
|
LocalPaneHazeState provides listPaneHazeState,
|
||||||
|
LocalDesktopMainTab provides pendingMainTab,
|
||||||
) {
|
) {
|
||||||
ConversationListDetailShell(
|
ConversationListDetailShell(
|
||||||
showProfilePane = showProfileThirdPane,
|
|
||||||
listPaneWidth = listPaneWidth,
|
listPaneWidth = listPaneWidth,
|
||||||
detailInPanel = detailInPanel,
|
detailInPanel = false,
|
||||||
detailEdgeToEdge = detailEdgeToEdge,
|
detailEdgeToEdge = detailEdgeToEdge,
|
||||||
listPane = {
|
listPane = {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
@@ -909,9 +920,28 @@ fun App(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
detailPane = {
|
detailPane = {
|
||||||
|
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(
|
AnimatedContent(
|
||||||
targetState = detailPaneState,
|
targetState = chatsDetailKey,
|
||||||
transitionSpec = {
|
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) +
|
fadeIn(animationSpec = detailPaneAnim) +
|
||||||
scaleIn(
|
scaleIn(
|
||||||
@@ -924,62 +954,87 @@ fun App(
|
|||||||
easing = FastOutSlowInEasing,
|
easing = FastOutSlowInEasing,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
transform.using(null)
|
||||||
},
|
},
|
||||||
label = "detailPane",
|
label = "chatsDetailPane",
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) { state ->
|
) { state ->
|
||||||
when {
|
when {
|
||||||
state == "split" -> {
|
state == "nav" -> {
|
||||||
val chatRouteForDetail =
|
AppNavHost(Modifier.fillMaxSize())
|
||||||
when {
|
|
||||||
isConversationProfileRoute(currentRoute) ->
|
|
||||||
currentRoute
|
|
||||||
isConversationChatRoute(previousRoute) ->
|
|
||||||
previousRoute
|
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
val dmUserId = dmUserIdFromRoute(chatRouteForDetail)
|
state.startsWith("retained:") -> {
|
||||||
?: dmUserIdFromRoute(currentRoute)
|
val route =
|
||||||
|
state.removePrefix("retained:")
|
||||||
|
val dmUserId = dmUserIdFromRoute(route)
|
||||||
when {
|
when {
|
||||||
dmUserId != null -> DmChatRoute(
|
dmUserId != null -> DmChatRoute(
|
||||||
otherUserId = dmUserId,
|
otherUserId = dmUserId,
|
||||||
scrollToMessageId = null,
|
scrollToMessageId = null,
|
||||||
navController = navController,
|
navController = navController,
|
||||||
sharedTransitionScope = this@SharedTransitionLayout,
|
sharedTransitionScope = null,
|
||||||
animatedVisibilityScope = this,
|
animatedVisibilityScope = null,
|
||||||
)
|
)
|
||||||
chatRouteForDetail == PublicChatNav.PROFILE_ROUTE ||
|
route == PublicChatNav.CHAT_ROUTE -> {
|
||||||
chatRouteForDetail == PublicChatNav.CHAT_ROUTE ||
|
|
||||||
currentRoute == PublicChatNav.PROFILE_ROUTE -> {
|
|
||||||
PublicChatChatRoute(
|
PublicChatChatRoute(
|
||||||
scrollToMessageId = scrollToMessageId,
|
scrollToMessageId =
|
||||||
|
scrollToMessageId,
|
||||||
navController = navController,
|
navController = navController,
|
||||||
sharedTransitionScope = this@SharedTransitionLayout,
|
sharedTransitionScope = null,
|
||||||
animatedVisibilityScope = this,
|
animatedVisibilityScope = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.startsWith("empty-") -> {
|
|
||||||
when (pendingMainTab) {
|
|
||||||
MAIN_PAGE_CONTACTS -> EmptyContactsPlaceholder()
|
|
||||||
MAIN_PAGE_SETTINGS -> EmptySettingsPlaceholder()
|
|
||||||
else -> EmptyConversationPlaceholder()
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
profilePane = {
|
contactsDetail = {
|
||||||
AppNavHost(Modifier.fillMaxSize())
|
EmptyContactsPlaceholder()
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
AppNavHost(Modifier.fillMaxSize())
|
AppNavHost(Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
CallOverlay(Modifier.fillMaxSize())
|
CallOverlay(Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ import ru.fromchat.cd_send
|
|||||||
import ru.fromchat.config.Settings
|
import ru.fromchat.config.Settings
|
||||||
import ru.fromchat.message_corrupted_short
|
import ru.fromchat.message_corrupted_short
|
||||||
import ru.fromchat.message_editing_title
|
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_placeholder
|
||||||
import ru.fromchat.message_replying_to
|
import ru.fromchat.message_replying_to
|
||||||
import ru.fromchat.suspend_chat_banner_message
|
import ru.fromchat.suspend_chat_banner_message
|
||||||
@@ -322,12 +324,14 @@ fun ChatInput(
|
|||||||
typingHandler.stopTyping()
|
typingHandler.stopTyping()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val chromeHorizontalPad =
|
||||||
|
if (LocalConversationListDetailActive.current) ConversationDetailContentPadding else 8.dp
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(Color.Transparent)
|
.background(Color.Transparent)
|
||||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
.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)
|
val pillShape = RoundedCornerShape(28.dp)
|
||||||
|
|
||||||
|
|||||||
@@ -839,12 +839,11 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
val inputPad = Modifier.padding(horizontal = detailContentPad)
|
|
||||||
if (peerDeleted && dmRecipientId != null) {
|
if (peerDeleted && dmRecipientId != null) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(inputPad)
|
.padding(horizontal = detailContentPad)
|
||||||
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
|
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
@@ -871,7 +870,6 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Column(modifier = inputPad) {
|
|
||||||
ChatInput(
|
ChatInput(
|
||||||
text = inputText,
|
text = inputText,
|
||||||
onTextChange = { inputText = it },
|
onTextChange = { inputText = it },
|
||||||
@@ -1069,7 +1067,6 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
val statusBarTopDp = with(density) {
|
val statusBarTopDp = with(density) {
|
||||||
WindowInsets.extraStatusBars.getTop(this).toDp()
|
WindowInsets.extraStatusBars.getTop(this).toDp()
|
||||||
@@ -1153,7 +1150,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
is ChatListItem.MessageRow -> {
|
is ChatListItem.MessageRow -> {
|
||||||
val message = item.message
|
val message = item.message
|
||||||
var tapPositionInRoot by remember {
|
var tapPositionInWindow by remember {
|
||||||
mutableStateOf(IntOffset(0, 0))
|
mutableStateOf(IntOffset(0, 0))
|
||||||
}
|
}
|
||||||
val messageKey = timestampGroupKey(message)
|
val messageKey = timestampGroupKey(message)
|
||||||
@@ -1291,11 +1288,11 @@ fun ChatScreen(
|
|||||||
contextMenuState = ContextMenuState(
|
contextMenuState = ContextMenuState(
|
||||||
isOpen = true,
|
isOpen = true,
|
||||||
message = message,
|
message = message,
|
||||||
position = tapPositionInRoot
|
position = tapPositionInWindow,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onTapPosition = { offset ->
|
onTapPosition = { offset ->
|
||||||
tapPositionInRoot =
|
tapPositionInWindow =
|
||||||
IntOffset(offset.x.toInt(), offset.y.toInt())
|
IntOffset(offset.x.toInt(), offset.y.toInt())
|
||||||
},
|
},
|
||||||
onUsernameClick =
|
onUsernameClick =
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ fun ChatTopBar(
|
|||||||
val layoutHeight = topBarPlaceable.height + arcExtentPx
|
val layoutHeight = topBarPlaceable.height + arcExtentPx
|
||||||
|
|
||||||
val bgPlaceable = subcompose("background") {
|
val bgPlaceable = subcompose("background") {
|
||||||
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
|
val hazeStyle = HazeMaterials.thin()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -402,11 +402,15 @@ fun ChatTopBar(
|
|||||||
BottomInsetTopBarShape(bottomCornerRadius)
|
BottomInsetTopBarShape(bottomCornerRadius)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.hazeEffect(state = hazeState) {
|
.hazeEffect(state = hazeState) {
|
||||||
blurEffect {
|
blurEffect {
|
||||||
blurEnabled = hazeBlurEnabled
|
blurEnabled = hazeBlurEnabled
|
||||||
style = hazeStyle
|
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
|
* Two-pane chat chrome: separate back pill + title pill. Matches the chat input bottom bar
|
||||||
* ([HazeMaterials.thin] + [androidx.compose.material3.ColorScheme.surfaceContainer] + progressive
|
* ([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
|
* blur). The haze strip is full-bleed; pills sit in [ConversationDetailContentPadding] so blur
|
||||||
* inset content so blur breathes at the edges.
|
* breathes at the edges (same horizontal inset as [ChatInput]).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatTopBarPill(
|
private fun ChatTopBarPill(
|
||||||
@@ -440,8 +444,6 @@ private fun ChatTopBarPill(
|
|||||||
val hazeStyle = HazeMaterials.thin()
|
val hazeStyle = HazeMaterials.thin()
|
||||||
val chromeColor = MaterialTheme.colorScheme.surfaceContainer
|
val chromeColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
val pillShape = RoundedCornerShape(28.dp)
|
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(modifier = modifier.fillMaxWidth()) {
|
||||||
Box(
|
Box(
|
||||||
@@ -471,7 +473,7 @@ private fun ChatTopBarPill(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(pillBg),
|
.background(chromeColor),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
@@ -485,7 +487,7 @@ private fun ChatTopBarPill(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.clip(pillShape)
|
.clip(pillShape)
|
||||||
.background(pillBg)
|
.background(chromeColor)
|
||||||
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
|
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -37,17 +37,18 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
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.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntRect
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
|
import androidx.compose.ui.window.PopupPositionProvider
|
||||||
import androidx.compose.ui.window.PopupProperties
|
import androidx.compose.ui.window.PopupProperties
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
@@ -147,83 +148,6 @@ fun MessageContextMenu(
|
|||||||
if (state.isOpen && state.message != null) {
|
if (state.isOpen && state.message != null) {
|
||||||
shouldShowPopup = true
|
shouldShowPopup = true
|
||||||
animationProgress.floatValue = 0f
|
animationProgress.floatValue = 0f
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
val adjustedOffset = remember(
|
|
||||||
measuredSize,
|
|
||||||
state.position,
|
|
||||||
rightEdge,
|
|
||||||
bottomEdge,
|
|
||||||
paddingPx,
|
|
||||||
allowOutsideWindow,
|
|
||||||
) {
|
|
||||||
if (allowOutsideWindow || measuredSize == 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
|
|
||||||
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(
|
animate(
|
||||||
initialValue = 0f,
|
initialValue = 0f,
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
@@ -237,19 +161,94 @@ fun MessageContextMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldShowPopup && state.message != null) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val paddingPx = with(density) { 16.dp.toPx().toInt() }
|
||||||
|
val allowOutsideWindow = supportsMouseMessageInteraction()
|
||||||
|
var popupSize by remember(state.message) { mutableStateOf(IntSize.Zero) }
|
||||||
|
|
||||||
|
// Clamp with popupContentSize — do not SubcomposeLayout-measure under
|
||||||
|
// SharedTransitionLayout (writes state during LookaheadMeasuring).
|
||||||
|
val positionProvider = remember(
|
||||||
|
state.position,
|
||||||
|
screenWidthPx,
|
||||||
|
screenHeightPx,
|
||||||
|
paddingPx,
|
||||||
|
allowOutsideWindow,
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
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 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 scale = 0.5f + 0.5f * animationProgress.floatValue
|
||||||
val alpha = animationProgress.floatValue
|
val alpha = animationProgress.floatValue
|
||||||
|
|
||||||
if (measuredSize != IntSize.Zero) {
|
// [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(
|
Popup(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
alignment = Alignment.TopStart,
|
popupPositionProvider = positionProvider,
|
||||||
offset = adjustedOffset,
|
|
||||||
properties = PopupProperties(
|
properties = PopupProperties(
|
||||||
dismissOnBackPress = true,
|
dismissOnBackPress = true,
|
||||||
dismissOnClickOutside = true,
|
dismissOnClickOutside = true,
|
||||||
clippingEnabled = false
|
clippingEnabled = false,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
ContextMenuContent(
|
ContextMenuContent(
|
||||||
message = state.message,
|
message = state.message,
|
||||||
@@ -283,17 +282,16 @@ fun MessageContextMenu(
|
|||||||
onRetrySend(it)
|
onRetrySend(it)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier.onSizeChanged { popupSize = it },
|
||||||
animated = true,
|
animated = true,
|
||||||
scale = scale,
|
scale = scale,
|
||||||
alpha = alpha,
|
alpha = alpha,
|
||||||
transformOriginX = transformOriginX,
|
transformOriginX = transformOriginX,
|
||||||
transformOriginY = transformOriginY,
|
transformOriginY = transformOriginY,
|
||||||
isReadOnly = isReadOnly
|
isReadOnly = isReadOnly,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ fun MessageItem(
|
|||||||
|
|
||||||
val openMessageMenu: (LayoutCoordinates?, Offset) -> Unit = { coords, localOffset ->
|
val openMessageMenu: (LayoutCoordinates?, Offset) -> Unit = { coords, localOffset ->
|
||||||
if (coords != null && coords.isAttached) {
|
if (coords != null && coords.isAttached) {
|
||||||
onTapPosition(coords.localToRoot(localOffset))
|
onTapPosition(coords.localToWindow(localOffset))
|
||||||
}
|
}
|
||||||
isPressed = true
|
isPressed = true
|
||||||
onLongPress()
|
onLongPress()
|
||||||
@@ -773,7 +773,7 @@ fun MessageItem(
|
|||||||
coords.size.height / 2f,
|
coords.size.height / 2f,
|
||||||
)
|
)
|
||||||
onTapPosition(
|
onTapPosition(
|
||||||
coords.localToRoot(center),
|
coords.localToWindow(center),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onLongPress()
|
onLongPress()
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ fun DmChatRoute(
|
|||||||
otherUserId: Int,
|
otherUserId: Int,
|
||||||
scrollToMessageId: Int? = null,
|
scrollToMessageId: Int? = null,
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
sharedTransitionScope: SharedTransitionScope,
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||||
|
|||||||
+2
-2
@@ -41,8 +41,8 @@ fun NavController.navigateToPublicChat(
|
|||||||
fun PublicChatChatRoute(
|
fun PublicChatChatRoute(
|
||||||
scrollToMessageId: Int? = null,
|
scrollToMessageId: Int? = null,
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
sharedTransitionScope: SharedTransitionScope,
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.chrisbanes.haze.HazeState
|
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
|
* Shared [HazeState] for the list pane’s context-menu blur. Provide above the list–detail shell;
|
||||||
* panel chrome / rounded outlines. Provide above the panel (list–detail); null in compact.
|
* [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<HazeState?> { null }
|
val LocalPaneHazeState = staticCompositionLocalOf<HazeState?> { null }
|
||||||
|
|
||||||
@@ -32,15 +32,8 @@ fun AppPanel(
|
|||||||
shape: Shape = RoundedCornerShape(24.dp),
|
shape: Shape = RoundedCornerShape(24.dp),
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val paneHazeState = LocalPaneHazeState.current
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier.then(
|
modifier = modifier,
|
||||||
if (paneHazeState != null) {
|
|
||||||
Modifier.hazeSource(paneHazeState)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
),
|
|
||||||
shape = shape,
|
shape = shape,
|
||||||
color = color,
|
color = color,
|
||||||
) {
|
) {
|
||||||
|
|||||||
+31
-127
@@ -1,15 +1,8 @@
|
|||||||
package ru.fromchat.ui.main
|
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.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
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.safeDrawing
|
||||||
import androidx.compose.foundation.layout.union
|
import androidx.compose.foundation.layout.union
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
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.Dp
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import ru.fromchat.ui.components.AppPanel
|
import ru.fromchat.ui.components.AppPanel
|
||||||
import ru.fromchat.ui.extraStatusBars
|
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
|
* 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
|
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
|
* True while the conversation list–detail shell is showing. Chat chrome uses this for
|
||||||
* pill-shaped top bars and edge-to-edge detail layout.
|
* pill-shaped top bars and edge-to-edge detail layout.
|
||||||
*/
|
*/
|
||||||
val LocalConversationListDetailActive = staticCompositionLocalOf { false }
|
val LocalConversationListDetailActive = staticCompositionLocalOf { false }
|
||||||
|
|
||||||
private const val ProfilePaneAnimMs = 280
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List–detail (optional profile) shell. The list pane always uses [AppPanel].
|
* List–detail shell. The list pane always uses [AppPanel] with full rounded corners
|
||||||
* Settings detail uses [AppPanel] too ([detailInPanel]); chat detail is edge-to-edge
|
* (including the edge facing the detail pane). Settings detail uses [AppPanel] too
|
||||||
* ([detailEdgeToEdge]) so floating chrome can blur past its inner padding toward the window.
|
* ([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
|
* 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
|
* 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
|
* — except when [detailEdgeToEdge], where the detail column omits outer padding and keeps
|
||||||
* insets available for its own chrome.
|
* 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
|
@Composable
|
||||||
fun ConversationListDetailShell(
|
fun ConversationListDetailShell(
|
||||||
showProfilePane: Boolean,
|
|
||||||
listPaneWidth: Dp,
|
listPaneWidth: Dp,
|
||||||
listPane: @Composable () -> Unit,
|
listPane: @Composable () -> Unit,
|
||||||
detailPane: @Composable () -> Unit,
|
detailPane: @Composable () -> Unit,
|
||||||
profilePane: @Composable () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
detailInPanel: Boolean = false,
|
detailInPanel: Boolean = false,
|
||||||
detailEdgeToEdge: 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) {
|
CompositionLocalProvider(LocalConversationListDetailActive provides true) {
|
||||||
if (detailEdgeToEdge) {
|
Row(modifier.fillMaxSize()) {
|
||||||
Row(Modifier.fillMaxSize()) {
|
|
||||||
AppPanel(
|
AppPanel(
|
||||||
Modifier
|
Modifier
|
||||||
.windowInsetsPadding(paneInsets)
|
.windowInsetsPadding(paneInsets)
|
||||||
.consumeWindowInsets(paneInsets)
|
.consumeWindowInsets(paneInsets)
|
||||||
.padding(start = 8.dp, top = 8.dp, bottom = 8.dp)
|
.padding(
|
||||||
|
start = 8.dp,
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
)
|
||||||
.width(listPaneWidth)
|
.width(listPaneWidth)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
shape = ListPaneShape,
|
shape = RoundedCornerShape(24.dp),
|
||||||
) {
|
) {
|
||||||
listPane()
|
listPane()
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight()
|
||||||
) {
|
.then(
|
||||||
detailPane()
|
if (detailEdgeToEdge) {
|
||||||
}
|
Modifier
|
||||||
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 {
|
} else {
|
||||||
Row(
|
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
|
||||||
.windowInsetsPadding(paneInsets)
|
.windowInsetsPadding(paneInsets)
|
||||||
.consumeWindowInsets(paneInsets)
|
.consumeWindowInsets(paneInsets)
|
||||||
.padding(8.dp),
|
.padding(
|
||||||
|
top = 8.dp,
|
||||||
|
end = 8.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
AppPanel(
|
|
||||||
Modifier
|
|
||||||
.width(listPaneWidth)
|
|
||||||
.fillMaxHeight(),
|
|
||||||
shape = ListPaneShape,
|
|
||||||
) {
|
|
||||||
listPane()
|
|
||||||
}
|
|
||||||
if (detailInPanel) {
|
if (detailInPanel) {
|
||||||
AppPanel(
|
AppPanel(
|
||||||
Modifier
|
Modifier.fillMaxSize(),
|
||||||
.weight(1f)
|
shape = RoundedCornerShape(24.dp),
|
||||||
.fillMaxHeight(),
|
|
||||||
shape = if (profileVisible) RectangleShape else DetailPaneShape,
|
|
||||||
) {
|
) {
|
||||||
detailPane()
|
detailPane()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight(),
|
|
||||||
) {
|
|
||||||
detailPane()
|
detailPane()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProfilePaneSlot(
|
|
||||||
visible = profileVisible,
|
|
||||||
content = profilePane,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ProfilePaneSlot(
|
|
||||||
visible: Boolean,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val fadeAnim = tween<Float>(
|
|
||||||
durationMillis = ProfilePaneAnimMs,
|
|
||||||
easing = FastOutSlowInEasing,
|
|
||||||
)
|
|
||||||
val sizeAnim = tween<IntSize>(
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,40 @@ package ru.fromchat.ui.main
|
|||||||
|
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavOptionsBuilder
|
import androidx.navigation.NavOptionsBuilder
|
||||||
|
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a main-hub detail destination, replacing any prior conversation/settings/profile
|
* 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.
|
* above the main `chat` root so large-screen switches do not stack previous panes.
|
||||||
* Matches [ru.fromchat.ui.chat.panels.dm.navigateToDmChat] / public chat.
|
* 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(
|
fun NavController.navigateReplacingMainDetail(
|
||||||
route: String,
|
route: String,
|
||||||
|
preserveConversationDetail: Boolean = false,
|
||||||
builder: NavOptionsBuilder.() -> Unit = {},
|
builder: NavOptionsBuilder.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
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) {
|
navigate(route) {
|
||||||
popUpTo("chat") { saveState = true }
|
popUpTo("chat") { saveState = true }
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
builder()
|
builder()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isConversationChatRoute(route: String?): Boolean =
|
||||||
|
route == PublicChatNav.CHAT_ROUTE ||
|
||||||
|
(route != null && route.startsWith("dm/") && "/chat" in route)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.imePadding
|
|||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -40,18 +39,9 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
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.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 androidx.compose.ui.zIndex
|
||||||
import com.pr0gramm3r101.utils.WindowWidthSizeClass
|
import com.pr0gramm3r101.utils.WindowWidthSizeClass
|
||||||
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
|
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.chats.ChatsTab
|
||||||
import ru.fromchat.ui.main.settings.SettingsTab
|
import ru.fromchat.ui.main.settings.SettingsTab
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
const val MAIN_PAGE_CHATS = 0
|
const val MAIN_PAGE_CHATS = 0
|
||||||
const val MAIN_PAGE_CONTACTS = 1
|
const val MAIN_PAGE_CONTACTS = 1
|
||||||
const val MAIN_PAGE_SETTINGS = 2
|
const val MAIN_PAGE_SETTINGS = 2
|
||||||
@@ -126,9 +114,7 @@ fun MainScreen(
|
|||||||
val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||||
val paneHazeState = LocalPaneHazeState.current
|
val paneHazeState = LocalPaneHazeState.current
|
||||||
val contextMenuHazeState = paneHazeState ?: rememberHazeState()
|
val contextMenuHazeState = paneHazeState ?: rememberHazeState()
|
||||||
val paneProvidesHazeSource = paneHazeState != null
|
|
||||||
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
|
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
|
||||||
var contextMenuBlurPaneBoundsInWindow by remember { mutableStateOf(IntRect.Zero) }
|
|
||||||
|
|
||||||
val widthClass = currentWindowAdaptiveInfo().widthSizeClass
|
val widthClass = currentWindowAdaptiveInfo().widthSizeClass
|
||||||
|
|
||||||
@@ -167,15 +153,12 @@ fun MainScreen(
|
|||||||
val settingsLabel = stringResource(Res.string.settings)
|
val settingsLabel = stringResource(Res.string.settings)
|
||||||
val profileLabel = stringResource(Res.string.profile)
|
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() {
|
fun openOwnProfileInDetailPane() {
|
||||||
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return
|
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return
|
||||||
navController.navigateReplacingMainDetail("profile/$userId")
|
navController.navigateReplacingMainDetail(
|
||||||
|
route = "profile/$userId",
|
||||||
|
preserveConversationDetail = embeddedInListDetail,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectMainPage(page: Int) {
|
fun selectMainPage(page: Int) {
|
||||||
@@ -184,17 +167,10 @@ fun MainScreen(
|
|||||||
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
||||||
openOwnProfileInDetailPane()
|
openOwnProfileInDetailPane()
|
||||||
}
|
}
|
||||||
page == PAGE_CHATS -> {
|
|
||||||
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
|
|
||||||
if (embeddedInListDetail && selectedPage != PAGE_CHATS) {
|
|
||||||
clearConversationStack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
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) }
|
scope.launch { pagerState.animateScrollToPage(page) }
|
||||||
if (embeddedInListDetail) {
|
|
||||||
clearConversationStack()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,28 +186,12 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
Modifier
|
Modifier.fillMaxSize(),
|
||||||
.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(),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.then(
|
.hazeSource(contextMenuHazeState),
|
||||||
if (paneProvidesHazeSource) {
|
|
||||||
Modifier
|
|
||||||
} else {
|
|
||||||
Modifier.hazeSource(contextMenuHazeState)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = {
|
snackbarHost = {
|
||||||
@@ -360,8 +320,6 @@ fun MainScreen(
|
|||||||
ChatContextMenuBlurLayer(
|
ChatContextMenuBlurLayer(
|
||||||
hazeState = contextMenuHazeState,
|
hazeState = contextMenuHazeState,
|
||||||
blurProgress = chatMenuBlurProgress,
|
blurProgress = chatMenuBlurProgress,
|
||||||
paneBoundsInWindow = contextMenuBlurPaneBoundsInWindow,
|
|
||||||
expandBeyondPane = paneProvidesHazeSource,
|
|
||||||
modifier = Modifier.zIndex(2f),
|
modifier = Modifier.zIndex(2f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -381,30 +339,17 @@ fun MainScreen(
|
|||||||
private fun ChatContextMenuBlurLayer(
|
private fun ChatContextMenuBlurLayer(
|
||||||
hazeState: HazeState,
|
hazeState: HazeState,
|
||||||
blurProgress: Float,
|
blurProgress: Float,
|
||||||
paneBoundsInWindow: IntRect,
|
|
||||||
expandBeyondPane: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val blurRadius = 12.dp * blurProgress
|
val blurRadius = 12.dp * blurProgress
|
||||||
if (blurRadius <= 0.dp) return
|
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) {
|
// 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 {
|
blurEffect {
|
||||||
style = HazeBlurStyle(
|
style = HazeBlurStyle(
|
||||||
blurRadius = blurRadius,
|
blurRadius = blurRadius,
|
||||||
@@ -414,45 +359,6 @@ private fun ChatContextMenuBlurLayer(
|
|||||||
fallbackColorEffect = HazeColorEffect.tint(Color.Transparent),
|
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import androidx.compose.material.icons.rounded.MarkEmailRead
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.pr0gramm3r101.utils.scaleOnPress
|
import com.pr0gramm3r101.utils.scaleOnPress
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.action_delete
|
import ru.fromchat.action_delete
|
||||||
@@ -57,6 +61,14 @@ internal fun ChatContextMenuMeasurer(
|
|||||||
screenHeightPx: Int,
|
screenHeightPx: Int,
|
||||||
onMeasured: (IntSize) -> Unit,
|
onMeasured: (IntSize) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val latestOnMeasured = rememberUpdatedState(onMeasured)
|
||||||
|
val measuredSizes = remember { Channel<IntSize>(Channel.CONFLATED) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
for (size in measuredSizes) {
|
||||||
|
latestOnMeasured.value(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SubcomposeLayout(Modifier.size(0.dp)) { _ ->
|
SubcomposeLayout(Modifier.size(0.dp)) { _ ->
|
||||||
val looseConstraints = Constraints(
|
val looseConstraints = Constraints(
|
||||||
minWidth = 0,
|
minWidth = 0,
|
||||||
@@ -84,8 +96,9 @@ internal fun ChatContextMenuMeasurer(
|
|||||||
)
|
)
|
||||||
}.map { it.measure(looseConstraints) }
|
}.map { it.measure(looseConstraints) }
|
||||||
val measured = placeables.firstOrNull()?.let { IntSize(it.width, it.height) } ?: IntSize.Zero
|
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) {
|
if (measured != IntSize.Zero) {
|
||||||
onMeasured(measured)
|
measuredSizes.trySend(measured)
|
||||||
}
|
}
|
||||||
layout(0, 0) {
|
layout(0, 0) {
|
||||||
placeables.forEach { it.placeRelative(-10000, -10000) }
|
placeables.forEach { it.placeRelative(-10000, -10000) }
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
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.PointerEvent
|
||||||
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
||||||
import androidx.compose.ui.input.pointer.PointerType
|
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
|
@Composable
|
||||||
internal fun ChatListHeadlineWithBadge(
|
internal fun ChatListHeadlineWithBadge(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -692,32 +739,12 @@ internal fun ChatRowAvatar(
|
|||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.pointerInput(enabled) {
|
.chatRowAvatarGestures(
|
||||||
if (!enabled) return@pointerInput
|
enabled = enabled,
|
||||||
awaitEachGesture {
|
onPressStart = onPressStart,
|
||||||
var downEvent: PointerEvent
|
onPressEnd = onPressEnd,
|
||||||
do {
|
onLongPress = onLongPress,
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = profilePictureUrl,
|
profilePictureUrl = profilePictureUrl,
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ fun SettingsTab() {
|
|||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
|
val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
|
||||||
|
|
||||||
|
fun openDetail(route: String) {
|
||||||
|
navController.navigateReplacingMainDetail(
|
||||||
|
route = route,
|
||||||
|
preserveConversationDetail = isTwoPane,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
@@ -104,7 +111,7 @@ fun SettingsTab() {
|
|||||||
supportingText = stringResource(Res.string.settings_hub_profile_sub),
|
supportingText = stringResource(Res.string.settings_hub_profile_sub),
|
||||||
onClick = {
|
onClick = {
|
||||||
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return@ListItem
|
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return@ListItem
|
||||||
navController.navigateReplacingMainDetail("profile/$userId")
|
openDetail("profile/$userId")
|
||||||
},
|
},
|
||||||
leadingContent = { Icon(Icons.Filled.Person, null) },
|
leadingContent = { Icon(Icons.Filled.Person, null) },
|
||||||
)
|
)
|
||||||
@@ -115,7 +122,7 @@ fun SettingsTab() {
|
|||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.settings_category_account),
|
headline = stringResource(Res.string.settings_category_account),
|
||||||
supportingText = stringResource(Res.string.settings_category_account_d),
|
supportingText = stringResource(Res.string.settings_category_account_d),
|
||||||
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Account) },
|
onClick = { openDetail(SettingsRoutes.Account) },
|
||||||
leadingContent = { Icon(Icons.Filled.AccountCircle, null) },
|
leadingContent = { Icon(Icons.Filled.AccountCircle, null) },
|
||||||
divider = true
|
divider = true
|
||||||
)
|
)
|
||||||
@@ -123,7 +130,7 @@ fun SettingsTab() {
|
|||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.settings_category_devices),
|
headline = stringResource(Res.string.settings_category_devices),
|
||||||
supportingText = stringResource(Res.string.settings_category_devices_d),
|
supportingText = stringResource(Res.string.settings_category_devices_d),
|
||||||
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Devices) },
|
onClick = { openDetail(SettingsRoutes.Devices) },
|
||||||
leadingContent = { Icon(Icons.Filled.Devices, null) },
|
leadingContent = { Icon(Icons.Filled.Devices, null) },
|
||||||
divider = true
|
divider = true
|
||||||
)
|
)
|
||||||
@@ -131,7 +138,7 @@ fun SettingsTab() {
|
|||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.settings_category_appearance),
|
headline = stringResource(Res.string.settings_category_appearance),
|
||||||
supportingText = stringResource(Res.string.settings_category_appearance_d),
|
supportingText = stringResource(Res.string.settings_category_appearance_d),
|
||||||
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Appearance) },
|
onClick = { openDetail(SettingsRoutes.Appearance) },
|
||||||
leadingContent = { Icon(Icons.Filled.Palette, null) },
|
leadingContent = { Icon(Icons.Filled.Palette, null) },
|
||||||
divider = true
|
divider = true
|
||||||
)
|
)
|
||||||
@@ -139,7 +146,7 @@ fun SettingsTab() {
|
|||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.settings_category_notifications),
|
headline = stringResource(Res.string.settings_category_notifications),
|
||||||
supportingText = stringResource(Res.string.settings_category_notifications_d),
|
supportingText = stringResource(Res.string.settings_category_notifications_d),
|
||||||
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Notifications) },
|
onClick = { openDetail(SettingsRoutes.Notifications) },
|
||||||
leadingContent = { Icon(Icons.Filled.Notifications, null) },
|
leadingContent = { Icon(Icons.Filled.Notifications, null) },
|
||||||
divider = true
|
divider = true
|
||||||
)
|
)
|
||||||
@@ -147,7 +154,7 @@ fun SettingsTab() {
|
|||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.change_server),
|
headline = stringResource(Res.string.change_server),
|
||||||
supportingText = stringResource(Res.string.change_server_d),
|
supportingText = stringResource(Res.string.change_server_d),
|
||||||
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.ServerConfig) },
|
onClick = { openDetail(SettingsRoutes.ServerConfig) },
|
||||||
leadingContent = { Icon(Icons.Filled.Storage, null) },
|
leadingContent = { Icon(Icons.Filled.Storage, null) },
|
||||||
divider = true
|
divider = true
|
||||||
)
|
)
|
||||||
@@ -155,7 +162,7 @@ fun SettingsTab() {
|
|||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.about),
|
headline = stringResource(Res.string.about),
|
||||||
supportingText = stringResource(Res.string.settings_hub_about_sub),
|
supportingText = stringResource(Res.string.settings_hub_about_sub),
|
||||||
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.About) },
|
onClick = { openDetail(SettingsRoutes.About) },
|
||||||
leadingContent = { Icon(Icons.Filled.Info, null) },
|
leadingContent = { Icon(Icons.Filled.Info, null) },
|
||||||
divider = true
|
divider = true
|
||||||
)
|
)
|
||||||
@@ -163,7 +170,7 @@ fun SettingsTab() {
|
|||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.logs_title),
|
headline = stringResource(Res.string.logs_title),
|
||||||
supportingText = stringResource(Res.string.settings_hub_logs_sub),
|
supportingText = stringResource(Res.string.settings_hub_logs_sub),
|
||||||
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Logs) },
|
onClick = { openDetail(SettingsRoutes.Logs) },
|
||||||
leadingContent = { Icon(Icons.Outlined.BugReport, null) }
|
leadingContent = { Icon(Icons.Outlined.BugReport, null) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
@@ -698,7 +699,15 @@ fun ProfileScreen(
|
|||||||
animatedVisibilityScope != null &&
|
animatedVisibilityScope != null &&
|
||||||
sharedAvatarKey != null
|
sharedAvatarKey != null
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.widthIn(max = 600.dp),
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -864,7 +873,9 @@ fun ProfileScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PublicChatProfileScreen(
|
fun PublicChatProfileScreen(
|
||||||
@@ -985,7 +996,15 @@ fun PublicChatProfileScreen(
|
|||||||
sharedAvatarKey != null
|
sharedAvatarKey != null
|
||||||
|
|
||||||
ScreenSurface(modifier = modifier) {
|
ScreenSurface(modifier = modifier) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.widthIn(max = 600.dp),
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -1073,6 +1092,7 @@ fun PublicChatProfileScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ import com.pr0gramm3r101.utils.plus
|
|||||||
import com.pr0gramm3r101.utils.right
|
import com.pr0gramm3r101.utils.right
|
||||||
import com.pr0gramm3r101.utils.scaleOnPress
|
import com.pr0gramm3r101.utils.scaleOnPress
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import tech.annexflow.constraintlayout.compose.ConstraintLayout
|
import tech.annexflow.constraintlayout.compose.ConstraintLayout
|
||||||
import tech.annexflow.constraintlayout.compose.ConstraintLayoutScope
|
import tech.annexflow.constraintlayout.compose.ConstraintLayoutScope
|
||||||
@@ -251,6 +252,12 @@ private fun ListItemContextMenuPopup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var measuredSize by remember { mutableStateOf(IntSize.Zero) }
|
var measuredSize by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
val measuredSizes = remember { Channel<IntSize>(Channel.CONFLATED) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
for (size in measuredSizes) {
|
||||||
|
if (measuredSize == IntSize.Zero) measuredSize = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SubcomposeLayout(Modifier.size(0.dp)) { _ ->
|
SubcomposeLayout(Modifier.size(0.dp)) { _ ->
|
||||||
val looseConstraints = Constraints(
|
val looseConstraints = Constraints(
|
||||||
@@ -269,7 +276,7 @@ private fun ListItemContextMenuPopup(
|
|||||||
}.map { it.measure(looseConstraints) }
|
}.map { it.measure(looseConstraints) }
|
||||||
val p = placeables.firstOrNull()
|
val p = placeables.firstOrNull()
|
||||||
if (p != null && measuredSize == IntSize.Zero) {
|
if (p != null && measuredSize == IntSize.Zero) {
|
||||||
measuredSize = IntSize(p.width, p.height)
|
measuredSizes.trySend(IntSize(p.width, p.height))
|
||||||
}
|
}
|
||||||
layout(0, 0) {
|
layout(0, 0) {
|
||||||
placeables.forEach { it.placeRelative(-10000, -10000) }
|
placeables.forEach { it.placeRelative(-10000, -10000) }
|
||||||
|
|||||||
Reference in New Issue
Block a user