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