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"