diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts
index 8b7447d..21a362f 100644
--- a/app/desktop/build.gradle.kts
+++ b/app/desktop/build.gradle.kts
@@ -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 = """
CFBundleURLTypes
@@ -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",
diff --git a/app/desktop/icons/app_window_icon.icns b/app/desktop/icons/app_window_icon.icns
new file mode 100644
index 0000000..8e3913a
Binary files /dev/null and b/app/desktop/icons/app_window_icon.icns differ
diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowPrefs.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowPrefs.kt
index 161f2bb..e970e6e 100644
--- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowPrefs.kt
+++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowPrefs.kt
@@ -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() }
}
}
diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt
index 988d292..f929b79 100644
--- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt
+++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt
@@ -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) {
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) {
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) {
}
}
+ 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) {
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) {
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) {
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) {
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
+ if (bytes.isEmpty()) continue
+ 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
+ }
+ // 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.e("DesktopIcon", "No icon resource found for tray=$tray (tried $names)")
+ return null
+}
+
+private fun readClasspathBytes(name: String, loaders: List): 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
- 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
+ if (bytes.isNotEmpty()) {
+ Logger.i("DesktopIcon", "Classpath hit $path via $loader (${bytes.size} bytes)")
+ return bytes
}
- 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))
+ // 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
}
diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index 1d96884..1e19d0a 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -1,6 +1,7 @@
FromChat
+ FromChat Beta
Назад
Настройки
Главная
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index cf2ef32..761a464 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -2,6 +2,7 @@
FromChat
+ FromChat Beta
Back
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/desktop/DesktopMenuCommands.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/desktop/DesktopMenuCommands.kt
index e89464e..91ead2a 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/desktop/DesktopMenuCommands.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/desktop/DesktopMenuCommands.kt
@@ -9,6 +9,8 @@ enum class DesktopMenuCommand {
NewChat,
SearchConversations,
EnterChatListSelection,
+ /** Open the in-app Settings → About screen (not a separate window). */
+ OpenAbout,
}
/**
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
index f7f6e35..55ccc97 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
@@ -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 { 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("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(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 = 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,11 +398,30 @@ 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") {
return@LaunchedEffect
@@ -475,10 +435,21 @@ fun App(
"navigating to dm user=${target.dmConversationUserId} " +
"messageId=${target.scrollToMessageId} launchId=${target.launchId}"
)
- navController.navigateToDmChat(
- otherUserId = target.dmConversationUserId,
- sourceMessageId = target.scrollToMessageId,
- )
+ 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 -> {
@@ -486,7 +457,15 @@ fun App(
"NotificationLaunch",
"navigating to public chat launchId=${target.launchId}"
)
- navController.navigateToPublicChat()
+ if (isDesktopListDetail) {
+ pendingMainTab = MAIN_PAGE_CHATS
+ if (navController.currentBackStackEntry?.destination?.route != "chat") {
+ navController.popBackStack("chat", inclusive = false)
+ }
+ chatsDetailNavController.navigateToPublicChat()
+ } else {
+ navController.navigateToPublicChat()
+ }
}
}
}
@@ -494,62 +473,55 @@ fun App(
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(null) }
- LaunchedEffect(currentRoute, pendingMainTab) {
- when {
- isConversationChatRoute(currentRoute) -> {
- retainedChatsRoute = currentRoute
- }
- currentRoute == "chat" &&
- pendingMainTab == MAIN_PAGE_CHATS -> {
- retainedChatsRoute = null
- }
- }
- }
@Composable
fun AppNavHost(modifier: Modifier = Modifier) {
@@ -562,345 +534,140 @@ fun App(
popEnterTransition = { rootNavPopEnterTransition() },
popExitTransition = { rootNavPopExitTransition() },
) {
- composable("serverConfig") {
- ServerConfigScreen()
- }
+ composable("welcome") {
+ WelcomeScreen(
+ onGetStarted = {
+ navController.navigate("auth") {
+ popUpTo("auth") { inclusive = true }
+ launchSingleTop = true
+ }
+ },
+ onAlreadyLoggedIn = {
+ WebSocketManager.connect(forceRestart = true)
+ navController.navigateAndWipeBackStack("chat")
+ },
+ )
+ }
- composable("welcome") {
- WelcomeScreen(
- onGetStarted = {
- navController.navigate("auth") {
- popUpTo("auth") { inclusive = true }
- launchSingleTop = true
- }
- },
- onAlreadyLoggedIn = {
- WebSocketManager.connect(forceRestart = true)
- navController.navigateAndWipeBackStack("chat")
- },
- )
- }
+ composable("auth") {
+ AuthScreen(
+ onAuthSuccess = {
+ MainScope().launch {
+ runCatching {
+ bootstrapSessionInstance(
+ hasToken = true,
+ forceNetwork = false,
+ )
+ }
+ PublicChatProfileSync.ensureStarted()
+ ProfileUpdateSync.ensureStarted()
+ StatusSubscriptionCoordinator.ensureStarted()
+ scheduleSessionInstanceNetworkRefresh()
+ }
+ WebSocketManager.connect(forceRestart = true)
+ navController.navigateAndWipeBackStack("chat")
+ },
+ onBackToWelcome = { navController.navigateUp() },
+ )
+ }
- composable("auth") {
- AuthScreen(
- onAuthSuccess = {
- MainScope().launch {
- runCatching {
- bootstrapSessionInstance(
- hasToken = true,
- forceNetwork = false,
- )
- }
- PublicChatProfileSync.ensureStarted()
- ProfileUpdateSync.ensureStarted()
- StatusSubscriptionCoordinator.ensureStarted()
- scheduleSessionInstanceNetworkRefresh()
- }
- WebSocketManager.connect(forceRestart = true)
- navController.navigateAndWipeBackStack("chat")
- },
- onBackToWelcome = { navController.navigateUp() },
- )
- }
+ composable(YandexOAuthNav.ROUTE) {
+ YandexOAuthScreen()
+ }
- composable(YandexOAuthNav.ROUTE) {
- YandexOAuthScreen()
- }
+ composable(SmartCaptchaNav.ROUTE) {
+ SmartCaptchaScreen()
+ }
- composable(SmartCaptchaNav.ROUTE) {
- SmartCaptchaScreen()
- }
-
- composable("chat") {
- if (showConversationListDetail) {
- EmptyConversationPlaceholder()
- } else {
- MainScreen(
- sharedTransitionScope = this@SharedTransitionLayout,
- animatedVisibilityScope = this,
- snackbarHostState = profileLookupSnackbarHostState,
- initialPage = pendingMainTab,
- )
- }
- }
-
- 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() },
- exitTransition = { searchScreenExitTransition() },
- popEnterTransition = { searchScreenEnterTransition() },
- popExitTransition = { searchScreenExitTransition() },
- ) {
- ChatsSearchScreen(
- onBack = { navController.popBackStack() },
- sharedTransitionScope = this@SharedTransitionLayout,
- animatedVisibilityScope = this,
- onOpenProfile = { userId: Int ->
- if (userId != 0) {
- navController.navigateReplacingMainDetail("profile/$userId")
- }
- },
- onOpenConversation = { userId: Int ->
- if (userId != 0) {
- navController.navigateToDmChat(userId)
+ composable("chat") {
+ // List–detail 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,
+ snackbarHostState = profileLookupSnackbarHostState,
+ initialPage = pendingMainTab,
+ )
}
}
- )
- }
- 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("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
+ composable(
+ route = "search/conversations",
+ enterTransition = { searchScreenEnterTransition() },
+ exitTransition = { searchScreenExitTransition() },
+ popEnterTransition = { searchScreenEnterTransition() },
+ popExitTransition = { searchScreenExitTransition() },
+ ) {
+ ChatsSearchScreen(
+ onBack = { navController.popBackStack() },
+ sharedTransitionScope = this@SharedTransitionLayout,
+ animatedVisibilityScope = this,
+ onOpenProfile = { userId: Int ->
+ 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) return@ChatsSearchScreen
+ if (isDesktopListDetail) {
+ navController.popBackStack("chat", inclusive = false)
+ pendingMainTab = MAIN_PAGE_CHATS
+ chatsDetailNavController.navigateToDmChat(userId)
+ } else {
+ navController.navigateToDmChat(userId)
+ }
+ }
+ )
+ }
- val fromDeepLink = when (val rawFromDeepLink = args.get("fromDeepLink")) {
- is Boolean -> rawFromDeepLink
- is String -> rawFromDeepLink == "true"
- else -> false
+ // Root graph keeps all detail routes for compact + welcome.
+ // Desktop list–detail navigates per-tab NavHosts instead and
+ // leaves the root on `chat` while the shell is visible.
+ conversationDetailDestinations(
+ navController = navController,
+ sharedTransitionScope = this@SharedTransitionLayout,
+ scrollToMessageId = scrollToMessageId,
+ )
+ profileDetailDestinations(
+ navController = navController,
+ sharedTransitionScope = this@SharedTransitionLayout,
+ )
+ settingsDetailDestinations(
+ navController = navController,
+ rootNavController = navController,
+ )
}
- 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(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("otherUserId")?.toIntOrNull() ?: 0
- val sourceMessageId = entry.savedStateHandle.get("sourceMessageId") ?: -1
- if (otherUserId <= 0) return@composable
- DmChatRoute(
- otherUserId = otherUserId,
- scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null,
- navController = navController,
- sharedTransitionScope = this@SharedTransitionLayout,
- animatedVisibilityScope = this,
- )
- }
-
- composable(
- route = DmNav.PROFILE_ROUTE,
- arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }),
- ) { entry ->
- val otherUserId = entry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0
- if (otherUserId <= 0) return@composable
- DmProfileRoute(
- otherUserId = otherUserId,
- 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()
- },
- )
- }
-
- settingsComposable(
- route = DocumentType.ROUTE,
- arguments = listOf(
- navArgument(DocumentType.ARG_DOCUMENT_TYPE) { type = NavType.StringType },
- ),
- ) { entry ->
- val type = entry.savedStateHandle
- .get(DocumentType.ARG_DOCUMENT_TYPE)
- ?.let(DocumentType::typeFromArg)
- ?: return@settingsComposable
- DocumentScreen(
- type = type,
- onBack = { navController.navigateUp() },
- onOpenLegalDocument = { linkedType ->
- navController.navigate(DocumentType.route(linkedType)) {
- launchSingleTop = true
+ DisposableEffect(navController) {
+ ApiClient.onAuthError = {
+ Logger.i("App", "Global auth error handler triggered, navigating to login")
+ runCatching {
+ navController.navigateAndWipeBackStack("welcome")
+ }.onFailure { e ->
+ Logger.w("App", "Auth navigation failed: ${e.message}", e)
}
- },
- )
- }
-
- 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")
- runCatching {
- navController.navigateAndWipeBackStack("welcome")
- }.onFailure { e ->
- Logger.w("App", "Auth navigation failed: ${e.message}", e)
+ }
+ onDispose {
+ ApiClient.onAuthError = null
+ }
}
}
- onDispose {
- ApiClient.onAuthError = null
- }
- }
- }
+
+ AppNavHost(Modifier.fillMaxSize())
if (showConversationListDetail) {
- val detailPaneAnim = tween(
- 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"
- )
- 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()
- }
- }
+ DesktopChatsDetailNavHost(
+ navController = chatsDetailNavController,
+ sharedTransitionScope =
+ this@SharedTransitionLayout,
+ scrollToMessageId = scrollToMessageId,
+ )
},
settingsDetail = {
- AnimatedContent(
- targetState = settingsDetailKey,
- transitionSpec = {
- (
- (
- fadeIn(animationSpec = detailPaneAnim) +
- scaleIn(
- initialScale = 0.98f,
- animationSpec = detailPaneAnim,
- )
- ) togetherWith fadeOut(
- animationSpec = tween(
- durationMillis = 180,
- easing = FastOutSlowInEasing,
- ),
+ DesktopSettingsDetailNavHost(
+ navController = settingsDetailNavController,
+ rootNavController = navController,
+ sharedTransitionScope =
+ this@SharedTransitionLayout,
+ onOpenChatFromProfile = { userId ->
+ if (userId > 0) {
+ pendingMainTab = MAIN_PAGE_CHATS
+ chatsDetailNavController.navigateToDmChat(
+ userId,
)
- ).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())
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt
index a898bd3..90bcf23 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt
@@ -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()
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt
index 7b68b64..9af504e 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt
@@ -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()
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt
new file mode 100644
index 0000000..30d290e
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt
@@ -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(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 list–detail.
+ */
+@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 list–detail
+ * (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()
+ }
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt
index 6c354bb..6f151e6 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt
@@ -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 list–detail, or null on compact.
+ * Prefer this (with fallback to [ru.fromchat.ui.LocalNavController]) when opening conversations.
+ */
+val LocalDesktopChatsNavController = compositionLocalOf { null }
+
+/**
+ * Settings-tab detail [NavController] on large-screen list–detail, or null on compact.
+ * Prefer this when opening settings / own profile / edit profile in the detail pane.
+ */
+val LocalDesktopSettingsNavController = compositionLocalOf { null }
/**
* Which main bottom-nav tab owns the list–detail detail pane on large screens.
@@ -17,10 +40,10 @@ val LocalDesktopMainTab = compositionLocalOf { MAIN_PAGE_CHATS }
/**
* Per-tab detail pane hosts for desktop list–detail.
*
- * 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(
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt
new file mode 100644
index 0000000..8de9498
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt
@@ -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("otherUserId")?.toIntOrNull() ?: 0
+ val sourceMessageId = entry.savedStateHandle.get("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("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("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("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(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(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()
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailNav.kt
index 2a017b9..4057d29 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailNav.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailNav.kt
@@ -2,42 +2,30 @@ 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 list–detail), 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 list–detail, 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) {
- launchSingleTop = true
- builder()
- }
- } else {
- navigate(route) {
- popUpTo("chat") { saveState = true }
- launchSingleTop = true
- builder()
- }
+ navigate(route) {
+ popUpTo(graph.startDestinationId) { 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)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt
index a5f8228..32062e5 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt
@@ -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) },
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
index 6c9aa3a..779d451 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
@@ -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()
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt
index a766b16..7681c4d 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt
@@ -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) {
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopAboutContent.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopAboutContent.kt
deleted file mode 100644
index c699a80..0000000
--- a/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopAboutContent.kt
+++ /dev/null
@@ -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(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"