mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Window insets and more UI work
This commit is contained in:
@@ -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 list–detail conversation layout.
|
||||
* list side of a list–detail 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
|
||||
Reference in New Issue
Block a user