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 package ru.fromchat.desktop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -7,28 +8,34 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap 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.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.Notification
import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.CoroutineScope import java.awt.Desktop
import kotlinx.coroutines.Dispatchers import java.awt.KeyboardFocusManager
import kotlinx.coroutines.SupervisorJob import java.awt.event.ActionEvent
import kotlinx.coroutines.launch
import org.jetbrains.skia.Image
import ru.fromchat.AppForeground
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.ui.App
import ru.fromchat.ui.LocalExtraStatusBarTop
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
@@ -37,8 +44,22 @@ import java.net.ServerSocket
import java.net.Socket import java.net.Socket
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JComponent
import javax.swing.JRootPane import javax.swing.JRootPane
import javax.swing.SwingUtilities
import javax.swing.text.DefaultEditorKit
import kotlin.concurrent.thread 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 object DesktopApplicationBootstrap {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -113,28 +134,19 @@ private object DesktopProtocolRegistration {
fun registerBestEffort() { fun registerBestEffort() {
runCatching { runCatching {
when { when {
isMac() -> Unit // Packaged macOS builds declare CFBundleURLTypes in Info.plist. isMacOs() -> Unit // Packaged macOS builds declare CFBundleURLTypes in Info.plist.
isWindows() -> registerWindows() isWindowsOs() -> registerWindows()
isLinux() -> registerLinux() 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 { private fun javaLauncherCommand(): String {
val javaHome = System.getProperty("java.home") 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 classPath = System.getProperty("java.class.path")
val main = "ru.fromchat.desktop.MainKt" val main = "ru.fromchat.desktop.MainKt"
return if (isWindows()) { return if (isWindowsOs()) {
"\"$javaBin\" -cp \"$classPath\" $main" "\"$javaBin\" -cp \"$classPath\" $main"
} else { } else {
"$javaBin -cp ${shellQuote(classPath)} $main" "$javaBin -cp ${shellQuote(classPath)} $main"
@@ -183,6 +195,10 @@ private object DesktopProtocolRegistration {
} }
fun main(args: Array<String>) { 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 if (!DesktopSingleInstance.acquireOrForward(args)) return
DesktopProtocolRegistration.registerBestEffort() DesktopProtocolRegistration.registerBestEffort()
args.filter { DesktopDeepLinkBus.isFromChatUri(it) } args.filter { DesktopDeepLinkBus.isFromChatUri(it) }
@@ -193,10 +209,17 @@ fun main(args: Array<String>) {
DesktopApplicationBootstrap.launchOnApplicationStart() DesktopApplicationBootstrap.launchOnApplicationStart()
val windowState = rememberWindowState() val windowState = rememberWindowState()
val aboutWindowState = rememberWindowState(
position = WindowPosition.Aligned(Alignment.Center),
size = DpSize(440.dp, 640.dp),
)
var windowVisible by remember { mutableStateOf(true) } var windowVisible by remember { mutableStateOf(true) }
var aboutOpen by remember { mutableStateOf(false) }
val trayState = rememberTrayState() 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 traySupported = remember { java.awt.SystemTray.isSupported() }
val mac = remember { isMacOs() }
DisposableEffect(trayState) { DisposableEffect(trayState) {
DesktopNotifier.sink = { title, body -> 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) { if (traySupported) {
Tray( Tray(
icon = appIcon, icon = trayIcon,
state = trayState, state = trayState,
tooltip = "FromChat", tooltip = "FromChat",
onAction = { windowVisible = true }, onAction = { windowVisible = true },
menu = { menu = {
Item("Show") { windowVisible = true } Item("Show") { windowVisible = true }
Item("About FromChat") { aboutOpen = true }
Separator()
Item("Quit") { exitApplication() } Item("Quit") { exitApplication() }
}, },
) )
@@ -235,7 +270,31 @@ fun main(args: Array<String>) {
title = "FromChat", title = "FromChat",
state = windowState, state = windowState,
visible = windowVisible || !traySupported, 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) { LaunchedEffect(window) {
enableEdgeToEdgeTitleBar(window.rootPane) enableEdgeToEdgeTitleBar(window.rootPane)
@@ -244,18 +303,109 @@ fun main(args: Array<String>) {
AppForeground.setForeground(true) AppForeground.setForeground(true)
onDispose { } 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( CompositionLocalProvider(
LocalExtraStatusBarTop provides if (isMacOs()) 28.dp else 0.dp, LocalExtraStatusBarTop provides if (mac) 28.dp else 0.dp,
) { ) {
App() 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 = private fun isMacOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("mac") 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) { private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
if (!isMacOs()) return if (!isMacOs()) return
rootPane.putClientProperty("apple.awt.fullWindowContent", true) rootPane.putClientProperty("apple.awt.fullWindowContent", true)
@@ -263,11 +413,43 @@ private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
rootPane.putClientProperty("apple.awt.windowTitleVisible", false) rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
} }
private fun loadAppIconPainter(): Painter { /**
val bytes = DesktopApplicationBootstrap::class.java.classLoader * Loads the desktop tray or window icon from packaged resources.
.getResourceAsStream("app_icon.png") * Prefers PNG, then WebP; returns a tiny empty bitmap only if every decode fails.
?.use { it.readBytes() } */
?: return BitmapPainter(ImageBitmap(32, 32)) private fun loadAppIconPainter(tray: Boolean): Painter {
val skiaImage = Image.makeFromEncoded(bytes) val names = if (tray) {
return BitmapPainter(skiaImage.toComposeImageBitmap()) 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="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
<string name="chats">Чаты</string> <string name="chats">Чаты</string>
<string name="select_chat_placeholder">Выберите чат, чтобы начать переписку</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="contacts">Контакты</string>
<string name="profile">Профиль</string> <string name="profile">Профиль</string>
<string name="dms">Личные чаты</string> <string name="dms">Личные чаты</string>
@@ -299,6 +301,8 @@
<string name="logout">Выйти</string> <string name="logout">Выйти</string>
<string name="materialYou">Material You</string> <string name="materialYou">Material You</string>
<string name="materialYou_d">Цвета как на обоях. Работает с Android 12 и новее.</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="theme">Оформление</string>
<string name="as_system">Как на телефоне</string> <string name="as_system">Как на телефоне</string>
<string name="light">Светлое</string> <string name="light">Светлое</string>
@@ -374,6 +378,7 @@
<string name="settings_security_step_confirm_body">Введите новый пароль ещё раз, чтобы убедиться, что без ошибок.</string> <string name="settings_security_step_confirm_body">Введите новый пароль ещё раз, чтобы убедиться, что без ошибок.</string>
<string name="settings_next">Далее</string> <string name="settings_next">Далее</string>
<string name="settings_hub_about_sub">Версия, ссылки и другое</string> <string name="settings_hub_about_sub">Версия, ссылки и другое</string>
<string name="settings_hub_profile_sub">Ваш профиль и публичная информация</string>
<string name="logs_title">Журнал</string> <string name="logs_title">Журнал</string>
<string name="settings_hub_logs_sub">Просмотр, отправка и очистка журнала</string> <string name="settings_hub_logs_sub">Просмотр, отправка и очистка журнала</string>
<string name="logs_empty">Записей пока нет</string> <string name="logs_empty">Записей пока нет</string>
@@ -72,6 +72,8 @@
<!-- Main Screen --> <!-- Main Screen -->
<string name="chats">Chats</string> <string name="chats">Chats</string>
<string name="select_chat_placeholder">Select a chat to start messaging</string> <string name="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="contacts">Contacts</string>
<string name="profile">Profile</string> <string name="profile">Profile</string>
<string name="dms">Private chats</string> <string name="dms">Private chats</string>
@@ -328,6 +330,8 @@
<string name="logout">Log out</string> <string name="logout">Log out</string>
<string name="materialYou">Material You</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="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="theme">Look</string>
<string name="as_system">Same as phone</string> <string name="as_system">Same as phone</string>
<string name="light">Light</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_security_step_confirm_body">Type your new password again to make sure it matches.</string>
<string name="settings_next">Next</string> <string name="settings_next">Next</string>
<string name="settings_hub_about_sub">Version, links, and more</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="logs_title">Logs</string>
<string name="settings_hub_logs_sub">View, share, and manage app logs</string> <string name="settings_hub_logs_sub">View, share, and manage app logs</string>
<string name="logs_empty">No log entries yet</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 { object Settings {
private const val MATERIAL_YOU_KEY = "materialYou" private const val MATERIAL_YOU_KEY = "materialYou"
private const val THEME_KEY = "theme" 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]. */ /** 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_URL_KEY = "server_url"
private const val SERVER_IP_KEY = "server_ip" 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)] } get() = runBlocking { Theme.entries[settings.getInt(THEME_KEY, Theme.AsSystem.ordinal)] }
set(value) = runIO { settings.putInt(THEME_KEY, value.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 var httpsEnabled: Boolean
get() = runBlocking { get() = runBlocking {
if (settings.contains(HTTPS_ENABLED_KEY)) { if (settings.contains(HTTPS_ENABLED_KEY)) {
@@ -12,13 +12,9 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect 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.DmChatRoute
import ru.fromchat.ui.chat.panels.dm.DmNav import ru.fromchat.ui.chat.panels.dm.DmNav
import ru.fromchat.ui.chat.panels.dm.DmProfileRoute 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.PublicChatChatRoute
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat
import ru.fromchat.ui.main.ConversationListDetailShell import ru.fromchat.ui.main.ConversationListDetailShell
import ru.fromchat.ui.main.ConversationProfileThirdPaneMinWidth import ru.fromchat.ui.main.ConversationProfileThirdPaneMinWidth
import ru.fromchat.ui.main.EmptyContactsPlaceholder
import ru.fromchat.ui.main.EmptyConversationPlaceholder 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_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.MainScreen
import ru.fromchat.ui.main.chats.ChatsSearchScreen 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.LOG_FILE_OPEN_RESULT_KEY
import ru.fromchat.ui.main.settings.LogFilesScreen import ru.fromchat.ui.main.settings.LogFilesScreen
import ru.fromchat.ui.main.settings.LogsScreen import ru.fromchat.ui.main.settings.LogsScreen
@@ -140,10 +143,45 @@ private fun isConversationProfileRoute(route: String?): Boolean =
route == PublicChatNav.PROFILE_ROUTE || route == PublicChatNav.PROFILE_ROUTE ||
(route != null && route.startsWith("dm/") && route.endsWith("/profile")) (route != null && route.startsWith("dm/") && route.endsWith("/profile"))
private fun isConversationShellRoute(route: String?): Boolean = private fun isStandaloneProfileRoute(route: String?): Boolean =
route == "chat" || route != null && route.startsWith("profile/")
isConversationChatRoute(route) ||
isConversationProfileRoute(route) 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? { private fun dmUserIdFromRoute(route: String?): Int? {
if (route == null || !route.startsWith("dm/")) return null if (route == null || !route.startsWith("dm/")) return null
@@ -427,15 +465,10 @@ fun App(
"navigating to dm user=${target.dmConversationUserId} " + "navigating to dm user=${target.dmConversationUserId} " +
"messageId=${target.scrollToMessageId} launchId=${target.launchId}" "messageId=${target.scrollToMessageId} launchId=${target.launchId}"
) )
navController.navigate( navController.navigateToDmChat(
DmNav.chatRoute( otherUserId = target.dmConversationUserId,
otherUserId = target.dmConversationUserId, sourceMessageId = target.scrollToMessageId,
sourceMessageId = target.scrollToMessageId, )
)
) {
launchSingleTop = true
popUpTo("chat") { saveState = true }
}
} }
target.startAtPublicChat -> { target.startAtPublicChat -> {
@@ -443,9 +476,7 @@ fun App(
"NotificationLaunch", "NotificationLaunch",
"navigating to public chat launchId=${target.launchId}" "navigating to public chat launchId=${target.launchId}"
) )
navController.navigate(PublicChatNav.CHAT_ROUTE) { navController.navigateToPublicChat()
launchSingleTop = true
}
} }
} }
} }
@@ -458,19 +489,47 @@ fun App(
if (startDestination != null) { if (startDestination != null) {
val currentEntry by navController.currentBackStackEntryAsState() val currentEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentEntry?.destination?.route val currentRoute = currentEntry?.destination?.route
val previousRoute =
navController.previousBackStackEntry?.destination?.route
val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass
var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) } var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) }
val ownUserId = ApiClient.user?.id
val showConversationListDetail = val showConversationListDetail =
widthSizeClass != WindowWidthSizeClass.COMPACT && widthSizeClass != WindowWidthSizeClass.COMPACT &&
isConversationShellRoute(currentRoute) isConversationShellRoute(currentRoute, previousRoute, ownUserId)
ScreenSurface { ScreenSurface {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
BoxWithConstraints(Modifier.fillMaxSize()) { BoxWithConstraints(Modifier.fillMaxSize()) {
val canFitProfileThirdPane =
maxWidth >= ConversationProfileThirdPaneMinWidth
val showProfileThirdPane = val showProfileThirdPane =
showConversationListDetail && showConversationListDetail &&
isConversationProfileRoute(currentRoute) && canFitProfileThirdPane &&
maxWidth >= ConversationProfileThirdPaneMinWidth (
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 @Composable
fun AppNavHost(modifier: Modifier = Modifier) { fun AppNavHost(modifier: Modifier = Modifier) {
@@ -575,12 +634,12 @@ fun App(
animatedVisibilityScope = this, animatedVisibilityScope = this,
onOpenProfile = { userId: Int -> onOpenProfile = { userId: Int ->
if (userId != 0) { if (userId != 0) {
navController.navigate("profile/$userId") navController.navigateReplacingMainDetail("profile/$userId")
} }
}, },
onOpenConversation = { userId: Int -> onOpenConversation = { userId: Int ->
if (userId != 0) { if (userId != 0) {
navController.navigate(DmNav.chatRoute(userId)) navController.navigateToDmChat(userId)
} }
} }
) )
@@ -620,9 +679,9 @@ fun App(
ProfileScreen( ProfileScreen(
userId = userId, userId = userId,
username = profileUsername, username = profileUsername,
showBackButton = true, showBackButton = !settingsProfileSplit,
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
onChat = { navController.navigate(DmNav.chatRoute(it)) }, onChat = { navController.navigateToDmChat(it) },
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable, animatedVisibilityScope = this@composable,
showErrorAsToast = fromDeepLink showErrorAsToast = fromDeepLink
@@ -802,56 +861,72 @@ fun App(
if (showConversationListDetail) { if (showConversationListDetail) {
ConversationListDetailShell( ConversationListDetailShell(
widthSizeClass = widthSizeClass,
showProfilePane = showProfileThirdPane, showProfilePane = showProfileThirdPane,
listPaneWidth = listPaneWidth,
listPane = { listPane = {
MainScreen( MainScreen(
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
snackbarHostState = profileLookupSnackbarHostState, snackbarHostState = profileLookupSnackbarHostState,
listPaneOnly = true, embeddedInListDetail = true,
initialPage = pendingMainTab,
forceSettingsTab = forceSettingsListTab,
onPageChanged = { pendingMainTab = it },
) )
}, },
detailPane = { detailPane = {
if (showProfileThirdPane) { when {
androidx.compose.animation.AnimatedVisibility( showProfileThirdPane -> {
visible = true, androidx.compose.animation.AnimatedVisibility(
enter = EnterTransition.None, visible = true,
exit = ExitTransition.None, enter = EnterTransition.None,
) { exit = ExitTransition.None,
val dmUserId = dmUserIdFromRoute(currentRoute) ) {
when { val chatRouteForDetail =
dmUserId != null -> DmChatRoute( when {
otherUserId = dmUserId, isConversationProfileRoute(currentRoute) ->
scrollToMessageId = null, currentRoute
navController = navController, isConversationChatRoute(previousRoute) ->
sharedTransitionScope = this@SharedTransitionLayout, previousRoute
animatedVisibilityScope = this, else -> null
) }
currentRoute == PublicChatNav.PROFILE_ROUTE || val dmUserId = dmUserIdFromRoute(chatRouteForDetail)
currentRoute == PublicChatNav.CHAT_ROUTE -> { ?: dmUserIdFromRoute(currentRoute)
PublicChatChatRoute( when {
scrollToMessageId = scrollToMessageId, dmUserId != null -> DmChatRoute(
otherUserId = dmUserId,
scrollToMessageId = null,
navController = navController, navController = navController,
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this, animatedVisibilityScope = this,
) )
chatRouteForDetail == PublicChatNav.PROFILE_ROUTE ||
chatRouteForDetail == PublicChatNav.CHAT_ROUTE ||
currentRoute == PublicChatNav.PROFILE_ROUTE -> {
PublicChatChatRoute(
scrollToMessageId = scrollToMessageId,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
}
} }
} }
} }
} else { isSettingsDetailRoute(currentRoute) ->
AppNavHost(Modifier.fillMaxSize()) AppNavHost(Modifier.fillMaxSize())
currentRoute == "chat" -> {
when (pendingMainTab) {
MAIN_PAGE_CONTACTS -> EmptyContactsPlaceholder()
MAIN_PAGE_SETTINGS -> EmptySettingsPlaceholder()
else -> EmptyConversationPlaceholder()
}
}
else -> AppNavHost(Modifier.fillMaxSize())
} }
}, },
profilePane = { profilePane = {
AppNavHost(Modifier.fillMaxSize()) AppNavHost(Modifier.fillMaxSize())
}, },
onSelectMainTab = { page ->
pendingMainTab = page
navController.navigate("chat") {
popUpTo("chat") { inclusive = true }
launchSingleTop = true
}
},
) )
} else { } else {
AppNavHost(Modifier.fillMaxSize()) AppNavHost(Modifier.fillMaxSize())
@@ -36,6 +36,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField 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.Icons
import androidx.compose.material.icons.automirrored.rounded.Reply import androidx.compose.material.icons.automirrored.rounded.Reply
import androidx.compose.material.icons.rounded.ArrowUpward 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.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector 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.layout.ContentScale
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.conditional
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect 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_pick_image
import ru.fromchat.cd_remove import ru.fromchat.cd_remove
import ru.fromchat.cd_send import ru.fromchat.cd_send
import ru.fromchat.config.Settings
import ru.fromchat.message_corrupted_short import ru.fromchat.message_corrupted_short
import ru.fromchat.message_editing_title import ru.fromchat.message_editing_title
import ru.fromchat.message_placeholder import ru.fromchat.message_placeholder
@@ -290,6 +301,7 @@ fun ChatInput(
} }
val canSend = !isReadOnly && (text.isNotBlank() || attachments.isNotEmpty()) val canSend = !isReadOnly && (text.isNotBlank() || attachments.isNotEmpty())
val enterToSend = Settings.enterToSend
val cdClose = stringResource(Res.string.cd_close) val cdClose = stringResource(Res.string.cd_close)
val cdRemove = stringResource(Res.string.cd_remove) val cdRemove = stringResource(Res.string.cd_remove)
val cdPickImage = stringResource(Res.string.cd_pick_image) val cdPickImage = stringResource(Res.string.cd_pick_image)
@@ -301,6 +313,15 @@ fun ChatInput(
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message) val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
val cdVoiceUnavailable = "Функция пока не готова. Следите за обновлениями!" val cdVoiceUnavailable = "Функция пока не готова. Следите за обновлениями!"
fun sendMessage() {
if (!canSend) return
val plaintext = text.trim().ifBlank { "" }
onSend(plaintext, attachments)
onTextChange("")
attachments = emptyList()
typingHandler.stopTyping()
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -447,10 +468,27 @@ fun ChatInput(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .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, textStyle = inputTextStyle,
singleLine = false, singleLine = false,
maxLines = 5, maxLines = 5,
keyboardOptions = KeyboardOptions(
imeAction = if (enterToSend) ImeAction.Send else ImeAction.Default,
),
keyboardActions = KeyboardActions(
onSend = { sendMessage() },
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
Box( Box(
@@ -545,11 +583,7 @@ fun ChatInput(
.clip(CircleShape) .clip(CircleShape)
.clickable { .clickable {
if (canSend) { if (canSend) {
val plaintext = text.trim().ifBlank { "" } sendMessage()
onSend(plaintext, attachments)
onTextChange("")
attachments = emptyList()
typingHandler.stopTyping()
} else { } else {
scope.launch { scope.launch {
snackbarHostState?.showSnackbar(message = cdVoiceUnavailable) snackbarHostState?.showSnackbar(message = cdVoiceUnavailable)
@@ -1075,13 +1075,18 @@ fun ChatScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.pointerInput(Unit) { .then(
detectTapGestures { if (contextMenuState.isOpen) {
if (contextMenuState.isOpen) { Modifier.pointerInput(Unit) {
contextMenuState = contextMenuState.copy(isOpen = false) detectTapGestures {
contextMenuState =
contextMenuState.copy(isOpen = false)
}
} }
} } else {
} Modifier
},
),
) { ) {
if (panelState.isLoading && panelState.messages.isEmpty()) { if (panelState.isLoading && panelState.messages.isEmpty()) {
Box( 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, transformOriginX: Float = 0f,
transformOriginY: Float = 0f, transformOriginY: Float = 0f,
) { ) {
val menuShape = RoundedCornerShape(16.dp)
val menuScrollState = rememberScrollState() 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 density = LocalDensity.current
val shadowElevationPx = if (withShadow) { val shadowElevationPx = if (withShadow) {
with(density) { 12.dp.toPx() } with(density) { 12.dp.toPx() }
} else { } else {
0f 0f
} }
val baseModifier = modifier.width(IntrinsicSize.Max) val baseModifier = modifier.width(IntrinsicSize.Max)
val containerModifier = val containerModifier =
if (animated) { if (animated) {
@@ -336,150 +435,78 @@ private fun ContextMenuContent(
transformOrigin = TransformOrigin(transformOriginX, transformOriginY), transformOrigin = TransformOrigin(transformOriginX, transformOriginY),
shadowElevation = shadowElevationPx, shadowElevation = shadowElevationPx,
shape = menuShape, shape = menuShape,
clip = true clip = true,
) )
} else { } else {
baseModifier.graphicsLayer( baseModifier.graphicsLayer(
shadowElevation = shadowElevationPx, shadowElevation = shadowElevationPx,
shape = menuShape, 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 = containerModifier) {
Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape)) Box(
Column(
modifier = Modifier modifier = Modifier
.padding(horizontal = edgePadding, vertical = edgePadding) .matchParentSize()
.verticalScroll(menuScrollState), .background(MaterialTheme.colorScheme.surfaceContainer, menuShape),
verticalArrangement = Arrangement.spacedBy(itemSpacing) )
) { content()
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
)
}
}
}
} }
} }
private val itemShape = RoundedCornerShape(12.dp)
@Composable @Composable
private fun ContextMenuItem( internal fun ChatStyleContextMenuItem(
icon: ImageVector, icon: ImageVector,
text: String, text: String,
onClick: () -> Unit, onClick: () -> Unit,
isError: Boolean = false, isError: Boolean = false,
modifier: Modifier = Modifier enabled: Boolean = true,
modifier: Modifier = Modifier,
) { ) {
val textColor = if (isError) { val textColor = when {
MaterialTheme.colorScheme.error !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else { isError -> MaterialTheme.colorScheme.error
MaterialTheme.colorScheme.onSurface else -> MaterialTheme.colorScheme.onSurface
}
val iconColor = if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
} }
val iconColor = textColor
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(4.dp) .padding(4.dp)
.clip(itemShape) .clip(chatStyleMenuItemShape)
.scaleOnPress( .then(
scale = 0.96f, if (enabled) {
onClick = onClick, Modifier.scaleOnPress(
indication = LocalIndication.current, scale = 0.96f,
animationSpec = spring( onClick = onClick,
dampingRatio = Spring.DampingRatioMediumBouncy, indication = LocalIndication.current,
stiffness = Spring.StiffnessMedium, animationSpec = spring(
), dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium,
),
)
} else {
Modifier
},
) )
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart,
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = text, contentDescription = text,
tint = iconColor, tint = iconColor,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp),
) )
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.bodyMedium, 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.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventType 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.isSecondaryPressed
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastAll
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch 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. * Scales layout height with the exit animation so the list collapses in real time.
*/ */
@@ -470,27 +501,22 @@ fun MessageItem(
val mouseMessageUi = supportsMouseMessageInteraction() val mouseMessageUi = supportsMouseMessageInteraction()
val openMessageMenu: (LayoutCoordinates?, Offset) -> Unit = { coords, localOffset ->
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(localOffset))
}
isPressed = true
onLongPress()
}
val rowLongPress = val rowLongPress =
if (isContextMenuOpen) Modifier if (isContextMenuOpen) Modifier
else Modifier.pointerInput(message.id, onLongPress, mouseMessageUi) { else if (mouseMessageUi) {
if (mouseMessageUi) { Modifier.onMouseSecondaryClick(message.id, enabled = true) { localOffset ->
awaitPointerEventScope { openMessageMenu(rowLayoutCoords, localOffset)
while (true) { }
val event = awaitPointerEvent(PointerEventPass.Main) } else {
if (event.type == PointerEventType.Press && Modifier.pointerInput(message.id, onLongPress) {
event.buttons.isSecondaryPressed
) {
event.changes.forEach { it.consume() }
val local = event.changes.firstOrNull()?.position ?: continue
val coords = rowLayoutCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(local))
}
onLongPress()
}
}
}
} else {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
isPressed = true isPressed = true
@@ -501,11 +527,7 @@ fun MessageItem(
} }
}, },
onLongPress = { localOffset -> onLongPress = { localOffset ->
val coords = rowLayoutCoords openMessageMenu(rowLayoutCoords, localOffset)
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(localOffset))
}
onLongPress()
}, },
) )
} }
@@ -614,49 +636,40 @@ fun MessageItem(
if (isContextMenuOpen) Modifier if (isContextMenuOpen) Modifier
else Modifier else Modifier
.onGloballyPositioned { bubbleContentCoords = it } .onGloballyPositioned { bubbleContentCoords = it }
.pointerInput(message.id, onBubbleTap, onLongPress, mouseMessageUi) { .then(
if (mouseMessageUi) { if (mouseMessageUi) {
awaitPointerEventScope { Modifier.onMouseSecondaryClick(
while (true) { message.id,
val event = awaitPointerEvent(PointerEventPass.Main) enabled = true,
when { ) { localOffset ->
event.type == PointerEventType.Press && openMessageMenu(bubbleContentCoords, localOffset)
event.buttons.isSecondaryPressed -> {
event.changes.forEach { it.consume() }
val local =
event.changes.firstOrNull()?.position
?: continue
val coords = bubbleContentCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(local))
}
isPressed = true
onLongPress()
}
}
}
} }
} else { } else {
detectTapGestures( Modifier.pointerInput(
onPress = { message.id,
isPressed = true onBubbleTap,
try { onLongPress,
awaitRelease() ) {
} finally { detectTapGestures(
isPressed = false onPress = {
} isPressed = true
}, try {
onTap = { onBubbleTap?.invoke() }, awaitRelease()
onLongPress = { localOffset -> } finally {
val coords = bubbleContentCoords isPressed = false
if (coords != null && coords.isAttached) { }
onTapPosition(coords.localToRoot(localOffset)) },
} onTap = { onBubbleTap?.invoke() },
onLongPress() onLongPress = { localOffset ->
}, openMessageMenu(
) bubbleContentCoords,
} localOffset,
} )
},
)
}
},
)
Box { Box {
Column( Column(
@@ -697,11 +710,23 @@ fun MessageItem(
Modifier.padding(start = 8.dp, end = 8.dp, bottom = 4.dp) Modifier.padding(start = 8.dp, end = 8.dp, bottom = 4.dp)
val usernameInset = val usernameInset =
Modifier.padding(horizontal = 4.dp, vertical = 2.dp) Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
if (onUsernameClick != null) { val usernameGestures =
Box( if (mouseMessageUi) {
modifier = usernameOutset Modifier
.clip(usernameShape) .onMouseSecondaryClick(
.pointerInput(message.id, onUsernameClick) { message.id,
enabled = !isContextMenuOpen,
) { localOffset ->
openMessageMenu(
rowLayoutCoords,
localOffset,
)
}
.pointerInput(
message.id,
onUsernameClick,
onBubbleTap,
) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
isPressed = true isPressed = true
@@ -711,25 +736,56 @@ fun MessageItem(
isPressed = false isPressed = false
} }
}, },
onTap = { onUsernameClick.invoke() }, onTap = {
onLongPress = { onUsernameClick?.invoke()
val coords = bubbleContentCoords ?: onBubbleTap?.invoke()
if (
coords != null &&
coords.isAttached
) {
val center = Offset(
coords.size.width / 2f,
coords.size.height / 2f,
)
onTapPosition(
coords.localToRoot(center),
)
}
onLongPress()
}, },
) )
} }
} 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 (
coords != null &&
coords.isAttached
) {
val center = Offset(
coords.size.width / 2f,
coords.size.height / 2f,
)
onTapPosition(
coords.localToRoot(center),
)
}
onLongPress()
},
)
}
}
if (onUsernameClick != null) {
Box(
modifier = usernameOutset
.clip(usernameShape)
.then(usernameGestures),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -750,36 +806,7 @@ fun MessageItem(
} }
} else { } else {
Box( Box(
modifier = usernameOutset modifier = usernameOutset.then(usernameGestures),
.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()
},
)
},
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -1079,13 +1106,17 @@ fun MessageItem(
!isFilenameOnlyMessageCaption(message) !isFilenameOnlyMessageCaption(message)
) { ) {
if (mouseMessageUi) { if (mouseMessageUi) {
SelectionContainer { ProvideChatTextSelectionMenu {
Text( SelectionContainer {
text = message.content, Text(
style = MaterialTheme.typography.bodyLarge, text = message.content,
color = contentColor, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 14.dp) color = contentColor,
) modifier = Modifier.padding(
horizontal = 14.dp,
),
)
}
} }
} else { } else {
Text( Text(
@@ -8,9 +8,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.navigation.NavController
import androidx.navigation.NavOptionsBuilder
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.ui.chat.rememberChatNavigationGate import ru.fromchat.ui.chat.rememberChatNavigationGate
import ru.fromchat.utils.haptic.HapticFeedbackEvent import ru.fromchat.utils.haptic.HapticFeedbackEvent
@@ -33,6 +34,22 @@ object DmNav {
fun profileRoute(otherUserId: Int) = "dm/$otherUserId/profile" 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-" private const val DM_AVATAR_KEY_PREFIX = "dm-avatar-"
@Composable @Composable
@@ -105,7 +122,7 @@ fun DmProfileRoute(
onChat = { onChat = {
runNav { runNav {
haptic(HapticFeedbackEvent.ProfileClosed) haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack() navController.navigateToDmChat(otherUserId)
} }
}, },
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
@@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavOptionsBuilder
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.ui.chat.rememberChatNavigationGate import ru.fromchat.ui.chat.rememberChatNavigationGate
@@ -23,6 +24,19 @@ object PublicChatNav {
const val SHARED_HEADER_KEY = "public-chat-header" 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 @Composable
fun PublicChatChatRoute( fun PublicChatChatRoute(
scrollToMessageId: Int? = null, scrollToMessageId: Int? = null,
@@ -78,7 +92,7 @@ fun PublicChatProfileRoute(
onChat = { onChat = {
runNav { runNav {
haptic(HapticFeedbackEvent.ProfileClosed) haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack() navController.navigateToPublicChat()
} }
}, },
modifier = modifier.fillMaxSize(), 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -21,29 +22,44 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ContactsTab() { fun ContactsTab() {
Scaffold( Scaffold(
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = { topBar = {
TopAppBar( TopAppBar(
windowInsets = if (LocalMainChromeInsets.current.top > 0.dp) {
WindowInsets.extraStatusBars
} else {
WindowInsets(0, 0, 0, 0)
},
title = { title = {
Text( Text(
text = stringResource(Res.string.contacts), text = stringResource(Res.string.contacts),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
) )
} }
) { innerPadding -> ) { innerPadding ->
@@ -1,195 +1,116 @@
package ru.fromchat.ui.main package ru.fromchat.ui.main
import androidx.compose.animation.AnimatedVisibility 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.core.tween
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.fadeIn
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets 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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.NavigationItem import ru.fromchat.ui.components.AppPanel
import com.pr0gramm3r101.utils.SimpleNavigationRail import ru.fromchat.ui.extraStatusBars
import com.pr0gramm3r101.utils.WindowWidthSizeClass
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.chats
import ru.fromchat.contacts
import ru.fromchat.profile
import ru.fromchat.settings
import ru.fromchat.ui.components.Text
/** Minimum width to show chat + profile side-by-side beside the list. */ /** Minimum width to show chat + profile side-by-side beside the list. */
val ConversationProfileThirdPaneMinWidth = 1100.dp val ConversationProfileThirdPaneMinWidth = 1100.dp
private val ProfilePaneSlideEasing = CubicBezierEasing(0.22f, 1f, 0.36f, 1f) private const val ProfilePaneAnimMs = 280
private const val ProfilePaneAnimMs = 520
/**
* 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 @Composable
fun ConversationListDetailShell( fun ConversationListDetailShell(
widthSizeClass: WindowWidthSizeClass,
showProfilePane: Boolean, showProfilePane: Boolean,
listPaneWidth: Dp,
listPane: @Composable () -> Unit, listPane: @Composable () -> Unit,
detailPane: @Composable () -> Unit, detailPane: @Composable () -> Unit,
profilePane: @Composable () -> Unit, profilePane: @Composable () -> Unit,
onSelectMainTab: (Int) -> Unit,
modifier: Modifier = Modifier, 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()) { BoxWithConstraints(modifier.fillMaxSize()) {
val canFitProfilePane = maxWidth >= ConversationProfileThirdPaneMinWidth val profileVisible = showProfilePane && maxWidth >= ConversationProfileThirdPaneMinWidth
val profileVisible = showProfilePane && canFitProfilePane val fadeAnim = tween<Float>(
durationMillis = ProfilePaneAnimMs,
easing = FastOutSlowInEasing,
)
val sizeAnim = tween<IntSize>(
durationMillis = ProfilePaneAnimMs,
easing = FastOutSlowInEasing,
)
val paneInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)
Row(Modifier.fillMaxSize()) { Row(
if (useRail) { Modifier
SimpleNavigationRail( .fillMaxSize()
selectedItem = MAIN_PAGE_CHATS, .windowInsetsPadding(paneInsets)
modifier = Modifier.fillMaxHeight(), .consumeWindowInsets(paneInsets)
NavigationItem( .padding(8.dp),
name = chatsLabel, horizontalArrangement = Arrangement.spacedBy(8.dp),
selectedIcon = Icons.AutoMirrored.Filled.Chat, ) {
onClick = { onSelectMainTab(MAIN_PAGE_CHATS) }, AppPanel(
), Modifier
NavigationItem( .width(listPaneWidth)
name = contactsLabel, .fillMaxHeight(),
selectedIcon = Icons.Filled.Contacts, ) {
onClick = { onSelectMainTab(MAIN_PAGE_CONTACTS) }, listPane()
),
NavigationItem(
name = settingsLabel,
selectedIcon = Icons.Filled.Settings,
onClick = { onSelectMainTab(MAIN_PAGE_SETTINGS) },
),
NavigationItem(
name = profileLabel,
selectedIcon = Icons.Filled.Person,
onClick = { onSelectMainTab(MAIN_PAGE_PROFILE) },
),
)
} }
Box(
Column(Modifier.weight(1f).fillMaxHeight()) { Modifier
Row(Modifier.weight(1f).fillMaxWidth()) { .weight(1f)
Box( .fillMaxHeight(),
Modifier ) {
.width(360.dp) detailPane()
.fillMaxHeight(), }
) { AnimatedVisibility(
listPane() visible = profileVisible,
} enter = fadeIn(animationSpec = fadeAnim) + expandHorizontally(
VerticalDivider() animationSpec = sizeAnim,
Box( expandFrom = Alignment.End,
Modifier clip = false,
.weight(1f) ),
.fillMaxHeight(), exit = fadeOut(
) { animationSpec = tween(
detailPane() durationMillis = 220,
} easing = FastOutSlowInEasing,
AnimatedVisibility( ),
visible = profileVisible, ) + shrinkHorizontally(
enter = slideInHorizontally( animationSpec = sizeAnim,
animationSpec = tween( shrinkTowards = Alignment.End,
durationMillis = ProfilePaneAnimMs, clip = false,
easing = ProfilePaneSlideEasing, ),
), ) {
initialOffsetX = { it }, Box(
), Modifier
exit = slideOutHorizontally( .widthIn(min = 320.dp, max = 420.dp)
animationSpec = tween( .width(380.dp)
durationMillis = ProfilePaneAnimMs, .fillMaxHeight(),
easing = ProfilePaneSlideEasing, ) {
), profilePane()
targetOffsetX = { it },
),
) {
Row(Modifier.fillMaxHeight()) {
VerticalDivider()
Box(
Modifier
.widthIn(min = 320.dp, max = 420.dp)
.fillMaxHeight()
.width(380.dp),
) {
profilePane()
}
}
}
}
if (!useRail) {
NavigationBar(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.imePadding(),
containerColor = Color.Transparent,
tonalElevation = 0.dp,
windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
) {
NavigationBarItem(
selected = true,
onClick = { onSelectMainTab(MAIN_PAGE_CHATS) },
label = { Text(chatsLabel) },
icon = {
Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null)
},
)
NavigationBarItem(
selected = false,
onClick = { onSelectMainTab(MAIN_PAGE_CONTACTS) },
label = { Text(contactsLabel) },
icon = {
Icon(Icons.Filled.Contacts, contentDescription = null)
},
)
NavigationBarItem(
selected = false,
onClick = { onSelectMainTab(MAIN_PAGE_SETTINGS) },
label = { Text(settingsLabel) },
icon = {
Icon(Icons.Filled.Settings, contentDescription = null)
},
)
NavigationBarItem(
selected = false,
onClick = { onSelectMainTab(MAIN_PAGE_PROFILE) },
label = { Text(profileLabel) },
icon = {
Icon(Icons.Filled.Person, contentDescription = null)
},
)
}
} }
} }
} }
@@ -14,27 +14,57 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.select_chat_placeholder 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.components.Text
import ru.fromchat.ui.extraStatusBars import ru.fromchat.ui.extraStatusBars
/** Empty detail pane when nothing is selected in a listdetail layout. */
@Composable @Composable
fun EmptyConversationPlaceholder(modifier: Modifier = Modifier) { fun EmptyPanePlaceholder(
val density = LocalDensity.current text: String,
val topInset = with(density) { modifier: Modifier = Modifier,
WindowInsets.extraStatusBars.getTop(this).toDp() ) {
}
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(top = topInset) .padding(
top = with(LocalDensity.current) {
WindowInsets.extraStatusBars.getTop(this).toDp()
},
)
.padding(horizontal = 32.dp), .padding(horizontal = 32.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = stringResource(Res.string.select_chat_placeholder), text = text,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, 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.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState 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.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.pr0gramm3r101.utils.NavigationItem
import com.pr0gramm3r101.utils.SimpleNavigationRail
import com.pr0gramm3r101.utils.WindowWidthSizeClass import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.widthSizeClass import com.pr0gramm3r101.utils.widthSizeClass
@@ -71,12 +65,12 @@ import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.FromChatSnackbarHost import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
import ru.fromchat.ui.main.chats.ChatsTab import ru.fromchat.ui.main.chats.ChatsTab
import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.extraStatusBars
const val MAIN_PAGE_CHATS = 0 const val MAIN_PAGE_CHATS = 0
const val MAIN_PAGE_CONTACTS = 1 const val MAIN_PAGE_CONTACTS = 1
@@ -90,9 +84,12 @@ private const val PAGE_PROFILE = MAIN_PAGE_PROFILE
private const val PAGE_COUNT = 4 private const val PAGE_COUNT = 4
/** /**
* @param listPaneOnly When true, renders only the chats list (no nav rail/bar) for use as the * @param embeddedInListDetail When true, this screen is the left pane of a listdetail layout.
* list side of a listdetail conversation layout. Host owns navigation chrome. * Nav chrome stays inside this pane (bottom bar), never spanning the window.
* @param initialPage Pager page when showing the full MainScreen. * @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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -100,8 +97,10 @@ fun MainScreen(
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
snackbarHostState: SnackbarHostState? = null, snackbarHostState: SnackbarHostState? = null,
listPaneOnly: Boolean = false, embeddedInListDetail: Boolean = false,
initialPage: Int = PAGE_CHATS, initialPage: Int = PAGE_CHATS,
forceSettingsTab: Boolean = false,
onPageChanged: (Int) -> Unit = {},
) { ) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() } val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current val navController = LocalNavController.current
@@ -117,29 +116,34 @@ fun MainScreen(
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
val widthClass = currentWindowAdaptiveInfo().widthSizeClass 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) { LaunchedEffect(forceSettingsTab) {
if (!listPaneOnly && pagerState.currentPage != initialPage) { if (forceSettingsTab && pagerState.currentPage != PAGE_SETTINGS) {
pagerState.scrollToPage(initialPage.coerceIn(0, PAGE_COUNT - 1)) pagerState.scrollToPage(PAGE_SETTINGS)
} }
} }
val selectedPage = pagerState.currentPage val selectedPage = pagerState.currentPage
LaunchedEffect(selectedPage) {
onPageChanged(selectedPage)
}
val isChatsPage = selectedPage == PAGE_CHATS val isChatsPage = selectedPage == PAGE_CHATS
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
val statusBarTop = with(density) { // Listdetail shell already pads/consumes safeDrawing extraStatusBars; do not re-apply top.
WindowInsets.extraStatusBars.getTop(this).toDp() val statusBarTop = if (embeddedInListDetail) {
0.dp
} else {
with(density) {
WindowInsets.extraStatusBars.getTop(this).toDp()
}
} }
var bottomChromeHeightDp by remember { mutableStateOf(0.dp) } var bottomChromeHeightDp by remember { mutableStateOf(0.dp) }
val mainChromeInsets = remember(statusBarTop, bottomChromeHeightDp, useNavigationRail, useBottomBar) { val mainChromeInsets = remember(statusBarTop, bottomChromeHeightDp) {
MainChromeInsets( MainChromeInsets(
top = statusBarTop, top = statusBarTop,
bottom = if (useBottomBar) bottomChromeHeightDp else 0.dp, bottom = bottomChromeHeightDp,
) )
} }
@@ -148,71 +152,82 @@ fun MainScreen(
val settingsLabel = stringResource(Res.string.settings) val settingsLabel = stringResource(Res.string.settings)
val profileLabel = stringResource(Res.string.profile) val profileLabel = stringResource(Res.string.profile)
BoxWithConstraints(Modifier.fillMaxSize()) { fun clearConversationStack() {
Row(Modifier.fillMaxSize()) { // Pop above chat only — inclusive pop remounts the chat destination and briefly
if (useNavigationRail) { // drops the shell route, which remounts listdetail ↔ compact and blinks forever.
SimpleNavigationRail( navController.popBackStack("chat", inclusive = false)
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) }
},
),
)
}
Box( 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
.fillMaxSize()
.hazeSource(contextMenuHazeState),
) {
Scaffold(
snackbarHost = {
FromChatSnackbarHost(
hostState = effectiveSnackbarHostState,
modifier = Modifier.padding(bottom = mainChromeInsets.bottom),
)
},
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxSize()
.fillMaxHeight() .imePadding(),
.hazeSource(contextMenuHazeState),
) { ) {
Scaffold( CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) {
snackbarHost = { Box(
FromChatSnackbarHost( modifier = Modifier
hostState = effectiveSnackbarHostState, .fillMaxSize()
modifier = Modifier.padding(bottom = mainChromeInsets.bottom), .hazeSource(navBarHazeState),
) ) {
}, HorizontalPager(
containerColor = Color.Transparent, state = pagerState,
contentWindowInsets = WindowInsets(0, 0, 0, 0), modifier = Modifier.fillMaxSize(),
modifier = Modifier beyondViewportPageCount = 1,
.fillMaxSize() userScrollEnabled = widthClass == WindowWidthSizeClass.COMPACT,
.imePadding(), ) { page ->
) { when (page) {
CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) { PAGE_CHATS -> ChatsTab(
Box( isVisible = isChatsPage,
modifier = Modifier
.fillMaxSize()
.hazeSource(navBarHazeState),
) {
if (listPaneOnly) {
ChatsTab(
isVisible = true,
onOpenSearch = { onOpenSearch = {
navController.navigate("search/conversations") navController.navigate("search/conversations")
}, },
@@ -220,118 +235,90 @@ fun MainScreen(
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
) )
} else { PAGE_CONTACTS -> ContactsTab()
HorizontalPager( PAGE_SETTINGS -> SettingsTab()
state = pagerState, PAGE_PROFILE -> {
modifier = Modifier.fillMaxSize(), ProfileScreen(
beyondViewportPageCount = 1, userId = ApiClient.user?.id,
) { page -> onBack = {},
when (page) { onChat = { _ -> },
PAGE_CHATS -> ChatsTab( modifier = Modifier
isVisible = isChatsPage, .fillMaxSize()
onOpenSearch = { .mainPagerBottomInset(),
navController.navigate("search/conversations") onOpenSettings = {
}, scope.launch {
chatContextMenuOverlay = chatContextMenuOverlay, pagerState.animateScrollToPage(PAGE_SETTINGS)
sharedTransitionScope = sharedTransitionScope, }
animatedVisibilityScope = animatedVisibilityScope, },
) )
PAGE_CONTACTS -> ContactsTab()
PAGE_SETTINGS -> SettingsTab()
PAGE_PROFILE -> {
ProfileScreen(
userId = ApiClient.user?.id,
onBack = {},
onChat = { _ -> },
modifier = Modifier
.fillMaxSize()
.mainPagerBottomInset(),
onOpenSettings = {
scope.launch {
pagerState.animateScrollToPage(PAGE_SETTINGS)
}
},
)
}
else -> Unit
}
} }
else -> Unit
} }
} }
} }
} }
}
if (useBottomBar) { Column(
Column( modifier = Modifier
modifier = Modifier .align(Alignment.BottomCenter)
.align(Alignment.BottomCenter) .fillMaxWidth()
.fillMaxWidth() .zIndex(1f)
.zIndex(1f) .imePadding()
.imePadding() .onSizeChanged { size ->
.onSizeChanged { size -> val measured = with(density) { size.height.toDp() }
val measured = with(density) { size.height.toDp() } if (measured != bottomChromeHeightDp) {
if (measured != bottomChromeHeightDp) { bottomChromeHeightDp = measured
bottomChromeHeightDp = measured
}
}
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = navBarHazeState) {
blurEffect {
style = navBarHazeStyle
}
},
) {
NavigationBar(
modifier = Modifier.fillMaxWidth(),
containerColor = Color.Transparent,
tonalElevation = 0.dp,
windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
) {
NavigationBarItem(
selected = selectedPage == PAGE_CHATS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
},
label = { Text(chatsLabel) },
icon = {
Icon(
Icons.AutoMirrored.Filled.Chat,
contentDescription = null,
)
},
)
NavigationBarItem(
selected = selectedPage == PAGE_CONTACTS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
},
label = { Text(contactsLabel) },
icon = {
Icon(Icons.Filled.Contacts, contentDescription = null)
},
)
NavigationBarItem(
selected = selectedPage == PAGE_SETTINGS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
},
label = { Text(settingsLabel) },
icon = {
Icon(Icons.Filled.Settings, contentDescription = null)
},
)
NavigationBarItem(
selected = selectedPage == PAGE_PROFILE,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
},
label = { Text(profileLabel) },
icon = {
Icon(Icons.Filled.Person, contentDescription = null)
},
)
} }
} }
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = navBarHazeState) {
blurEffect {
style = navBarHazeStyle
}
},
) {
NavigationBar(
modifier = Modifier.fillMaxWidth(),
containerColor = Color.Transparent,
tonalElevation = 0.dp,
windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
) {
NavigationBarItem(
selected = navSelectedPage == PAGE_CHATS,
onClick = { selectMainPage(PAGE_CHATS) },
label = { Text(chatsLabel) },
icon = {
Icon(
Icons.AutoMirrored.Filled.Chat,
contentDescription = null,
)
},
)
NavigationBarItem(
selected = navSelectedPage == PAGE_CONTACTS,
onClick = { selectMainPage(PAGE_CONTACTS) },
label = { Text(contactsLabel) },
icon = {
Icon(Icons.Filled.Contacts, contentDescription = null)
},
)
NavigationBarItem(
selected = navSelectedPage == PAGE_SETTINGS,
onClick = { selectMainPage(PAGE_SETTINGS) },
label = { Text(settingsLabel) },
icon = {
Icon(Icons.Filled.Settings, contentDescription = null)
},
)
NavigationBarItem(
selected = navSelectedPage == PAGE_PROFILE,
onClick = { selectMainPage(PAGE_PROFILE) },
label = { Text(profileLabel) },
icon = {
Icon(Icons.Filled.Person, contentDescription = null)
},
)
} }
} }
} }
@@ -14,7 +14,9 @@ import androidx.compose.animation.scaleOut
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues 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.Color
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.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.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot 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.Dp
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp 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.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement import ru.fromchat.ui.components.SearchBarSharedElement
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
@@ -126,6 +135,52 @@ private val ChatListCategoryMargin = PaddingValues(
bottom = 12.dp, 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 @Composable
internal fun ChatListHeadlineWithBadge( internal fun ChatListHeadlineWithBadge(
title: String, title: String,
@@ -721,15 +776,11 @@ internal fun PublicChatRow(
.alpha(if (isHiddenForOverlay) 0f else 1f) .alpha(if (isHiddenForOverlay) 0f else 1f)
.fillMaxWidth() .fillMaxWidth()
.clip(clipShape) .clip(clipShape)
.combinedClickable( .chatRowOpenAndSelectGestures(
selectionEnabled = listMode == ChatsListMode.Normal,
interactionSource = rowInteractionSource, interactionSource = rowInteractionSource,
indication = ripple(), onOpen = onOpenPublic,
onClick = onOpenPublic, onEnterSelection = onBodyLongPress,
onLongClick = if (listMode == ChatsListMode.Normal) {
{ onBodyLongPress() }
} else {
null
},
) )
.onGloballyPositioned { coords -> .onGloballyPositioned { coords ->
rowRootOffset = coords.positionInRoot() rowRootOffset = coords.positionInRoot()
@@ -871,15 +922,11 @@ internal fun DmConversationRow(
.alpha(if (isHiddenForOverlay) 0f else 1f) .alpha(if (isHiddenForOverlay) 0f else 1f)
.fillMaxWidth() .fillMaxWidth()
.clip(clipShape) .clip(clipShape)
.combinedClickable( .chatRowOpenAndSelectGestures(
selectionEnabled = listMode == ChatsListMode.Normal,
interactionSource = rowInteractionSource, interactionSource = rowInteractionSource,
indication = ripple(), onOpen = onOpenConversation,
onClick = onOpenConversation, onEnterSelection = onBodyLongPress,
onLongClick = if (listMode == ChatsListMode.Normal) {
{ onBodyLongPress() }
} else {
null
},
) )
.onGloballyPositioned { coords -> .onGloballyPositioned { coords ->
rowRootOffset = coords.positionInRoot() rowRootOffset = coords.positionInRoot()
@@ -53,7 +53,6 @@ import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.union
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@@ -141,7 +140,8 @@ import ru.fromchat.status_updating
import ru.fromchat.account_suspended import ru.fromchat.account_suspended
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.main.LocalMainChromeInsets 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.BackHandler
import ru.fromchat.ui.components.BrandTitle import ru.fromchat.ui.components.BrandTitle
import ru.fromchat.ui.components.ConnectingEllipsis import ru.fromchat.ui.components.ConnectingEllipsis
@@ -337,7 +337,9 @@ private fun rememberSearchBarCollapseSnap(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun chatsTopAppBarColors(blurReveal: Float) = TopAppBarDefaults.topAppBarColors( 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, scrolledContainerColor = Color.Transparent,
) )
@@ -375,7 +377,12 @@ private fun ChatsNormalTopBar(
) { ) {
TopAppBar( TopAppBar(
modifier = modifier, 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), colors = chatsTopAppBarColors(blurReveal),
title = { title = {
Row( Row(
@@ -384,7 +391,7 @@ private fun ChatsNormalTopBar(
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
) { ) {
AsyncImage( AsyncImage(
model = Res.getUri("drawable/logo_square.svg"), model = Res.getUri("drawable/logo_square.png"),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
@@ -469,7 +476,11 @@ private fun ChatsSelectionTopBar(
TopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
windowInsets = WindowInsets.extraStatusBars, windowInsets = if (LocalMainChromeInsets.current.top > 0.dp) {
WindowInsets.extraStatusBars
} else {
WindowInsets(0, 0, 0, 0)
},
colors = chatsTopAppBarColors(blurReveal), colors = chatsTopAppBarColors(blurReveal),
navigationIcon = { navigationIcon = {
IconButton(onClick = onClose) { IconButton(onClick = onClose) {
@@ -841,6 +852,7 @@ fun ChatsTab(
PredictiveBackHandler( PredictiveBackHandler(
enabled = selectionMode, enabled = selectionMode,
onProgress = { backProgress -> onProgress = { backProgress ->
if (!selectionMode) return@PredictiveBackHandler
scope.launch { scope.launch {
selectionTransitionProgress.snapTo((1f - backProgress).coerceIn(0f, 1f)) selectionTransitionProgress.snapTo((1f - backProgress).coerceIn(0f, 1f))
} }
@@ -950,7 +962,7 @@ fun ChatsTab(
when { when {
selectionMode && publicChatSelected -> publicChatSelected = false selectionMode && publicChatSelected -> publicChatSelected = false
selectionMode -> publicChatSelected = true selectionMode -> publicChatSelected = true
else -> navController.navigate("chats/publicChat") else -> navController.navigateToPublicChat()
} }
}, },
onOpenConversation = { userId -> onOpenConversation = { userId ->
@@ -961,7 +973,7 @@ fun ChatsTab(
selectionMode -> selectedOtherUserIds += userId selectionMode -> selectedOtherUserIds += userId
userId != 0 -> navController.navigate(DmNav.chatRoute(userId)) userId != 0 -> navController.navigateToDmChat(userId)
} }
}, },
onAvatarContextMenuPressStart = { lazyIndex, target, userId, rowOffset, rowSize, position, groupCount -> onAvatarContextMenuPressStart = { lazyIndex, target, userId, rowOffset, rowSize, position, groupCount ->
@@ -1084,9 +1096,9 @@ fun ChatsTab(
} }
chatContextMenuOverlay.onMessage = { chatContextMenuOverlay.onMessage = {
when (contextMenuState.target) { when (contextMenuState.target) {
ChatContextMenuTarget.Public -> navController.navigate("chats/publicChat") ChatContextMenuTarget.Public -> navController.navigateToPublicChat()
ChatContextMenuTarget.Dm -> { 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.Description
import androidx.compose.material.icons.outlined.Shield import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import androidx.compose.material3.TopAppBarDefaults 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_link_website
import ru.fromchat.about_version import ru.fromchat.about_version
import ru.fromchat.app_desc import ru.fromchat.app_desc
import ru.fromchat.back
import ru.fromchat.legal.DocumentType import ru.fromchat.legal.DocumentType
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.BrandTitle import ru.fromchat.ui.components.BrandTitle
@@ -59,30 +55,28 @@ private const val URL_WEBSITE = "https://fromchat.ru"
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AboutScreen() { fun AboutScreen() {
val useCollapsing = settingsDetailUseCollapsingTopBar()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current val navController = LocalNavController.current
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = if (useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
topBar = { topBar = {
MediumTopAppBar( SettingsDetailTopBar(
title = { title = {
Text( Text(
stringResource(Res.string.about), stringResource(Res.string.about),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
) )
}, },
navigationIcon = { onBack = { navController.navigateUp() },
IconButton(onClick = { navController.navigateUp() }) { scrollBehavior = scrollBehavior,
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
},
scrollBehavior = scrollBehavior
) )
}, },
) { innerPadding -> ) { innerPadding ->
@@ -100,7 +94,7 @@ fun AboutScreen() {
) { ) {
Box(Modifier.padding(bottom = 12.dp)) { Box(Modifier.padding(bottom = 12.dp)) {
AsyncImage( AsyncImage(
model = Res.getUri("drawable/logo_square.svg"), model = Res.getUri("drawable/logo_square.png"),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
@@ -11,18 +11,16 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.Brush
import androidx.compose.material.icons.filled.DarkMode 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.LightMode
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Wallpaper import androidx.compose.material.icons.filled.Wallpaper
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@@ -44,9 +42,10 @@ import com.pr0gramm3r101.utils.materialYouAvailable
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.as_system import ru.fromchat.as_system
import ru.fromchat.back
import ru.fromchat.config.Settings import ru.fromchat.config.Settings
import ru.fromchat.dark import ru.fromchat.dark
import ru.fromchat.enter_to_send
import ru.fromchat.enter_to_send_d
import ru.fromchat.light import ru.fromchat.light
import ru.fromchat.materialYou import ru.fromchat.materialYou
import ru.fromchat.materialYou_d import ru.fromchat.materialYou_d
@@ -59,24 +58,26 @@ import ru.fromchat.ui.theme
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun AppearanceScreen(onBack: () -> Unit) { fun AppearanceScreen(onBack: () -> Unit) {
val useCollapsing = settingsDetailUseCollapsingTopBar()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
var materialYouSwitch by remember { var materialYouSwitch by remember {
mutableStateOf(Settings.materialYou && materialYouAvailable) mutableStateOf(Settings.materialYou && materialYouAvailable)
} }
var enterToSendSwitch by remember { mutableStateOf(Settings.enterToSend) }
var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) } var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = if (useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
topBar = { topBar = {
MediumTopAppBar( SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_category_appearance)) }, title = { Text(stringResource(Res.string.settings_category_appearance)) },
navigationIcon = { onBack = onBack,
IconButton(onClick = onBack) { scrollBehavior = scrollBehavior,
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
},
scrollBehavior = scrollBehavior
) )
} }
) { innerPadding -> ) { innerPadding ->
@@ -105,6 +106,19 @@ fun AppearanceScreen(onBack: () -> Unit) {
Icon(Icons.Filled.Wallpaper, null) 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( ListItem(
headline = stringResource(Res.string.theme), headline = stringResource(Res.string.theme),
leadingContent = { Icon(Icons.Filled.Brush, null) }, leadingContent = { Icon(Icons.Filled.Brush, null) },
@@ -145,4 +159,4 @@ fun AppearanceScreen(onBack: () -> Unit) {
} }
} }
} }
} }
@@ -434,8 +434,10 @@ fun DevicesScreen(onBack: () -> Unit) {
TopAppBar( TopAppBar(
title = {}, title = {},
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { if (settingsDetailShowBackButton()) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
@@ -376,6 +376,9 @@ fun LogsScreen() {
} }
fun enterEntrySelection(entryId: Long) { fun enterEntrySelection(entryId: Long) {
if (searchMode) {
exitSearchMode()
}
haptic(HapticFeedbackEvent.SelectionModeEntered) haptic(HapticFeedbackEvent.SelectionModeEntered)
scope.launch { selectionTransitionProgress.snapTo(0f) } scope.launch { selectionTransitionProgress.snapTo(0f) }
listMode = LogsListMode.Selecting listMode = LogsListMode.Selecting
@@ -467,6 +470,12 @@ fun LogsScreen() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
AppLogStore.ensureLoaded() AppLogStore.ensureLoaded()
listMode = LogsListMode.Normal
selectedEntryIds = emptySet()
searchMode = false
searchQuery = ""
selectionTransitionProgress.snapTo(0f)
searchTransitionProgress.snapTo(0f)
} }
LaunchedEffect(navController.currentBackStackEntry) { LaunchedEffect(navController.currentBackStackEntry) {
@@ -767,14 +776,16 @@ fun LogsScreen() {
} }
.background(MaterialTheme.colorScheme.surfaceContainer), .background(MaterialTheme.colorScheme.surfaceContainer),
navigationIcon = { navigationIcon = {
IconButton( if (settingsDetailShowBackButton()) {
onClick = { navController.navigateUp() }, IconButton(
enabled = selectionProgress < 1f && searchProgress < 1f, onClick = { navController.navigateUp() },
) { enabled = selectionProgress < 1f && searchProgress < 1f,
Icon( ) {
imageVector = Icons.AutoMirrored.Filled.ArrowBack, Icon(
contentDescription = stringResource(Res.string.back), imageVector = Icons.AutoMirrored.Filled.ArrowBack,
) contentDescription = stringResource(Res.string.back),
)
}
} }
}, },
title = { title = {
@@ -871,7 +882,7 @@ fun LogsScreen() {
}, },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
if (searchMode || searchProgress > 0f) { if (searchMode) {
TopAppBar( TopAppBar(
modifier = Modifier.graphicsLayer { alpha = searchProgress }, modifier = Modifier.graphicsLayer { alpha = searchProgress },
colors = logsTransparentTopAppBarColors(), colors = logsTransparentTopAppBarColors(),
@@ -931,7 +942,7 @@ fun LogsScreen() {
}, },
) )
} }
if (selectionMode || selectionProgress > 0f) { if (selectionMode) {
TopAppBar( TopAppBar(
modifier = Modifier.graphicsLayer { alpha = selectionProgress }, modifier = Modifier.graphicsLayer { alpha = selectionProgress },
navigationIcon = { navigationIcon = {
@@ -6,13 +6,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.Notifications
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@@ -36,7 +33,6 @@ import ru.fromchat.Res
import ru.fromchat.api.ensureFcmTokenRegistered import ru.fromchat.api.ensureFcmTokenRegistered
import ru.fromchat.api.isFcmPushRegisteredLocally import ru.fromchat.api.isFcmPushRegisteredLocally
import ru.fromchat.api.unregisterFcmTokenFromServer import ru.fromchat.api.unregisterFcmTokenFromServer
import ru.fromchat.back
import ru.fromchat.error_unexpected import ru.fromchat.error_unexpected
import ru.fromchat.settings_notification_settings import ru.fromchat.settings_notification_settings
import ru.fromchat.settings_notification_settings_d import ru.fromchat.settings_notification_settings_d
@@ -51,6 +47,7 @@ import ru.fromchat.ui.components.Text
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NotificationsScreen(onBack: () -> Unit) { fun NotificationsScreen(onBack: () -> Unit) {
val useCollapsing = settingsDetailUseCollapsingTopBar()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -63,16 +60,16 @@ fun NotificationsScreen(onBack: () -> Unit) {
Scaffold( Scaffold(
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = if (useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
topBar = { topBar = {
MediumTopAppBar( SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_notifications_title)) }, title = { Text(stringResource(Res.string.settings_notifications_title)) },
navigationIcon = { onBack = onBack,
IconButton(onClick = onBack) { scrollBehavior = scrollBehavior,
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
},
scrollBehavior = scrollBehavior
) )
} }
) { innerPadding -> ) { 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.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons 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.Devices
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications 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.outlined.BugReport
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Storage
@@ -21,17 +23,23 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.verticalScroll import com.pr0gramm3r101.utils.verticalScroll
import com.pr0gramm3r101.utils.widthSizeClass
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.about import ru.fromchat.about
import ru.fromchat.api.ApiClient
import ru.fromchat.change_server import ru.fromchat.change_server
import ru.fromchat.change_server_d import ru.fromchat.change_server_d
import ru.fromchat.profile
import ru.fromchat.settings import ru.fromchat.settings
import ru.fromchat.settings_category_account import ru.fromchat.settings_category_account
import ru.fromchat.settings_category_account_d 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.logs_title
import ru.fromchat.settings_hub_about_sub import ru.fromchat.settings_hub_about_sub
import ru.fromchat.settings_hub_logs_sub import ru.fromchat.settings_hub_logs_sub
import ru.fromchat.settings_hub_profile_sub
import ru.fromchat.ui.LocalNavController 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.mainPagerBottomInset
import ru.fromchat.ui.main.navigateReplacingMainDetail
val SettingsStepHorizontalPadding = 24.dp val SettingsStepHorizontalPadding = 24.dp
@@ -54,14 +66,26 @@ val SettingsStepHorizontalPadding = 24.dp
fun SettingsTab() { fun SettingsTab() {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current val navController = LocalNavController.current
val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = { topBar = {
MediumTopAppBar( MediumTopAppBar(
windowInsets = if (LocalMainChromeInsets.current.top > 0.dp) {
WindowInsets.extraStatusBars
} else {
WindowInsets(0, 0, 0, 0)
},
title = { title = {
Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis) Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
}, },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
) )
} }
@@ -73,11 +97,25 @@ fun SettingsTab() {
.mainPagerBottomInset() .mainPagerBottomInset()
.padding(innerPadding) .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)) { Category(Modifier.padding(top = 16.dp)) {
ListItem( ListItem(
headline = stringResource(Res.string.settings_category_account), headline = stringResource(Res.string.settings_category_account),
supportingText = stringResource(Res.string.settings_category_account_d), supportingText = stringResource(Res.string.settings_category_account_d),
onClick = { navController.navigate(SettingsRoutes.Account) }, onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Account) },
leadingContent = { Icon(Icons.Filled.AccountCircle, null) }, leadingContent = { Icon(Icons.Filled.AccountCircle, null) },
divider = true divider = true
) )
@@ -85,7 +123,7 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.settings_category_devices), headline = stringResource(Res.string.settings_category_devices),
supportingText = stringResource(Res.string.settings_category_devices_d), supportingText = stringResource(Res.string.settings_category_devices_d),
onClick = { navController.navigate(SettingsRoutes.Devices) }, onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Devices) },
leadingContent = { Icon(Icons.Filled.Devices, null) }, leadingContent = { Icon(Icons.Filled.Devices, null) },
divider = true divider = true
) )
@@ -93,7 +131,7 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.settings_category_appearance), headline = stringResource(Res.string.settings_category_appearance),
supportingText = stringResource(Res.string.settings_category_appearance_d), supportingText = stringResource(Res.string.settings_category_appearance_d),
onClick = { navController.navigate(SettingsRoutes.Appearance) }, onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Appearance) },
leadingContent = { Icon(Icons.Filled.Palette, null) }, leadingContent = { Icon(Icons.Filled.Palette, null) },
divider = true divider = true
) )
@@ -101,7 +139,7 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.settings_category_notifications), headline = stringResource(Res.string.settings_category_notifications),
supportingText = stringResource(Res.string.settings_category_notifications_d), supportingText = stringResource(Res.string.settings_category_notifications_d),
onClick = { navController.navigate(SettingsRoutes.Notifications) }, onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Notifications) },
leadingContent = { Icon(Icons.Filled.Notifications, null) }, leadingContent = { Icon(Icons.Filled.Notifications, null) },
divider = true divider = true
) )
@@ -109,7 +147,7 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.change_server), headline = stringResource(Res.string.change_server),
supportingText = stringResource(Res.string.change_server_d), supportingText = stringResource(Res.string.change_server_d),
onClick = { navController.navigate(SettingsRoutes.ServerConfig) }, onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.ServerConfig) },
leadingContent = { Icon(Icons.Filled.Storage, null) }, leadingContent = { Icon(Icons.Filled.Storage, null) },
divider = true divider = true
) )
@@ -117,7 +155,7 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.about), headline = stringResource(Res.string.about),
supportingText = stringResource(Res.string.settings_hub_about_sub), supportingText = stringResource(Res.string.settings_hub_about_sub),
onClick = { navController.navigate(SettingsRoutes.About) }, onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.About) },
leadingContent = { Icon(Icons.Filled.Info, null) }, leadingContent = { Icon(Icons.Filled.Info, null) },
divider = true divider = true
) )
@@ -125,10 +163,10 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.logs_title), headline = stringResource(Res.string.logs_title),
supportingText = stringResource(Res.string.settings_hub_logs_sub), supportingText = stringResource(Res.string.settings_hub_logs_sub),
onClick = { navController.navigate(SettingsRoutes.Logs) }, onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Logs) },
leadingContent = { Icon(Icons.Outlined.BugReport, null) } 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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.AccountCircle 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.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@@ -41,7 +38,6 @@ import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.back
import ru.fromchat.cancel import ru.fromchat.cancel
import ru.fromchat.ic_yandex import ru.fromchat.ic_yandex
import ru.fromchat.logout import ru.fromchat.logout
@@ -55,6 +51,8 @@ import ru.fromchat.settings_account_title
import ru.fromchat.settings_change_password import ru.fromchat.settings_change_password
import ru.fromchat.settings_security_change_password_sub import ru.fromchat.settings_security_change_password_sub
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.main.settings.SettingsDetailTopBar
import ru.fromchat.ui.main.settings.settingsDetailUseCollapsingTopBar
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -65,6 +63,7 @@ fun AccountScreen(
onChangeYandexId: () -> Unit, onChangeYandexId: () -> Unit,
onDeleteAccount: () -> Unit, onDeleteAccount: () -> Unit,
) { ) {
val useCollapsing = settingsDetailUseCollapsingTopBar()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showLogoutConfirm by remember { mutableStateOf(false) } var showLogoutConfirm by remember { mutableStateOf(false) }
@@ -76,16 +75,16 @@ fun AccountScreen(
} }
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = if (useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
topBar = { topBar = {
MediumTopAppBar( SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_account_title)) }, title = { Text(stringResource(Res.string.settings_account_title)) },
navigationIcon = { onBack = onBack,
IconButton(onClick = onBack) { scrollBehavior = scrollBehavior,
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
},
scrollBehavior = scrollBehavior
) )
} }
) { innerPadding -> ) { innerPadding ->
@@ -119,6 +119,7 @@ import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
import ru.fromchat.ui.components.rememberLazyListFocusScrollState import ru.fromchat.ui.components.rememberLazyListFocusScrollState
import ru.fromchat.ui.components.trackLazyListFocus import ru.fromchat.ui.components.trackLazyListFocus
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import ru.fromchat.ui.main.settings.settingsDetailShowBackButton
private object ServerConfigLazyListIndices { private object ServerConfigLazyListIndices {
const val SERVER_IP_FIELD = 2 const val SERVER_IP_FIELD = 2
@@ -334,11 +335,13 @@ fun ServerConfigScreen() {
TopAppBar( TopAppBar(
title = {}, title = {},
navigationIcon = { navigationIcon = {
IconButton(onClick = navController::navigateUp) { if (settingsDetailShowBackButton()) {
Icon( IconButton(onClick = navController::navigateUp) {
Icons.AutoMirrored.Filled.ArrowBack, Icon(
contentDescription = stringResource(Res.string.back) Icons.AutoMirrored.Filled.ArrowBack,
) contentDescription = stringResource(Res.string.back)
)
}
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
@@ -63,7 +63,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester 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.ShimmerBox
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.showReplacingSnackbar import ru.fromchat.ui.components.showReplacingSnackbar
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.utils.RegistrationDateFormatStrings import ru.fromchat.utils.RegistrationDateFormatStrings
import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.formatLastSeen
import ru.fromchat.utils.formatProfileRegistrationDate import ru.fromchat.utils.formatProfileRegistrationDate
@@ -269,7 +269,7 @@ fun ProfileScreen(
val navController = LocalNavController.current val navController = LocalNavController.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val density = LocalDensity.current 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 profileAvatarTop = statusBarTopDp + 24.dp
val profileLoadFailed = stringResource(Res.string.profile_load_failed) val profileLoadFailed = stringResource(Res.string.profile_load_failed)
@@ -881,7 +881,7 @@ fun PublicChatProfileScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val density = LocalDensity.current 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 profileAvatarTop = statusBarTopDp + 24.dp
val profileLoadFailed = stringResource(Res.string.profile_load_failed) val profileLoadFailed = stringResource(Res.string.profile_load_failed)
val profileNotFound = stringResource(Res.string.profile_not_found) val profileNotFound = stringResource(Res.string.profile_not_found)
@@ -2058,7 +2058,7 @@ private fun ProfileFloatingBackBar(
onClick = onBack, onClick = onBack,
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.statusBars), .windowInsetsPadding(WindowInsets.extraStatusBars),
colors = IconButtonDefaults.iconButtonColors( colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.onSurface, 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()
},
)
}
}
}
}
}
}