mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
@@ -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) {
|
// List–detail 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 list–detail 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()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 list–detail, 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 list–detail, 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 list–detail detail pane on large screens.
|
* 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.
|
* Per-tab detail pane hosts for desktop list–detail.
|
||||||
*
|
*
|
||||||
* 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 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
|
* 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 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(
|
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"
|
|
||||||
Reference in New Issue
Block a user