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 { compose.desktop {
application { application {
mainClass = "ru.fromchat.desktop.MainKt" mainClass = "ru.fromchat.desktop.MainKt"
@@ -42,8 +45,16 @@ compose.desktop {
"javafx.swing", "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 { macOS {
bundleID = "ru.fromchat.desktop" bundleID = "ru.fromchat.desktop"
iconFile.set(desktopWindowIconIcns)
infoPlist { infoPlist {
extraKeysRawXml = """ extraKeysRawXml = """
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -80,8 +91,20 @@ tasks.matching { it.name == "run" }.configureEach {
javafxModules.any { module -> file.name.startsWith("javafx-$module-") } javafxModules.any { module -> file.name.startsWith("javafx-$module-") }
} }
classpath = files(classpath.files.filterNot { it in javafxJars.toSet() }) 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( jvmArgs(
listOf( dockIconArgs + listOf(
"--module-path", "--module-path",
javafxJars.joinToString(File.pathSeparator) { it.absolutePath }, javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
"--add-modules", "--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.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowPosition
import java.util.prefs.Preferences 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`), * Stored in the same JVM prefs node as PlatformSettings (`ru.fromchat.settings`):
* keys `desktop_window_width` / `desktop_window_height` (float dp). * - `desktop_window_width` / `desktop_window_height` (float dp)
* - `desktop_window_x` / `desktop_window_y` (float dp, absolute; absent → platform default)
*/ */
internal object DesktopWindowPrefs { internal object DesktopWindowPrefs {
private const val WIDTH_KEY = "desktop_window_width" private const val WIDTH_KEY = "desktop_window_width"
private const val HEIGHT_KEY = "desktop_window_height" 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 = private val prefs: Preferences =
Preferences.userRoot().node("ru.fromchat.settings") 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(WIDTH_KEY, size.width.value)
prefs.putFloat(HEIGHT_KEY, size.height.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() } runCatching { prefs.flush() }
} }
} }
@@ -8,11 +8,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.graphics.toPainter
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.KeyShortcut 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.isShiftPressed
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.MenuBar 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.Tray
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import java.awt.Desktop import java.awt.Desktop
import java.awt.Image
import java.awt.KeyboardFocusManager import java.awt.KeyboardFocusManager
import java.awt.RenderingHints
import java.awt.Taskbar
import java.awt.event.ActionEvent import java.awt.event.ActionEvent
import java.awt.image.BufferedImage
import java.io.BufferedReader import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.File
import java.io.InputStreamReader import java.io.InputStreamReader
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.net.InetAddress import java.net.InetAddress
@@ -44,6 +46,7 @@ import java.net.ServerSocket
import java.net.Socket import java.net.Socket
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.imageio.ImageIO
import javax.swing.JComponent import javax.swing.JComponent
import javax.swing.JRootPane import javax.swing.JRootPane
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
@@ -55,7 +58,8 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource 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.AppForeground
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.Res import ru.fromchat.Res
@@ -64,6 +68,7 @@ import ru.fromchat.action_select
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.app_name import ru.fromchat.app_name
import ru.fromchat.app_name_beta
import ru.fromchat.desktop_about_app import ru.fromchat.desktop_about_app
import ru.fromchat.desktop_cut import ru.fromchat.desktop_cut
import ru.fromchat.desktop_menu_edit import ru.fromchat.desktop_menu_edit
@@ -220,8 +225,11 @@ fun main(args: Array<String>) {
System.setProperty("compose.layers.type", "WINDOW") System.setProperty("compose.layers.type", "WINDOW")
if (isMacOs()) { if (isMacOs()) {
// Must be set before AWT Toolkit init (do not call getString / Compose here). // Must be set before AWT Toolkit init (do not call getString / Compose here).
// Value matches Res.string.app_name in values / values-ru. // Matches Res.string.app_name / app_name_beta.
System.setProperty("apple.awt.application.name", "FromChat") System.setProperty(
"apple.awt.application.name",
if (AppBuildInfo.isDebug) "FromChat Beta" else "FromChat",
)
System.setProperty("apple.laf.useScreenMenuBar", "true") System.setProperty("apple.laf.useScreenMenuBar", "true")
} }
if (!DesktopSingleInstance.acquireOrForward(args)) return if (!DesktopSingleInstance.acquireOrForward(args)) return
@@ -229,30 +237,38 @@ fun main(args: Array<String>) {
args.filter { DesktopDeepLinkBus.isFromChatUri(it) } args.filter { DesktopDeepLinkBus.isFromChatUri(it) }
.forEach { DesktopDeepLinkBus.handleUri(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 { application {
UtilsLibrary.init() UtilsLibrary.init()
DesktopApplicationBootstrap.launchOnApplicationStart() DesktopApplicationBootstrap.launchOnApplicationStart()
val windowState = rememberWindowState(size = remember { DesktopWindowPrefs.loadSize() }) val windowState = rememberWindowState(
val aboutWindowState = rememberWindowState( size = remember { DesktopWindowPrefs.loadSize() },
position = WindowPosition.Aligned(Alignment.Center), position = remember { DesktopWindowPrefs.loadPosition() },
size = DpSize(440.dp, 640.dp),
) )
var windowVisible by remember { mutableStateOf(true) } var windowVisible by remember { mutableStateOf(true) }
var aboutOpen by remember { mutableStateOf(false) }
val trayState = rememberTrayState() val trayState = rememberTrayState()
// BufferedImage.toPainter() keeps the AWT pixels (avoids blank BitmapPainter round-trip).
val trayIcon = remember { loadAppIconPainter(tray = true) } 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 traySupported = remember { java.awt.SystemTray.isSupported() }
val mac = remember { isMacOs() } 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 aboutApp = stringResource(Res.string.desktop_about_app)
val trayShow = stringResource(Res.string.desktop_tray_show) val trayShow = stringResource(Res.string.desktop_tray_show)
val quit = stringResource(Res.string.desktop_quit) val quit = stringResource(Res.string.desktop_quit)
LaunchedEffect(windowState.size) { LaunchedEffect(windowState.size, windowState.position) {
delay(3_000) delay(3_000)
DesktopWindowPrefs.saveSize(windowState.size) DesktopWindowPrefs.save(windowState.size, windowState.position)
} }
DisposableEffect(trayState) { DisposableEffect(trayState) {
@@ -267,12 +283,17 @@ fun main(args: Array<String>) {
} }
} }
fun openAbout() {
windowVisible = true
DesktopMenuCommands.emit(DesktopMenuCommand.OpenAbout)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!mac || !Desktop.isDesktopSupported()) return@LaunchedEffect if (!mac || !Desktop.isDesktopSupported()) return@LaunchedEffect
val desktop = Desktop.getDesktop() val desktop = Desktop.getDesktop()
if (desktop.isSupported(Desktop.Action.APP_ABOUT)) { if (desktop.isSupported(Desktop.Action.APP_ABOUT)) {
desktop.setAboutHandler { desktop.setAboutHandler {
SwingUtilities.invokeLater { aboutOpen = true } SwingUtilities.invokeLater { openAbout() }
} }
} }
} }
@@ -285,7 +306,7 @@ fun main(args: Array<String>) {
onAction = { windowVisible = true }, onAction = { windowVisible = true },
menu = { menu = {
Item(trayShow) { windowVisible = true } Item(trayShow) { windowVisible = true }
Item(aboutApp) { aboutOpen = true } Item(aboutApp) { openAbout() }
Separator() Separator()
Item(quit) { exitApplication() } Item(quit) { exitApplication() }
}, },
@@ -327,7 +348,7 @@ fun main(args: Array<String>) {
true true
} }
event.isShiftPressed && event.key == Key.A -> { event.isShiftPressed && event.key == Key.A -> {
aboutOpen = true openAbout()
true true
} }
event.isShiftPressed && event.key == Key.N -> { event.isShiftPressed && event.key == Key.N -> {
@@ -349,6 +370,10 @@ fun main(args: Array<String>) {
LaunchedEffect(window, appName) { LaunchedEffect(window, appName) {
window.title = appName window.title = appName
enableEdgeToEdgeTitleBar(window.rootPane) enableEdgeToEdgeTitleBar(window.rootPane)
dockIconImage?.let { image ->
window.iconImages = listOf(image)
applyDockIcon(image)
}
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
AppForeground.setForeground(true) AppForeground.setForeground(true)
@@ -389,17 +414,6 @@ fun main(args: Array<String>) {
App() 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) 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. * Loads the desktop tray or window/dock icon from packaged resources.
* Prefers PNG, then WebP; returns a tiny empty bitmap only if every decode fails. * 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 { 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) { val names = if (tray) {
listOf("app_icon.png", "app_icon.webp", "app_window_icon.png", "app_window_icon.webp") listOf("app_icon.png", "app_icon.webp", "app_window_icon.png", "app_window_icon.webp")
} else { } else {
listOf("app_window_icon.png", "app_window_icon.webp", "app_icon.png", "app_icon.webp") listOf("app_window_icon.png", "app_window_icon.webp", "app_icon.png", "app_icon.webp")
} }
val loaders = listOfNotNull( val loaders = listOfNotNull(
Thread.currentThread().contextClassLoader,
DesktopApplicationBootstrap::class.java.classLoader, DesktopApplicationBootstrap::class.java.classLoader,
Thread.currentThread().contextClassLoader,
ClassLoader.getSystemClassLoader(), ClassLoader.getSystemClassLoader(),
) ).distinct()
for (name in names) { for (name in names) {
for (loader in loaders) { val bytes = readClasspathBytes(name, loaders)
val stream = loader.getResourceAsStream(name) ?: readFileFallbackBytes(name)
?: loader.getResourceAsStream("/$name")
?: continue ?: continue
val bytes = runCatching { stream.use { it.readBytes() } }.getOrNull() ?: continue
if (bytes.isEmpty()) continue if (bytes.isEmpty()) continue
val bitmap = runCatching { val decoded = decodeImageBytes(bytes, name) ?: continue
Image.makeFromEncoded(bytes).toComposeImageBitmap() if (decoded.width < 16 || decoded.height < 16) {
}.onFailure { error -> Logger.w("DesktopIcon", "Skipping $name — decoded size ${decoded.width}x${decoded.height}")
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 continue
} }
Logger.i("DesktopIcon", "Loaded $name (${bitmap.width}x${bitmap.height}) tray=$tray") // Ensure TYPE_INT_ARGB so AWT Tray/Taskbar get a predictable raster.
return BitmapPainter(bitmap) 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
Logger.w("DesktopIcon", "No app icon resource decoded; using empty placeholder") }
return BitmapPainter(ImageBitmap(32, 32))
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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">FromChat</string> <string name="app_name">FromChat</string>
<string name="app_name_beta">FromChat Beta</string>
<string name="back">Назад</string> <string name="back">Назад</string>
<string name="settings">Настройки</string> <string name="settings">Настройки</string>
<string name="home">Главная</string> <string name="home">Главная</string>
@@ -2,6 +2,7 @@
<resources> <resources>
<!-- App --> <!-- App -->
<string name="app_name">FromChat</string> <string name="app_name">FromChat</string>
<string name="app_name_beta">FromChat Beta</string>
<!-- Navigation --> <!-- Navigation -->
<string name="back">Back</string> <string name="back">Back</string>
@@ -9,6 +9,8 @@ enum class DesktopMenuCommand {
NewChat, NewChat,
SearchConversations, SearchConversations,
EnterChatListSelection, EnterChatListSelection,
/** Open the in-app Settings → About screen (not a separate window). */
OpenAbout,
} }
/** /**
@@ -1,7 +1,5 @@
package ru.fromchat.ui package ru.fromchat.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionLayout
@@ -11,7 +9,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
@@ -30,16 +27,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import coil3.ImageLoader import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory import coil3.compose.setSingletonImageLoaderFactory
import coil3.network.ktor3.KtorNetworkFetcherFactory 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.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
import ru.fromchat.config.ServerConfig import ru.fromchat.config.ServerConfig
import ru.fromchat.legal.DocumentScreen import ru.fromchat.desktop.DesktopMenuCommand
import ru.fromchat.legal.DocumentType import ru.fromchat.desktop.DesktopMenuCommands
import ru.fromchat.notifications.NotificationLaunchCoordinator import ru.fromchat.notifications.NotificationLaunchCoordinator
import ru.fromchat.ui.auth.AuthScreen import ru.fromchat.ui.auth.AuthScreen
import ru.fromchat.ui.auth.captcha.SmartCaptchaNav 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.YandexOAuthNav
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
import ru.fromchat.ui.calls.CallOverlay 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.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.PublicChatNav
import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat
import ru.fromchat.ui.main.ConversationListDetailShell import ru.fromchat.ui.main.ConversationListDetailShell
import ru.fromchat.ui.main.DesktopChatsDetailNavHost
import ru.fromchat.ui.main.DesktopContactsDetailNavHost
import ru.fromchat.ui.main.DesktopSettingsDetailNavHost
import ru.fromchat.ui.main.DesktopTabDetailHosts import ru.fromchat.ui.main.DesktopTabDetailHosts
import ru.fromchat.ui.main.EmptyContactsPlaceholder import ru.fromchat.ui.main.LocalDesktopChatsNavController
import ru.fromchat.ui.main.EmptyConversationPlaceholder
import ru.fromchat.ui.main.EmptySettingsPlaceholder
import ru.fromchat.ui.main.LocalDesktopMainTab 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_CHATS
import ru.fromchat.ui.main.MAIN_PAGE_CONTACTS import ru.fromchat.ui.main.MAIN_PAGE_CONTACTS
import ru.fromchat.ui.main.MAIN_PAGE_SETTINGS import ru.fromchat.ui.main.MAIN_PAGE_SETTINGS
import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.main.chats.ChatsSearchScreen import ru.fromchat.ui.main.chats.ChatsSearchScreen
import ru.fromchat.ui.main.conversationDetailDestinations
import ru.fromchat.ui.main.navigateReplacingMainDetail import ru.fromchat.ui.main.navigateReplacingMainDetail
import ru.fromchat.ui.main.settings.LOG_FILE_OPEN_RESULT_KEY import ru.fromchat.ui.main.profileDetailDestinations
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.settings.SettingsRoutes import ru.fromchat.ui.main.settings.SettingsRoutes
import ru.fromchat.ui.main.settings.account.AccountScreen import ru.fromchat.ui.main.settingsDetailDestinations
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.ProfileRoutes
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.components.AppPanel
import ru.fromchat.ui.components.LocalPaneHazeState import ru.fromchat.ui.components.LocalPaneHazeState
import ru.fromchat.ui.components.ScreenSurface import ru.fromchat.ui.components.ScreenSurface
import androidx.compose.foundation.shape.RoundedCornerShape
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.NetworkConnectivity
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") } 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 = private fun isStandaloneProfileRoute(route: String?): Boolean =
route != null && route != null &&
route.startsWith("profile/") && route.startsWith("profile/") &&
!route.startsWith(ProfileRoutes.Edit.substringBefore("?")) !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 — * Nav destination.route is the pattern (`profile/{userId}…`), not the filled path —
* read the userId from entry args / saved state. * 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 } 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 val rootNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
private fun rootNavEnterTransition(): EnterTransition = 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 @Composable
fun App( fun App(
scrollToMessageId: Int? = null, scrollToMessageId: Int? = null,
@@ -336,9 +259,7 @@ fun App(
} }
startDestination = when { startDestination = when {
hasToken && startAtDmConversationUserId != null -> "chat" hasToken -> "chat"
hasToken && startAtPublicChat -> "chats/publicChat"
hasToken && !startAtPublicChat -> "chat"
else -> "welcome" else -> "welcome"
} }
@@ -416,6 +337,9 @@ fun App(
FromChatTheme { FromChatTheme {
SharedTransitionLayout { SharedTransitionLayout {
val navController = rememberNavController() val navController = rememberNavController()
val chatsDetailNavController = rememberNavController()
val settingsDetailNavController = rememberNavController()
val contactsDetailNavController = rememberNavController()
val profileLookupSnackbarHostState = remember { SnackbarHostState() } val profileLookupSnackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(profileLookupErrorMessage) { LaunchedEffect(profileLookupErrorMessage) {
profileLookupErrorMessage?.let { message -> 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) // Handle startup/deep-link navigation targets (profile links)
LaunchedEffect( LaunchedEffect(
startAtProfileUserId, startAtProfileUserId,
startAtProfileUsername, startAtProfileUsername,
startDestination startDestination,
isDesktopListDetail,
) { ) {
Logger.d( Logger.d(
"ProfileDeepLink", "ProfileDeepLink",
@@ -449,7 +378,19 @@ fun App(
"ProfileDeepLink", "ProfileDeepLink",
"navigating by deep link userId=$startAtProfileUserId" "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 { } else {
val trimmedUsername = startAtProfileUsername?.trim() val trimmedUsername = startAtProfileUsername?.trim()
if (!trimmedUsername.isNullOrBlank()) { if (!trimmedUsername.isNullOrBlank()) {
@@ -457,10 +398,29 @@ fun App(
"ProfileDeepLink", "ProfileDeepLink",
"navigating by deep link username=$trimmedUsername" "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) { LaunchedEffect(startDestination) {
if (startDestination == null || startDestination == "welcome") { if (startDestination == null || startDestination == "welcome") {
@@ -475,81 +435,93 @@ fun App(
"navigating to dm user=${target.dmConversationUserId} " + "navigating to dm user=${target.dmConversationUserId} " +
"messageId=${target.scrollToMessageId} launchId=${target.launchId}" "messageId=${target.scrollToMessageId} launchId=${target.launchId}"
) )
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( navController.navigateToDmChat(
otherUserId = target.dmConversationUserId, otherUserId = target.dmConversationUserId,
sourceMessageId = target.scrollToMessageId, sourceMessageId = target.scrollToMessageId,
) )
} }
}
target.startAtPublicChat -> { target.startAtPublicChat -> {
Logger.d( Logger.d(
"NotificationLaunch", "NotificationLaunch",
"navigating to public chat launchId=${target.launchId}" "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() navController.navigateToPublicChat()
} }
} }
} }
} }
}
CompositionLocalProvider( CompositionLocalProvider(
LocalNavController provides navController, LocalNavController provides navController,
LocalDesktopChatsNavController provides
if (isDesktopListDetail) chatsDetailNavController else null,
LocalDesktopSettingsNavController provides
if (isDesktopListDetail) settingsDetailNavController else null,
LocalSystemBarsVisibility provides rememberSystemBarsController() LocalSystemBarsVisibility provides rememberSystemBarsController()
) { ) {
if (startDestination != null) { if (startDestination != null) {
val currentEntry by navController.currentBackStackEntryAsState() val currentEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentEntry?.destination?.route val currentRoute = currentEntry?.destination?.route
val previousRoute = val settingsEntry by settingsDetailNavController.currentBackStackEntryAsState()
navController.previousBackStackEntry?.destination?.route val settingsRoute = settingsEntry?.destination?.route
val profileUserId = standaloneProfileUserId(currentEntry) val settingsProfileUserId = standaloneProfileUserId(settingsEntry)
val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass
var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) }
val ownUserId = ApiClient.user?.id val ownUserId = ApiClient.user?.id
val showConversationListDetail = val showConversationListDetail =
widthSizeClass != WindowWidthSizeClass.COMPACT && isDesktopListDetail && currentRoute == "chat"
isConversationShellRoute( val forceSettingsListTab =
currentRoute, showConversationListDetail && (
previousRoute, isEditProfileRoute(settingsRoute) ||
ownUserId, (
profileUserId, 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 { ScreenSurface {
Box(Modifier.fillMaxSize()) { 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 = val listPaneWidth =
if (widthSizeClass == WindowWidthSizeClass.EXPANDED) { if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
448.dp 448.dp
} else { } else {
360.dp 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 @Composable
fun AppNavHost(modifier: Modifier = Modifier) { fun AppNavHost(modifier: Modifier = Modifier) {
@@ -562,10 +534,6 @@ fun App(
popEnterTransition = { rootNavPopEnterTransition() }, popEnterTransition = { rootNavPopEnterTransition() },
popExitTransition = { rootNavPopExitTransition() }, popExitTransition = { rootNavPopExitTransition() },
) { ) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("welcome") { composable("welcome") {
WelcomeScreen( WelcomeScreen(
onGetStarted = { onGetStarted = {
@@ -612,9 +580,11 @@ fun App(
} }
composable("chat") { composable("chat") {
if (showConversationListDetail) { // Listdetail keeps the root on `chat` under the shell;
EmptyConversationPlaceholder() // empty placeholders live in Desktop*DetailNavHost only.
} else { // Drawing EmptyConversationPlaceholder here ghosts a
// second copy through the transparent chats detail pane.
if (!showConversationListDetail) {
MainScreen( MainScreen(
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this, 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( composable(
route = "search/conversations", route = "search/conversations",
enterTransition = { searchScreenEnterTransition() }, enterTransition = { searchScreenEnterTransition() },
@@ -653,217 +606,50 @@ fun App(
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this, animatedVisibilityScope = this,
onOpenProfile = { userId: Int -> onOpenProfile = { userId: Int ->
if (userId != 0) { if (userId == 0) return@ChatsSearchScreen
navController.navigateReplacingMainDetail("profile/$userId") if (isDesktopListDetail) {
navController.popBackStack("chat", inclusive = false)
pendingMainTab = MAIN_PAGE_CHATS
chatsDetailNavController.navigateReplacingMainDetail(
"profile/$userId",
)
} else {
navController.navigateReplacingMainDetail(
"profile/$userId",
)
} }
}, },
onOpenConversation = { userId: Int -> 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) navController.navigateToDmChat(userId)
} }
} }
) )
} }
composable( // Root graph keeps all detail routes for compact + welcome.
route = "profile/{userId}?fromDeepLink={fromDeepLink}", // Desktop listdetail navigates per-tab NavHosts instead and
arguments = listOf( // leaves the root on `chat` while the shell is visible.
navArgument("userId") { type = NavType.StringType }, conversationDetailDestinations(
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,
navController = navController, navController = navController,
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this, scrollToMessageId = scrollToMessageId,
) )
} profileDetailDestinations(
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, navController = navController,
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
) )
} settingsDetailDestinations(
navController = navController,
settingsComposable("about") { rootNavController = navController,
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<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) { DisposableEffect(navController) {
ApiClient.onAuthError = { ApiClient.onAuthError = {
Logger.i("App", "Global auth error handler triggered, navigating to login") Logger.i("App", "Global auth error handler triggered, navigating to login")
@@ -879,28 +665,9 @@ fun App(
} }
} }
AppNavHost(Modifier.fillMaxSize())
if (showConversationListDetail) { 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 = val detailEdgeToEdge =
pendingMainTab == MAIN_PAGE_CHATS || pendingMainTab == MAIN_PAGE_CHATS ||
pendingMainTab == MAIN_PAGE_CONTACTS pendingMainTab == MAIN_PAGE_CONTACTS
@@ -927,117 +694,38 @@ fun App(
DesktopTabDetailHosts( DesktopTabDetailHosts(
selectedTab = pendingMainTab, selectedTab = pendingMainTab,
chatsDetail = { chatsDetail = {
// SizeTransform uses Lookahead under SharedTransitionLayout; DesktopChatsDetailNavHost(
// nesting that with always-composed tab hosts crashes with navController = chatsDetailNavController,
// "Parents state is LookaheadMeasuring". Disable size morph. sharedTransitionScope =
AnimatedContent( this@SharedTransitionLayout,
targetState = chatsDetailKey, scrollToMessageId = scrollToMessageId,
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()
}
}
}, },
settingsDetail = { settingsDetail = {
AnimatedContent( DesktopSettingsDetailNavHost(
targetState = settingsDetailKey, navController = settingsDetailNavController,
transitionSpec = { rootNavController = navController,
( sharedTransitionScope =
( this@SharedTransitionLayout,
fadeIn(animationSpec = detailPaneAnim) + onOpenChatFromProfile = { userId ->
scaleIn( if (userId > 0) {
initialScale = 0.98f, pendingMainTab = MAIN_PAGE_CHATS
animationSpec = detailPaneAnim, 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 = { contactsDetail = {
EmptyContactsPlaceholder() DesktopContactsDetailNavHost(
navController = contactsDetailNavController,
)
}, },
) )
}, },
) )
} }
} else {
AppNavHost(Modifier.fillMaxSize())
} }
CallOverlay(Modifier.fillMaxSize()) CallOverlay(Modifier.fillMaxSize())
@@ -44,7 +44,7 @@ fun NavController.navigateToDmChat(
builder: NavOptionsBuilder.() -> Unit = {}, builder: NavOptionsBuilder.() -> Unit = {},
) { ) {
navigate(DmNav.chatRoute(otherUserId, sourceMessageId)) { navigate(DmNav.chatRoute(otherUserId, sourceMessageId)) {
popUpTo("chat") { saveState = true } popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = true launchSingleTop = true
builder() builder()
} }
@@ -31,7 +31,7 @@ fun NavController.navigateToPublicChat(
builder: NavOptionsBuilder.() -> Unit = {}, builder: NavOptionsBuilder.() -> Unit = {},
) { ) {
navigate(PublicChatNav.CHAT_ROUTE) { navigate(PublicChatNav.CHAT_ROUTE) {
popUpTo("chat") { saveState = true } popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = true launchSingleTop = true
builder() 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.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.zIndex 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. * 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. * Per-tab detail pane hosts for desktop listdetail.
* *
* Each tab supplies its own detail content. Only the [selectedTab] host is visible * Each tab supplies its own detail content (typically its own [androidx.navigation.compose.NavHost]).
* (alpha 1 + highest z-index); the others stay composed at alpha 0 underneath so opening * Only the [selectedTab] host is visible (alpha 1 + highest z-index); the others stay composed at
* Settings does not dispose an open Chats detail, and returning to Chats shows it again * alpha 0 underneath so opening Settings does not dispose an open Chats detail, and returning to
* without clearing the chat route. * Chats shows it again without clearing the chat route.
*/ */
@Composable @Composable
fun DesktopTabDetailHosts( 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.NavController
import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavOptionsBuilder
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
/** /**
* Opens a main-hub detail destination, replacing any prior conversation/settings/profile * Opens a main-hub detail destination, replacing any prior detail above this graph's start
* above the main `chat` root so large-screen switches do not stack previous panes. * destination so large-screen switches do not stack previous panes.
* Matches [ru.fromchat.ui.chat.panels.dm.navigateToDmChat] / public chat. * 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 * No-ops when [route] is already the current main-detail destination (avoids pop+push
* churn and AnimatedContent / NavHost transitions for the same screen). * 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( fun NavController.navigateReplacingMainDetail(
route: String, route: String,
preserveConversationDetail: Boolean = false, @Suppress("UNUSED_PARAMETER") preserveConversationDetail: Boolean = false,
builder: NavOptionsBuilder.() -> Unit = {}, builder: NavOptionsBuilder.() -> Unit = {},
) { ) {
if (isCurrentMainDetailRoute(route)) return 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) { navigate(route) {
popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = true launchSingleTop = true
builder() 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 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 * File-private (not a local fun inside [MainScreen]) so desktop JVM incremental runs do not
* chase missing nested classes like `MainScreenKt$MainScreen$openOwnProfileInDetailPane$1`. * chase missing nested classes like `MainScreenKt$MainScreen$openOwnProfileInDetailPane$1`.
*/ */
private fun openOwnProfileInDetailPane( private fun openOwnProfileInDetailPane(navController: NavController) {
navController: NavController,
embeddedInListDetail: Boolean,
) {
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return
navController.navigateReplacingMainDetail( navController.navigateReplacingMainDetail(route = "profile/$userId")
route = "profile/$userId",
preserveConversationDetail = embeddedInListDetail,
)
} }
/** /**
@@ -120,8 +114,7 @@ private fun selectMainPage(
widthClass: WindowWidthSizeClass, widthClass: WindowWidthSizeClass,
scope: CoroutineScope, scope: CoroutineScope,
pagerState: PagerState, pagerState: PagerState,
navController: NavController, settingsDetailNavController: NavController,
embeddedInListDetail: Boolean,
) { ) {
when { when {
page == PAGE_PROFILE && widthClass != WindowWidthSizeClass.COMPACT -> { page == PAGE_PROFILE && widthClass != WindowWidthSizeClass.COMPACT -> {
@@ -129,13 +122,13 @@ private fun selectMainPage(
val ownUserId = ApiClient.user?.id?.takeIf { it > 0 } val ownUserId = ApiClient.user?.id?.takeIf { it > 0 }
val alreadyShowingOwnProfile = val alreadyShowingOwnProfile =
ownUserId != null && ownUserId != null &&
navController.isCurrentMainDetailRoute("profile/$ownUserId") settingsDetailNavController.isCurrentMainDetailRoute("profile/$ownUserId")
if (alreadyOnSettingsList && alreadyShowingOwnProfile) return if (alreadyOnSettingsList && alreadyShowingOwnProfile) return
if (!alreadyOnSettingsList) { if (!alreadyOnSettingsList) {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
} }
if (!alreadyShowingOwnProfile) { if (!alreadyShowingOwnProfile) {
openOwnProfileInDetailPane(navController, embeddedInListDetail) openOwnProfileInDetailPane(settingsDetailNavController)
} }
} }
else -> { else -> {
@@ -168,6 +161,8 @@ fun MainScreen(
) { ) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() } val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current val navController = LocalNavController.current
val settingsDetailNavController =
LocalDesktopSettingsNavController.current ?: navController
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
initialPage = initialPage.coerceIn(0, PAGE_COUNT - 1), initialPage = initialPage.coerceIn(0, PAGE_COUNT - 1),
pageCount = { PAGE_COUNT }, pageCount = { PAGE_COUNT },
@@ -213,6 +208,11 @@ fun MainScreen(
} }
chatListSelectionRequestId += 1 chatListSelectionRequestId += 1
} }
DesktopMenuCommand.OpenAbout -> {
if (pagerState.currentPage != PAGE_SETTINGS) {
pagerState.scrollToPage(PAGE_SETTINGS)
}
}
} }
} }
} }
@@ -347,8 +347,7 @@ fun MainScreen(
widthClass = widthClass, widthClass = widthClass,
scope = scope, scope = scope,
pagerState = pagerState, pagerState = pagerState,
navController = navController, settingsDetailNavController = settingsDetailNavController,
embeddedInListDetail = embeddedInListDetail,
) )
}, },
label = { Text(chatsLabel) }, label = { Text(chatsLabel) },
@@ -367,8 +366,7 @@ fun MainScreen(
widthClass = widthClass, widthClass = widthClass,
scope = scope, scope = scope,
pagerState = pagerState, pagerState = pagerState,
navController = navController, settingsDetailNavController = settingsDetailNavController,
embeddedInListDetail = embeddedInListDetail,
) )
}, },
label = { Text(contactsLabel) }, label = { Text(contactsLabel) },
@@ -384,8 +382,7 @@ fun MainScreen(
widthClass = widthClass, widthClass = widthClass,
scope = scope, scope = scope,
pagerState = pagerState, pagerState = pagerState,
navController = navController, settingsDetailNavController = settingsDetailNavController,
embeddedInListDetail = embeddedInListDetail,
) )
}, },
label = { Text(settingsLabel) }, label = { Text(settingsLabel) },
@@ -404,8 +401,7 @@ fun MainScreen(
widthClass = widthClass, widthClass = widthClass,
scope = scope, scope = scope,
pagerState = pagerState, pagerState = pagerState,
navController = navController, settingsDetailNavController = settingsDetailNavController,
embeddedInListDetail = embeddedInListDetail,
) )
}, },
label = { Text(profileLabel) }, label = { Text(profileLabel) },
@@ -139,6 +139,7 @@ import ru.fromchat.status_connecting
import ru.fromchat.status_updating import ru.fromchat.status_updating
import ru.fromchat.account_suspended import ru.fromchat.account_suspended
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.main.LocalDesktopChatsNavController
import ru.fromchat.ui.main.LocalMainChromeInsets import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat
@@ -543,7 +544,8 @@ fun ChatsTab(
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
enterSelectionRequestId: Long = 0L, enterSelectionRequestId: Long = 0L,
) { ) {
val navController = LocalNavController.current val navController =
LocalDesktopChatsNavController.current ?: LocalNavController.current
val clipboard = supportClipboardManagerImpl val clipboard = supportClipboardManagerImpl
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -1,6 +1,5 @@
package ru.fromchat.ui.main.settings package ru.fromchat.ui.main.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -22,7 +21,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -34,10 +32,6 @@ import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.verticalScroll import com.pr0gramm3r101.utils.verticalScroll
import com.pr0gramm3r101.utils.widthSizeClass 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 org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.about 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_logs_sub
import ru.fromchat.settings_hub_profile_sub import ru.fromchat.settings_hub_profile_sub
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.LocalDesktopSettingsNavController
import ru.fromchat.ui.main.LocalMainChromeInsets import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.main.mainPagerBottomInset import ru.fromchat.ui.main.mainPagerBottomInset
import ru.fromchat.ui.main.navigateReplacingMainDetail import ru.fromchat.ui.main.navigateReplacingMainDetail
@@ -71,17 +65,12 @@ val SettingsStepHorizontalPadding = 24.dp
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun SettingsTab() { fun SettingsTab() {
val navController = LocalNavController.current val navController =
LocalDesktopSettingsNavController.current ?: LocalNavController.current
val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT 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) { fun openDetail(route: String) {
navController.navigateReplacingMainDetail( navController.navigateReplacingMainDetail(route = route)
route = route,
preserveConversationDetail = isTwoPane,
)
} }
Scaffold( Scaffold(
@@ -99,28 +88,15 @@ fun SettingsTab() {
title = { title = {
Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis) Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
}, },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
// Match MainScreen bottom chrome: surfaceContainer + hazeEffect/blurEffect.
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = topChromeHazeState) {
blurEffect {
style = topChromeHazeStyle
}
},
) )
} }
) { innerPadding -> ) { innerPadding ->
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.hazeSource(topChromeHazeState)
.verticalScroll() .verticalScroll()
.mainPagerBottomInset() .mainPagerBottomInset()
) { ) {
// Scrollable top inset so rows can pass under the frosted top chrome.
Spacer(Modifier.height(innerPadding.calculateTopPadding())) Spacer(Modifier.height(innerPadding.calculateTopPadding()))
if (isTwoPane) { 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"