Improve UI

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-26 18:26:56 +03:00
Unverified
parent b07cd8abaf
commit 7c28f8539d
44 changed files with 1650 additions and 4313 deletions
@@ -1,5 +1,6 @@
package ru.fromchat.desktop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -7,28 +8,34 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.KeyShortcut
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.MenuBar
import androidx.compose.ui.window.Notification
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
import com.pr0gramm3r101.utils.UtilsLibrary
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.awt.Desktop
import java.awt.KeyboardFocusManager
import java.awt.event.ActionEvent
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
@@ -37,8 +44,22 @@ import java.net.ServerSocket
import java.net.Socket
import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JComponent
import javax.swing.JRootPane
import javax.swing.SwingUtilities
import javax.swing.text.DefaultEditorKit
import kotlin.concurrent.thread
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.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.ui.App
import ru.fromchat.ui.LocalExtraStatusBarTop
private object DesktopApplicationBootstrap {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -113,28 +134,19 @@ private object DesktopProtocolRegistration {
fun registerBestEffort() {
runCatching {
when {
isMac() -> Unit // Packaged macOS builds declare CFBundleURLTypes in Info.plist.
isWindows() -> registerWindows()
isLinux() -> registerLinux()
isMacOs() -> Unit // Packaged macOS builds declare CFBundleURLTypes in Info.plist.
isWindowsOs() -> registerWindows()
isLinuxOs() -> registerLinux()
}
}
}
private fun isMac(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
private fun isWindows(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("win")
private fun isLinux(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("linux")
private fun javaLauncherCommand(): String {
val javaHome = System.getProperty("java.home")
val javaBin = if (isWindows()) "$javaHome\\bin\\javaw.exe" else "$javaHome/bin/java"
val javaBin = if (isWindowsOs()) "$javaHome\\bin\\javaw.exe" else "$javaHome/bin/java"
val classPath = System.getProperty("java.class.path")
val main = "ru.fromchat.desktop.MainKt"
return if (isWindows()) {
return if (isWindowsOs()) {
"\"$javaBin\" -cp \"$classPath\" $main"
} else {
"$javaBin -cp ${shellQuote(classPath)} $main"
@@ -183,6 +195,10 @@ private object DesktopProtocolRegistration {
}
fun main(args: Array<String>) {
if (isMacOs()) {
System.setProperty("apple.awt.application.name", "FromChat")
System.setProperty("apple.laf.useScreenMenuBar", "true")
}
if (!DesktopSingleInstance.acquireOrForward(args)) return
DesktopProtocolRegistration.registerBestEffort()
args.filter { DesktopDeepLinkBus.isFromChatUri(it) }
@@ -193,10 +209,17 @@ fun main(args: Array<String>) {
DesktopApplicationBootstrap.launchOnApplicationStart()
val windowState = rememberWindowState()
val aboutWindowState = rememberWindowState(
position = WindowPosition.Aligned(Alignment.Center),
size = DpSize(440.dp, 640.dp),
)
var windowVisible by remember { mutableStateOf(true) }
var aboutOpen by remember { mutableStateOf(false) }
val trayState = rememberTrayState()
val appIcon = remember { loadAppIconPainter() }
val trayIcon = remember { loadAppIconPainter(tray = true) }
val windowIcon = remember { loadAppIconPainter(tray = false) }
val traySupported = remember { java.awt.SystemTray.isSupported() }
val mac = remember { isMacOs() }
DisposableEffect(trayState) {
DesktopNotifier.sink = { title, body ->
@@ -210,14 +233,26 @@ fun main(args: Array<String>) {
}
}
LaunchedEffect(Unit) {
if (!mac || !Desktop.isDesktopSupported()) return@LaunchedEffect
val desktop = Desktop.getDesktop()
if (desktop.isSupported(Desktop.Action.APP_ABOUT)) {
desktop.setAboutHandler {
SwingUtilities.invokeLater { aboutOpen = true }
}
}
}
if (traySupported) {
Tray(
icon = appIcon,
icon = trayIcon,
state = trayState,
tooltip = "FromChat",
onAction = { windowVisible = true },
menu = {
Item("Show") { windowVisible = true }
Item("About FromChat") { aboutOpen = true }
Separator()
Item("Quit") { exitApplication() }
},
)
@@ -235,7 +270,31 @@ fun main(args: Array<String>) {
title = "FromChat",
state = windowState,
visible = windowVisible || !traySupported,
icon = appIcon,
icon = windowIcon,
onPreviewKeyEvent = { event ->
if (mac || event.type != KeyEventType.KeyDown || !event.isCtrlPressed) {
return@Window false
}
when {
event.isShiftPressed && event.key == Key.A -> {
aboutOpen = true
true
}
event.isShiftPressed && event.key == Key.N -> {
windowVisible = true
true
}
event.key == Key.M -> {
windowState.isMinimized = true
true
}
event.key == Key.Q -> {
exitApplication()
true
}
else -> false
}
},
) {
LaunchedEffect(window) {
enableEdgeToEdgeTitleBar(window.rootPane)
@@ -244,18 +303,109 @@ fun main(args: Array<String>) {
AppForeground.setForeground(true)
onDispose { }
}
if (mac) {
FromChatMenuBar(
onAbout = { aboutOpen = true },
onShow = { windowVisible = true },
onMinimize = { windowState.isMinimized = true },
onZoom = {
windowState.placement =
if (windowState.placement == WindowPlacement.Maximized) {
WindowPlacement.Floating
} else {
WindowPlacement.Maximized
}
},
)
}
CompositionLocalProvider(
LocalExtraStatusBarTop provides if (isMacOs()) 28.dp else 0.dp,
LocalExtraStatusBarTop provides if (mac) 28.dp else 0.dp,
) {
App()
}
}
if (aboutOpen) {
Window(
onCloseRequest = { aboutOpen = false },
title = "About FromChat",
state = aboutWindowState,
icon = windowIcon,
) {
DesktopAboutContent(onClose = { aboutOpen = false })
}
}
}
}
@Composable
private fun FrameWindowScope.FromChatMenuBar(
onAbout: () -> Unit,
onShow: () -> Unit,
onMinimize: () -> Unit,
onZoom: () -> Unit,
) {
MenuBar {
Menu("Actions", mnemonic = 'A') {
Item("About FromChat", onClick = onAbout)
Item(
"Show FromChat",
shortcut = KeyShortcut(Key.N, meta = true, shift = true),
onClick = onShow,
)
}
Menu("Edit", mnemonic = 'E') {
Item(
"Cut",
shortcut = KeyShortcut(Key.X, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.cutAction) },
)
Item(
"Copy",
shortcut = KeyShortcut(Key.C, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.copyAction) },
)
Item(
"Paste",
shortcut = KeyShortcut(Key.V, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.pasteAction) },
)
Separator()
Item(
"Select All",
shortcut = KeyShortcut(Key.A, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.selectAllAction) },
)
}
Menu("Window", mnemonic = 'W') {
Item(
"Minimize",
shortcut = KeyShortcut(Key.M, meta = true),
onClick = onMinimize,
)
Item("Zoom", onClick = onZoom)
}
}
}
private fun performAwtEditAction(actionName: String) {
val focusOwner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner as? JComponent
?: return
val action = focusOwner.actionMap.get(actionName) ?: return
action.actionPerformed(ActionEvent(focusOwner, ActionEvent.ACTION_PERFORMED, actionName))
}
private fun isMacOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
private fun isWindowsOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("win")
private fun isLinuxOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("linux")
private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
if (!isMacOs()) return
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
@@ -263,11 +413,43 @@ private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
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())
/**
* Loads the desktop tray or window icon from packaged resources.
* Prefers PNG, then WebP; returns a tiny empty bitmap only if every decode fails.
*/
private fun loadAppIconPainter(tray: Boolean): Painter {
val names = if (tray) {
listOf("app_icon.png", "app_icon.webp", "app_window_icon.png", "app_window_icon.webp")
} else {
listOf("app_window_icon.png", "app_window_icon.webp", "app_icon.png", "app_icon.webp")
}
val loaders = listOfNotNull(
Thread.currentThread().contextClassLoader,
DesktopApplicationBootstrap::class.java.classLoader,
ClassLoader.getSystemClassLoader(),
)
for (name in names) {
for (loader in loaders) {
val stream = loader.getResourceAsStream(name)
?: loader.getResourceAsStream("/$name")
?: continue
val bytes = runCatching { stream.use { it.readBytes() } }.getOrNull() ?: continue
if (bytes.isEmpty()) continue
val bitmap = runCatching {
Image.makeFromEncoded(bytes).toComposeImageBitmap()
}.onFailure { error ->
Logger.w("DesktopIcon", "Failed to decode $name: ${error.message}")
}.getOrNull() ?: continue
if (bitmap.width < 16 || bitmap.height < 16) {
Logger.w("DesktopIcon", "Skipping $name — decoded size ${bitmap.width}x${bitmap.height}")
continue
}
Logger.i("DesktopIcon", "Loaded $name (${bitmap.width}x${bitmap.height}) tray=$tray")
return BitmapPainter(bitmap)
}
}
Logger.w("DesktopIcon", "No app icon resource decoded; using empty placeholder")
return BitmapPainter(ImageBitmap(32, 32))
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -0,0 +1,3 @@
package ru.fromchat.config
internal actual fun defaultEnterToSend(): Boolean = false
@@ -0,0 +1,8 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
@Composable
internal actual fun ProvideChatTextSelectionMenu(content: @Composable () -> Unit) {
content()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 879 KiB

After

Width:  |  Height:  |  Size: 94 KiB

@@ -64,6 +64,8 @@
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
<string name="chats">Чаты</string>
<string name="select_chat_placeholder">Выберите чат, чтобы начать переписку</string>
<string name="select_contact_placeholder">Выберите контакт</string>
<string name="select_settings_placeholder">Выберите раздел настроек</string>
<string name="contacts">Контакты</string>
<string name="profile">Профиль</string>
<string name="dms">Личные чаты</string>
@@ -299,6 +301,8 @@
<string name="logout">Выйти</string>
<string name="materialYou">Material You</string>
<string name="materialYou_d">Цвета как на обоях. Работает с Android 12 и новее.</string>
<string name="enter_to_send">Отправка по Enter</string>
<string name="enter_to_send_d">Enter отправляет сообщение. Shift+Enter — новая строка.</string>
<string name="theme">Оформление</string>
<string name="as_system">Как на телефоне</string>
<string name="light">Светлое</string>
@@ -374,6 +378,7 @@
<string name="settings_security_step_confirm_body">Введите новый пароль ещё раз, чтобы убедиться, что без ошибок.</string>
<string name="settings_next">Далее</string>
<string name="settings_hub_about_sub">Версия, ссылки и другое</string>
<string name="settings_hub_profile_sub">Ваш профиль и публичная информация</string>
<string name="logs_title">Журнал</string>
<string name="settings_hub_logs_sub">Просмотр, отправка и очистка журнала</string>
<string name="logs_empty">Записей пока нет</string>
@@ -72,6 +72,8 @@
<!-- Main Screen -->
<string name="chats">Chats</string>
<string name="select_chat_placeholder">Select a chat to start messaging</string>
<string name="select_contact_placeholder">Select a contact to view</string>
<string name="select_settings_placeholder">Select a setting to view</string>
<string name="contacts">Contacts</string>
<string name="profile">Profile</string>
<string name="dms">Private chats</string>
@@ -328,6 +330,8 @@
<string name="logout">Log out</string>
<string name="materialYou">Material You</string>
<string name="materialYou_d">Match colors to your wallpaper. Works on Android 12 and up.</string>
<string name="enter_to_send">Send on Enter</string>
<string name="enter_to_send_d">Enter sends the message. Shift+Enter adds a new line.</string>
<string name="theme">Look</string>
<string name="as_system">Same as phone</string>
<string name="light">Light</string>
@@ -402,6 +406,7 @@
<string name="settings_security_step_confirm_body">Type your new password again to make sure it matches.</string>
<string name="settings_next">Next</string>
<string name="settings_hub_about_sub">Version, links, and more</string>
<string name="settings_hub_profile_sub">Your profile and public info</string>
<string name="logs_title">Logs</string>
<string name="settings_hub_logs_sub">View, share, and manage app logs</string>
<string name="logs_empty">No log entries yet</string>
@@ -0,0 +1,4 @@
package ru.fromchat.config
/** Platform default for [Settings.enterToSend] when the key is unset. */
internal expect fun defaultEnterToSend(): Boolean
@@ -17,6 +17,7 @@ import com.pr0gramm3r101.utils.settings.Settings as PlatformSettings
object Settings {
private const val MATERIAL_YOU_KEY = "materialYou"
private const val THEME_KEY = "theme"
private const val ENTER_TO_SEND_KEY = "enter_to_send"
/** Legacy single field "host:port" or hostname — migrated once to [SERVER_IP_KEY] / [API_PORT_KEY]. */
private const val SERVER_URL_KEY = "server_url"
private const val SERVER_IP_KEY = "server_ip"
@@ -80,6 +81,16 @@ object Settings {
get() = runBlocking { Theme.entries[settings.getInt(THEME_KEY, Theme.AsSystem.ordinal)] }
set(value) = runIO { settings.putInt(THEME_KEY, value.ordinal) }
/**
* When true, Enter sends the message and Shift+Enter inserts a newline.
* When false, Enter inserts a newline (previous composer behavior).
*
* Default: ON on desktop (JVM), OFF on mobile (Android / iOS).
*/
var enterToSend: Boolean
get() = runBlocking { settings.getBoolean(ENTER_TO_SEND_KEY, defaultEnterToSend()) }
set(value) = runIO { settings.putBoolean(ENTER_TO_SEND_KEY, value) }
var httpsEnabled: Boolean
get() = runBlocking {
if (settings.contains(HTTPS_ENABLED_KEY)) {
@@ -12,13 +12,9 @@ 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
import androidx.compose.foundation.layout.width
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -99,15 +95,22 @@ import ru.fromchat.ui.calls.CallOverlay
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
import ru.fromchat.ui.chat.panels.dm.DmNav
import ru.fromchat.ui.chat.panels.dm.DmProfileRoute
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
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.chat.panels.publicchat.navigateToPublicChat
import ru.fromchat.ui.main.ConversationListDetailShell
import ru.fromchat.ui.main.ConversationProfileThirdPaneMinWidth
import ru.fromchat.ui.main.EmptyContactsPlaceholder
import ru.fromchat.ui.main.EmptyConversationPlaceholder
import ru.fromchat.ui.main.EmptySettingsPlaceholder
import ru.fromchat.ui.main.MAIN_PAGE_CHATS
import ru.fromchat.ui.main.MAIN_PAGE_CONTACTS
import ru.fromchat.ui.main.MAIN_PAGE_SETTINGS
import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.main.chats.ChatsSearchScreen
import ru.fromchat.ui.main.navigateReplacingMainDetail
import ru.fromchat.ui.main.settings.LOG_FILE_OPEN_RESULT_KEY
import ru.fromchat.ui.main.settings.LogFilesScreen
import ru.fromchat.ui.main.settings.LogsScreen
@@ -140,10 +143,45 @@ 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 isStandaloneProfileRoute(route: String?): Boolean =
route != null && route.startsWith("profile/")
private fun standaloneProfileUserId(route: String?): Int? {
if (route == null || !isStandaloneProfileRoute(route)) return null
return route
.substringAfter("profile/")
.substringBefore("?")
.toIntOrNull()
?.takeIf { it > 0 }
}
private fun isConversationShellRoute(
route: String?,
previousRoute: String?,
ownUserId: Int?,
): Boolean {
if (route == "chat" || isConversationChatRoute(route) || isConversationProfileRoute(route)) {
return true
}
if (isStandaloneProfileRoute(route)) {
if (isConversationChatRoute(previousRoute)) return true
return ownUserId != null && standaloneProfileUserId(route) == ownUserId
}
if (route != null && route.startsWith("settings/")) return true
if (route == "about") return true
if (route == SettingsRoutes.ServerConfig) {
return previousRoute == "chat" ||
previousRoute == "about" ||
(previousRoute != null && previousRoute.startsWith("settings/"))
}
return false
}
private fun isSettingsDetailRoute(route: String?): Boolean {
if (route == null) return false
if (route.startsWith("settings/") || route == "about") return true
return route == SettingsRoutes.ServerConfig
}
private fun dmUserIdFromRoute(route: String?): Int? {
if (route == null || !route.startsWith("dm/")) return null
@@ -427,15 +465,10 @@ fun App(
"navigating to dm user=${target.dmConversationUserId} " +
"messageId=${target.scrollToMessageId} launchId=${target.launchId}"
)
navController.navigate(
DmNav.chatRoute(
navController.navigateToDmChat(
otherUserId = target.dmConversationUserId,
sourceMessageId = target.scrollToMessageId,
)
) {
launchSingleTop = true
popUpTo("chat") { saveState = true }
}
}
target.startAtPublicChat -> {
@@ -443,9 +476,7 @@ fun App(
"NotificationLaunch",
"navigating to public chat launchId=${target.launchId}"
)
navController.navigate(PublicChatNav.CHAT_ROUTE) {
launchSingleTop = true
}
navController.navigateToPublicChat()
}
}
}
@@ -458,19 +489,47 @@ fun App(
if (startDestination != null) {
val currentEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentEntry?.destination?.route
val previousRoute =
navController.previousBackStackEntry?.destination?.route
val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass
var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) }
val ownUserId = ApiClient.user?.id
val showConversationListDetail =
widthSizeClass != WindowWidthSizeClass.COMPACT &&
isConversationShellRoute(currentRoute)
isConversationShellRoute(currentRoute, previousRoute, ownUserId)
ScreenSurface {
Box(Modifier.fillMaxSize()) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val canFitProfileThirdPane =
maxWidth >= ConversationProfileThirdPaneMinWidth
val showProfileThirdPane =
showConversationListDetail &&
isConversationProfileRoute(currentRoute) &&
maxWidth >= ConversationProfileThirdPaneMinWidth
canFitProfileThirdPane &&
(
isConversationProfileRoute(currentRoute) ||
(
isStandaloneProfileRoute(currentRoute) &&
isConversationChatRoute(previousRoute)
)
)
val settingsProfileSplit =
showConversationListDetail &&
isStandaloneProfileRoute(currentRoute) &&
!showProfileThirdPane &&
standaloneProfileUserId(currentRoute) == ownUserId
val listPaneWidth =
if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
448.dp
} else {
360.dp
}
val forceSettingsListTab =
settingsProfileSplit ||
(
showConversationListDetail &&
isSettingsDetailRoute(currentRoute)
)
@Composable
fun AppNavHost(modifier: Modifier = Modifier) {
@@ -575,12 +634,12 @@ fun App(
animatedVisibilityScope = this,
onOpenProfile = { userId: Int ->
if (userId != 0) {
navController.navigate("profile/$userId")
navController.navigateReplacingMainDetail("profile/$userId")
}
},
onOpenConversation = { userId: Int ->
if (userId != 0) {
navController.navigate(DmNav.chatRoute(userId))
navController.navigateToDmChat(userId)
}
}
)
@@ -620,9 +679,9 @@ fun App(
ProfileScreen(
userId = userId,
username = profileUsername,
showBackButton = true,
showBackButton = !settingsProfileSplit,
onBack = { navController.navigateUp() },
onChat = { navController.navigate(DmNav.chatRoute(it)) },
onChat = { navController.navigateToDmChat(it) },
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable,
showErrorAsToast = fromDeepLink
@@ -802,23 +861,36 @@ fun App(
if (showConversationListDetail) {
ConversationListDetailShell(
widthSizeClass = widthSizeClass,
showProfilePane = showProfileThirdPane,
listPaneWidth = listPaneWidth,
listPane = {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
snackbarHostState = profileLookupSnackbarHostState,
listPaneOnly = true,
embeddedInListDetail = true,
initialPage = pendingMainTab,
forceSettingsTab = forceSettingsListTab,
onPageChanged = { pendingMainTab = it },
)
},
detailPane = {
if (showProfileThirdPane) {
when {
showProfileThirdPane -> {
androidx.compose.animation.AnimatedVisibility(
visible = true,
enter = EnterTransition.None,
exit = ExitTransition.None,
) {
val dmUserId = dmUserIdFromRoute(currentRoute)
val chatRouteForDetail =
when {
isConversationProfileRoute(currentRoute) ->
currentRoute
isConversationChatRoute(previousRoute) ->
previousRoute
else -> null
}
val dmUserId = dmUserIdFromRoute(chatRouteForDetail)
?: dmUserIdFromRoute(currentRoute)
when {
dmUserId != null -> DmChatRoute(
otherUserId = dmUserId,
@@ -827,8 +899,9 @@ fun App(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
currentRoute == PublicChatNav.PROFILE_ROUTE ||
currentRoute == PublicChatNav.CHAT_ROUTE -> {
chatRouteForDetail == PublicChatNav.PROFILE_ROUTE ||
chatRouteForDetail == PublicChatNav.CHAT_ROUTE ||
currentRoute == PublicChatNav.PROFILE_ROUTE -> {
PublicChatChatRoute(
scrollToMessageId = scrollToMessageId,
navController = navController,
@@ -838,20 +911,22 @@ fun App(
}
}
}
} else {
}
isSettingsDetailRoute(currentRoute) ->
AppNavHost(Modifier.fillMaxSize())
currentRoute == "chat" -> {
when (pendingMainTab) {
MAIN_PAGE_CONTACTS -> EmptyContactsPlaceholder()
MAIN_PAGE_SETTINGS -> EmptySettingsPlaceholder()
else -> EmptyConversationPlaceholder()
}
}
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())
@@ -36,6 +36,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Reply
import androidx.compose.material.icons.rounded.ArrowUpward
@@ -63,14 +65,22 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.conditional
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
@@ -85,6 +95,7 @@ import ru.fromchat.cd_pick_file
import ru.fromchat.cd_pick_image
import ru.fromchat.cd_remove
import ru.fromchat.cd_send
import ru.fromchat.config.Settings
import ru.fromchat.message_corrupted_short
import ru.fromchat.message_editing_title
import ru.fromchat.message_placeholder
@@ -290,6 +301,7 @@ fun ChatInput(
}
val canSend = !isReadOnly && (text.isNotBlank() || attachments.isNotEmpty())
val enterToSend = Settings.enterToSend
val cdClose = stringResource(Res.string.cd_close)
val cdRemove = stringResource(Res.string.cd_remove)
val cdPickImage = stringResource(Res.string.cd_pick_image)
@@ -301,6 +313,15 @@ fun ChatInput(
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
val cdVoiceUnavailable = "Функция пока не готова. Следите за обновлениями!"
fun sendMessage() {
if (!canSend) return
val plaintext = text.trim().ifBlank { "" }
onSend(plaintext, attachments)
onTextChange("")
attachments = emptyList()
typingHandler.stopTyping()
}
Box(
modifier = Modifier
.fillMaxWidth()
@@ -447,10 +468,27 @@ fun ChatInput(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.align(Alignment.Bottom),
.align(Alignment.Bottom)
.conditional(enterToSend) {
onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
if (event.key != Key.Enter && event.key != Key.NumPadEnter) {
return@onPreviewKeyEvent false
}
if (event.isShiftPressed) return@onPreviewKeyEvent false
sendMessage()
true
}
},
textStyle = inputTextStyle,
singleLine = false,
maxLines = 5,
keyboardOptions = KeyboardOptions(
imeAction = if (enterToSend) ImeAction.Send else ImeAction.Default,
),
keyboardActions = KeyboardActions(
onSend = { sendMessage() },
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box(
@@ -545,11 +583,7 @@ fun ChatInput(
.clip(CircleShape)
.clickable {
if (canSend) {
val plaintext = text.trim().ifBlank { "" }
onSend(plaintext, attachments)
onTextChange("")
attachments = emptyList()
typingHandler.stopTyping()
sendMessage()
} else {
scope.launch {
snackbarHostState?.showSnackbar(message = cdVoiceUnavailable)
@@ -1075,13 +1075,18 @@ fun ChatScreen(
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures {
.then(
if (contextMenuState.isOpen) {
contextMenuState = contextMenuState.copy(isOpen = false)
}
Modifier.pointerInput(Unit) {
detectTapGestures {
contextMenuState =
contextMenuState.copy(isOpen = false)
}
}
} else {
Modifier
},
),
) {
if (panelState.isLoading && panelState.messages.isEmpty()) {
Box(
@@ -0,0 +1,10 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
/**
* On desktop, provides a [MessageContextMenu]-styled text selection menu (copy, etc.).
* On touch targets this is a no-op passthrough.
*/
@Composable
internal expect fun ProvideChatTextSelectionMenu(content: @Composable () -> Unit)
@@ -317,15 +317,114 @@ private fun ContextMenuContent(
transformOriginX: Float = 0f,
transformOriginY: Float = 0f,
) {
val menuShape = RoundedCornerShape(16.dp)
val menuScrollState = rememberScrollState()
val labelReply = stringResource(Res.string.action_reply)
val labelEdit = stringResource(Res.string.action_edit)
val labelDelete = stringResource(Res.string.action_delete)
val labelCopy = stringResource(Res.string.action_copy)
val labelSave = stringResource(Res.string.action_save)
val labelCancelSend = stringResource(Res.string.action_cancel_send)
val labelRetrySend = stringResource(Res.string.action_retry_send)
val isQueued = message.isQueuedOutbound() && isAuthor
val sendFailed = isQueued && !message.uploadError.isNullOrBlank()
val canSave = resolveSavableMessageImage(message) != null ||
resolveSavableMessageFile(message) != null
ChatStyleContextMenuFrame(
modifier = modifier,
animated = animated,
withShadow = withShadow,
scale = scale,
alpha = alpha,
transformOriginX = transformOriginX,
transformOriginY = transformOriginY,
) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 8.dp)
.verticalScroll(menuScrollState),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (!message.isContentCorrupted) {
ChatStyleContextMenuItem(
icon = Icons.Rounded.ContentCopy,
text = labelCopy,
onClick = { onCopy(message) },
)
}
if (canSave) {
ChatStyleContextMenuItem(
icon = Icons.Rounded.SaveAlt,
text = labelSave,
onClick = { onSave(message) },
)
}
if (sendFailed) {
ChatStyleContextMenuItem(
icon = Icons.Rounded.Refresh,
text = labelRetrySend,
onClick = { onRetrySend(message) },
)
ChatStyleContextMenuItem(
icon = Icons.Rounded.Close,
text = labelCancelSend,
onClick = { onCancelSend(message) },
isError = true,
)
} else if (isQueued) {
ChatStyleContextMenuItem(
icon = Icons.Rounded.Close,
text = labelCancelSend,
onClick = { onCancelSend(message) },
isError = true,
)
} else if (!isReadOnly) {
ChatStyleContextMenuItem(
icon = Icons.AutoMirrored.Rounded.Reply,
text = labelReply,
onClick = { onReply(message) },
)
if (isAuthor && !message.isContentCorrupted) {
ChatStyleContextMenuItem(
icon = Icons.Rounded.Edit,
text = labelEdit,
onClick = { onEdit(message) },
)
}
if (canDelete) {
ChatStyleContextMenuItem(
icon = Icons.Rounded.Delete,
text = labelDelete,
onClick = { onDelete(message) },
isError = true,
)
}
}
}
}
}
private val chatStyleMenuItemShape = RoundedCornerShape(12.dp)
/** Shared chrome for message + text-selection context menus. */
@Composable
internal fun ChatStyleContextMenuFrame(
modifier: Modifier = Modifier,
animated: Boolean = false,
withShadow: Boolean = true,
scale: Float = 1f,
alpha: Float = 1f,
transformOriginX: Float = 0f,
transformOriginY: Float = 0f,
content: @Composable () -> Unit,
) {
val menuShape = RoundedCornerShape(16.dp)
val density = LocalDensity.current
val shadowElevationPx = if (withShadow) {
with(density) { 12.dp.toPx() }
} else {
0f
}
val baseModifier = modifier.width(IntrinsicSize.Max)
val containerModifier =
if (animated) {
@@ -336,125 +435,49 @@ private fun ContextMenuContent(
transformOrigin = TransformOrigin(transformOriginX, transformOriginY),
shadowElevation = shadowElevationPx,
shape = menuShape,
clip = true
clip = true,
)
} else {
baseModifier.graphicsLayer(
shadowElevation = shadowElevationPx,
shape = menuShape,
clip = true
clip = true,
)
}
val menuColor = MaterialTheme.colorScheme.surfaceContainer
val edgePadding = 8.dp
val itemSpacing = 2.dp
val labelReply = stringResource(Res.string.action_reply)
val labelEdit = stringResource(Res.string.action_edit)
val labelDelete = stringResource(Res.string.action_delete)
val labelCopy = stringResource(Res.string.action_copy)
val labelSave = stringResource(Res.string.action_save)
val labelCancelSend = stringResource(Res.string.action_cancel_send)
val labelRetrySend = stringResource(Res.string.action_retry_send)
val isQueued = message.isQueuedOutbound() && isAuthor
val sendFailed = isQueued && !message.uploadError.isNullOrBlank()
val savableImage = resolveSavableMessageImage(message)
val savableFile = resolveSavableMessageFile(message)
val canSave = savableImage != null || savableFile != null
Box(modifier = containerModifier) {
Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape))
Column(
Box(
modifier = Modifier
.padding(horizontal = edgePadding, vertical = edgePadding)
.verticalScroll(menuScrollState),
verticalArrangement = Arrangement.spacedBy(itemSpacing)
) {
if (!message.isContentCorrupted) {
ContextMenuItem(
icon = Icons.Rounded.ContentCopy,
text = labelCopy,
onClick = { onCopy(message) }
)
}
if (canSave) {
ContextMenuItem(
icon = Icons.Rounded.SaveAlt,
text = labelSave,
onClick = { onSave(message) }
)
}
if (sendFailed) {
ContextMenuItem(
icon = Icons.Rounded.Refresh,
text = labelRetrySend,
onClick = { onRetrySend(message) },
)
ContextMenuItem(
icon = Icons.Rounded.Close,
text = labelCancelSend,
onClick = { onCancelSend(message) },
isError = true,
)
} else if (isQueued) {
ContextMenuItem(
icon = Icons.Rounded.Close,
text = labelCancelSend,
onClick = { onCancelSend(message) },
isError = true
)
} else if (!isReadOnly) {
ContextMenuItem(
icon = Icons.AutoMirrored.Rounded.Reply,
text = labelReply,
onClick = { onReply(message) }
)
if (isAuthor && !message.isContentCorrupted) {
ContextMenuItem(
icon = Icons.Rounded.Edit,
text = labelEdit,
onClick = { onEdit(message) }
)
}
if (canDelete) {
ContextMenuItem(
icon = Icons.Rounded.Delete,
text = labelDelete,
onClick = { onDelete(message) },
isError = true
.matchParentSize()
.background(MaterialTheme.colorScheme.surfaceContainer, menuShape),
)
content()
}
}
}
}
}
private val itemShape = RoundedCornerShape(12.dp)
@Composable
private fun ContextMenuItem(
internal fun ChatStyleContextMenuItem(
icon: ImageVector,
text: String,
onClick: () -> Unit,
isError: Boolean = false,
modifier: Modifier = Modifier
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
val textColor = if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
}
val iconColor = if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
val textColor = when {
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
isError -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface
}
val iconColor = textColor
Box(
modifier = modifier
.fillMaxWidth()
.padding(4.dp)
.clip(itemShape)
.scaleOnPress(
.clip(chatStyleMenuItemShape)
.then(
if (enabled) {
Modifier.scaleOnPress(
scale = 0.96f,
onClick = onClick,
indication = LocalIndication.current,
@@ -463,23 +486,27 @@ private fun ContextMenuItem(
stiffness = Spring.StiffnessMedium,
),
)
} else {
Modifier
},
)
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart
contentAlignment = Alignment.CenterStart,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = text,
tint = iconColor,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(20.dp),
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = textColor
color = textColor,
)
}
}
@@ -11,6 +11,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -46,18 +47,16 @@ 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.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.changedToDown
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
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -65,6 +64,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastAll
import kotlin.math.roundToInt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@@ -115,6 +115,37 @@ private fun isMessageCorrupted(message: Message): Boolean {
}
}
/** First pointer-down event; does not require the press to be unconsumed. */
private suspend fun AwaitPointerEventScope.awaitEventFirstDown(): PointerEvent {
var event: PointerEvent
do {
event = awaitPointerEvent()
} while (!event.changes.fastAll { it.changedToDown() })
return event
}
/**
* Secondary-click only. Primary clicks pass through so [SelectionContainer] can select text.
* Skips already-consumed presses (e.g. text-selection context menu).
*/
private fun Modifier.onMouseSecondaryClick(
key: Any?,
enabled: Boolean,
onSecondaryClick: (localOffset: Offset) -> Unit,
): Modifier {
if (!enabled) return this
return pointerInput(key) {
awaitEachGesture {
val event = awaitEventFirstDown()
if (!event.buttons.isSecondaryPressed) return@awaitEachGesture
val change = event.changes.firstOrNull() ?: return@awaitEachGesture
if (change.isConsumed) return@awaitEachGesture
event.changes.forEach { it.consume() }
onSecondaryClick(change.position)
}
}
}
/**
* Scales layout height with the exit animation so the list collapses in real time.
*/
@@ -470,27 +501,22 @@ fun MessageItem(
val mouseMessageUi = supportsMouseMessageInteraction()
val rowLongPress =
if (isContextMenuOpen) Modifier
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
val openMessageMenu: (LayoutCoordinates?, Offset) -> Unit = { coords, localOffset ->
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(local))
onTapPosition(coords.localToRoot(localOffset))
}
isPressed = true
onLongPress()
}
}
val rowLongPress =
if (isContextMenuOpen) Modifier
else if (mouseMessageUi) {
Modifier.onMouseSecondaryClick(message.id, enabled = true) { localOffset ->
openMessageMenu(rowLayoutCoords, localOffset)
}
} else {
Modifier.pointerInput(message.id, onLongPress) {
detectTapGestures(
onPress = {
isPressed = true
@@ -501,11 +527,7 @@ fun MessageItem(
}
},
onLongPress = { localOffset ->
val coords = rowLayoutCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(localOffset))
}
onLongPress()
openMessageMenu(rowLayoutCoords, localOffset)
},
)
}
@@ -614,29 +636,20 @@ fun MessageItem(
if (isContextMenuOpen) Modifier
else Modifier
.onGloballyPositioned { bubbleContentCoords = it }
.pointerInput(message.id, onBubbleTap, onLongPress, mouseMessageUi) {
.then(
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()
}
}
}
Modifier.onMouseSecondaryClick(
message.id,
enabled = true,
) { localOffset ->
openMessageMenu(bubbleContentCoords, localOffset)
}
} else {
Modifier.pointerInput(
message.id,
onBubbleTap,
onLongPress,
) {
detectTapGestures(
onPress = {
isPressed = true
@@ -648,15 +661,15 @@ fun MessageItem(
},
onTap = { onBubbleTap?.invoke() },
onLongPress = { localOffset ->
val coords = bubbleContentCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(localOffset))
}
onLongPress()
openMessageMenu(
bubbleContentCoords,
localOffset,
)
},
)
}
}
},
)
Box {
Column(
@@ -697,11 +710,23 @@ fun MessageItem(
Modifier.padding(start = 8.dp, end = 8.dp, bottom = 4.dp)
val usernameInset =
Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
if (onUsernameClick != null) {
Box(
modifier = usernameOutset
.clip(usernameShape)
.pointerInput(message.id, onUsernameClick) {
val usernameGestures =
if (mouseMessageUi) {
Modifier
.onMouseSecondaryClick(
message.id,
enabled = !isContextMenuOpen,
) { localOffset ->
openMessageMenu(
rowLayoutCoords,
localOffset,
)
}
.pointerInput(
message.id,
onUsernameClick,
onBubbleTap,
) {
detectTapGestures(
onPress = {
isPressed = true
@@ -711,7 +736,32 @@ fun MessageItem(
isPressed = false
}
},
onTap = { onUsernameClick.invoke() },
onTap = {
onUsernameClick?.invoke()
?: onBubbleTap?.invoke()
},
)
}
} else {
Modifier.pointerInput(
message.id,
onUsernameClick,
onBubbleTap,
onLongPress,
) {
detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
}
},
onTap = {
onUsernameClick?.invoke()
?: onBubbleTap?.invoke()
},
onLongPress = {
val coords = bubbleContentCoords
if (
@@ -730,6 +780,12 @@ fun MessageItem(
},
)
}
}
if (onUsernameClick != null) {
Box(
modifier = usernameOutset
.clip(usernameShape)
.then(usernameGestures),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -750,36 +806,7 @@ fun MessageItem(
}
} else {
Box(
modifier = usernameOutset
.pointerInput(message.id, onBubbleTap) {
detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
}
},
onTap = { onBubbleTap?.invoke() },
onLongPress = {
val coords = bubbleContentCoords
if (
coords != null &&
coords.isAttached
) {
val center = Offset(
coords.size.width / 2f,
coords.size.height / 2f,
)
onTapPosition(
coords.localToRoot(center),
)
}
onLongPress()
},
)
},
modifier = usernameOutset.then(usernameGestures),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1079,14 +1106,18 @@ fun MessageItem(
!isFilenameOnlyMessageCaption(message)
) {
if (mouseMessageUi) {
ProvideChatTextSelectionMenu {
SelectionContainer {
Text(
text = message.content,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
modifier = Modifier.padding(horizontal = 14.dp)
modifier = Modifier.padding(
horizontal = 14.dp,
),
)
}
}
} else {
Text(
text = message.content,
@@ -8,9 +8,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.navigation.NavController
import androidx.navigation.NavOptionsBuilder
import ru.fromchat.api.local.cache.CacheContext
import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.ui.chat.rememberChatNavigationGate
import ru.fromchat.utils.haptic.HapticFeedbackEvent
@@ -33,6 +34,22 @@ object DmNav {
fun profileRoute(otherUserId: Int) = "dm/$otherUserId/profile"
}
/**
* Opens a DM chat, replacing any prior conversation/profile above the main `chat` root
* so large-screen switches do not stack previous chats.
*/
fun NavController.navigateToDmChat(
otherUserId: Int,
sourceMessageId: Int? = null,
builder: NavOptionsBuilder.() -> Unit = {},
) {
navigate(DmNav.chatRoute(otherUserId, sourceMessageId)) {
popUpTo("chat") { saveState = true }
launchSingleTop = true
builder()
}
}
private const val DM_AVATAR_KEY_PREFIX = "dm-avatar-"
@Composable
@@ -105,7 +122,7 @@ fun DmProfileRoute(
onChat = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
navController.navigateToDmChat(otherUserId)
}
},
modifier = modifier.fillMaxSize(),
@@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import androidx.navigation.NavOptionsBuilder
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.ui.chat.rememberChatNavigationGate
@@ -23,6 +24,19 @@ object PublicChatNav {
const val SHARED_HEADER_KEY = "public-chat-header"
}
/**
* Opens the public chat, replacing any prior conversation/profile above the main `chat` root.
*/
fun NavController.navigateToPublicChat(
builder: NavOptionsBuilder.() -> Unit = {},
) {
navigate(PublicChatNav.CHAT_ROUTE) {
popUpTo("chat") { saveState = true }
launchSingleTop = true
builder()
}
}
@Composable
fun PublicChatChatRoute(
scrollToMessageId: Int? = null,
@@ -78,7 +92,7 @@ fun PublicChatProfileRoute(
onChat = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
navController.navigateToPublicChat()
}
},
modifier = modifier.fillMaxSize(),
@@ -0,0 +1,36 @@
package ru.fromchat.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
/**
* Rounded lowest-surface container for listdetail panes and other full-area content.
* Size with [modifier]; content fills the panel.
*/
@Composable
fun AppPanel(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.surfaceContainerLowest,
shape: Shape = RoundedCornerShape(24.dp),
content: @Composable BoxScope.() -> Unit,
) {
Surface(
modifier = modifier,
shape = shape,
color = color,
) {
Box(
modifier = Modifier.fillMaxSize(),
content = content,
)
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -21,29 +22,44 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import ru.fromchat.ui.components.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.contacts
import ru.fromchat.contacts_empty_body
import ru.fromchat.contacts_empty_title
import ru.fromchat.ui.extraStatusBars
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactsTab() {
Scaffold(
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
TopAppBar(
windowInsets = if (LocalMainChromeInsets.current.top > 0.dp) {
WindowInsets.extraStatusBars
} else {
WindowInsets(0, 0, 0, 0)
},
title = {
Text(
text = stringResource(Res.string.contacts),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
)
}
) { innerPadding ->
@@ -1,114 +1,84 @@
package ru.fromchat.ui.main
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Arrangement
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.consumeWindowInsets
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.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.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.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
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
import ru.fromchat.ui.components.AppPanel
import ru.fromchat.ui.extraStatusBars
/** Minimum width to show chat + profile side-by-side beside the list. */
val ConversationProfileThirdPaneMinWidth = 1100.dp
private val ProfilePaneSlideEasing = CubicBezierEasing(0.22f, 1f, 0.36f, 1f)
private const val ProfilePaneAnimMs = 520
private const val ProfilePaneAnimMs = 280
/**
* Listdetail (optional profile) shell. The list pane uses [AppPanel]; detail and profile
* panes stay plain (no rounded surface wrapper). Spacing alone separates panes.
*
* Pads with [WindowInsets.safeDrawing] [WindowInsets.extraStatusBars] so panes clear the
* macOS title bar / system bars, then consumes those insets so child top bars do not double-pad.
* Window background stays edge-to-edge behind this layer.
*/
@Composable
fun ConversationListDetailShell(
widthSizeClass: WindowWidthSizeClass,
showProfilePane: Boolean,
listPaneWidth: Dp,
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) },
),
val profileVisible = showProfilePane && maxWidth >= ConversationProfileThirdPaneMinWidth
val fadeAnim = tween<Float>(
durationMillis = ProfilePaneAnimMs,
easing = FastOutSlowInEasing,
)
}
val sizeAnim = tween<IntSize>(
durationMillis = ProfilePaneAnimMs,
easing = FastOutSlowInEasing,
)
val paneInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)
Column(Modifier.weight(1f).fillMaxHeight()) {
Row(Modifier.weight(1f).fillMaxWidth()) {
Box(
Row(
Modifier
.width(360.dp)
.fillMaxSize()
.windowInsetsPadding(paneInsets)
.consumeWindowInsets(paneInsets)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AppPanel(
Modifier
.width(listPaneWidth)
.fillMaxHeight(),
) {
listPane()
}
VerticalDivider()
Box(
Modifier
.weight(1f)
@@ -118,80 +88,31 @@ fun ConversationListDetailShell(
}
AnimatedVisibility(
visible = profileVisible,
enter = slideInHorizontally(
enter = fadeIn(animationSpec = fadeAnim) + expandHorizontally(
animationSpec = sizeAnim,
expandFrom = Alignment.End,
clip = false,
),
exit = fadeOut(
animationSpec = tween(
durationMillis = ProfilePaneAnimMs,
easing = ProfilePaneSlideEasing,
durationMillis = 220,
easing = FastOutSlowInEasing,
),
initialOffsetX = { it },
),
exit = slideOutHorizontally(
animationSpec = tween(
durationMillis = ProfilePaneAnimMs,
easing = ProfilePaneSlideEasing,
),
targetOffsetX = { it },
) + shrinkHorizontally(
animationSpec = sizeAnim,
shrinkTowards = Alignment.End,
clip = false,
),
) {
Row(Modifier.fillMaxHeight()) {
VerticalDivider()
Box(
Modifier
.widthIn(min = 320.dp, max = 420.dp)
.fillMaxHeight()
.width(380.dp),
.width(380.dp)
.fillMaxHeight(),
) {
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)
},
)
}
}
}
}
}
}
@@ -14,27 +14,57 @@ 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.select_contact_placeholder
import ru.fromchat.select_settings_placeholder
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
/** Empty detail pane when nothing is selected in a listdetail layout. */
@Composable
fun EmptyConversationPlaceholder(modifier: Modifier = Modifier) {
val density = LocalDensity.current
val topInset = with(density) {
WindowInsets.extraStatusBars.getTop(this).toDp()
}
fun EmptyPanePlaceholder(
text: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxSize()
.padding(top = topInset)
.padding(
top = with(LocalDensity.current) {
WindowInsets.extraStatusBars.getTop(this).toDp()
},
)
.padding(horizontal = 32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.select_chat_placeholder),
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@Composable
fun EmptyConversationPlaceholder(modifier: Modifier = Modifier) {
EmptyPanePlaceholder(
text = stringResource(Res.string.select_chat_placeholder),
modifier = modifier,
)
}
@Composable
fun EmptyContactsPlaceholder(modifier: Modifier = Modifier) {
EmptyPanePlaceholder(
text = stringResource(Res.string.select_contact_placeholder),
modifier = modifier,
)
}
@Composable
fun EmptySettingsPlaceholder(modifier: Modifier = Modifier) {
EmptyPanePlaceholder(
text = stringResource(Res.string.select_settings_placeholder),
modifier = modifier,
)
}
@@ -0,0 +1,20 @@
package ru.fromchat.ui.main
import androidx.navigation.NavController
import androidx.navigation.NavOptionsBuilder
/**
* Opens a main-hub detail destination, replacing any prior conversation/settings/profile
* above the main `chat` root so large-screen switches do not stack previous panes.
* Matches [ru.fromchat.ui.chat.panels.dm.navigateToDmChat] / public chat.
*/
fun NavController.navigateReplacingMainDetail(
route: String,
builder: NavOptionsBuilder.() -> Unit = {},
) {
navigate(route) {
popUpTo("chat") { saveState = true }
launchSingleTop = true
builder()
}
}
@@ -6,17 +6,13 @@ 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.asPaddingValues
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.statusBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -47,8 +43,6 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.pr0gramm3r101.utils.NavigationItem
import com.pr0gramm3r101.utils.SimpleNavigationRail
import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.widthSizeClass
@@ -71,12 +65,12 @@ import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController
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
const val MAIN_PAGE_CHATS = 0
const val MAIN_PAGE_CONTACTS = 1
@@ -90,9 +84,12 @@ 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 a listdetail conversation layout. Host owns navigation chrome.
* @param embeddedInListDetail When true, this screen is the left pane of a listdetail layout.
* Nav chrome stays inside this pane (bottom bar), never spanning the window.
* @param initialPage Pager page when showing the full MainScreen.
* @param forceSettingsTab When true, scrolls the pager to settings (settings/profile detail open).
* Does not snap back when cleared avoids fighting user tab changes.
* @param onPageChanged Invoked when the pager settles on a page (for listdetail empty panes).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -100,8 +97,10 @@ fun MainScreen(
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
snackbarHostState: SnackbarHostState? = null,
listPaneOnly: Boolean = false,
embeddedInListDetail: Boolean = false,
initialPage: Int = PAGE_CHATS,
forceSettingsTab: Boolean = false,
onPageChanged: (Int) -> Unit = {},
) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current
@@ -117,29 +116,34 @@ fun MainScreen(
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
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))
LaunchedEffect(forceSettingsTab) {
if (forceSettingsTab && pagerState.currentPage != PAGE_SETTINGS) {
pagerState.scrollToPage(PAGE_SETTINGS)
}
}
val selectedPage = pagerState.currentPage
LaunchedEffect(selectedPage) {
onPageChanged(selectedPage)
}
val isChatsPage = selectedPage == PAGE_CHATS
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
val statusBarTop = with(density) {
// Listdetail shell already pads/consumes safeDrawing extraStatusBars; do not re-apply top.
val statusBarTop = if (embeddedInListDetail) {
0.dp
} else {
with(density) {
WindowInsets.extraStatusBars.getTop(this).toDp()
}
}
var bottomChromeHeightDp by remember { mutableStateOf(0.dp) }
val mainChromeInsets = remember(statusBarTop, bottomChromeHeightDp, useNavigationRail, useBottomBar) {
val mainChromeInsets = remember(statusBarTop, bottomChromeHeightDp) {
MainChromeInsets(
top = statusBarTop,
bottom = if (useBottomBar) bottomChromeHeightDp else 0.dp,
bottom = bottomChromeHeightDp,
)
}
@@ -148,47 +152,52 @@ fun MainScreen(
val settingsLabel = stringResource(Res.string.settings)
val profileLabel = stringResource(Res.string.profile)
BoxWithConstraints(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize()) {
if (useNavigationRail) {
SimpleNavigationRail(
selectedItem = selectedPage,
modifier = Modifier.fillMaxHeight(),
NavigationItem(
name = chatsLabel,
selectedIcon = Icons.AutoMirrored.Filled.Chat,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
},
),
NavigationItem(
name = contactsLabel,
selectedIcon = Icons.Filled.Contacts,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
},
),
NavigationItem(
name = settingsLabel,
selectedIcon = Icons.Filled.Settings,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
},
),
NavigationItem(
name = profileLabel,
selectedIcon = Icons.Filled.Person,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
},
),
)
fun clearConversationStack() {
// Pop above chat only — inclusive pop remounts the chat destination and briefly
// drops the shell route, which remounts listdetail ↔ compact and blinks forever.
navController.popBackStack("chat", inclusive = false)
}
fun openOwnProfileInDetailPane() {
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return
navController.navigateReplacingMainDetail("profile/$userId")
}
fun selectMainPage(page: Int) {
when {
page == PAGE_PROFILE && widthClass != WindowWidthSizeClass.COMPACT -> {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
openOwnProfileInDetailPane()
}
page == PAGE_CHATS -> {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
if (embeddedInListDetail && selectedPage != PAGE_CHATS) {
clearConversationStack()
}
}
else -> {
scope.launch { pagerState.animateScrollToPage(page) }
if (embeddedInListDetail) {
clearConversationStack()
}
}
}
}
val navSelectedPage =
if (selectedPage == PAGE_SETTINGS &&
widthClass != WindowWidthSizeClass.COMPACT &&
navController.currentBackStackEntry?.destination?.route?.startsWith("profile/") == true
) {
PAGE_PROFILE
} else {
selectedPage
}
BoxWithConstraints(Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.fillMaxSize()
.hazeSource(contextMenuHazeState),
) {
Scaffold(
@@ -210,21 +219,11 @@ fun MainScreen(
.fillMaxSize()
.hazeSource(navBarHazeState),
) {
if (listPaneOnly) {
ChatsTab(
isVisible = true,
onOpenSearch = {
navController.navigate("search/conversations")
},
chatContextMenuOverlay = chatContextMenuOverlay,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
} else {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
userScrollEnabled = widthClass == WindowWidthSizeClass.COMPACT,
) { page ->
when (page) {
PAGE_CHATS -> ChatsTab(
@@ -259,9 +258,7 @@ fun MainScreen(
}
}
}
}
if (useBottomBar) {
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
@@ -288,10 +285,8 @@ fun MainScreen(
windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
) {
NavigationBarItem(
selected = selectedPage == PAGE_CHATS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
},
selected = navSelectedPage == PAGE_CHATS,
onClick = { selectMainPage(PAGE_CHATS) },
label = { Text(chatsLabel) },
icon = {
Icon(
@@ -301,30 +296,24 @@ fun MainScreen(
},
)
NavigationBarItem(
selected = selectedPage == PAGE_CONTACTS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
},
selected = navSelectedPage == PAGE_CONTACTS,
onClick = { selectMainPage(PAGE_CONTACTS) },
label = { Text(contactsLabel) },
icon = {
Icon(Icons.Filled.Contacts, contentDescription = null)
},
)
NavigationBarItem(
selected = selectedPage == PAGE_SETTINGS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
},
selected = navSelectedPage == PAGE_SETTINGS,
onClick = { selectMainPage(PAGE_SETTINGS) },
label = { Text(settingsLabel) },
icon = {
Icon(Icons.Filled.Settings, contentDescription = null)
},
)
NavigationBarItem(
selected = selectedPage == PAGE_PROFILE,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
},
selected = navSelectedPage == PAGE_PROFILE,
onClick = { selectMainPage(PAGE_PROFILE) },
label = { Text(profileLabel) },
icon = {
Icon(Icons.Filled.Person, contentDescription = null)
@@ -333,8 +322,6 @@ fun MainScreen(
}
}
}
}
}
if (chatMenuBlurProgress > 0f) {
ChatContextMenuBlurLayer(
@@ -14,7 +14,9 @@ import androidx.compose.animation.scaleOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -60,6 +62,11 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
@@ -67,6 +74,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAll
import kotlinx.coroutines.withTimeout
import ru.fromchat.ui.components.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement
import com.pr0gramm3r101.components.Category
@@ -126,6 +135,52 @@ private val ChatListCategoryMargin = PaddingValues(
bottom = 12.dp,
)
/**
* Opens on primary click. Selection mode: touch/stylus long-press, or mouse secondary click.
* Mouse primary long-press does not enter selection.
*/
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.chatRowOpenAndSelectGestures(
selectionEnabled: Boolean,
interactionSource: MutableInteractionSource,
onOpen: () -> Unit,
onEnterSelection: () -> Unit,
): Modifier =
combinedClickable(
interactionSource = interactionSource,
indication = ripple(),
onClick = onOpen,
).pointerInput(selectionEnabled) {
if (!selectionEnabled) return@pointerInput
awaitEachGesture {
var downEvent: PointerEvent
do {
downEvent = awaitPointerEvent()
} while (!downEvent.changes.fastAll { it.changedToDown() })
val down = downEvent.changes.firstOrNull() ?: return@awaitEachGesture
when (down.type) {
PointerType.Mouse -> {
if (!downEvent.buttons.isSecondaryPressed || down.isConsumed) {
return@awaitEachGesture
}
downEvent.changes.forEach { it.consume() }
onEnterSelection()
}
PointerType.Touch, PointerType.Stylus -> {
try {
withTimeout(viewConfiguration.longPressTimeoutMillis) {
waitForUpOrCancellation()
}
} catch (_: PointerEventTimeoutCancellationException) {
onEnterSelection()
waitForUpOrCancellation()
}
}
else -> Unit
}
}
}
@Composable
internal fun ChatListHeadlineWithBadge(
title: String,
@@ -721,15 +776,11 @@ internal fun PublicChatRow(
.alpha(if (isHiddenForOverlay) 0f else 1f)
.fillMaxWidth()
.clip(clipShape)
.combinedClickable(
.chatRowOpenAndSelectGestures(
selectionEnabled = listMode == ChatsListMode.Normal,
interactionSource = rowInteractionSource,
indication = ripple(),
onClick = onOpenPublic,
onLongClick = if (listMode == ChatsListMode.Normal) {
{ onBodyLongPress() }
} else {
null
},
onOpen = onOpenPublic,
onEnterSelection = onBodyLongPress,
)
.onGloballyPositioned { coords ->
rowRootOffset = coords.positionInRoot()
@@ -871,15 +922,11 @@ internal fun DmConversationRow(
.alpha(if (isHiddenForOverlay) 0f else 1f)
.fillMaxWidth()
.clip(clipShape)
.combinedClickable(
.chatRowOpenAndSelectGestures(
selectionEnabled = listMode == ChatsListMode.Normal,
interactionSource = rowInteractionSource,
indication = ripple(),
onClick = onOpenConversation,
onLongClick = if (listMode == ChatsListMode.Normal) {
{ onBodyLongPress() }
} else {
null
},
onOpen = onOpenConversation,
onEnterSelection = onBodyLongPress,
)
.onGloballyPositioned { coords ->
rowRootOffset = coords.positionInRoot()
@@ -53,7 +53,6 @@ import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
@@ -141,7 +140,8 @@ import ru.fromchat.status_updating
import ru.fromchat.account_suspended
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.chat.panels.dm.DmNav
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat
import ru.fromchat.ui.components.BackHandler
import ru.fromchat.ui.components.BrandTitle
import ru.fromchat.ui.components.ConnectingEllipsis
@@ -337,7 +337,9 @@ private fun rememberSearchBarCollapseSnap(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun chatsTopAppBarColors(blurReveal: Float) = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 1f - blurReveal.coerceIn(0f, 1f)),
// Match AppPanel (`surfaceContainerLowest`) when unscrolled so the bar is not a separate stripe.
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest
.copy(alpha = 1f - blurReveal.coerceIn(0f, 1f)),
scrolledContainerColor = Color.Transparent,
)
@@ -375,7 +377,12 @@ private fun ChatsNormalTopBar(
) {
TopAppBar(
modifier = modifier,
windowInsets = WindowInsets.extraStatusBars,
// Shell already applies/consumes status insets in listdetail; avoid double top padding.
windowInsets = if (LocalMainChromeInsets.current.top > 0.dp) {
WindowInsets.extraStatusBars
} else {
WindowInsets(0, 0, 0, 0)
},
colors = chatsTopAppBarColors(blurReveal),
title = {
Row(
@@ -384,7 +391,7 @@ private fun ChatsNormalTopBar(
horizontalArrangement = Arrangement.Start,
) {
AsyncImage(
model = Res.getUri("drawable/logo_square.svg"),
model = Res.getUri("drawable/logo_square.png"),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
@@ -469,7 +476,11 @@ private fun ChatsSelectionTopBar(
TopAppBar(
modifier = modifier,
windowInsets = WindowInsets.extraStatusBars,
windowInsets = if (LocalMainChromeInsets.current.top > 0.dp) {
WindowInsets.extraStatusBars
} else {
WindowInsets(0, 0, 0, 0)
},
colors = chatsTopAppBarColors(blurReveal),
navigationIcon = {
IconButton(onClick = onClose) {
@@ -841,6 +852,7 @@ fun ChatsTab(
PredictiveBackHandler(
enabled = selectionMode,
onProgress = { backProgress ->
if (!selectionMode) return@PredictiveBackHandler
scope.launch {
selectionTransitionProgress.snapTo((1f - backProgress).coerceIn(0f, 1f))
}
@@ -950,7 +962,7 @@ fun ChatsTab(
when {
selectionMode && publicChatSelected -> publicChatSelected = false
selectionMode -> publicChatSelected = true
else -> navController.navigate("chats/publicChat")
else -> navController.navigateToPublicChat()
}
},
onOpenConversation = { userId ->
@@ -961,7 +973,7 @@ fun ChatsTab(
selectionMode -> selectedOtherUserIds += userId
userId != 0 -> navController.navigate(DmNav.chatRoute(userId))
userId != 0 -> navController.navigateToDmChat(userId)
}
},
onAvatarContextMenuPressStart = { lazyIndex, target, userId, rowOffset, rowSize, position, groupCount ->
@@ -1084,9 +1096,9 @@ fun ChatsTab(
}
chatContextMenuOverlay.onMessage = {
when (contextMenuState.target) {
ChatContextMenuTarget.Public -> navController.navigate("chats/publicChat")
ChatContextMenuTarget.Public -> navController.navigateToPublicChat()
ChatContextMenuTarget.Dm -> {
contextMenuState.otherUserId?.let { navController.navigate(DmNav.chatRoute(it)) }
contextMenuState.otherUserId?.let { navController.navigateToDmChat(it) }
}
}
}
@@ -9,14 +9,11 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import ru.fromchat.ui.components.Text
import androidx.compose.material3.TopAppBarDefaults
@@ -47,7 +44,6 @@ import ru.fromchat.about_link_terms
import ru.fromchat.about_link_website
import ru.fromchat.about_version
import ru.fromchat.app_desc
import ru.fromchat.back
import ru.fromchat.legal.DocumentType
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.BrandTitle
@@ -59,30 +55,28 @@ private const val URL_WEBSITE = "https://fromchat.ru"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen() {
val useCollapsing = settingsDetailUseCollapsingTopBar()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current
val uriHandler = LocalUriHandler.current
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
modifier = if (useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
topBar = {
MediumTopAppBar(
SettingsDetailTopBar(
title = {
Text(
stringResource(Res.string.about),
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
},
scrollBehavior = scrollBehavior
onBack = { navController.navigateUp() },
scrollBehavior = scrollBehavior,
)
},
) { innerPadding ->
@@ -100,7 +94,7 @@ fun AboutScreen() {
) {
Box(Modifier.padding(bottom = 12.dp)) {
AsyncImage(
model = Res.getUri("drawable/logo_square.svg"),
model = Res.getUri("drawable/logo_square.png"),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
@@ -11,18 +11,16 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.KeyboardReturn
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Wallpaper
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import ru.fromchat.ui.components.Text
import androidx.compose.material3.TopAppBarDefaults
@@ -44,9 +42,10 @@ import com.pr0gramm3r101.utils.materialYouAvailable
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.as_system
import ru.fromchat.back
import ru.fromchat.config.Settings
import ru.fromchat.dark
import ru.fromchat.enter_to_send
import ru.fromchat.enter_to_send_d
import ru.fromchat.light
import ru.fromchat.materialYou
import ru.fromchat.materialYou_d
@@ -59,24 +58,26 @@ import ru.fromchat.ui.theme
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun AppearanceScreen(onBack: () -> Unit) {
val useCollapsing = settingsDetailUseCollapsingTopBar()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
var materialYouSwitch by remember {
mutableStateOf(Settings.materialYou && materialYouAvailable)
}
var enterToSendSwitch by remember { mutableStateOf(Settings.enterToSend) }
var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = { Text(stringResource(Res.string.settings_category_appearance)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
modifier = if (useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
scrollBehavior = scrollBehavior
topBar = {
SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_category_appearance)) },
onBack = onBack,
scrollBehavior = scrollBehavior,
)
}
) { innerPadding ->
@@ -105,6 +106,19 @@ fun AppearanceScreen(onBack: () -> Unit) {
Icon(Icons.Filled.Wallpaper, null)
}
)
SwitchListItem(
headline = stringResource(Res.string.enter_to_send),
supportingText = stringResource(Res.string.enter_to_send_d),
checked = enterToSendSwitch,
onCheckedChange = {
enterToSendSwitch = it
Settings.enterToSend = it
},
divider = true,
leadingContent = {
Icon(Icons.Filled.KeyboardReturn, null)
}
)
ListItem(
headline = stringResource(Res.string.theme),
leadingContent = { Icon(Icons.Filled.Brush, null) },
@@ -434,9 +434,11 @@ fun DevicesScreen(onBack: () -> Unit) {
TopAppBar(
title = {},
navigationIcon = {
if (settingsDetailShowBackButton()) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
@@ -376,6 +376,9 @@ fun LogsScreen() {
}
fun enterEntrySelection(entryId: Long) {
if (searchMode) {
exitSearchMode()
}
haptic(HapticFeedbackEvent.SelectionModeEntered)
scope.launch { selectionTransitionProgress.snapTo(0f) }
listMode = LogsListMode.Selecting
@@ -467,6 +470,12 @@ fun LogsScreen() {
LaunchedEffect(Unit) {
AppLogStore.ensureLoaded()
listMode = LogsListMode.Normal
selectedEntryIds = emptySet()
searchMode = false
searchQuery = ""
selectionTransitionProgress.snapTo(0f)
searchTransitionProgress.snapTo(0f)
}
LaunchedEffect(navController.currentBackStackEntry) {
@@ -767,6 +776,7 @@ fun LogsScreen() {
}
.background(MaterialTheme.colorScheme.surfaceContainer),
navigationIcon = {
if (settingsDetailShowBackButton()) {
IconButton(
onClick = { navController.navigateUp() },
enabled = selectionProgress < 1f && searchProgress < 1f,
@@ -776,6 +786,7 @@ fun LogsScreen() {
contentDescription = stringResource(Res.string.back),
)
}
}
},
title = {
Column {
@@ -871,7 +882,7 @@ fun LogsScreen() {
},
scrollBehavior = scrollBehavior,
)
if (searchMode || searchProgress > 0f) {
if (searchMode) {
TopAppBar(
modifier = Modifier.graphicsLayer { alpha = searchProgress },
colors = logsTransparentTopAppBarColors(),
@@ -931,7 +942,7 @@ fun LogsScreen() {
},
)
}
if (selectionMode || selectionProgress > 0f) {
if (selectionMode) {
TopAppBar(
modifier = Modifier.graphicsLayer { alpha = selectionProgress },
navigationIcon = {
@@ -6,13 +6,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarDefaults
@@ -36,7 +33,6 @@ import ru.fromchat.Res
import ru.fromchat.api.ensureFcmTokenRegistered
import ru.fromchat.api.isFcmPushRegisteredLocally
import ru.fromchat.api.unregisterFcmTokenFromServer
import ru.fromchat.back
import ru.fromchat.error_unexpected
import ru.fromchat.settings_notification_settings
import ru.fromchat.settings_notification_settings_d
@@ -51,6 +47,7 @@ import ru.fromchat.ui.components.Text
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationsScreen(onBack: () -> Unit) {
val useCollapsing = settingsDetailUseCollapsingTopBar()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
@@ -63,16 +60,16 @@ fun NotificationsScreen(onBack: () -> Unit) {
Scaffold(
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = { Text(stringResource(Res.string.settings_notifications_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
modifier = if (useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
scrollBehavior = scrollBehavior
topBar = {
SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_notifications_title)) },
onBack = onBack,
scrollBehavior = scrollBehavior,
)
}
) { innerPadding ->
@@ -0,0 +1,67 @@
package ru.fromchat.ui.main.settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.widthSizeClass
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.back
import ru.fromchat.ui.LocalNavController
/**
* Back is shown on compact (full-screen stack), and on large screens only when the
* previous destination is not the listdetail shell (`chat`) i.e. when nested
* deeper than a settings detail opened as the right pane.
*/
@Composable
fun settingsDetailShowBackButton(): Boolean {
if (currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT) {
return true
}
val previousRoute = LocalNavController.current.previousBackStackEntry?.destination?.route
return previousRoute != null && previousRoute != "chat"
}
@Composable
fun settingsDetailUseCollapsingTopBar(): Boolean =
currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsDetailTopBar(
title: @Composable () -> Unit,
onBack: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
val navigationIcon: @Composable () -> Unit = {
if (settingsDetailShowBackButton()) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back),
)
}
}
}
if (settingsDetailUseCollapsingTopBar()) {
MediumTopAppBar(
title = title,
navigationIcon = navigationIcon,
scrollBehavior = scrollBehavior,
)
} else {
TopAppBar(
title = title,
navigationIcon = navigationIcon,
)
}
}
@@ -2,6 +2,7 @@ package ru.fromchat.ui.main.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
@@ -9,6 +10,7 @@ import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Storage
@@ -21,17 +23,23 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.verticalScroll
import com.pr0gramm3r101.utils.widthSizeClass
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.about
import ru.fromchat.api.ApiClient
import ru.fromchat.change_server
import ru.fromchat.change_server_d
import ru.fromchat.profile
import ru.fromchat.settings
import ru.fromchat.settings_category_account
import ru.fromchat.settings_category_account_d
@@ -44,8 +52,12 @@ import ru.fromchat.settings_category_notifications_d
import ru.fromchat.logs_title
import ru.fromchat.settings_hub_about_sub
import ru.fromchat.settings_hub_logs_sub
import ru.fromchat.settings_hub_profile_sub
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.main.mainPagerBottomInset
import ru.fromchat.ui.main.navigateReplacingMainDetail
val SettingsStepHorizontalPadding = 24.dp
@@ -54,14 +66,26 @@ val SettingsStepHorizontalPadding = 24.dp
fun SettingsTab() {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current
val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
MediumTopAppBar(
windowInsets = if (LocalMainChromeInsets.current.top > 0.dp) {
WindowInsets.extraStatusBars
} else {
WindowInsets(0, 0, 0, 0)
},
title = {
Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
scrollBehavior = scrollBehavior
)
}
@@ -73,11 +97,25 @@ fun SettingsTab() {
.mainPagerBottomInset()
.padding(innerPadding)
) {
if (isTwoPane) {
Category(Modifier.padding(top = 16.dp)) {
ListItem(
headline = stringResource(Res.string.profile),
supportingText = stringResource(Res.string.settings_hub_profile_sub),
onClick = {
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return@ListItem
navController.navigateReplacingMainDetail("profile/$userId")
},
leadingContent = { Icon(Icons.Filled.Person, null) },
)
}
}
Category(Modifier.padding(top = 16.dp)) {
ListItem(
headline = stringResource(Res.string.settings_category_account),
supportingText = stringResource(Res.string.settings_category_account_d),
onClick = { navController.navigate(SettingsRoutes.Account) },
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Account) },
leadingContent = { Icon(Icons.Filled.AccountCircle, null) },
divider = true
)
@@ -85,7 +123,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.settings_category_devices),
supportingText = stringResource(Res.string.settings_category_devices_d),
onClick = { navController.navigate(SettingsRoutes.Devices) },
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Devices) },
leadingContent = { Icon(Icons.Filled.Devices, null) },
divider = true
)
@@ -93,7 +131,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.settings_category_appearance),
supportingText = stringResource(Res.string.settings_category_appearance_d),
onClick = { navController.navigate(SettingsRoutes.Appearance) },
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Appearance) },
leadingContent = { Icon(Icons.Filled.Palette, null) },
divider = true
)
@@ -101,7 +139,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.settings_category_notifications),
supportingText = stringResource(Res.string.settings_category_notifications_d),
onClick = { navController.navigate(SettingsRoutes.Notifications) },
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Notifications) },
leadingContent = { Icon(Icons.Filled.Notifications, null) },
divider = true
)
@@ -109,7 +147,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.change_server),
supportingText = stringResource(Res.string.change_server_d),
onClick = { navController.navigate(SettingsRoutes.ServerConfig) },
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.ServerConfig) },
leadingContent = { Icon(Icons.Filled.Storage, null) },
divider = true
)
@@ -117,7 +155,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.about),
supportingText = stringResource(Res.string.settings_hub_about_sub),
onClick = { navController.navigate(SettingsRoutes.About) },
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.About) },
leadingContent = { Icon(Icons.Filled.Info, null) },
divider = true
)
@@ -125,7 +163,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.logs_title),
supportingText = stringResource(Res.string.settings_hub_logs_sub),
onClick = { navController.navigate(SettingsRoutes.Logs) },
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Logs) },
leadingContent = { Icon(Icons.Outlined.BugReport, null) }
)
}
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.AccountCircle
@@ -17,9 +16,7 @@ import androidx.compose.material.icons.filled.Key
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
@@ -41,7 +38,6 @@ import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.back
import ru.fromchat.cancel
import ru.fromchat.ic_yandex
import ru.fromchat.logout
@@ -55,6 +51,8 @@ import ru.fromchat.settings_account_title
import ru.fromchat.settings_change_password
import ru.fromchat.settings_security_change_password_sub
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.main.settings.SettingsDetailTopBar
import ru.fromchat.ui.main.settings.settingsDetailUseCollapsingTopBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -65,6 +63,7 @@ fun AccountScreen(
onChangeYandexId: () -> Unit,
onDeleteAccount: () -> Unit,
) {
val useCollapsing = settingsDetailUseCollapsingTopBar()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val scope = rememberCoroutineScope()
var showLogoutConfirm by remember { mutableStateOf(false) }
@@ -76,16 +75,16 @@ fun AccountScreen(
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = { Text(stringResource(Res.string.settings_account_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
modifier = if (useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
scrollBehavior = scrollBehavior
topBar = {
SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_account_title)) },
onBack = onBack,
scrollBehavior = scrollBehavior,
)
}
) { innerPadding ->
@@ -119,6 +119,7 @@ import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
import ru.fromchat.ui.components.rememberLazyListFocusScrollState
import ru.fromchat.ui.components.trackLazyListFocus
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import ru.fromchat.ui.main.settings.settingsDetailShowBackButton
private object ServerConfigLazyListIndices {
const val SERVER_IP_FIELD = 2
@@ -334,12 +335,14 @@ fun ServerConfigScreen() {
TopAppBar(
title = {},
navigationIcon = {
if (settingsDetailShowBackButton()) {
IconButton(onClick = navController::navigateUp) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
@@ -63,7 +63,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester
@@ -192,6 +191,7 @@ import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.ShimmerBox
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.showReplacingSnackbar
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.utils.RegistrationDateFormatStrings
import ru.fromchat.utils.formatLastSeen
import ru.fromchat.utils.formatProfileRegistrationDate
@@ -269,7 +269,7 @@ fun ProfileScreen(
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val density = LocalDensity.current
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val statusBarTopDp = with(density) { WindowInsets.extraStatusBars.getTop(this).toDp() }
val profileAvatarTop = statusBarTopDp + 24.dp
val profileLoadFailed = stringResource(Res.string.profile_load_failed)
@@ -881,7 +881,7 @@ fun PublicChatProfileScreen(
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val density = LocalDensity.current
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val statusBarTopDp = with(density) { WindowInsets.extraStatusBars.getTop(this).toDp() }
val profileAvatarTop = statusBarTopDp + 24.dp
val profileLoadFailed = stringResource(Res.string.profile_load_failed)
val profileNotFound = stringResource(Res.string.profile_not_found)
@@ -2058,7 +2058,7 @@ private fun ProfileFloatingBackBar(
onClick = onBack,
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.statusBars),
.windowInsetsPadding(WindowInsets.extraStatusBars),
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.onSurface,
),
@@ -0,0 +1,3 @@
package ru.fromchat.config
internal actual fun defaultEnterToSend(): Boolean = false
@@ -0,0 +1,8 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
@Composable
internal actual fun ProvideChatTextSelectionMenu(content: @Composable () -> Unit) {
content()
}
@@ -0,0 +1,3 @@
package ru.fromchat.config
internal actual fun defaultEnterToSend(): Boolean = true
@@ -0,0 +1,80 @@
package ru.fromchat.desktop
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
import androidx.compose.runtime.setValue
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import ru.fromchat.legal.DocumentScreen
import ru.fromchat.legal.DocumentType
import ru.fromchat.ui.FromChatTheme
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.main.settings.AboutScreen
/**
* Hosts [AboutScreen] (and linked legal docs) for a dedicated desktop About window.
* Back from the root About destination closes via [onClose].
*/
@Composable
fun DesktopAboutContent(onClose: () -> Unit) {
FromChatTheme {
ScreenSurface {
val navController = rememberNavController()
var aboutOpened by remember { mutableStateOf(false) }
CompositionLocalProvider(LocalNavController provides navController) {
NavHost(
navController = navController,
startDestination = ABOUT_GATE_ROUTE,
) {
composable(ABOUT_GATE_ROUTE) {
LaunchedEffect(Unit) {
if (!aboutOpened) {
aboutOpened = true
navController.navigate(ABOUT_ROUTE)
} else {
onClose()
}
}
}
composable(ABOUT_ROUTE) {
AboutScreen()
}
composable(
route = DocumentType.ROUTE,
arguments = listOf(
navArgument(DocumentType.ARG_DOCUMENT_TYPE) { type = NavType.StringType },
),
) { entry ->
val type = entry.savedStateHandle
.get<String>(DocumentType.ARG_DOCUMENT_TYPE)
?.let(DocumentType::typeFromArg)
?: return@composable
DocumentScreen(
type = type,
onBack = { navController.navigateUp() },
onOpenLegalDocument = { linkedType ->
navController.navigate(DocumentType.route(linkedType)) {
launchSingleTop = true
}
},
)
}
}
}
}
}
}
private const val ABOUT_GATE_ROUTE = "_about_gate"
private const val ABOUT_ROUTE = "about"
@@ -0,0 +1,121 @@
package ru.fromchat.ui.chat
import androidx.compose.foundation.ContextMenuArea
import androidx.compose.foundation.ContextMenuItem
import androidx.compose.foundation.ContextMenuRepresentation
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.LocalContextMenuRepresentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.LocalTextContextMenu
import androidx.compose.foundation.text.TextContextMenu
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.action_copy
import androidx.compose.foundation.ContextMenuState as FoundationContextMenuState
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal actual fun ProvideChatTextSelectionMenu(content: @Composable () -> Unit) {
val labelCopy = stringResource(Res.string.action_copy)
CompositionLocalProvider(
LocalContextMenuRepresentation provides ChatStyleTextContextMenuRepresentation,
LocalTextContextMenu provides ChatStyleTextContextMenu(labelCopy),
content = content,
)
}
@OptIn(ExperimentalFoundationApi::class)
private class ChatStyleTextContextMenu(
private val copyLabel: String,
) : TextContextMenu {
@Composable
override fun Area(
textManager: TextContextMenu.TextManager,
state: FoundationContextMenuState,
content: @Composable () -> Unit,
) {
ContextMenuArea(
items = {
listOfNotNull(
textManager.copy?.takeIf { it.enabled }?.let { action ->
ContextMenuItem(copyLabel) { action.execute() }
},
)
},
state = state,
// Empty selection: let secondary click reach the message menu handler.
enabled = textManager.selectedText.isNotEmpty(),
content = content,
)
}
}
private object ChatStyleTextContextMenuRepresentation : ContextMenuRepresentation {
@Composable
override fun Representation(
state: FoundationContextMenuState,
items: () -> List<ContextMenuItem>,
) {
val status = state.status as? FoundationContextMenuState.Status.Open ?: return
val menuItems = items()
if (menuItems.isEmpty()) {
SideEffect {
state.status = FoundationContextMenuState.Status.Closed
}
return
}
Popup(
alignment = Alignment.TopStart,
offset = IntOffset(
status.rect.left.toInt(),
status.rect.bottom.toInt(),
),
onDismissRequest = {
state.status = FoundationContextMenuState.Status.Closed
},
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = false,
),
) {
ChatStyleContextMenuFrame {
Column(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
for (item in menuItems) {
ChatStyleContextMenuItem(
icon = Icons.Rounded.ContentCopy,
text = item.label,
enabled = item.enabled,
onClick = {
state.status = FoundationContextMenuState.Status.Closed
item.onClick()
},
)
}
}
}
}
}
}