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
|
||||||
|
if (bytes.isEmpty()) continue
|
||||||
|
val decoded = decodeImageBytes(bytes, name) ?: continue
|
||||||
|
if (decoded.width < 16 || decoded.height < 16) {
|
||||||
|
Logger.w("DesktopIcon", "Skipping $name — decoded size ${decoded.width}x${decoded.height}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Ensure TYPE_INT_ARGB so AWT Tray/Taskbar get a predictable raster.
|
||||||
|
val argb = ensureArgb(decoded)
|
||||||
|
val image = if (tray) scaleBufferedImage(argb, 64) else argb
|
||||||
|
Logger.i("DesktopIcon", "Loaded $name (${image.width}x${image.height}) tray=$tray")
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
Logger.e("DesktopIcon", "No icon resource found for tray=$tray (tried $names)")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readClasspathBytes(name: String, loaders: List<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
|
?: continue
|
||||||
val bytes = runCatching { stream.use { it.readBytes() } }.getOrNull() ?: continue
|
if (bytes.isNotEmpty()) {
|
||||||
if (bytes.isEmpty()) continue
|
Logger.i("DesktopIcon", "Classpath hit $path via $loader (${bytes.size} bytes)")
|
||||||
val bitmap = runCatching {
|
return bytes
|
||||||
Image.makeFromEncoded(bytes).toComposeImageBitmap()
|
|
||||||
}.onFailure { error ->
|
|
||||||
Logger.w("DesktopIcon", "Failed to decode $name: ${error.message}")
|
|
||||||
}.getOrNull() ?: continue
|
|
||||||
if (bitmap.width < 16 || bitmap.height < 16) {
|
|
||||||
Logger.w("DesktopIcon", "Skipping $name — decoded size ${bitmap.width}x${bitmap.height}")
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
Logger.i("DesktopIcon", "Loaded $name (${bitmap.width}x${bitmap.height}) tray=$tray")
|
|
||||||
return BitmapPainter(bitmap)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Explicit class resource (works when context CL is a filtered compose run CL).
|
||||||
Logger.w("DesktopIcon", "No app icon resource decoded; using empty placeholder")
|
for (path in paths) {
|
||||||
return BitmapPainter(ImageBitmap(32, 32))
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,42 +2,30 @@ 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) {
|
navigate(route) {
|
||||||
while (true) {
|
popUpTo(graph.startDestinationId) { saveState = true }
|
||||||
val current = currentBackStackEntry?.destination?.route ?: break
|
launchSingleTop = true
|
||||||
if (current == "chat" || isConversationChatRoute(current)) break
|
builder()
|
||||||
if (!popBackStack()) break
|
|
||||||
}
|
|
||||||
navigate(route) {
|
|
||||||
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
|
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