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