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
|
package ru.fromchat.desktop
|
||||||
|
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
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.Notification
|
||||||
import androidx.compose.ui.window.Tray
|
import androidx.compose.ui.window.Tray
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
@@ -20,10 +23,12 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.skia.Image
|
||||||
import ru.fromchat.AppForeground
|
import ru.fromchat.AppForeground
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
||||||
import ru.fromchat.ui.App
|
import ru.fromchat.ui.App
|
||||||
|
import ru.fromchat.ui.LocalExtraStatusBarTop
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.io.OutputStreamWriter
|
import java.io.OutputStreamWriter
|
||||||
@@ -32,6 +37,7 @@ import java.net.ServerSocket
|
|||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import javax.swing.JRootPane
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
private object DesktopApplicationBootstrap {
|
private object DesktopApplicationBootstrap {
|
||||||
@@ -189,7 +195,7 @@ fun main(args: Array<String>) {
|
|||||||
val windowState = rememberWindowState()
|
val windowState = rememberWindowState()
|
||||||
var windowVisible by remember { mutableStateOf(true) }
|
var windowVisible by remember { mutableStateOf(true) }
|
||||||
val trayState = rememberTrayState()
|
val trayState = rememberTrayState()
|
||||||
val trayIcon = remember { createTrayIconPainter() }
|
val appIcon = remember { loadAppIconPainter() }
|
||||||
val traySupported = remember { java.awt.SystemTray.isSupported() }
|
val traySupported = remember { java.awt.SystemTray.isSupported() }
|
||||||
|
|
||||||
DisposableEffect(trayState) {
|
DisposableEffect(trayState) {
|
||||||
@@ -206,7 +212,7 @@ fun main(args: Array<String>) {
|
|||||||
|
|
||||||
if (traySupported) {
|
if (traySupported) {
|
||||||
Tray(
|
Tray(
|
||||||
icon = trayIcon,
|
icon = appIcon,
|
||||||
state = trayState,
|
state = trayState,
|
||||||
tooltip = "FromChat",
|
tooltip = "FromChat",
|
||||||
onAction = { windowVisible = true },
|
onAction = { windowVisible = true },
|
||||||
@@ -229,21 +235,39 @@ fun main(args: Array<String>) {
|
|||||||
title = "FromChat",
|
title = "FromChat",
|
||||||
state = windowState,
|
state = windowState,
|
||||||
visible = windowVisible || !traySupported,
|
visible = windowVisible || !traySupported,
|
||||||
icon = trayIcon,
|
icon = appIcon,
|
||||||
) {
|
) {
|
||||||
|
LaunchedEffect(window) {
|
||||||
|
enableEdgeToEdgeTitleBar(window.rootPane)
|
||||||
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
AppForeground.setForeground(true)
|
AppForeground.setForeground(true)
|
||||||
onDispose { }
|
onDispose { }
|
||||||
}
|
}
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalExtraStatusBarTop provides if (isMacOs()) 28.dp else 0.dp,
|
||||||
|
) {
|
||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun createTrayIconPainter(): Painter = object : Painter() {
|
|
||||||
override val intrinsicSize: Size = Size(32f, 32f)
|
|
||||||
|
|
||||||
override fun DrawScope.onDraw() {
|
|
||||||
drawRect(Color(0xFF4484F4))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isMacOs(): Boolean =
|
||||||
|
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
|
||||||
|
|
||||||
|
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_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
|
||||||
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
||||||
<string name="chats">Чаты</string>
|
<string name="chats">Чаты</string>
|
||||||
|
<string name="select_chat_placeholder">Выберите чат, чтобы начать переписку</string>
|
||||||
<string name="contacts">Контакты</string>
|
<string name="contacts">Контакты</string>
|
||||||
<string name="profile">Профиль</string>
|
<string name="profile">Профиль</string>
|
||||||
<string name="dms">Личные чаты</string>
|
<string name="dms">Личные чаты</string>
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
||||||
<!-- Main Screen -->
|
<!-- Main Screen -->
|
||||||
<string name="chats">Chats</string>
|
<string name="chats">Chats</string>
|
||||||
|
<string name="select_chat_placeholder">Select a chat to start messaging</string>
|
||||||
<string name="contacts">Contacts</string>
|
<string name="contacts">Contacts</string>
|
||||||
<string name="profile">Profile</string>
|
<string name="profile">Profile</string>
|
||||||
<string name="dms">Private chats</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.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -24,6 +25,7 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.PublicChatChatRoute
|
||||||
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
|
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
|
||||||
import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute
|
import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute
|
||||||
|
import ru.fromchat.ui.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.MainScreen
|
||||||
import ru.fromchat.ui.main.chats.ChatsSearchScreen
|
import ru.fromchat.ui.main.chats.ChatsSearchScreen
|
||||||
import ru.fromchat.ui.main.settings.LOG_FILE_OPEN_RESULT_KEY
|
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 == PublicChatNav.CHAT_ROUTE ||
|
||||||
(route != null && route.startsWith("dm/") && "/chat" in 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 val rootNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||||
|
|
||||||
private fun rootNavEnterTransition(): EnterTransition =
|
private fun rootNavEnterTransition(): EnterTransition =
|
||||||
@@ -437,31 +457,27 @@ fun App(
|
|||||||
) {
|
) {
|
||||||
if (startDestination != null) {
|
if (startDestination != null) {
|
||||||
val currentEntry by navController.currentBackStackEntryAsState()
|
val currentEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = currentEntry?.destination?.route
|
||||||
|
val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass
|
||||||
|
var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) }
|
||||||
val showConversationListDetail =
|
val showConversationListDetail =
|
||||||
currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.EXPANDED &&
|
widthSizeClass != WindowWidthSizeClass.COMPACT &&
|
||||||
isConversationChatRoute(currentEntry?.destination?.route)
|
isConversationShellRoute(currentRoute)
|
||||||
|
|
||||||
ScreenSurface {
|
ScreenSurface {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Row(Modifier.fillMaxSize()) {
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
if (showConversationListDetail) {
|
val showProfileThirdPane =
|
||||||
Box(
|
showConversationListDetail &&
|
||||||
modifier = Modifier
|
isConversationProfileRoute(currentRoute) &&
|
||||||
.width(360.dp)
|
maxWidth >= ConversationProfileThirdPaneMinWidth
|
||||||
.fillMaxHeight(),
|
|
||||||
) {
|
@Composable
|
||||||
MainScreen(
|
fun AppNavHost(modifier: Modifier = Modifier) {
|
||||||
sharedTransitionScope = this@SharedTransitionLayout,
|
|
||||||
snackbarHostState = profileLookupSnackbarHostState,
|
|
||||||
listPaneOnly = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
VerticalDivider()
|
|
||||||
}
|
|
||||||
Box(Modifier.weight(1f).fillMaxSize()) {
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination!!,
|
startDestination = startDestination!!,
|
||||||
|
modifier = modifier,
|
||||||
enterTransition = { rootNavEnterTransition() },
|
enterTransition = { rootNavEnterTransition() },
|
||||||
exitTransition = { rootNavExitTransition() },
|
exitTransition = { rootNavExitTransition() },
|
||||||
popEnterTransition = { rootNavPopEnterTransition() },
|
popEnterTransition = { rootNavPopEnterTransition() },
|
||||||
@@ -517,12 +533,17 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable("chat") {
|
composable("chat") {
|
||||||
|
if (showConversationListDetail) {
|
||||||
|
EmptyConversationPlaceholder()
|
||||||
|
} else {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
sharedTransitionScope = this@SharedTransitionLayout,
|
sharedTransitionScope = this@SharedTransitionLayout,
|
||||||
animatedVisibilityScope = this,
|
animatedVisibilityScope = this,
|
||||||
snackbarHostState = profileLookupSnackbarHostState
|
snackbarHostState = profileLookupSnackbarHostState,
|
||||||
|
initialPage = pendingMainTab,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
composable(PublicChatNav.CHAT_ROUTE) {
|
composable(PublicChatNav.CHAT_ROUTE) {
|
||||||
PublicChatChatRoute(
|
PublicChatChatRoute(
|
||||||
@@ -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())
|
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.chat.utils.visibleMessageIdsInChatList
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
|
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
|
||||||
|
import ru.fromchat.ui.extraStatusBars
|
||||||
import ru.fromchat.utils.NetworkConnectivity
|
import ru.fromchat.utils.NetworkConnectivity
|
||||||
import ru.fromchat.utils.formatLastSeen
|
import ru.fromchat.utils.formatLastSeen
|
||||||
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
||||||
@@ -1060,8 +1061,9 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
val density = LocalDensity.current
|
val statusBarTopDp = with(density) {
|
||||||
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
WindowInsets.extraStatusBars.getTop(this).toDp()
|
||||||
|
}
|
||||||
val floatingHeaderClearance =
|
val floatingHeaderClearance =
|
||||||
statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius
|
statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius
|
||||||
SideEffect {
|
SideEffect {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import ru.fromchat.chat_members_count
|
|||||||
import ru.fromchat.ui.chat.utils.TypingUser
|
import ru.fromchat.ui.chat.utils.TypingUser
|
||||||
import ru.fromchat.ui.components.ConnectingEllipsis
|
import ru.fromchat.ui.components.ConnectingEllipsis
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
|
import ru.fromchat.ui.extraStatusBars
|
||||||
import ru.fromchat.ui.profile.StatusBadge
|
import ru.fromchat.ui.profile.StatusBadge
|
||||||
import ru.fromchat.ui.profile.peerIsDeleted
|
import ru.fromchat.ui.profile.peerIsDeleted
|
||||||
import ru.fromchat.ui.profile.resolveVerificationStatus
|
import ru.fromchat.ui.profile.resolveVerificationStatus
|
||||||
@@ -338,7 +339,7 @@ fun ChatTopBar(
|
|||||||
val topBarPlaceable = subcompose("topBar") {
|
val topBarPlaceable = subcompose("topBar") {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
windowInsets = WindowInsets.statusBars,
|
windowInsets = WindowInsets.extraStatusBars,
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
scrolledContainerColor = 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.download.resolveSavableMessageImage
|
||||||
import ru.fromchat.api.local.messages.isQueuedOutbound
|
import ru.fromchat.api.local.messages.isQueuedOutbound
|
||||||
import ru.fromchat.api.schema.messages.Message
|
import ru.fromchat.api.schema.messages.Message
|
||||||
|
import ru.fromchat.supportsMouseMessageInteraction
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import com.pr0gramm3r101.utils.scaleOnPress
|
import com.pr0gramm3r101.utils.scaleOnPress
|
||||||
|
|
||||||
@@ -188,11 +189,19 @@ fun MessageContextMenu(
|
|||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val paddingPx = with(density) { 16.dp.toPx().toInt() }
|
val paddingPx = with(density) { 16.dp.toPx().toInt() }
|
||||||
|
val allowOutsideWindow = supportsMouseMessageInteraction()
|
||||||
val rightEdge = screenWidthPx - paddingPx
|
val rightEdge = screenWidthPx - paddingPx
|
||||||
val bottomEdge = screenHeightPx - paddingPx
|
val bottomEdge = screenHeightPx - paddingPx
|
||||||
|
|
||||||
val adjustedOffset = remember(measuredSize, state.position, rightEdge, bottomEdge, paddingPx) {
|
val adjustedOffset = remember(
|
||||||
if (measuredSize == IntSize.Zero) {
|
measuredSize,
|
||||||
|
state.position,
|
||||||
|
rightEdge,
|
||||||
|
bottomEdge,
|
||||||
|
paddingPx,
|
||||||
|
allowOutsideWindow,
|
||||||
|
) {
|
||||||
|
if (allowOutsideWindow || measuredSize == IntSize.Zero) {
|
||||||
state.position
|
state.position
|
||||||
} else {
|
} else {
|
||||||
var x = state.position.x
|
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.widthIn
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Timer
|
import androidx.compose.material.icons.outlined.Timer
|
||||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
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.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.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.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
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_jump_cd
|
||||||
import ru.fromchat.message_reply_photo
|
import ru.fromchat.message_reply_photo
|
||||||
import ru.fromchat.message_send_failed
|
import ru.fromchat.message_send_failed
|
||||||
|
import ru.fromchat.supportsMouseMessageInteraction
|
||||||
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
|
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
|
||||||
import ru.fromchat.ui.chat.utils.imageAttachmentKey
|
import ru.fromchat.ui.chat.utils.imageAttachmentKey
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
@@ -246,7 +252,14 @@ fun MessageItem(
|
|||||||
var replyPressed by remember(message.id) { mutableStateOf(false) }
|
var replyPressed by remember(message.id) { mutableStateOf(false) }
|
||||||
var rowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
|
var rowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
|
||||||
var bubbleContentCoords 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 avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f
|
||||||
val replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f
|
val replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f
|
||||||
val scale by animateFloatAsState(
|
val scale by animateFloatAsState(
|
||||||
@@ -455,9 +468,29 @@ fun MessageItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val mouseMessageUi = supportsMouseMessageInteraction()
|
||||||
|
|
||||||
val rowLongPress =
|
val rowLongPress =
|
||||||
if (isContextMenuOpen) Modifier
|
if (isContextMenuOpen) Modifier
|
||||||
else Modifier.pointerInput(message.id, onLongPress) {
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onPress = {
|
onPress = {
|
||||||
isPressed = true
|
isPressed = true
|
||||||
@@ -476,6 +509,7 @@ fun MessageItem(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -580,7 +614,29 @@ fun MessageItem(
|
|||||||
if (isContextMenuOpen) Modifier
|
if (isContextMenuOpen) Modifier
|
||||||
else Modifier
|
else Modifier
|
||||||
.onGloballyPositioned { bubbleContentCoords = it }
|
.onGloballyPositioned { bubbleContentCoords = it }
|
||||||
.pointerInput(message.id, onBubbleTap, onLongPress) {
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onPress = {
|
onPress = {
|
||||||
isPressed = true
|
isPressed = true
|
||||||
@@ -600,6 +656,7 @@ fun MessageItem(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
Column(
|
Column(
|
||||||
@@ -1021,6 +1078,8 @@ fun MessageItem(
|
|||||||
!isCorrupted &&
|
!isCorrupted &&
|
||||||
!isFilenameOnlyMessageCaption(message)
|
!isFilenameOnlyMessageCaption(message)
|
||||||
) {
|
) {
|
||||||
|
if (mouseMessageUi) {
|
||||||
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
@@ -1028,6 +1087,15 @@ fun MessageItem(
|
|||||||
modifier = Modifier.padding(horizontal = 14.dp)
|
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.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.chats.ChatsTab
|
||||||
import ru.fromchat.ui.main.settings.SettingsTab
|
import ru.fromchat.ui.main.settings.SettingsTab
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
|
import ru.fromchat.ui.extraStatusBars
|
||||||
|
|
||||||
private const val PAGE_CHATS = 0
|
const val MAIN_PAGE_CHATS = 0
|
||||||
private const val PAGE_CONTACTS = 1
|
const val MAIN_PAGE_CONTACTS = 1
|
||||||
private const val PAGE_SETTINGS = 2
|
const val MAIN_PAGE_SETTINGS = 2
|
||||||
private const val PAGE_PROFILE = 3
|
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
|
private const val PAGE_COUNT = 4
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param listPaneOnly When true, renders only the chats list (no nav rail/bar) for use as the
|
* @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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -93,11 +101,12 @@ fun MainScreen(
|
|||||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
snackbarHostState: SnackbarHostState? = null,
|
snackbarHostState: SnackbarHostState? = null,
|
||||||
listPaneOnly: Boolean = false,
|
listPaneOnly: Boolean = false,
|
||||||
|
initialPage: Int = PAGE_CHATS,
|
||||||
) {
|
) {
|
||||||
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
|
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(
|
||||||
initialPage = PAGE_CHATS,
|
initialPage = initialPage.coerceIn(0, PAGE_COUNT - 1),
|
||||||
pageCount = { PAGE_COUNT },
|
pageCount = { PAGE_COUNT },
|
||||||
)
|
)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -107,20 +116,30 @@ fun MainScreen(
|
|||||||
val contextMenuHazeState = rememberHazeState()
|
val contextMenuHazeState = rememberHazeState()
|
||||||
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
|
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
|
||||||
|
|
||||||
val useNavigationRail = !listPaneOnly &&
|
val widthClass = currentWindowAdaptiveInfo().widthSizeClass
|
||||||
currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
|
// 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 selectedPage = pagerState.currentPage
|
||||||
val isChatsPage = selectedPage == PAGE_CHATS
|
val isChatsPage = selectedPage == PAGE_CHATS
|
||||||
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
|
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) }
|
var bottomChromeHeightDp by remember { mutableStateOf(0.dp) }
|
||||||
|
|
||||||
val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp, useNavigationRail, listPaneOnly) {
|
val mainChromeInsets = remember(statusBarTop, bottomChromeHeightDp, useNavigationRail, useBottomBar) {
|
||||||
MainChromeInsets(
|
MainChromeInsets(
|
||||||
top = statusBarTopDp,
|
top = statusBarTop,
|
||||||
bottom = if (useNavigationRail || listPaneOnly) 0.dp else bottomChromeHeightDp,
|
bottom = if (useBottomBar) bottomChromeHeightDp else 0.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +261,7 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!useNavigationRail && !listPaneOnly) {
|
if (useBottomBar) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import ru.fromchat.ui.extraStatusBars
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -374,7 +375,7 @@ private fun ChatsNormalTopBar(
|
|||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
windowInsets = WindowInsets.statusBars,
|
windowInsets = WindowInsets.extraStatusBars,
|
||||||
colors = chatsTopAppBarColors(blurReveal),
|
colors = chatsTopAppBarColors(blurReveal),
|
||||||
title = {
|
title = {
|
||||||
Row(
|
Row(
|
||||||
@@ -468,7 +469,7 @@ private fun ChatsSelectionTopBar(
|
|||||||
|
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
windowInsets = WindowInsets.statusBars,
|
windowInsets = WindowInsets.extraStatusBars,
|
||||||
colors = chatsTopAppBarColors(blurReveal),
|
colors = chatsTopAppBarColors(blurReveal),
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onClose) {
|
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