Window insets and more UI work

This commit is contained in:
2026-07-26 16:40:00 +03:00
Unverified
parent 2b46c88bf3
commit b07cd8abaf
19 changed files with 582 additions and 97 deletions
@@ -1,14 +1,17 @@
package ru.fromchat.desktop
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Notification
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
@@ -20,10 +23,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.jetbrains.skia.Image
import ru.fromchat.AppForeground
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.ui.App
import ru.fromchat.ui.LocalExtraStatusBarTop
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
@@ -32,6 +37,7 @@ import java.net.ServerSocket
import java.net.Socket
import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JRootPane
import kotlin.concurrent.thread
private object DesktopApplicationBootstrap {
@@ -189,7 +195,7 @@ fun main(args: Array<String>) {
val windowState = rememberWindowState()
var windowVisible by remember { mutableStateOf(true) }
val trayState = rememberTrayState()
val trayIcon = remember { createTrayIconPainter() }
val appIcon = remember { loadAppIconPainter() }
val traySupported = remember { java.awt.SystemTray.isSupported() }
DisposableEffect(trayState) {
@@ -206,7 +212,7 @@ fun main(args: Array<String>) {
if (traySupported) {
Tray(
icon = trayIcon,
icon = appIcon,
state = trayState,
tooltip = "FromChat",
onAction = { windowVisible = true },
@@ -229,21 +235,39 @@ fun main(args: Array<String>) {
title = "FromChat",
state = windowState,
visible = windowVisible || !traySupported,
icon = trayIcon,
icon = appIcon,
) {
LaunchedEffect(window) {
enableEdgeToEdgeTitleBar(window.rootPane)
}
DisposableEffect(Unit) {
AppForeground.setForeground(true)
onDispose { }
}
App()
CompositionLocalProvider(
LocalExtraStatusBarTop provides if (isMacOs()) 28.dp else 0.dp,
) {
App()
}
}
}
}
private fun createTrayIconPainter(): Painter = object : Painter() {
override val intrinsicSize: Size = Size(32f, 32f)
private fun isMacOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
override fun DrawScope.onDraw() {
drawRect(Color(0xFF4484F4))
}
private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
if (!isMacOs()) return
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
}
private fun loadAppIconPainter(): Painter {
val bytes = DesktopApplicationBootstrap::class.java.classLoader
.getResourceAsStream("app_icon.png")
?.use { it.readBytes() }
?: return BitmapPainter(ImageBitmap(32, 32))
val skiaImage = Image.makeFromEncoded(bytes)
return BitmapPainter(skiaImage.toComposeImageBitmap())
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,3 @@
package ru.fromchat
actual fun supportsMouseMessageInteraction(): Boolean = false
@@ -63,6 +63,7 @@
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
<string name="chats">Чаты</string>
<string name="select_chat_placeholder">Выберите чат, чтобы начать переписку</string>
<string name="contacts">Контакты</string>
<string name="profile">Профиль</string>
<string name="dms">Личные чаты</string>
@@ -71,6 +71,7 @@
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
<!-- Main Screen -->
<string name="chats">Chats</string>
<string name="select_chat_placeholder">Select a chat to start messaging</string>
<string name="contacts">Contacts</string>
<string name="profile">Profile</string>
<string name="dms">Private chats</string>
@@ -0,0 +1,7 @@
package ru.fromchat
/**
* True on desktop/JVM where mouse left-click should select message text and
* right-click opens the context menu.
*/
expect fun supportsMouseMessageInteraction(): Boolean
@@ -11,6 +11,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@@ -24,6 +25,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -100,6 +102,10 @@ import ru.fromchat.ui.chat.panels.dm.DmProfileRoute
import ru.fromchat.ui.chat.panels.publicchat.PublicChatChatRoute
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute
import ru.fromchat.ui.main.ConversationListDetailShell
import ru.fromchat.ui.main.ConversationProfileThirdPaneMinWidth
import ru.fromchat.ui.main.EmptyConversationPlaceholder
import ru.fromchat.ui.main.MAIN_PAGE_CHATS
import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.main.chats.ChatsSearchScreen
import ru.fromchat.ui.main.settings.LOG_FILE_OPEN_RESULT_KEY
@@ -130,6 +136,20 @@ private fun isConversationChatRoute(route: String?): Boolean =
route == PublicChatNav.CHAT_ROUTE ||
(route != null && route.startsWith("dm/") && "/chat" in route)
private fun isConversationProfileRoute(route: String?): Boolean =
route == PublicChatNav.PROFILE_ROUTE ||
(route != null && route.startsWith("dm/") && route.endsWith("/profile"))
private fun isConversationShellRoute(route: String?): Boolean =
route == "chat" ||
isConversationChatRoute(route) ||
isConversationProfileRoute(route)
private fun dmUserIdFromRoute(route: String?): Int? {
if (route == null || !route.startsWith("dm/")) return null
return route.substringAfter("dm/").substringBefore("/").toIntOrNull()?.takeIf { it > 0 }
}
private val rootNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
private fun rootNavEnterTransition(): EnterTransition =
@@ -437,31 +457,27 @@ fun App(
) {
if (startDestination != null) {
val currentEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentEntry?.destination?.route
val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass
var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) }
val showConversationListDetail =
currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.EXPANDED &&
isConversationChatRoute(currentEntry?.destination?.route)
widthSizeClass != WindowWidthSizeClass.COMPACT &&
isConversationShellRoute(currentRoute)
ScreenSurface {
Box(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize()) {
if (showConversationListDetail) {
Box(
modifier = Modifier
.width(360.dp)
.fillMaxHeight(),
) {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
snackbarHostState = profileLookupSnackbarHostState,
listPaneOnly = true,
)
}
VerticalDivider()
}
Box(Modifier.weight(1f).fillMaxSize()) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val showProfileThirdPane =
showConversationListDetail &&
isConversationProfileRoute(currentRoute) &&
maxWidth >= ConversationProfileThirdPaneMinWidth
@Composable
fun AppNavHost(modifier: Modifier = Modifier) {
NavHost(
navController = navController,
startDestination = startDestination!!,
modifier = modifier,
enterTransition = { rootNavEnterTransition() },
exitTransition = { rootNavExitTransition() },
popEnterTransition = { rootNavPopEnterTransition() },
@@ -517,11 +533,16 @@ fun App(
}
composable("chat") {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
snackbarHostState = profileLookupSnackbarHostState
)
if (showConversationListDetail) {
EmptyConversationPlaceholder()
} else {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
snackbarHostState = profileLookupSnackbarHostState,
initialPage = pendingMainTab,
)
}
}
composable(PublicChatNav.CHAT_ROUTE) {
@@ -778,6 +799,63 @@ fun App(
}
}
}
if (showConversationListDetail) {
ConversationListDetailShell(
widthSizeClass = widthSizeClass,
showProfilePane = showProfileThirdPane,
listPane = {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
snackbarHostState = profileLookupSnackbarHostState,
listPaneOnly = true,
)
},
detailPane = {
if (showProfileThirdPane) {
androidx.compose.animation.AnimatedVisibility(
visible = true,
enter = EnterTransition.None,
exit = ExitTransition.None,
) {
val dmUserId = dmUserIdFromRoute(currentRoute)
when {
dmUserId != null -> DmChatRoute(
otherUserId = dmUserId,
scrollToMessageId = null,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
currentRoute == PublicChatNav.PROFILE_ROUTE ||
currentRoute == PublicChatNav.CHAT_ROUTE -> {
PublicChatChatRoute(
scrollToMessageId = scrollToMessageId,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
}
}
}
} else {
AppNavHost(Modifier.fillMaxSize())
}
},
profilePane = {
AppNavHost(Modifier.fillMaxSize())
},
onSelectMainTab = { page ->
pendingMainTab = page
navController.navigate("chat") {
popUpTo("chat") { inclusive = true }
launchSingleTop = true
}
},
)
} else {
AppNavHost(Modifier.fillMaxSize())
}
}
CallOverlay(Modifier.fillMaxSize())
@@ -0,0 +1,28 @@
package ru.fromchat.ui
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
/**
* Extra top inset for desktop edge-to-edge windows where the title bar
* is treated like a status bar.
*/
val LocalExtraStatusBarTop = staticCompositionLocalOf { 0.dp }
/**
* [WindowInsets.statusBars] plus [LocalExtraStatusBarTop] (title-bar inset on desktop).
*/
val WindowInsets.Companion.extraStatusBars: WindowInsets
@Composable
get() {
val extra = LocalExtraStatusBarTop.current
if (extra <= 0.dp) return statusBars
val density = LocalDensity.current
val extraPx = with(density) { extra.roundToPx() }
return statusBars.union(WindowInsets(0, extraPx, 0, 0))
}
@@ -149,6 +149,7 @@ import ru.fromchat.ui.chat.utils.imageAttachmentKey
import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.utils.NetworkConnectivity
import ru.fromchat.utils.formatLastSeen
import ru.fromchat.utils.haptic.HapticFeedbackEvent
@@ -1060,8 +1061,9 @@ fun ChatScreen(
}
}
) { innerPadding ->
val density = LocalDensity.current
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val statusBarTopDp = with(density) {
WindowInsets.extraStatusBars.getTop(this).toDp()
}
val floatingHeaderClearance =
statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius
SideEffect {
@@ -72,6 +72,7 @@ import ru.fromchat.chat_members_count
import ru.fromchat.ui.chat.utils.TypingUser
import ru.fromchat.ui.components.ConnectingEllipsis
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.profile.StatusBadge
import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.ui.profile.resolveVerificationStatus
@@ -338,7 +339,7 @@ fun ChatTopBar(
val topBarPlaceable = subcompose("topBar") {
TopAppBar(
modifier = Modifier.fillMaxWidth(),
windowInsets = WindowInsets.statusBars,
windowInsets = WindowInsets.extraStatusBars,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
@@ -62,6 +62,7 @@ import ru.fromchat.api.local.download.resolveSavableMessageFile
import ru.fromchat.api.local.download.resolveSavableMessageImage
import ru.fromchat.api.local.messages.isQueuedOutbound
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.supportsMouseMessageInteraction
import ru.fromchat.ui.components.Text
import com.pr0gramm3r101.utils.scaleOnPress
@@ -188,11 +189,19 @@ fun MessageContextMenu(
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) {
if (measuredSize == IntSize.Zero) {
val adjustedOffset = remember(
measuredSize,
state.position,
rightEdge,
bottomEdge,
paddingPx,
allowOutsideWindow,
) {
if (allowOutsideWindow || measuredSize == IntSize.Zero) {
state.position
} else {
var x = state.position.x
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material.icons.rounded.ErrorOutline
@@ -45,8 +46,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
@@ -77,6 +82,7 @@ import ru.fromchat.message_edited_suffix
import ru.fromchat.message_reply_jump_cd
import ru.fromchat.message_reply_photo
import ru.fromchat.message_send_failed
import ru.fromchat.supportsMouseMessageInteraction
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
import ru.fromchat.ui.chat.utils.imageAttachmentKey
import ru.fromchat.ui.components.Text
@@ -246,7 +252,14 @@ fun MessageItem(
var replyPressed by remember(message.id) { mutableStateOf(false) }
var rowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
var bubbleContentCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f
LaunchedEffect(isContextMenuForThisMessage) {
if (!isContextMenuForThisMessage) isPressed = false
}
val scaleTarget = when {
isContextMenuForThisMessage -> 0.96f
isPressed && !isContextMenuOpen -> 0.96f
else -> 1f
}
val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f
val replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f
val scale by animateFloatAsState(
@@ -455,26 +468,47 @@ fun MessageItem(
)
}
val mouseMessageUi = supportsMouseMessageInteraction()
val rowLongPress =
if (isContextMenuOpen) Modifier
else Modifier.pointerInput(message.id, onLongPress) {
detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
else Modifier.pointerInput(message.id, onLongPress, mouseMessageUi) {
if (mouseMessageUi) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
if (event.type == PointerEventType.Press &&
event.buttons.isSecondaryPressed
) {
event.changes.forEach { it.consume() }
val local = event.changes.firstOrNull()?.position ?: continue
val coords = rowLayoutCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(local))
}
onLongPress()
}
}
},
onLongPress = { localOffset ->
val coords = rowLayoutCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(localOffset))
}
onLongPress()
},
)
}
} else {
detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
}
},
onLongPress = { localOffset ->
val coords = rowLayoutCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(localOffset))
}
onLongPress()
},
)
}
}
Row(
@@ -580,25 +614,48 @@ fun MessageItem(
if (isContextMenuOpen) Modifier
else Modifier
.onGloballyPositioned { bubbleContentCoords = it }
.pointerInput(message.id, onBubbleTap, onLongPress) {
detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
.pointerInput(message.id, onBubbleTap, onLongPress, mouseMessageUi) {
if (mouseMessageUi) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
when {
event.type == PointerEventType.Press &&
event.buttons.isSecondaryPressed -> {
event.changes.forEach { it.consume() }
val local =
event.changes.firstOrNull()?.position
?: continue
val coords = bubbleContentCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(local))
}
isPressed = true
onLongPress()
}
}
}
},
onTap = { onBubbleTap?.invoke() },
onLongPress = { localOffset ->
val coords = bubbleContentCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(localOffset))
}
onLongPress()
},
)
}
} else {
detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
}
},
onTap = { onBubbleTap?.invoke() },
onLongPress = { localOffset ->
val coords = bubbleContentCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(localOffset))
}
onLongPress()
},
)
}
}
Box {
@@ -1021,12 +1078,23 @@ fun MessageItem(
!isCorrupted &&
!isFilenameOnlyMessageCaption(message)
) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
modifier = Modifier.padding(horizontal = 14.dp)
)
if (mouseMessageUi) {
SelectionContainer {
Text(
text = message.content,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
modifier = Modifier.padding(horizontal = 14.dp)
)
}
} else {
Text(
text = message.content,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
modifier = Modifier.padding(horizontal = 14.dp)
)
}
}
}
}
@@ -0,0 +1,197 @@
package ru.fromchat.ui.main
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.NavigationItem
import com.pr0gramm3r101.utils.SimpleNavigationRail
import com.pr0gramm3r101.utils.WindowWidthSizeClass
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.chats
import ru.fromchat.contacts
import ru.fromchat.profile
import ru.fromchat.settings
import ru.fromchat.ui.components.Text
/** Minimum width to show chat + profile side-by-side beside the list. */
val ConversationProfileThirdPaneMinWidth = 1100.dp
private val ProfilePaneSlideEasing = CubicBezierEasing(0.22f, 1f, 0.36f, 1f)
private const val ProfilePaneAnimMs = 520
@Composable
fun ConversationListDetailShell(
widthSizeClass: WindowWidthSizeClass,
showProfilePane: Boolean,
listPane: @Composable () -> Unit,
detailPane: @Composable () -> Unit,
profilePane: @Composable () -> Unit,
onSelectMainTab: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val useRail = widthSizeClass == WindowWidthSizeClass.EXPANDED
val chatsLabel = stringResource(Res.string.chats)
val contactsLabel = stringResource(Res.string.contacts)
val settingsLabel = stringResource(Res.string.settings)
val profileLabel = stringResource(Res.string.profile)
BoxWithConstraints(modifier.fillMaxSize()) {
val canFitProfilePane = maxWidth >= ConversationProfileThirdPaneMinWidth
val profileVisible = showProfilePane && canFitProfilePane
Row(Modifier.fillMaxSize()) {
if (useRail) {
SimpleNavigationRail(
selectedItem = MAIN_PAGE_CHATS,
modifier = Modifier.fillMaxHeight(),
NavigationItem(
name = chatsLabel,
selectedIcon = Icons.AutoMirrored.Filled.Chat,
onClick = { onSelectMainTab(MAIN_PAGE_CHATS) },
),
NavigationItem(
name = contactsLabel,
selectedIcon = Icons.Filled.Contacts,
onClick = { onSelectMainTab(MAIN_PAGE_CONTACTS) },
),
NavigationItem(
name = settingsLabel,
selectedIcon = Icons.Filled.Settings,
onClick = { onSelectMainTab(MAIN_PAGE_SETTINGS) },
),
NavigationItem(
name = profileLabel,
selectedIcon = Icons.Filled.Person,
onClick = { onSelectMainTab(MAIN_PAGE_PROFILE) },
),
)
}
Column(Modifier.weight(1f).fillMaxHeight()) {
Row(Modifier.weight(1f).fillMaxWidth()) {
Box(
Modifier
.width(360.dp)
.fillMaxHeight(),
) {
listPane()
}
VerticalDivider()
Box(
Modifier
.weight(1f)
.fillMaxHeight(),
) {
detailPane()
}
AnimatedVisibility(
visible = profileVisible,
enter = slideInHorizontally(
animationSpec = tween(
durationMillis = ProfilePaneAnimMs,
easing = ProfilePaneSlideEasing,
),
initialOffsetX = { it },
),
exit = slideOutHorizontally(
animationSpec = tween(
durationMillis = ProfilePaneAnimMs,
easing = ProfilePaneSlideEasing,
),
targetOffsetX = { it },
),
) {
Row(Modifier.fillMaxHeight()) {
VerticalDivider()
Box(
Modifier
.widthIn(min = 320.dp, max = 420.dp)
.fillMaxHeight()
.width(380.dp),
) {
profilePane()
}
}
}
}
if (!useRail) {
NavigationBar(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.imePadding(),
containerColor = Color.Transparent,
tonalElevation = 0.dp,
windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
) {
NavigationBarItem(
selected = true,
onClick = { onSelectMainTab(MAIN_PAGE_CHATS) },
label = { Text(chatsLabel) },
icon = {
Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null)
},
)
NavigationBarItem(
selected = false,
onClick = { onSelectMainTab(MAIN_PAGE_CONTACTS) },
label = { Text(contactsLabel) },
icon = {
Icon(Icons.Filled.Contacts, contentDescription = null)
},
)
NavigationBarItem(
selected = false,
onClick = { onSelectMainTab(MAIN_PAGE_SETTINGS) },
label = { Text(settingsLabel) },
icon = {
Icon(Icons.Filled.Settings, contentDescription = null)
},
)
NavigationBarItem(
selected = false,
onClick = { onSelectMainTab(MAIN_PAGE_PROFILE) },
label = { Text(profileLabel) },
icon = {
Icon(Icons.Filled.Person, contentDescription = null)
},
)
}
}
}
}
}
}
@@ -0,0 +1,40 @@
package ru.fromchat.ui.main
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.select_chat_placeholder
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
@Composable
fun EmptyConversationPlaceholder(modifier: Modifier = Modifier) {
val density = LocalDensity.current
val topInset = with(density) {
WindowInsets.extraStatusBars.getTop(this).toDp()
}
Box(
modifier = modifier
.fillMaxSize()
.padding(top = topInset)
.padding(horizontal = 32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.select_chat_placeholder),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@@ -34,6 +34,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -75,16 +76,23 @@ 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 ru.fromchat.ui.extraStatusBars
private const val PAGE_CHATS = 0
private const val PAGE_CONTACTS = 1
private const val PAGE_SETTINGS = 2
private const val PAGE_PROFILE = 3
const val MAIN_PAGE_CHATS = 0
const val MAIN_PAGE_CONTACTS = 1
const val MAIN_PAGE_SETTINGS = 2
const val MAIN_PAGE_PROFILE = 3
private const val PAGE_CHATS = MAIN_PAGE_CHATS
private const val PAGE_CONTACTS = MAIN_PAGE_CONTACTS
private const val PAGE_SETTINGS = MAIN_PAGE_SETTINGS
private const val PAGE_PROFILE = MAIN_PAGE_PROFILE
private const val PAGE_COUNT = 4
/**
* @param listPaneOnly When true, renders only the chats list (no nav rail/bar) for use as the
* list side of an expanded listdetail conversation layout.
* list side of a listdetail conversation layout. Host owns navigation chrome.
* @param initialPage Pager page when showing the full MainScreen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -93,11 +101,12 @@ fun MainScreen(
animatedVisibilityScope: AnimatedVisibilityScope? = null,
snackbarHostState: SnackbarHostState? = null,
listPaneOnly: Boolean = false,
initialPage: Int = PAGE_CHATS,
) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current
val pagerState = rememberPagerState(
initialPage = PAGE_CHATS,
initialPage = initialPage.coerceIn(0, PAGE_COUNT - 1),
pageCount = { PAGE_COUNT },
)
val scope = rememberCoroutineScope()
@@ -107,20 +116,30 @@ fun MainScreen(
val contextMenuHazeState = rememberHazeState()
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
val useNavigationRail = !listPaneOnly &&
currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
val widthClass = currentWindowAdaptiveInfo().widthSizeClass
// Compact/medium: bottom bar. Expanded: rail. List pane never owns chrome.
val useNavigationRail = !listPaneOnly && widthClass == WindowWidthSizeClass.EXPANDED
val useBottomBar = !listPaneOnly && !useNavigationRail
LaunchedEffect(initialPage) {
if (!listPaneOnly && pagerState.currentPage != initialPage) {
pagerState.scrollToPage(initialPage.coerceIn(0, PAGE_COUNT - 1))
}
}
val selectedPage = pagerState.currentPage
val isChatsPage = selectedPage == PAGE_CHATS
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
val statusBarTopDp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val statusBarTop = with(density) {
WindowInsets.extraStatusBars.getTop(this).toDp()
}
var bottomChromeHeightDp by remember { mutableStateOf(0.dp) }
val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp, useNavigationRail, listPaneOnly) {
val mainChromeInsets = remember(statusBarTop, bottomChromeHeightDp, useNavigationRail, useBottomBar) {
MainChromeInsets(
top = statusBarTopDp,
bottom = if (useNavigationRail || listPaneOnly) 0.dp else bottomChromeHeightDp,
top = statusBarTop,
bottom = if (useBottomBar) bottomChromeHeightDp else 0.dp,
)
}
@@ -242,7 +261,7 @@ fun MainScreen(
}
}
if (!useNavigationRail && !listPaneOnly) {
if (useBottomBar) {
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
@@ -75,6 +75,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import ru.fromchat.ui.extraStatusBars
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -374,7 +375,7 @@ private fun ChatsNormalTopBar(
) {
TopAppBar(
modifier = modifier,
windowInsets = WindowInsets.statusBars,
windowInsets = WindowInsets.extraStatusBars,
colors = chatsTopAppBarColors(blurReveal),
title = {
Row(
@@ -468,7 +469,7 @@ private fun ChatsSelectionTopBar(
TopAppBar(
modifier = modifier,
windowInsets = WindowInsets.statusBars,
windowInsets = WindowInsets.extraStatusBars,
colors = chatsTopAppBarColors(blurReveal),
navigationIcon = {
IconButton(onClick = onClose) {
@@ -0,0 +1,3 @@
package ru.fromchat
actual fun supportsMouseMessageInteraction(): Boolean = false
@@ -0,0 +1,3 @@
package ru.fromchat
actual fun supportsMouseMessageInteraction(): Boolean = true