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>) {
|
||||
// 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)
|
||||
|
||||
@@ -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<String?>(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<Float>(
|
||||
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<Float>(
|
||||
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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
@@ -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<HazeState?> { 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,
|
||||
) {
|
||||
|
||||
+53
-149
@@ -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<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.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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<IntSize>(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) }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IntSize>(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) }
|
||||
|
||||
Reference in New Issue
Block a user