Improve UI

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-27 00:55:21 +03:00
Unverified
parent 5952e1a9c5
commit 5166f3507d
18 changed files with 1025 additions and 784 deletions
+24 -1
View File
@@ -23,6 +23,9 @@ dependencies {
}
}
val desktopWindowIconPng = layout.projectDirectory.file("src/main/resources/app_window_icon.png")
val desktopWindowIconIcns = layout.projectDirectory.file("icons/app_window_icon.icns")
compose.desktop {
application {
mainClass = "ru.fromchat.desktop.MainKt"
@@ -42,8 +45,16 @@ compose.desktop {
"javafx.swing",
)
// Without iconFile, packaged apps (and macOS dock via jpackage) use Compose's default logo.
linux {
iconFile.set(desktopWindowIconPng)
}
windows {
iconFile.set(desktopWindowIconPng)
}
macOS {
bundleID = "ru.fromchat.desktop"
iconFile.set(desktopWindowIconIcns)
infoPlist {
extraKeysRawXml = """
<key>CFBundleURLTypes</key>
@@ -80,8 +91,20 @@ tasks.matching { it.name == "run" }.configureEach {
javafxModules.any { module -> file.name.startsWith("javafx-$module-") }
}
classpath = files(classpath.files.filterNot { it in javafxJars.toSet() })
val dockIconArgs = buildList {
// Compose only adds -Xdock:icon when macOS.iconFile is set; pin PNG for :run too.
val os = System.getProperty("os.name").orEmpty().lowercase()
if (os.contains("mac")) {
val png = desktopWindowIconPng.asFile
val icns = desktopWindowIconIcns.asFile
when {
icns.isFile -> add("-Xdock:icon=${icns.absolutePath}")
png.isFile -> add("-Xdock:icon=${png.absolutePath}")
}
}
}
jvmArgs(
listOf(
dockIconArgs + listOf(
"--module-path",
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
"--add-modules",
Binary file not shown.
@@ -2,17 +2,21 @@ package ru.fromchat.desktop
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowPosition
import java.util.prefs.Preferences
/**
* Desktop main-window size prefs.
* Desktop main-window size and position prefs.
*
* Stored in the same JVM prefs node as PlatformSettings (`ru.fromchat.settings`),
* keys `desktop_window_width` / `desktop_window_height` (float dp).
* Stored in the same JVM prefs node as PlatformSettings (`ru.fromchat.settings`):
* - `desktop_window_width` / `desktop_window_height` (float dp)
* - `desktop_window_x` / `desktop_window_y` (float dp, absolute; absent → platform default)
*/
internal object DesktopWindowPrefs {
private const val WIDTH_KEY = "desktop_window_width"
private const val HEIGHT_KEY = "desktop_window_height"
private const val X_KEY = "desktop_window_x"
private const val Y_KEY = "desktop_window_y"
private val prefs: Preferences =
Preferences.userRoot().node("ru.fromchat.settings")
@@ -27,9 +31,20 @@ internal object DesktopWindowPrefs {
}
}
fun saveSize(size: DpSize) {
fun loadPosition(): WindowPosition {
if (prefs.get(X_KEY, null) == null || prefs.get(Y_KEY, null) == null) {
return WindowPosition.PlatformDefault
}
return WindowPosition(prefs.getFloat(X_KEY, 0f).dp, prefs.getFloat(Y_KEY, 0f).dp)
}
fun save(size: DpSize, position: WindowPosition) {
prefs.putFloat(WIDTH_KEY, size.width.value)
prefs.putFloat(HEIGHT_KEY, size.height.value)
if (position is WindowPosition.Absolute) {
prefs.putFloat(X_KEY, position.x.value)
prefs.putFloat(Y_KEY, position.y.value)
}
runCatching { prefs.flush() }
}
}
@@ -8,11 +8,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.graphics.toPainter
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.KeyShortcut
@@ -20,7 +18,6 @@ import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.MenuBar
@@ -28,15 +25,20 @@ import androidx.compose.ui.window.Notification
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
import com.pr0gramm3r101.utils.UtilsLibrary
import java.awt.Desktop
import java.awt.Image
import java.awt.KeyboardFocusManager
import java.awt.RenderingHints
import java.awt.Taskbar
import java.awt.event.ActionEvent
import java.awt.image.BufferedImage
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.File
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetAddress
@@ -44,6 +46,7 @@ import java.net.ServerSocket
import java.net.Socket
import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicBoolean
import javax.imageio.ImageIO
import javax.swing.JComponent
import javax.swing.JRootPane
import javax.swing.SwingUtilities
@@ -55,7 +58,8 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.skia.Image
import org.jetbrains.skia.Image as SkiaImage
import ru.fromchat.AppBuildInfo
import ru.fromchat.AppForeground
import ru.fromchat.Logger
import ru.fromchat.Res
@@ -64,6 +68,7 @@ import ru.fromchat.action_select
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.app_name
import ru.fromchat.app_name_beta
import ru.fromchat.desktop_about_app
import ru.fromchat.desktop_cut
import ru.fromchat.desktop_menu_edit
@@ -220,8 +225,11 @@ fun main(args: Array<String>) {
System.setProperty("compose.layers.type", "WINDOW")
if (isMacOs()) {
// Must be set before AWT Toolkit init (do not call getString / Compose here).
// Value matches Res.string.app_name in values / values-ru.
System.setProperty("apple.awt.application.name", "FromChat")
// Matches Res.string.app_name / app_name_beta.
System.setProperty(
"apple.awt.application.name",
if (AppBuildInfo.isDebug) "FromChat Beta" else "FromChat",
)
System.setProperty("apple.laf.useScreenMenuBar", "true")
}
if (!DesktopSingleInstance.acquireOrForward(args)) return
@@ -229,30 +237,38 @@ fun main(args: Array<String>) {
args.filter { DesktopDeepLinkBus.isFromChatUri(it) }
.forEach { DesktopDeepLinkBus.handleUri(it) }
val dockIconImage = loadAppIconBufferedImage(tray = false)
applyDockIcon(dockIconImage)
// Re-apply after AWT is fully up — Taskbar can ignore early sets on macOS :run.
SwingUtilities.invokeLater { applyDockIcon(dockIconImage) }
application {
UtilsLibrary.init()
DesktopApplicationBootstrap.launchOnApplicationStart()
val windowState = rememberWindowState(size = remember { DesktopWindowPrefs.loadSize() })
val aboutWindowState = rememberWindowState(
position = WindowPosition.Aligned(Alignment.Center),
size = DpSize(440.dp, 640.dp),
val windowState = rememberWindowState(
size = remember { DesktopWindowPrefs.loadSize() },
position = remember { DesktopWindowPrefs.loadPosition() },
)
var windowVisible by remember { mutableStateOf(true) }
var aboutOpen by remember { mutableStateOf(false) }
val trayState = rememberTrayState()
// BufferedImage.toPainter() keeps the AWT pixels (avoids blank BitmapPainter round-trip).
val trayIcon = remember { loadAppIconPainter(tray = true) }
val windowIcon = remember { loadAppIconPainter(tray = false) }
val windowIcon = remember {
dockIconImage?.toPainter() ?: loadAppIconPainter(tray = false)
}
val traySupported = remember { java.awt.SystemTray.isSupported() }
val mac = remember { isMacOs() }
val appName = stringResource(Res.string.app_name)
val appName = stringResource(
if (AppBuildInfo.isDebug) Res.string.app_name_beta else Res.string.app_name,
)
val aboutApp = stringResource(Res.string.desktop_about_app)
val trayShow = stringResource(Res.string.desktop_tray_show)
val quit = stringResource(Res.string.desktop_quit)
LaunchedEffect(windowState.size) {
LaunchedEffect(windowState.size, windowState.position) {
delay(3_000)
DesktopWindowPrefs.saveSize(windowState.size)
DesktopWindowPrefs.save(windowState.size, windowState.position)
}
DisposableEffect(trayState) {
@@ -267,12 +283,17 @@ fun main(args: Array<String>) {
}
}
fun openAbout() {
windowVisible = true
DesktopMenuCommands.emit(DesktopMenuCommand.OpenAbout)
}
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 }
SwingUtilities.invokeLater { openAbout() }
}
}
}
@@ -285,7 +306,7 @@ fun main(args: Array<String>) {
onAction = { windowVisible = true },
menu = {
Item(trayShow) { windowVisible = true }
Item(aboutApp) { aboutOpen = true }
Item(aboutApp) { openAbout() }
Separator()
Item(quit) { exitApplication() }
},
@@ -327,7 +348,7 @@ fun main(args: Array<String>) {
true
}
event.isShiftPressed && event.key == Key.A -> {
aboutOpen = true
openAbout()
true
}
event.isShiftPressed && event.key == Key.N -> {
@@ -349,6 +370,10 @@ fun main(args: Array<String>) {
LaunchedEffect(window, appName) {
window.title = appName
enableEdgeToEdgeTitleBar(window.rootPane)
dockIconImage?.let { image ->
window.iconImages = listOf(image)
applyDockIcon(image)
}
}
DisposableEffect(Unit) {
AppForeground.setForeground(true)
@@ -389,17 +414,6 @@ fun main(args: Array<String>) {
App()
}
}
if (aboutOpen) {
Window(
onCloseRequest = { aboutOpen = false },
title = aboutApp,
state = aboutWindowState,
icon = windowIcon,
) {
DesktopAboutContent(onClose = { aboutOpen = false })
}
}
}
}
@@ -515,43 +529,191 @@ private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
}
private fun applyDockIcon(image: BufferedImage?) {
if (image == null) {
Logger.w("DesktopIcon", "applyDockIcon skipped — image is null")
return
}
runCatching {
if (!Taskbar.isTaskbarSupported()) {
Logger.w("DesktopIcon", "Taskbar unsupported; dock icon not set")
return
}
val taskbar = Taskbar.getTaskbar()
if (taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) {
taskbar.iconImage = image
Logger.i("DesktopIcon", "Taskbar dock icon set (${image.width}x${image.height})")
} else {
Logger.w("DesktopIcon", "Taskbar.Feature.ICON_IMAGE unsupported")
}
}.onFailure { error ->
Logger.w("DesktopIcon", "Failed to set dock icon: ${error.message}")
}
}
/**
* Loads the desktop tray or window icon from packaged resources.
* Prefers PNG, then WebP; returns a tiny empty bitmap only if every decode fails.
* Loads the desktop tray or window/dock icon from packaged resources.
* Tray prefers the flat mark (`app_icon`); dock/window prefer the gradient (`app_window_icon`).
*
* Uses [BufferedImage.toPainter] so Compose Tray/Window hand the same AWT pixels to the OS
* (BitmapPainter → ImageBitmap round-trips can rasterize blank and fall back to Compose's default).
*/
private fun loadAppIconPainter(tray: Boolean): Painter {
val image = loadAppIconBufferedImage(tray)
if (image == null) {
Logger.e(
"DesktopIcon",
"loadAppIconPainter fell through — no resource decoded (tray=$tray); using solid fallback",
)
return solidFallbackIcon().toPainter()
}
Logger.i(
"DesktopIcon",
"loadAppIconPainter ok tray=$tray size=${image.width}x${image.height} type=${image.type}",
)
return image.toPainter()
}
private fun loadAppIconBufferedImage(tray: Boolean): BufferedImage? {
val names = if (tray) {
listOf("app_icon.png", "app_icon.webp", "app_window_icon.png", "app_window_icon.webp")
} else {
listOf("app_window_icon.png", "app_window_icon.webp", "app_icon.png", "app_icon.webp")
}
val loaders = listOfNotNull(
Thread.currentThread().contextClassLoader,
DesktopApplicationBootstrap::class.java.classLoader,
Thread.currentThread().contextClassLoader,
ClassLoader.getSystemClassLoader(),
)
).distinct()
for (name in names) {
for (loader in loaders) {
val stream = loader.getResourceAsStream(name)
?: loader.getResourceAsStream("/$name")
val bytes = readClasspathBytes(name, loaders)
?: readFileFallbackBytes(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}")
val decoded = decodeImageBytes(bytes, name) ?: continue
if (decoded.width < 16 || decoded.height < 16) {
Logger.w("DesktopIcon", "Skipping $name — decoded size ${decoded.width}x${decoded.height}")
continue
}
Logger.i("DesktopIcon", "Loaded $name (${bitmap.width}x${bitmap.height}) tray=$tray")
return BitmapPainter(bitmap)
// Ensure TYPE_INT_ARGB so AWT Tray/Taskbar get a predictable raster.
val argb = ensureArgb(decoded)
val image = if (tray) scaleBufferedImage(argb, 64) else argb
Logger.i("DesktopIcon", "Loaded $name (${image.width}x${image.height}) tray=$tray")
return image
}
}
Logger.w("DesktopIcon", "No app icon resource decoded; using empty placeholder")
return BitmapPainter(ImageBitmap(32, 32))
Logger.e("DesktopIcon", "No icon resource found for tray=$tray (tried $names)")
return null
}
private fun readClasspathBytes(name: String, loaders: List<ClassLoader>): ByteArray? {
val paths = listOf(name, "/$name")
for (loader in loaders) {
for (path in paths) {
val stream = loader.getResourceAsStream(path) ?: continue
val bytes = runCatching { stream.use { it.readBytes() } }
.onFailure { error ->
Logger.w("DesktopIcon", "Failed reading $path from $loader: ${error.message}")
}
.getOrNull()
?: continue
if (bytes.isNotEmpty()) {
Logger.i("DesktopIcon", "Classpath hit $path via $loader (${bytes.size} bytes)")
return bytes
}
}
}
// Explicit class resource (works when context CL is a filtered compose run CL).
for (path in paths) {
val stream = DesktopApplicationBootstrap::class.java.getResourceAsStream(path) ?: continue
val bytes = runCatching { stream.use { it.readBytes() } }.getOrNull() ?: continue
if (bytes.isNotEmpty()) {
Logger.i("DesktopIcon", "Class resource hit $path (${bytes.size} bytes)")
return bytes
}
}
return null
}
/** Last resort when classpath packaging under `:run` omits resources. */
private fun readFileFallbackBytes(name: String): ByteArray? {
val candidates = listOf(
File("app/desktop/src/main/resources", name),
File("src/main/resources", name),
File(System.getProperty("user.dir"), "src/main/resources/$name"),
File(System.getProperty("user.dir"), "app/desktop/src/main/resources/$name"),
)
for (file in candidates) {
if (!file.isFile) continue
val bytes = runCatching { file.readBytes() }.getOrNull() ?: continue
if (bytes.isNotEmpty()) {
Logger.i("DesktopIcon", "File fallback hit ${file.absolutePath} (${bytes.size} bytes)")
return bytes
}
}
return null
}
private fun decodeImageBytes(bytes: ByteArray, name: String): BufferedImage? {
val viaImageIo = runCatching {
ImageIO.read(ByteArrayInputStream(bytes))
}.onFailure { error ->
Logger.w("DesktopIcon", "ImageIO failed for $name: ${error.message}")
}.getOrNull()
if (viaImageIo != null) return viaImageIo
return runCatching {
val skia = SkiaImage.makeFromEncoded(bytes)
bufferedImageFromComposeArgb(skia.toComposeImageBitmap())
}.onFailure { error ->
Logger.w("DesktopIcon", "Skia decode failed for $name: ${error.message}")
}.getOrNull()
}
private fun ensureArgb(source: BufferedImage): BufferedImage {
if (source.type == BufferedImage.TYPE_INT_ARGB) return source
val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_ARGB)
val graphics = out.createGraphics()
graphics.drawImage(source, 0, 0, null)
graphics.dispose()
return out
}
private fun bufferedImageFromComposeArgb(
bitmap: androidx.compose.ui.graphics.ImageBitmap,
): BufferedImage {
val w = bitmap.width
val h = bitmap.height
val pixels = IntArray(w * h)
bitmap.readPixels(pixels)
val out = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB)
out.setRGB(0, 0, w, h, pixels, 0, w)
return out
}
private fun scaleBufferedImage(source: BufferedImage, size: Int): BufferedImage {
if (source.width == size && source.height == size) return source
val out = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB)
val graphics = out.createGraphics()
graphics.setRenderingHint(
RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR,
)
graphics.setRenderingHint(
RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY,
)
graphics.drawImage(source.getScaledInstance(size, size, Image.SCALE_SMOOTH), 0, 0, null)
graphics.dispose()
return out
}
/** Opaque mark so Tray never substitutes the Compose default for a blank painter. */
private fun solidFallbackIcon(): BufferedImage {
val out = BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB)
val graphics = out.createGraphics()
graphics.color = java.awt.Color(0x1A, 0x73, 0xE8)
graphics.fillRoundRect(2, 2, 28, 28, 8, 8)
graphics.dispose()
return out
}
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">FromChat</string>
<string name="app_name_beta">FromChat Beta</string>
<string name="back">Назад</string>
<string name="settings">Настройки</string>
<string name="home">Главная</string>
@@ -2,6 +2,7 @@
<resources>
<!-- App -->
<string name="app_name">FromChat</string>
<string name="app_name_beta">FromChat Beta</string>
<!-- Navigation -->
<string name="back">Back</string>
@@ -9,6 +9,8 @@ enum class DesktopMenuCommand {
NewChat,
SearchConversations,
EnterChatListSelection,
/** Open the in-app Settings → About screen (not a separate window). */
OpenAbout,
}
/**
@@ -1,7 +1,5 @@
package ru.fromchat.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionLayout
@@ -11,7 +9,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.SnackbarDuration
@@ -30,16 +27,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
import coil3.network.ktor3.KtorNetworkFetcherFactory
@@ -84,8 +77,8 @@ import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
import ru.fromchat.config.ServerConfig
import ru.fromchat.legal.DocumentScreen
import ru.fromchat.legal.DocumentType
import ru.fromchat.desktop.DesktopMenuCommand
import ru.fromchat.desktop.DesktopMenuCommands
import ru.fromchat.notifications.NotificationLaunchCoordinator
import ru.fromchat.ui.auth.AuthScreen
import ru.fromchat.ui.auth.captcha.SmartCaptchaNav
@@ -93,67 +86,43 @@ import ru.fromchat.ui.auth.captcha.SmartCaptchaScreen
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
import ru.fromchat.ui.calls.CallOverlay
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
import ru.fromchat.ui.chat.panels.dm.DmNav
import ru.fromchat.ui.chat.panels.dm.DmProfileRoute
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
import ru.fromchat.ui.chat.panels.publicchat.PublicChatChatRoute
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat
import ru.fromchat.ui.main.ConversationListDetailShell
import ru.fromchat.ui.main.DesktopChatsDetailNavHost
import ru.fromchat.ui.main.DesktopContactsDetailNavHost
import ru.fromchat.ui.main.DesktopSettingsDetailNavHost
import ru.fromchat.ui.main.DesktopTabDetailHosts
import ru.fromchat.ui.main.EmptyContactsPlaceholder
import ru.fromchat.ui.main.EmptyConversationPlaceholder
import ru.fromchat.ui.main.EmptySettingsPlaceholder
import ru.fromchat.ui.main.LocalDesktopChatsNavController
import ru.fromchat.ui.main.LocalDesktopMainTab
import ru.fromchat.ui.main.LocalDesktopSettingsNavController
import ru.fromchat.ui.main.MAIN_PAGE_CHATS
import ru.fromchat.ui.main.MAIN_PAGE_CONTACTS
import ru.fromchat.ui.main.MAIN_PAGE_SETTINGS
import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.main.chats.ChatsSearchScreen
import ru.fromchat.ui.main.conversationDetailDestinations
import ru.fromchat.ui.main.navigateReplacingMainDetail
import ru.fromchat.ui.main.settings.LOG_FILE_OPEN_RESULT_KEY
import ru.fromchat.ui.main.settings.LogFilesScreen
import ru.fromchat.ui.main.settings.LogsScreen
import ru.fromchat.ui.main.settings.AboutScreen
import ru.fromchat.ui.main.settings.AppearanceScreen
import ru.fromchat.ui.main.settings.DevicesScreen
import ru.fromchat.ui.main.settings.NotificationsScreen
import ru.fromchat.ui.main.profileDetailDestinations
import ru.fromchat.ui.main.settings.SettingsRoutes
import ru.fromchat.ui.main.settings.account.AccountScreen
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexConfirmScreen
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexDoneScreen
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexOAuthScreen
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
import ru.fromchat.ui.main.settings.server.ServerConfigScreen
import ru.fromchat.ui.profile.EditProfileFocusField
import ru.fromchat.ui.profile.EditProfileScreen
import ru.fromchat.ui.main.settingsDetailDestinations
import ru.fromchat.ui.profile.ProfileRoutes
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.components.AppPanel
import ru.fromchat.ui.components.LocalPaneHazeState
import ru.fromchat.ui.components.ScreenSurface
import androidx.compose.foundation.shape.RoundedCornerShape
import dev.chrisbanes.haze.rememberHazeState
import ru.fromchat.utils.NetworkConnectivity
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
private fun isConversationChatRoute(route: String?): Boolean =
route == PublicChatNav.CHAT_ROUTE ||
(route != null && route.startsWith("dm/") && "/chat" in route)
private fun isConversationProfileRoute(route: String?): Boolean =
route == PublicChatNav.PROFILE_ROUTE ||
(route != null && route.startsWith("dm/") && route.endsWith("/profile"))
private fun isStandaloneProfileRoute(route: String?): Boolean =
route != null &&
route.startsWith("profile/") &&
!route.startsWith(ProfileRoutes.Edit.substringBefore("?"))
private fun isEditProfileRoute(route: String?): Boolean =
route != null && route.startsWith("profile/edit")
/**
* Nav destination.route is the pattern (`profile/{userId}…`), not the filled path —
* read the userId from entry args / saved state.
@@ -164,40 +133,6 @@ private fun standaloneProfileUserId(entry: NavBackStackEntry?): Int? {
return entry.savedStateHandle.get<String>("userId")?.toIntOrNull()?.takeIf { it > 0 }
}
private fun isConversationShellRoute(
route: String?,
previousRoute: String?,
ownUserId: Int?,
profileUserId: Int?,
): Boolean {
if (route == "chat" || isConversationChatRoute(route) || isConversationProfileRoute(route)) {
return true
}
if (isStandaloneProfileRoute(route)) {
if (isConversationChatRoute(previousRoute)) return true
return ownUserId != null && profileUserId == ownUserId
}
if (route != null && route.startsWith("settings/")) return true
if (route == "about") return true
if (route == SettingsRoutes.ServerConfig) {
return previousRoute == "chat" ||
previousRoute == "about" ||
(previousRoute != null && previousRoute.startsWith("settings/"))
}
return false
}
private fun isSettingsDetailRoute(route: String?): Boolean {
if (route == null) return false
if (route.startsWith("settings/") || route == "about") return true
return route == SettingsRoutes.ServerConfig
}
private fun dmUserIdFromRoute(route: String?): Int? {
if (route == null || !route.startsWith("dm/")) return null
return route.substringAfter("dm/").substringBefore("/").toIntOrNull()?.takeIf { it > 0 }
}
private val rootNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
private fun rootNavEnterTransition(): EnterTransition =
@@ -278,18 +213,6 @@ private fun handlePresenceEvent(message: WebSocketMessage) {
}
}
private fun NavGraphBuilder.settingsComposable(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
) {
composable(
route = route,
arguments = arguments,
content = content,
)
}
@Composable
fun App(
scrollToMessageId: Int? = null,
@@ -336,9 +259,7 @@ fun App(
}
startDestination = when {
hasToken && startAtDmConversationUserId != null -> "chat"
hasToken && startAtPublicChat -> "chats/publicChat"
hasToken && !startAtPublicChat -> "chat"
hasToken -> "chat"
else -> "welcome"
}
@@ -416,6 +337,9 @@ fun App(
FromChatTheme {
SharedTransitionLayout {
val navController = rememberNavController()
val chatsDetailNavController = rememberNavController()
val settingsDetailNavController = rememberNavController()
val contactsDetailNavController = rememberNavController()
val profileLookupSnackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(profileLookupErrorMessage) {
profileLookupErrorMessage?.let { message ->
@@ -429,11 +353,16 @@ fun App(
}
}
val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass
val isDesktopListDetail = widthSizeClass != WindowWidthSizeClass.COMPACT
var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) }
// Handle startup/deep-link navigation targets (profile links)
LaunchedEffect(
startAtProfileUserId,
startAtProfileUsername,
startDestination
startDestination,
isDesktopListDetail,
) {
Logger.d(
"ProfileDeepLink",
@@ -449,7 +378,19 @@ fun App(
"ProfileDeepLink",
"navigating by deep link userId=$startAtProfileUserId"
)
navController.navigate("profile/$startAtProfileUserId?fromDeepLink=true")
val route = "profile/$startAtProfileUserId?fromDeepLink=true"
val ownId = ApiClient.user?.id
if (isDesktopListDetail) {
if (ownId != null && startAtProfileUserId == ownId) {
pendingMainTab = MAIN_PAGE_SETTINGS
settingsDetailNavController.navigateReplacingMainDetail(route)
} else {
pendingMainTab = MAIN_PAGE_CHATS
chatsDetailNavController.navigateReplacingMainDetail(route)
}
} else {
navController.navigate(route)
}
} else {
val trimmedUsername = startAtProfileUsername?.trim()
if (!trimmedUsername.isNullOrBlank()) {
@@ -457,10 +398,29 @@ fun App(
"ProfileDeepLink",
"navigating by deep link username=$trimmedUsername"
)
navController.navigate("profile/$trimmedUsername?fromDeepLink=true")
val route = "profile/$trimmedUsername?fromDeepLink=true"
if (isDesktopListDetail) {
pendingMainTab = MAIN_PAGE_CHATS
chatsDetailNavController.navigateReplacingMainDetail(route)
} else {
navController.navigate(route)
}
}
}
}
LaunchedEffect(startDestination) {
if (startDestination == null || startDestination == "welcome") {
return@LaunchedEffect
}
if (!startAtPublicChat) return@LaunchedEffect
if (isDesktopListDetail) {
pendingMainTab = MAIN_PAGE_CHATS
chatsDetailNavController.navigateToPublicChat()
} else {
navController.navigateToPublicChat()
}
}
LaunchedEffect(startDestination) {
if (startDestination == null || startDestination == "welcome") {
@@ -475,81 +435,93 @@ fun App(
"navigating to dm user=${target.dmConversationUserId} " +
"messageId=${target.scrollToMessageId} launchId=${target.launchId}"
)
if (isDesktopListDetail) {
pendingMainTab = MAIN_PAGE_CHATS
if (navController.currentBackStackEntry?.destination?.route != "chat") {
navController.popBackStack("chat", inclusive = false)
}
chatsDetailNavController.navigateToDmChat(
otherUserId = target.dmConversationUserId,
sourceMessageId = target.scrollToMessageId,
)
} else {
navController.navigateToDmChat(
otherUserId = target.dmConversationUserId,
sourceMessageId = target.scrollToMessageId,
)
}
}
target.startAtPublicChat -> {
Logger.d(
"NotificationLaunch",
"navigating to public chat launchId=${target.launchId}"
)
if (isDesktopListDetail) {
pendingMainTab = MAIN_PAGE_CHATS
if (navController.currentBackStackEntry?.destination?.route != "chat") {
navController.popBackStack("chat", inclusive = false)
}
chatsDetailNavController.navigateToPublicChat()
} else {
navController.navigateToPublicChat()
}
}
}
}
}
CompositionLocalProvider(
LocalNavController provides navController,
LocalDesktopChatsNavController provides
if (isDesktopListDetail) chatsDetailNavController else null,
LocalDesktopSettingsNavController provides
if (isDesktopListDetail) settingsDetailNavController else null,
LocalSystemBarsVisibility provides rememberSystemBarsController()
) {
if (startDestination != null) {
val currentEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentEntry?.destination?.route
val previousRoute =
navController.previousBackStackEntry?.destination?.route
val profileUserId = standaloneProfileUserId(currentEntry)
val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass
var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) }
val settingsEntry by settingsDetailNavController.currentBackStackEntryAsState()
val settingsRoute = settingsEntry?.destination?.route
val settingsProfileUserId = standaloneProfileUserId(settingsEntry)
val ownUserId = ApiClient.user?.id
val showConversationListDetail =
widthSizeClass != WindowWidthSizeClass.COMPACT &&
isConversationShellRoute(
currentRoute,
previousRoute,
ownUserId,
profileUserId,
isDesktopListDetail && currentRoute == "chat"
val forceSettingsListTab =
showConversationListDetail && (
isEditProfileRoute(settingsRoute) ||
(
isStandaloneProfileRoute(settingsRoute) &&
settingsProfileUserId != null &&
settingsProfileUserId == ownUserId
)
)
LaunchedEffect(Unit) {
DesktopMenuCommands.commands.collect { command ->
if (command != DesktopMenuCommand.OpenAbout) return@collect
pendingMainTab = MAIN_PAGE_SETTINGS
if (isDesktopListDetail) {
settingsDetailNavController.navigateReplacingMainDetail(
route = SettingsRoutes.About,
)
} else {
navController.navigateReplacingMainDetail(
route = SettingsRoutes.About,
)
}
}
}
ScreenSurface {
Box(Modifier.fillMaxSize()) {
// Own profile (Profile tab / settings hub) always uses the settings
// detail host — even when a chat remains under the back stack via
// preserveConversationDetail. Other users' profiles opened from a
// conversation stay in the chats detail (see chatsDetailKey).
val settingsProfileSplit =
showConversationListDetail &&
isStandaloneProfileRoute(currentRoute) &&
profileUserId != null &&
profileUserId == ownUserId
val listPaneWidth =
if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
448.dp
} else {
360.dp
}
// Own profile lives in the settings detail host: keep the Settings
// list tab active (Profile is only a shortcut button in two-pane).
// forceSettingsTab only scrolls when true — clearing it does not
// snap back, so switching to Chats while profile stays on the stack
// is fine.
val forceSettingsListTab = settingsProfileSplit
var retainedChatsRoute by remember { mutableStateOf<String?>(null) }
LaunchedEffect(currentRoute, pendingMainTab) {
when {
isConversationChatRoute(currentRoute) -> {
retainedChatsRoute = currentRoute
}
currentRoute == "chat" &&
pendingMainTab == MAIN_PAGE_CHATS -> {
retainedChatsRoute = null
}
}
}
@Composable
fun AppNavHost(modifier: Modifier = Modifier) {
@@ -562,10 +534,6 @@ fun App(
popEnterTransition = { rootNavPopEnterTransition() },
popExitTransition = { rootNavPopExitTransition() },
) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("welcome") {
WelcomeScreen(
onGetStarted = {
@@ -612,9 +580,11 @@ fun App(
}
composable("chat") {
if (showConversationListDetail) {
EmptyConversationPlaceholder()
} else {
// Listdetail keeps the root on `chat` under the shell;
// empty placeholders live in Desktop*DetailNavHost only.
// Drawing EmptyConversationPlaceholder here ghosts a
// second copy through the transparent chats detail pane.
if (!showConversationListDetail) {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
@@ -624,23 +594,6 @@ fun App(
}
}
composable(PublicChatNav.CHAT_ROUTE) {
PublicChatChatRoute(
scrollToMessageId = scrollToMessageId,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
}
composable(PublicChatNav.PROFILE_ROUTE) {
PublicChatProfileRoute(
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
}
composable(
route = "search/conversations",
enterTransition = { searchScreenEnterTransition() },
@@ -653,217 +606,50 @@ fun App(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
onOpenProfile = { userId: Int ->
if (userId != 0) {
navController.navigateReplacingMainDetail("profile/$userId")
if (userId == 0) return@ChatsSearchScreen
if (isDesktopListDetail) {
navController.popBackStack("chat", inclusive = false)
pendingMainTab = MAIN_PAGE_CHATS
chatsDetailNavController.navigateReplacingMainDetail(
"profile/$userId",
)
} else {
navController.navigateReplacingMainDetail(
"profile/$userId",
)
}
},
onOpenConversation = { userId: Int ->
if (userId != 0) {
if (userId == 0) return@ChatsSearchScreen
if (isDesktopListDetail) {
navController.popBackStack("chat", inclusive = false)
pendingMainTab = MAIN_PAGE_CHATS
chatsDetailNavController.navigateToDmChat(userId)
} else {
navController.navigateToDmChat(userId)
}
}
)
}
composable(
route = "profile/{userId}?fromDeepLink={fromDeepLink}",
arguments = listOf(
navArgument("userId") { type = NavType.StringType },
navArgument("fromDeepLink") {
type = NavType.BoolType
defaultValue = false
},
)
) { backStackEntry ->
val args = backStackEntry.savedStateHandle
val userIdParam = args.get<String>("userId")
val parsedUserId = userIdParam?.toIntOrNull()
val userId = if ((parsedUserId ?: 0) > 0) parsedUserId else null
val profileUsername = if (userId == null) {
userIdParam?.trim()?.takeIf { it.isNotBlank() }
} else null
val fromDeepLink = when (val rawFromDeepLink = args.get<Any?>("fromDeepLink")) {
is Boolean -> rawFromDeepLink
is String -> rawFromDeepLink == "true"
else -> false
}
Logger.d(
"ProfileRoute",
"profile entry args: rawUserId=$userIdParam parsedUserId=$parsedUserId resolvedUserId=$userId " +
"resolvedUsername=$profileUsername fromDeepLink=$fromDeepLink " +
"currentRoute=${backStackEntry.destination.route}"
)
ProfileScreen(
userId = userId,
username = profileUsername,
showBackButton = !settingsProfileSplit,
onBack = { navController.navigateUp() },
onChat = { navController.navigateToDmChat(it) },
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable,
showErrorAsToast = fromDeepLink
)
}
composable(
route = ProfileRoutes.Edit,
arguments = listOf(
navArgument(ProfileRoutes.ARG_FOCUS) {
type = NavType.StringType
defaultValue = ""
},
),
) { entry ->
val focusField = EditProfileFocusField.fromArg(
entry.savedStateHandle.get<String>(ProfileRoutes.ARG_FOCUS),
)
EditProfileScreen(
onBack = { navController.navigateUp() },
initialFocusField = focusField,
)
}
composable(
route = DmNav.CHAT_ROUTE,
arguments = listOf(
navArgument("otherUserId") { type = NavType.StringType },
navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 },
),
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
val sourceMessageId = entry.savedStateHandle.get<Int>("sourceMessageId") ?: -1
if (otherUserId <= 0) return@composable
DmChatRoute(
otherUserId = otherUserId,
scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null,
// Root graph keeps all detail routes for compact + welcome.
// Desktop listdetail navigates per-tab NavHosts instead and
// leaves the root on `chat` while the shell is visible.
conversationDetailDestinations(
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
scrollToMessageId = scrollToMessageId,
)
}
composable(
route = DmNav.PROFILE_ROUTE,
arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }),
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
if (otherUserId <= 0) return@composable
DmProfileRoute(
otherUserId = otherUserId,
profileDetailDestinations(
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
}
settingsComposable("about") {
AboutScreen()
}
settingsComposable(SettingsRoutes.Logs) {
LogsScreen()
}
settingsComposable(SettingsRoutes.LogFiles) {
LogFilesScreen(
onOpenFile = { file ->
navController.previousBackStackEntry
?.savedStateHandle
?.set(LOG_FILE_OPEN_RESULT_KEY, file.path)
navController.navigateUp()
},
settingsDetailDestinations(
navController = navController,
rootNavController = navController,
)
}
settingsComposable(
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@settingsComposable
DocumentScreen(
type = type,
onBack = { navController.navigateUp() },
onOpenLegalDocument = { linkedType ->
navController.navigate(DocumentType.route(linkedType)) {
launchSingleTop = true
}
},
)
}
settingsComposable(SettingsRoutes.Appearance) {
AppearanceScreen(onBack = { navController.navigateUp() })
}
settingsComposable(SettingsRoutes.Notifications) {
NotificationsScreen(onBack = { navController.navigateUp() })
}
settingsComposable(SettingsRoutes.Devices) {
DevicesScreen(onBack = { navController.navigateUp() })
}
settingsComposable(SettingsRoutes.SecurityPasswordFlow) {
ChangePasswordScreen(
onBack = { navController.navigateUp() },
onDone = { navController.popBackStack() },
)
}
settingsComposable(SettingsRoutes.AccountDeleteFlow) {
DeleteAccountScreen(
onBack = { navController.navigateUp() },
onDeleted = {
navController.navigate("welcome") {
popUpTo("chat") { inclusive = true }
}
},
)
}
settingsComposable(SettingsRoutes.AccountYandexFlow) {
ChangeYandexConfirmScreen(
onBack = { navController.navigateUp() },
)
}
settingsComposable(SettingsRoutes.AccountYandexOAuth) {
ChangeYandexOAuthScreen(
onBack = { navController.navigateUp() },
)
}
settingsComposable(SettingsRoutes.AccountYandexDone) {
ChangeYandexDoneScreen(
onDone = {
navController.popBackStack(SettingsRoutes.Account, inclusive = false)
},
)
}
settingsComposable(SettingsRoutes.Account) {
AccountScreen(
onBack = { navController.navigateUp() },
onLogout = {
navController.navigate("welcome") {
popUpTo("chat") { inclusive = true }
}
},
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) },
onChangeYandexId = { navController.navigate(SettingsRoutes.AccountYandexFlow) },
onDeleteAccount = { navController.navigate(SettingsRoutes.AccountDeleteFlow) },
)
}
}
DisposableEffect(navController) {
ApiClient.onAuthError = {
Logger.i("App", "Global auth error handler triggered, navigating to login")
@@ -879,28 +665,9 @@ fun App(
}
}
AppNavHost(Modifier.fillMaxSize())
if (showConversationListDetail) {
val detailPaneAnim = tween<Float>(
durationMillis = 280,
easing = FastOutSlowInEasing,
)
val chatsDetailKey = when {
isConversationChatRoute(currentRoute) ||
isConversationProfileRoute(currentRoute) ||
(
isStandaloneProfileRoute(currentRoute) &&
!settingsProfileSplit &&
isConversationChatRoute(previousRoute)
) -> "nav"
retainedChatsRoute != null -> "retained:$retainedChatsRoute"
else -> "empty"
}
val settingsDetailKey = when {
settingsProfileSplit || isSettingsDetailRoute(currentRoute) -> "nav"
else -> "empty"
}
// Chats / contacts stay edge-to-edge; settings host wraps its
// own AppPanel so shell structure stays stable across tabs.
val detailEdgeToEdge =
pendingMainTab == MAIN_PAGE_CHATS ||
pendingMainTab == MAIN_PAGE_CONTACTS
@@ -927,117 +694,38 @@ fun App(
DesktopTabDetailHosts(
selectedTab = pendingMainTab,
chatsDetail = {
// SizeTransform uses Lookahead under SharedTransitionLayout;
// nesting that with always-composed tab hosts crashes with
// "Parents state is LookaheadMeasuring". Disable size morph.
AnimatedContent(
targetState = chatsDetailKey,
transitionSpec = {
val navHostSwap =
(
initialState == "nav" &&
targetState.startsWith("retained:")
) ||
(
initialState.startsWith("retained:") &&
targetState == "nav"
DesktopChatsDetailNavHost(
navController = chatsDetailNavController,
sharedTransitionScope =
this@SharedTransitionLayout,
scrollToMessageId = scrollToMessageId,
)
val transform = if (navHostSwap) {
EnterTransition.None togetherWith
ExitTransition.None
} else {
(
fadeIn(animationSpec = detailPaneAnim) +
scaleIn(
initialScale = 0.98f,
animationSpec = detailPaneAnim,
)
) togetherWith fadeOut(
animationSpec = tween(
durationMillis = 180,
easing = FastOutSlowInEasing,
),
)
}
transform.using(null)
},
label = "chatsDetailPane",
modifier = Modifier.fillMaxSize(),
) { state ->
when {
state == "nav" -> {
AppNavHost(Modifier.fillMaxSize())
}
state.startsWith("retained:") -> {
val route =
state.removePrefix("retained:")
val dmUserId = dmUserIdFromRoute(route)
when {
dmUserId != null -> DmChatRoute(
otherUserId = dmUserId,
scrollToMessageId = null,
navController = navController,
sharedTransitionScope = null,
animatedVisibilityScope = null,
)
route == PublicChatNav.CHAT_ROUTE -> {
PublicChatChatRoute(
scrollToMessageId =
scrollToMessageId,
navController = navController,
sharedTransitionScope = null,
animatedVisibilityScope = null,
)
}
}
}
else -> EmptyConversationPlaceholder()
}
}
},
settingsDetail = {
AnimatedContent(
targetState = settingsDetailKey,
transitionSpec = {
(
(
fadeIn(animationSpec = detailPaneAnim) +
scaleIn(
initialScale = 0.98f,
animationSpec = detailPaneAnim,
DesktopSettingsDetailNavHost(
navController = settingsDetailNavController,
rootNavController = navController,
sharedTransitionScope =
this@SharedTransitionLayout,
onOpenChatFromProfile = { userId ->
if (userId > 0) {
pendingMainTab = MAIN_PAGE_CHATS
chatsDetailNavController.navigateToDmChat(
userId,
)
) togetherWith fadeOut(
animationSpec = tween(
durationMillis = 180,
easing = FastOutSlowInEasing,
),
)
).using(null)
}
},
label = "settingsDetailPane",
modifier = Modifier.fillMaxSize(),
) { state ->
if (state == "nav") {
AppPanel(
Modifier.fillMaxSize(),
shape = RoundedCornerShape(24.dp),
) {
AppNavHost(Modifier.fillMaxSize())
}
} else {
EmptySettingsPlaceholder()
}
}
)
},
contactsDetail = {
EmptyContactsPlaceholder()
DesktopContactsDetailNavHost(
navController = contactsDetailNavController,
)
},
)
},
)
}
} else {
AppNavHost(Modifier.fillMaxSize())
}
CallOverlay(Modifier.fillMaxSize())
@@ -44,7 +44,7 @@ fun NavController.navigateToDmChat(
builder: NavOptionsBuilder.() -> Unit = {},
) {
navigate(DmNav.chatRoute(otherUserId, sourceMessageId)) {
popUpTo("chat") { saveState = true }
popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = true
builder()
}
@@ -31,7 +31,7 @@ fun NavController.navigateToPublicChat(
builder: NavOptionsBuilder.() -> Unit = {},
) {
navigate(PublicChatNav.CHAT_ROUTE) {
popUpTo("chat") { saveState = true }
popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = true
builder()
}
@@ -0,0 +1,156 @@
package ru.fromchat.ui.main
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.AppPanel
private val detailNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
private fun detailNavEnter(): EnterTransition =
scaleIn(initialScale = 0.9f, animationSpec = detailNavTween) +
fadeIn(animationSpec = detailNavTween)
private fun detailNavExit(): ExitTransition =
scaleOut(targetScale = 1.1f, animationSpec = detailNavTween) +
fadeOut(animationSpec = detailNavTween)
private fun detailNavPopEnter(): EnterTransition =
scaleIn(initialScale = 1.1f, animationSpec = detailNavTween) +
fadeIn(animationSpec = detailNavTween)
private fun detailNavPopExit(): ExitTransition =
scaleOut(targetScale = 0.9f, animationSpec = detailNavTween) +
fadeOut(animationSpec = detailNavTween)
/**
* Independent Chats-tab detail [NavHost] for desktop listdetail.
*/
@Composable
fun DesktopChatsDetailNavHost(
navController: NavHostController,
sharedTransitionScope: SharedTransitionScope,
scrollToMessageId: Int?,
modifier: Modifier = Modifier,
) {
CompositionLocalProvider(LocalNavController provides navController) {
NavHost(
navController = navController,
startDestination = DesktopDetailNav.CHATS_ROOT,
modifier = modifier.fillMaxSize(),
enterTransition = { detailNavEnter() },
exitTransition = { detailNavExit() },
popEnterTransition = { detailNavPopEnter() },
popExitTransition = { detailNavPopExit() },
) {
composable(DesktopDetailNav.CHATS_ROOT) {
EmptyConversationPlaceholder()
}
conversationDetailDestinations(
navController = navController,
sharedTransitionScope = sharedTransitionScope,
scrollToMessageId = scrollToMessageId,
)
profileDetailDestinations(
navController = navController,
sharedTransitionScope = sharedTransitionScope,
showBackOnProfile = true,
)
}
}
}
/**
* Independent Settings-tab detail [NavHost] for desktop listdetail
* (settings screens, own profile, and edit profile on the same stack).
*
* [AppPanel] is drawn only behind non-empty destinations so the empty root stays
* bare (no surface fill), matching the prior AnimatedContent empty vs panel split.
* Panel is a sibling under the [NavHost] so the host is not remounted empty content.
*/
@Composable
fun DesktopSettingsDetailNavHost(
navController: NavHostController,
rootNavController: NavHostController,
sharedTransitionScope: SharedTransitionScope,
onOpenChatFromProfile: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val detailRoute =
navController.currentBackStackEntryAsState().value?.destination?.route
val showPanel =
detailRoute != null && detailRoute != DesktopDetailNav.SETTINGS_ROOT
Box(modifier.fillMaxSize()) {
if (showPanel) {
AppPanel(
Modifier.fillMaxSize(),
shape = RoundedCornerShape(24.dp),
) {}
}
CompositionLocalProvider(LocalNavController provides navController) {
NavHost(
navController = navController,
startDestination = DesktopDetailNav.SETTINGS_ROOT,
modifier = Modifier.fillMaxSize(),
enterTransition = { detailNavEnter() },
exitTransition = { detailNavExit() },
popEnterTransition = { detailNavPopEnter() },
popExitTransition = { detailNavPopExit() },
) {
composable(DesktopDetailNav.SETTINGS_ROOT) {
EmptySettingsPlaceholder()
}
settingsDetailDestinations(
navController = navController,
rootNavController = rootNavController,
)
profileDetailDestinations(
navController = navController,
sharedTransitionScope = sharedTransitionScope,
showBackOnProfile = false,
onOpenChat = onOpenChatFromProfile,
)
}
}
}
}
/**
* Independent Contacts-tab detail [NavHost] (placeholder root only for now).
*/
@Composable
fun DesktopContactsDetailNavHost(
navController: NavHostController,
modifier: Modifier = Modifier,
) {
CompositionLocalProvider(LocalNavController provides navController) {
NavHost(
navController = navController,
startDestination = DesktopDetailNav.CONTACTS_ROOT,
modifier = modifier.fillMaxSize(),
) {
composable(DesktopDetailNav.CONTACTS_ROOT) {
EmptyContactsPlaceholder()
}
}
}
}
@@ -7,6 +7,29 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.zIndex
import androidx.navigation.NavController
/**
* Empty roots for desktop per-tab detail [androidx.navigation.NavHost]s.
* Independent back stacks so Chats / Settings / Contacts do not share one detail stack.
*/
object DesktopDetailNav {
const val CHATS_ROOT = "desktop_chats_detail_root"
const val SETTINGS_ROOT = "desktop_settings_detail_root"
const val CONTACTS_ROOT = "desktop_contacts_detail_root"
}
/**
* Chats-tab detail [NavController] on large-screen listdetail, or null on compact.
* Prefer this (with fallback to [ru.fromchat.ui.LocalNavController]) when opening conversations.
*/
val LocalDesktopChatsNavController = compositionLocalOf<NavController?> { null }
/**
* Settings-tab detail [NavController] on large-screen listdetail, or null on compact.
* Prefer this when opening settings / own profile / edit profile in the detail pane.
*/
val LocalDesktopSettingsNavController = compositionLocalOf<NavController?> { null }
/**
* Which main bottom-nav tab owns the listdetail detail pane on large screens.
@@ -17,10 +40,10 @@ val LocalDesktopMainTab = compositionLocalOf { MAIN_PAGE_CHATS }
/**
* Per-tab detail pane hosts for desktop listdetail.
*
* Each tab supplies its own detail content. Only the [selectedTab] host is visible
* (alpha 1 + highest z-index); the others stay composed at alpha 0 underneath so opening
* Settings does not dispose an open Chats detail, and returning to Chats shows it again
* without clearing the chat route.
* Each tab supplies its own detail content (typically its own [androidx.navigation.compose.NavHost]).
* Only the [selectedTab] host is visible (alpha 1 + highest z-index); the others stay composed at
* alpha 0 underneath so opening Settings does not dispose an open Chats detail, and returning to
* Chats shows it again without clearing the chat route.
*/
@Composable
fun DesktopTabDetailHosts(
@@ -0,0 +1,292 @@
package ru.fromchat.ui.main
import androidx.compose.animation.SharedTransitionScope
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import ru.fromchat.Logger
import ru.fromchat.legal.DocumentScreen
import ru.fromchat.legal.DocumentType
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
import ru.fromchat.ui.chat.panels.dm.DmNav
import ru.fromchat.ui.chat.panels.dm.DmProfileRoute
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
import ru.fromchat.ui.chat.panels.publicchat.PublicChatChatRoute
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute
import ru.fromchat.ui.main.settings.AboutScreen
import ru.fromchat.ui.main.settings.AppearanceScreen
import ru.fromchat.ui.main.settings.DevicesScreen
import ru.fromchat.ui.main.settings.LOG_FILE_OPEN_RESULT_KEY
import ru.fromchat.ui.main.settings.LogFilesScreen
import ru.fromchat.ui.main.settings.LogsScreen
import ru.fromchat.ui.main.settings.NotificationsScreen
import ru.fromchat.ui.main.settings.SettingsRoutes
import ru.fromchat.ui.main.settings.account.AccountScreen
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexConfirmScreen
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexDoneScreen
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexOAuthScreen
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
import ru.fromchat.ui.main.settings.server.ServerConfigScreen
import ru.fromchat.ui.profile.EditProfileFocusField
import ru.fromchat.ui.profile.EditProfileScreen
import ru.fromchat.ui.profile.ProfileRoutes
import ru.fromchat.ui.profile.ProfileScreen
/**
* Conversation detail destinations (DM / public chat + in-conversation profiles).
* Used by the root NavHost (compact) and the desktop Chats detail NavHost.
*/
fun NavGraphBuilder.conversationDetailDestinations(
navController: NavController,
sharedTransitionScope: SharedTransitionScope,
scrollToMessageId: Int?,
) {
composable(PublicChatNav.CHAT_ROUTE) {
PublicChatChatRoute(
scrollToMessageId = scrollToMessageId,
navController = navController,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this,
)
}
composable(PublicChatNav.PROFILE_ROUTE) {
PublicChatProfileRoute(
navController = navController,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this,
)
}
composable(
route = DmNav.CHAT_ROUTE,
arguments = listOf(
navArgument("otherUserId") { type = NavType.StringType },
navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 },
),
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
val sourceMessageId = entry.savedStateHandle.get<Int>("sourceMessageId") ?: -1
if (otherUserId <= 0) return@composable
DmChatRoute(
otherUserId = otherUserId,
scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null,
navController = navController,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this,
)
}
composable(
route = DmNav.PROFILE_ROUTE,
arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }),
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
if (otherUserId <= 0) return@composable
DmProfileRoute(
otherUserId = otherUserId,
navController = navController,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this,
)
}
}
/**
* Standalone profile + edit-profile destinations.
*
* @param showBackOnProfile When false (desktop settings detail own-profile), hides the back button.
* @param onOpenChat Opens a DM; on desktop settings host this should target the Chats detail nav.
*/
fun NavGraphBuilder.profileDetailDestinations(
navController: NavController,
sharedTransitionScope: SharedTransitionScope,
showBackOnProfile: Boolean = true,
onOpenChat: (Int) -> Unit = { navController.navigateToDmChat(it) },
) {
composable(
route = "profile/{userId}?fromDeepLink={fromDeepLink}",
arguments = listOf(
navArgument("userId") { type = NavType.StringType },
navArgument("fromDeepLink") {
type = NavType.BoolType
defaultValue = false
},
),
) { backStackEntry ->
val args = backStackEntry.savedStateHandle
val userIdParam = args.get<String>("userId")
val parsedUserId = userIdParam?.toIntOrNull()
val userId = if ((parsedUserId ?: 0) > 0) parsedUserId else null
val profileUsername = if (userId == null) {
userIdParam?.trim()?.takeIf { it.isNotBlank() }
} else {
null
}
val fromDeepLink = when (val rawFromDeepLink = args.get<Any?>("fromDeepLink")) {
is Boolean -> rawFromDeepLink
is String -> rawFromDeepLink == "true"
else -> false
}
Logger.d(
"ProfileRoute",
"profile entry args: rawUserId=$userIdParam parsedUserId=$parsedUserId resolvedUserId=$userId " +
"resolvedUsername=$profileUsername fromDeepLink=$fromDeepLink " +
"currentRoute=${backStackEntry.destination.route}",
)
ProfileScreen(
userId = userId,
username = profileUsername,
showBackButton = showBackOnProfile,
onBack = { navController.navigateUp() },
onChat = onOpenChat,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this@composable,
showErrorAsToast = fromDeepLink,
)
}
composable(
route = ProfileRoutes.Edit,
arguments = listOf(
navArgument(ProfileRoutes.ARG_FOCUS) {
type = NavType.StringType
defaultValue = ""
},
),
) { entry ->
val focusField = EditProfileFocusField.fromArg(
entry.savedStateHandle.get<String>(ProfileRoutes.ARG_FOCUS),
)
EditProfileScreen(
onBack = { navController.navigateUp() },
initialFocusField = focusField,
)
}
}
/**
* Settings hub detail destinations (account, appearance, about, ).
*
* @param rootNavController For logout / delete that wipe back to welcome on the root graph.
*/
fun NavGraphBuilder.settingsDetailDestinations(
navController: NavController,
rootNavController: NavController = navController,
) {
composable("about") {
AboutScreen()
}
composable(SettingsRoutes.Logs) {
LogsScreen()
}
composable(SettingsRoutes.LogFiles) {
LogFilesScreen(
onOpenFile = { file ->
navController.previousBackStackEntry
?.savedStateHandle
?.set(LOG_FILE_OPEN_RESULT_KEY, file.path)
navController.navigateUp()
},
)
}
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
}
},
)
}
composable(SettingsRoutes.Appearance) {
AppearanceScreen(onBack = { navController.navigateUp() })
}
composable(SettingsRoutes.Notifications) {
NotificationsScreen(onBack = { navController.navigateUp() })
}
composable(SettingsRoutes.Devices) {
DevicesScreen(onBack = { navController.navigateUp() })
}
composable(SettingsRoutes.SecurityPasswordFlow) {
ChangePasswordScreen(
onBack = { navController.navigateUp() },
onDone = { navController.popBackStack() },
)
}
composable(SettingsRoutes.AccountDeleteFlow) {
DeleteAccountScreen(
onBack = { navController.navigateUp() },
onDeleted = {
rootNavController.navigate("welcome") {
popUpTo("chat") { inclusive = true }
}
},
)
}
composable(SettingsRoutes.AccountYandexFlow) {
ChangeYandexConfirmScreen(
onBack = { navController.navigateUp() },
)
}
composable(SettingsRoutes.AccountYandexOAuth) {
ChangeYandexOAuthScreen(
onBack = { navController.navigateUp() },
)
}
composable(SettingsRoutes.AccountYandexDone) {
ChangeYandexDoneScreen(
onDone = {
navController.popBackStack(SettingsRoutes.Account, inclusive = false)
},
)
}
composable(SettingsRoutes.Account) {
AccountScreen(
onBack = { navController.navigateUp() },
onLogout = {
rootNavController.navigate("welcome") {
popUpTo("chat") { inclusive = true }
}
},
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) },
onChangeYandexId = { navController.navigate(SettingsRoutes.AccountYandexFlow) },
onDeleteAccount = { navController.navigate(SettingsRoutes.AccountDeleteFlow) },
)
}
// Same route string as pre-auth server config on the root graph; fine on a nested graph.
composable(SettingsRoutes.ServerConfig) {
ServerConfigScreen()
}
}
@@ -2,43 +2,31 @@ package ru.fromchat.ui.main
import androidx.navigation.NavController
import androidx.navigation.NavOptionsBuilder
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
/**
* 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.
* Opens a main-hub detail destination, replacing any prior detail above this graph's start
* destination so large-screen switches do not stack previous panes.
* Matches [ru.fromchat.ui.chat.panels.dm.navigateToDmChat] / public chat.
*
* When [preserveConversationDetail] is true (desktop listdetail), keeps an open chat under
* the new settings/profile destination so tab hosts can retain the Chats detail.
*
* No-ops when [route] is already the current main-detail destination (avoids pop+push
* churn and AnimatedContent / NavHost transitions for the same screen).
*
* On desktop listdetail, call this on the tab's own detail [NavController]
* ([LocalDesktopSettingsNavController] / [LocalDesktopChatsNavController]) so stacks stay
* independent. [preserveConversationDetail] is ignored separate NavHosts retain other tabs.
*/
fun NavController.navigateReplacingMainDetail(
route: String,
preserveConversationDetail: Boolean = false,
@Suppress("UNUSED_PARAMETER") preserveConversationDetail: Boolean = false,
builder: NavOptionsBuilder.() -> Unit = {},
) {
if (isCurrentMainDetailRoute(route)) return
if (preserveConversationDetail) {
while (true) {
val current = currentBackStackEntry?.destination?.route ?: break
if (current == "chat" || isConversationChatRoute(current)) break
if (!popBackStack()) break
}
navigate(route) {
popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = true
builder()
}
} else {
navigate(route) {
popUpTo("chat") { saveState = true }
launchSingleTop = true
builder()
}
}
}
/**
@@ -56,7 +44,3 @@ fun NavController.isCurrentMainDetailRoute(route: String): Boolean {
}
return current == route || current.substringBefore("?") == target
}
private fun isConversationChatRoute(route: String?): Boolean =
route == PublicChatNav.CHAT_ROUTE ||
(route != null && route.startsWith("dm/") && "/chat" in route)
@@ -95,15 +95,9 @@ private const val PAGE_COUNT = 4
* File-private (not a local fun inside [MainScreen]) so desktop JVM incremental runs do not
* chase missing nested classes like `MainScreenKt$MainScreen$openOwnProfileInDetailPane$1`.
*/
private fun openOwnProfileInDetailPane(
navController: NavController,
embeddedInListDetail: Boolean,
) {
private fun openOwnProfileInDetailPane(navController: NavController) {
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return
navController.navigateReplacingMainDetail(
route = "profile/$userId",
preserveConversationDetail = embeddedInListDetail,
)
navController.navigateReplacingMainDetail(route = "profile/$userId")
}
/**
@@ -120,8 +114,7 @@ private fun selectMainPage(
widthClass: WindowWidthSizeClass,
scope: CoroutineScope,
pagerState: PagerState,
navController: NavController,
embeddedInListDetail: Boolean,
settingsDetailNavController: NavController,
) {
when {
page == PAGE_PROFILE && widthClass != WindowWidthSizeClass.COMPACT -> {
@@ -129,13 +122,13 @@ private fun selectMainPage(
val ownUserId = ApiClient.user?.id?.takeIf { it > 0 }
val alreadyShowingOwnProfile =
ownUserId != null &&
navController.isCurrentMainDetailRoute("profile/$ownUserId")
settingsDetailNavController.isCurrentMainDetailRoute("profile/$ownUserId")
if (alreadyOnSettingsList && alreadyShowingOwnProfile) return
if (!alreadyOnSettingsList) {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
}
if (!alreadyShowingOwnProfile) {
openOwnProfileInDetailPane(navController, embeddedInListDetail)
openOwnProfileInDetailPane(settingsDetailNavController)
}
}
else -> {
@@ -168,6 +161,8 @@ fun MainScreen(
) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current
val settingsDetailNavController =
LocalDesktopSettingsNavController.current ?: navController
val pagerState = rememberPagerState(
initialPage = initialPage.coerceIn(0, PAGE_COUNT - 1),
pageCount = { PAGE_COUNT },
@@ -213,6 +208,11 @@ fun MainScreen(
}
chatListSelectionRequestId += 1
}
DesktopMenuCommand.OpenAbout -> {
if (pagerState.currentPage != PAGE_SETTINGS) {
pagerState.scrollToPage(PAGE_SETTINGS)
}
}
}
}
}
@@ -347,8 +347,7 @@ fun MainScreen(
widthClass = widthClass,
scope = scope,
pagerState = pagerState,
navController = navController,
embeddedInListDetail = embeddedInListDetail,
settingsDetailNavController = settingsDetailNavController,
)
},
label = { Text(chatsLabel) },
@@ -367,8 +366,7 @@ fun MainScreen(
widthClass = widthClass,
scope = scope,
pagerState = pagerState,
navController = navController,
embeddedInListDetail = embeddedInListDetail,
settingsDetailNavController = settingsDetailNavController,
)
},
label = { Text(contactsLabel) },
@@ -384,8 +382,7 @@ fun MainScreen(
widthClass = widthClass,
scope = scope,
pagerState = pagerState,
navController = navController,
embeddedInListDetail = embeddedInListDetail,
settingsDetailNavController = settingsDetailNavController,
)
},
label = { Text(settingsLabel) },
@@ -404,8 +401,7 @@ fun MainScreen(
widthClass = widthClass,
scope = scope,
pagerState = pagerState,
navController = navController,
embeddedInListDetail = embeddedInListDetail,
settingsDetailNavController = settingsDetailNavController,
)
},
label = { Text(profileLabel) },
@@ -139,6 +139,7 @@ import ru.fromchat.status_connecting
import ru.fromchat.status_updating
import ru.fromchat.account_suspended
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.main.LocalDesktopChatsNavController
import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat
@@ -543,7 +544,8 @@ fun ChatsTab(
animatedVisibilityScope: AnimatedVisibilityScope? = null,
enterSelectionRequestId: Long = 0L,
) {
val navController = LocalNavController.current
val navController =
LocalDesktopChatsNavController.current ?: LocalNavController.current
val clipboard = supportClipboardManagerImpl
val haptic = rememberHapticFeedback()
val scope = rememberCoroutineScope()
@@ -1,6 +1,5 @@
package ru.fromchat.ui.main.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
@@ -22,7 +21,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -34,10 +32,6 @@ import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.verticalScroll
import com.pr0gramm3r101.utils.widthSizeClass
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.about
@@ -59,9 +53,9 @@ import ru.fromchat.settings_hub_about_sub
import ru.fromchat.settings_hub_logs_sub
import ru.fromchat.settings_hub_profile_sub
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.LocalDesktopSettingsNavController
import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.main.mainPagerBottomInset
import ru.fromchat.ui.main.navigateReplacingMainDetail
@@ -71,17 +65,12 @@ val SettingsStepHorizontalPadding = 24.dp
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun SettingsTab() {
val navController = LocalNavController.current
val navController =
LocalDesktopSettingsNavController.current ?: LocalNavController.current
val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
val topChromeHazeState = rememberHazeState()
// Same material as MainScreen bottom nav (not HazeMaterials.thin).
val topChromeHazeStyle = rememberChatSurfaceContainerHazeStyle()
fun openDetail(route: String) {
navController.navigateReplacingMainDetail(
route = route,
preserveConversationDetail = isTwoPane,
)
navController.navigateReplacingMainDetail(route = route)
}
Scaffold(
@@ -99,28 +88,15 @@ fun SettingsTab() {
title = {
Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
// Match MainScreen bottom chrome: surfaceContainer + hazeEffect/blurEffect.
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = topChromeHazeState) {
blurEffect {
style = topChromeHazeStyle
}
},
)
}
) { innerPadding ->
Column(
Modifier
.fillMaxSize()
.hazeSource(topChromeHazeState)
.verticalScroll()
.mainPagerBottomInset()
) {
// Scrollable top inset so rows can pass under the frosted top chrome.
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
if (isTwoPane) {
@@ -1,80 +0,0 @@
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"