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