More UI improvements

This commit is contained in:
2026-07-26 22:17:41 +03:00
Unverified
parent f3c50557de
commit 16a14da642
17 changed files with 539 additions and 125 deletions
@@ -53,25 +53,26 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import ru.fromchat.AppForeground import ru.fromchat.AppForeground
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.action_copy import ru.fromchat.action_copy
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.desktop_about_app import ru.fromchat.desktop_about_app
import ru.fromchat.desktop_cut import ru.fromchat.desktop_cut
import ru.fromchat.desktop_menu_actions
import ru.fromchat.desktop_menu_edit import ru.fromchat.desktop_menu_edit
import ru.fromchat.desktop_menu_file
import ru.fromchat.desktop_menu_window import ru.fromchat.desktop_menu_window
import ru.fromchat.desktop_minimize import ru.fromchat.desktop_minimize
import ru.fromchat.desktop_new_chat
import ru.fromchat.desktop_paste import ru.fromchat.desktop_paste
import ru.fromchat.desktop_quit import ru.fromchat.desktop_quit
import ru.fromchat.desktop_search_conversations
import ru.fromchat.desktop_select_all import ru.fromchat.desktop_select_all
import ru.fromchat.desktop_show_app import ru.fromchat.desktop_show_app
import ru.fromchat.desktop_tray_show import ru.fromchat.desktop_tray_show
@@ -217,8 +218,9 @@ fun main(args: Array<String>) {
// (closer to Android Popup behavior). Must be set before any Compose UI runs. // (closer to Android Popup behavior). Must be set before any Compose UI runs.
System.setProperty("compose.layers.type", "WINDOW") System.setProperty("compose.layers.type", "WINDOW")
if (isMacOs()) { if (isMacOs()) {
val appName = runBlocking { getString(Res.string.app_name) } // Must be set before AWT Toolkit init (do not call getString / Compose here).
System.setProperty("apple.awt.application.name", appName) // Value matches Res.string.app_name in values / values-ru.
System.setProperty("apple.awt.application.name", "FromChat")
System.setProperty("apple.laf.useScreenMenuBar", "true") System.setProperty("apple.laf.useScreenMenuBar", "true")
} }
if (!DesktopSingleInstance.acquireOrForward(args)) return if (!DesktopSingleInstance.acquireOrForward(args)) return
@@ -298,10 +300,26 @@ fun main(args: Array<String>) {
visible = windowVisible || !traySupported, visible = windowVisible || !traySupported,
icon = windowIcon, icon = windowIcon,
onPreviewKeyEvent = { event -> onPreviewKeyEvent = { event ->
// macOS: MenuBar KeyShortcuts handle these. Win/Linux: no menu bar.
if (mac || event.type != KeyEventType.KeyDown || !event.isCtrlPressed) { if (mac || event.type != KeyEventType.KeyDown || !event.isCtrlPressed) {
return@Window false return@Window false
} }
when { when {
!event.isShiftPressed && event.key == Key.N -> {
windowVisible = true
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
true
}
!event.isShiftPressed && event.key == Key.F -> {
windowVisible = true
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
true
}
event.isShiftPressed && event.key == Key.S -> {
windowVisible = true
DesktopMenuCommands.emit(DesktopMenuCommand.EnterChatListSelection)
true
}
event.isShiftPressed && event.key == Key.A -> { event.isShiftPressed && event.key == Key.A -> {
aboutOpen = true aboutOpen = true
true true
@@ -322,7 +340,8 @@ fun main(args: Array<String>) {
} }
}, },
) { ) {
LaunchedEffect(window) { LaunchedEffect(window, appName) {
window.title = appName
enableEdgeToEdgeTitleBar(window.rootPane) enableEdgeToEdgeTitleBar(window.rootPane)
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
@@ -332,7 +351,18 @@ fun main(args: Array<String>) {
if (mac) { if (mac) {
FromChatMenuBar( FromChatMenuBar(
onAbout = { aboutOpen = true }, onNewChat = {
windowVisible = true
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
},
onSearchConversations = {
windowVisible = true
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
},
onSelectChats = {
windowVisible = true
DesktopMenuCommands.emit(DesktopMenuCommand.EnterChatListSelection)
},
onShow = { windowVisible = true }, onShow = { windowVisible = true },
onMinimize = { windowState.isMinimized = true }, onMinimize = { windowState.isMinimized = true },
onZoom = { onZoom = {
@@ -343,6 +373,7 @@ fun main(args: Array<String>) {
WindowPlacement.Maximized WindowPlacement.Maximized
} }
}, },
onQuit = { exitApplication() },
) )
} }
@@ -368,30 +399,46 @@ fun main(args: Array<String>) {
@Composable @Composable
private fun FrameWindowScope.FromChatMenuBar( private fun FrameWindowScope.FromChatMenuBar(
onAbout: () -> Unit, onNewChat: () -> Unit,
onSearchConversations: () -> Unit,
onSelectChats: () -> Unit,
onShow: () -> Unit, onShow: () -> Unit,
onMinimize: () -> Unit, onMinimize: () -> Unit,
onZoom: () -> Unit, onZoom: () -> Unit,
onQuit: () -> Unit,
) { ) {
val actions = stringResource(Res.string.desktop_menu_actions) val file = stringResource(Res.string.desktop_menu_file)
val aboutApp = stringResource(Res.string.desktop_about_app) val newChat = stringResource(Res.string.desktop_new_chat)
val showApp = stringResource(Res.string.desktop_show_app) val searchConversations = stringResource(Res.string.desktop_search_conversations)
val quit = stringResource(Res.string.desktop_quit)
val edit = stringResource(Res.string.desktop_menu_edit) val edit = stringResource(Res.string.desktop_menu_edit)
val cut = stringResource(Res.string.desktop_cut) val cut = stringResource(Res.string.desktop_cut)
val copy = stringResource(Res.string.action_copy) val copy = stringResource(Res.string.action_copy)
val paste = stringResource(Res.string.desktop_paste) val paste = stringResource(Res.string.desktop_paste)
val selectAll = stringResource(Res.string.desktop_select_all) val selectAll = stringResource(Res.string.desktop_select_all)
val select = stringResource(Res.string.action_select)
val windowMenu = stringResource(Res.string.desktop_menu_window) val windowMenu = stringResource(Res.string.desktop_menu_window)
val minimize = stringResource(Res.string.desktop_minimize) val minimize = stringResource(Res.string.desktop_minimize)
val zoom = stringResource(Res.string.desktop_zoom) val zoom = stringResource(Res.string.desktop_zoom)
val showApp = stringResource(Res.string.desktop_show_app)
MenuBar { MenuBar {
Menu(actions, mnemonic = 'A') { Menu(file, mnemonic = 'F') {
Item(aboutApp, onClick = onAbout)
Item( Item(
showApp, newChat,
shortcut = KeyShortcut(Key.N, meta = true, shift = true), shortcut = KeyShortcut(Key.N, meta = true),
onClick = onShow, onClick = onNewChat,
)
Item(
searchConversations,
shortcut = KeyShortcut(Key.F, meta = true),
onClick = onSearchConversations,
)
Separator()
Item(
quit,
shortcut = KeyShortcut(Key.Q, meta = true),
onClick = onQuit,
) )
} }
Menu(edit, mnemonic = 'E') { Menu(edit, mnemonic = 'E') {
@@ -416,6 +463,11 @@ private fun FrameWindowScope.FromChatMenuBar(
shortcut = KeyShortcut(Key.A, meta = true), shortcut = KeyShortcut(Key.A, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.selectAllAction) }, onClick = { performAwtEditAction(DefaultEditorKit.selectAllAction) },
) )
Item(
select,
shortcut = KeyShortcut(Key.S, meta = true, shift = true),
onClick = onSelectChats,
)
} }
Menu(windowMenu, mnemonic = 'W') { Menu(windowMenu, mnemonic = 'W') {
Item( Item(
@@ -424,6 +476,12 @@ private fun FrameWindowScope.FromChatMenuBar(
onClick = onMinimize, onClick = onMinimize,
) )
Item(zoom, onClick = onZoom) Item(zoom, onClick = onZoom)
Separator()
Item(
showApp,
shortcut = KeyShortcut(Key.N, meta = true, shift = true),
onClick = onShow,
)
} }
} }
} }
@@ -18,13 +18,15 @@
<string name="auth_legal_notice_prefix">Регистрируясь, вы соглашаетесь с </string> <string name="auth_legal_notice_prefix">Регистрируясь, вы соглашаетесь с </string>
<string name="auth_legal_notice_and"> и </string> <string name="auth_legal_notice_and"> и </string>
<string name="app_desc">100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.</string> <string name="app_desc">100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.</string>
<string name="desktop_menu_actions">Действия</string> <string name="desktop_menu_file">Файл</string>
<string name="desktop_menu_edit">Правка</string> <string name="desktop_menu_edit">Правка</string>
<string name="desktop_menu_window">Окно</string> <string name="desktop_menu_window">Окно</string>
<string name="desktop_about_app">О FromChat</string> <string name="desktop_about_app">О FromChat</string>
<string name="desktop_tray_show">Показать</string> <string name="desktop_tray_show">Показать</string>
<string name="desktop_show_app">Показать FromChat</string> <string name="desktop_show_app">Показать FromChat</string>
<string name="desktop_quit">Выйти</string> <string name="desktop_quit">Выйти</string>
<string name="desktop_new_chat">Новый чат</string>
<string name="desktop_search_conversations">Поиск чатов</string>
<string name="desktop_cut">Вырезать</string> <string name="desktop_cut">Вырезать</string>
<string name="desktop_paste">Вставить</string> <string name="desktop_paste">Вставить</string>
<string name="desktop_select_all">Выделить всё</string> <string name="desktop_select_all">Выделить всё</string>
@@ -23,13 +23,15 @@
<string name="app_desc">100% free and open messenger. Supports self-hosted installation on your own server.</string> <string name="app_desc">100% free and open messenger. Supports self-hosted installation on your own server.</string>
<!-- Desktop menu / tray --> <!-- Desktop menu / tray -->
<string name="desktop_menu_actions">Actions</string> <string name="desktop_menu_file">File</string>
<string name="desktop_menu_edit">Edit</string> <string name="desktop_menu_edit">Edit</string>
<string name="desktop_menu_window">Window</string> <string name="desktop_menu_window">Window</string>
<string name="desktop_about_app">About FromChat</string> <string name="desktop_about_app">About FromChat</string>
<string name="desktop_tray_show">Show</string> <string name="desktop_tray_show">Show</string>
<string name="desktop_show_app">Show FromChat</string> <string name="desktop_show_app">Show FromChat</string>
<string name="desktop_quit">Quit</string> <string name="desktop_quit">Quit</string>
<string name="desktop_new_chat">New Chat</string>
<string name="desktop_search_conversations">Search Conversations</string>
<string name="desktop_cut">Cut</string> <string name="desktop_cut">Cut</string>
<string name="desktop_paste">Paste</string> <string name="desktop_paste">Paste</string>
<string name="desktop_select_all">Select All</string> <string name="desktop_select_all">Select All</string>
@@ -0,0 +1,28 @@
package ru.fromchat.desktop
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
enum class DesktopMenuCommand {
NewChat,
SearchConversations,
EnterChatListSelection,
}
/**
* Desktop menu / hotkey actions → UI (MainScreen, ChatsTab).
* No-ops on mobile when nothing emits.
*/
object DesktopMenuCommands {
private val commandsFlow = MutableSharedFlow<DesktopMenuCommand>(
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val commands: SharedFlow<DesktopMenuCommand> = commandsFlow.asSharedFlow()
fun emit(command: DesktopMenuCommand) {
commandsFlow.tryEmit(command)
}
}
@@ -516,23 +516,26 @@ fun App(
ScreenSurface { ScreenSurface {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
// Own profile from the Profile tab uses the settings detail host. // Own profile (Profile tab / settings hub) always uses the settings
// Profile opened from a chat stays in the chats detail (push/slide). // 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 = val settingsProfileSplit =
showConversationListDetail && showConversationListDetail &&
isStandaloneProfileRoute(currentRoute) && isStandaloneProfileRoute(currentRoute) &&
profileUserId != null && profileUserId != null &&
profileUserId == ownUserId && profileUserId == ownUserId
!isConversationChatRoute(previousRoute)
val listPaneWidth = val listPaneWidth =
if (widthSizeClass == WindowWidthSizeClass.EXPANDED) { if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
448.dp 448.dp
} else { } else {
360.dp 360.dp
} }
// Only nudge the list pager when opening own profile from the // Own profile lives in the settings detail host: keep the Settings
// Profile tab — do not yank the user back when they switch to Chats // list tab active (Profile is only a shortcut button in two-pane).
// while a settings destination is still on the back stack. // 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 val forceSettingsListTab = settingsProfileSplit
var retainedChatsRoute by remember { mutableStateOf<String?>(null) } var retainedChatsRoute by remember { mutableStateOf<String?>(null) }
@@ -886,6 +889,7 @@ fun App(
isConversationProfileRoute(currentRoute) || isConversationProfileRoute(currentRoute) ||
( (
isStandaloneProfileRoute(currentRoute) && isStandaloneProfileRoute(currentRoute) &&
!settingsProfileSplit &&
isConversationChatRoute(previousRoute) isConversationChatRoute(previousRoute)
) -> "nav" ) -> "nav"
retainedChatsRoute != null -> "retained:$retainedChatsRoute" retainedChatsRoute != null -> "retained:$retainedChatsRoute"
@@ -67,7 +67,6 @@ import dev.chrisbanes.haze.blur.HazeBlurStyle
import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.HazeProgressive import dev.chrisbanes.haze.blur.HazeProgressive
import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -393,7 +392,7 @@ fun ChatTopBar(
val layoutHeight = topBarPlaceable.height + arcExtentPx val layoutHeight = topBarPlaceable.height + arcExtentPx
val bgPlaceable = subcompose("background") { val bgPlaceable = subcompose("background") {
val hazeStyle = HazeMaterials.thin() val hazeStyle = rememberChatSurfaceContainerHazeStyle()
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -424,10 +423,10 @@ fun ChatTopBar(
} }
/** /**
* Two-pane chat chrome: separate back pill + title pill. Matches the chat input bottom bar * Two-pane chat chrome: separate back pill + title pill. Matches [ChatInput] composer chrome
* ([HazeMaterials.thin] + [androidx.compose.material3.ColorScheme.surfaceContainer] + progressive * ([rememberChatSurfaceContainerHazeStyle] + [androidx.compose.material3.ColorScheme.surfaceContainer]).
* blur). The haze strip is full-bleed; pills sit in [ConversationDetailContentPadding] so blur * Full-bleed progressive haze strip behind the pills (high at top → fade toward bottom); pills keep
* breathes at the edges (same horizontal inset as [ChatInput]). * their own surfaceContainer + [hazeEffect]. Horizontal inset matches [ChatInput].
*/ */
@Composable @Composable
private fun ChatTopBarPill( private fun ChatTopBarPill(
@@ -441,7 +440,7 @@ private fun ChatTopBarPill(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hazeBlurEnabled: Boolean = true, hazeBlurEnabled: Boolean = true,
) { ) {
val hazeStyle = HazeMaterials.thin() val hazeStyle = rememberChatSurfaceContainerHazeStyle()
val chromeColor = MaterialTheme.colorScheme.surfaceContainer val chromeColor = MaterialTheme.colorScheme.surfaceContainer
val pillShape = RoundedCornerShape(28.dp) val pillShape = RoundedCornerShape(28.dp)
@@ -473,7 +472,13 @@ private fun ChatTopBarPill(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(chromeColor), .background(chromeColor)
.hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = hazeStyle
}
},
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
@@ -486,8 +491,14 @@ private fun ChatTopBarPill(
Row( Row(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.background(chromeColor, pillShape)
.clip(pillShape) .clip(pillShape)
.background(chromeColor) .hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = hazeStyle
}
}
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -29,13 +29,16 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Timer import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -51,8 +54,10 @@ import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.changedToDown import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
@@ -1106,16 +1111,41 @@ fun MessageItem(
!isFilenameOnlyMessageCaption(message) !isFilenameOnlyMessageCaption(message)
) { ) {
if (mouseMessageUi) { if (mouseMessageUi) {
// Default selection colors use primary; on author
// bubbles that matches the background, so
// highlight/handles are invisible.
val selectionHandle =
if (isAuthor) {
contentColor
} else {
MaterialTheme.colorScheme.primary
}
ProvideChatTextSelectionMenu { ProvideChatTextSelectionMenu {
SelectionContainer { CompositionLocalProvider(
Text( LocalTextSelectionColors provides
text = message.content, TextSelectionColors(
style = MaterialTheme.typography.bodyLarge, handleColor = selectionHandle,
color = contentColor, backgroundColor =
modifier = Modifier.padding( selectionHandle.copy(
horizontal = 14.dp, alpha = 0.35f,
),
), ),
) ) {
SelectionContainer(
modifier = Modifier.pointerHoverIcon(
PointerIcon.Text,
overrideDescendants = true,
),
) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
modifier = Modifier.padding(
horizontal = 14.dp,
),
)
}
} }
} }
} else { } else {
@@ -35,6 +35,8 @@ fun DesktopTabDetailHosts(
active = selectedTab == MAIN_PAGE_CHATS, active = selectedTab == MAIN_PAGE_CHATS,
content = chatsDetail, content = chatsDetail,
) )
// Profile bottom-nav is a Settings shortcut in two-pane (pager stays on Settings).
// MAIN_PAGE_PROFILE is retained only as a defensive fallback.
TabDetailHost( TabDetailHost(
active = selectedTab == MAIN_PAGE_SETTINGS || selectedTab == MAIN_PAGE_PROFILE, active = selectedTab == MAIN_PAGE_SETTINGS || selectedTab == MAIN_PAGE_PROFILE,
content = settingsDetail, content = settingsDetail,
@@ -11,12 +11,17 @@ import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
* *
* When [preserveConversationDetail] is true (desktop listdetail), keeps an open chat under * When [preserveConversationDetail] is true (desktop listdetail), keeps an open chat under
* the new settings/profile destination so tab hosts can retain the Chats detail. * 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).
*/ */
fun NavController.navigateReplacingMainDetail( fun NavController.navigateReplacingMainDetail(
route: String, route: String,
preserveConversationDetail: Boolean = false, preserveConversationDetail: Boolean = false,
builder: NavOptionsBuilder.() -> Unit = {}, builder: NavOptionsBuilder.() -> Unit = {},
) { ) {
if (isCurrentMainDetailRoute(route)) return
if (preserveConversationDetail) { if (preserveConversationDetail) {
while (true) { while (true) {
val current = currentBackStackEntry?.destination?.route ?: break val current = currentBackStackEntry?.destination?.route ?: break
@@ -36,6 +41,22 @@ fun NavController.navigateReplacingMainDetail(
} }
} }
/**
* Whether the back stack top already shows [route] as the main-detail destination.
* Handles patterned routes such as `profile/{userId}` vs filled `profile/123`.
*/
fun NavController.isCurrentMainDetailRoute(route: String): Boolean {
val entry = currentBackStackEntry ?: return false
val current = entry.destination.route ?: return false
val target = route.substringBefore("?")
if (target.startsWith("profile/")) {
if (!current.startsWith("profile/") || current.startsWith("profile/edit")) return false
val targetUserId = target.removePrefix("profile/").substringBefore("/")
return entry.savedStateHandle.get<String>("userId") == targetUserId
}
return current == route || current.substringBefore("?") == target
}
private fun isConversationChatRoute(route: String?): Boolean = private fun isConversationChatRoute(route: String?): Boolean =
route == PublicChatNav.CHAT_ROUTE || route == PublicChatNav.CHAT_ROUTE ||
(route != null && route.startsWith("dm/") && "/chat" in route) (route != null && route.startsWith("dm/") && "/chat" in route)
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
@@ -43,6 +44,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.navigation.NavController
import com.pr0gramm3r101.utils.WindowWidthSizeClass import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.widthSizeClass import com.pr0gramm3r101.utils.widthSizeClass
@@ -53,12 +55,15 @@ import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.chats import ru.fromchat.chats
import ru.fromchat.contacts import ru.fromchat.contacts
import ru.fromchat.desktop.DesktopMenuCommand
import ru.fromchat.desktop.DesktopMenuCommands
import ru.fromchat.profile import ru.fromchat.profile
import ru.fromchat.settings import ru.fromchat.settings
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
@@ -72,6 +77,7 @@ import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
import ru.fromchat.ui.main.chats.ChatsTab import ru.fromchat.ui.main.chats.ChatsTab
import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
const val MAIN_PAGE_CHATS = 0 const val MAIN_PAGE_CHATS = 0
const val MAIN_PAGE_CONTACTS = 1 const val MAIN_PAGE_CONTACTS = 1
const val MAIN_PAGE_SETTINGS = 2 const val MAIN_PAGE_SETTINGS = 2
@@ -83,6 +89,64 @@ private const val PAGE_SETTINGS = MAIN_PAGE_SETTINGS
private const val PAGE_PROFILE = MAIN_PAGE_PROFILE private const val PAGE_PROFILE = MAIN_PAGE_PROFILE
private const val PAGE_COUNT = 4 private const val PAGE_COUNT = 4
/**
* Opens the signed-in user's profile in the listdetail pane (large screens).
*
* 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,
) {
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return
navController.navigateReplacingMainDetail(
route = "profile/$userId",
preserveConversationDetail = embeddedInListDetail,
)
}
/**
* Switches the main hub pager page. On large screens, Profile is a button: selects Settings
* in the list and opens own profile in the detail pane (Profile is never the active tab).
*
* Kept file-private (not nested in [MainScreen]) for the same desktop JVM class-loading reason
* as [openOwnProfileInDetailPane] / ChatRowAvatar gesture helpers.
*
* Skips pager animation and detail navigation when the destination is already showing.
*/
private fun selectMainPage(
page: Int,
widthClass: WindowWidthSizeClass,
scope: CoroutineScope,
pagerState: PagerState,
navController: NavController,
embeddedInListDetail: Boolean,
) {
when {
page == PAGE_PROFILE && widthClass != WindowWidthSizeClass.COMPACT -> {
val alreadyOnSettingsList = pagerState.currentPage == PAGE_SETTINGS
val ownUserId = ApiClient.user?.id?.takeIf { it > 0 }
val alreadyShowingOwnProfile =
ownUserId != null &&
navController.isCurrentMainDetailRoute("profile/$ownUserId")
if (alreadyOnSettingsList && alreadyShowingOwnProfile) return
if (!alreadyOnSettingsList) {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
}
if (!alreadyShowingOwnProfile) {
openOwnProfileInDetailPane(navController, embeddedInListDetail)
}
}
else -> {
// Desktop listdetail: do not pop the chat (or settings) stack — each tab
// keeps its own detail host, so switching tabs must not wipe the other tab.
if (pagerState.currentPage == page) return
scope.launch { pagerState.animateScrollToPage(page) }
}
}
}
/** /**
* @param embeddedInListDetail When true, this screen is the left pane of a listdetail layout. * @param embeddedInListDetail When true, this screen is the left pane of a listdetail layout.
* Nav chrome stays inside this pane (bottom bar), never spanning the window. * Nav chrome stays inside this pane (bottom bar), never spanning the window.
@@ -115,6 +179,7 @@ fun MainScreen(
val paneHazeState = LocalPaneHazeState.current val paneHazeState = LocalPaneHazeState.current
val contextMenuHazeState = paneHazeState ?: rememberHazeState() val contextMenuHazeState = paneHazeState ?: rememberHazeState()
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
var chatListSelectionRequestId by remember { mutableStateOf(0L) }
val widthClass = currentWindowAdaptiveInfo().widthSizeClass val widthClass = currentWindowAdaptiveInfo().widthSizeClass
@@ -124,6 +189,34 @@ fun MainScreen(
} }
} }
LaunchedEffect(Unit) {
DesktopMenuCommands.commands.collect { command ->
when (command) {
DesktopMenuCommand.NewChat,
DesktopMenuCommand.SearchConversations,
-> {
if (pagerState.currentPage != PAGE_CHATS) {
pagerState.scrollToPage(PAGE_CHATS)
}
navController.navigate("search/conversations") {
launchSingleTop = true
}
}
DesktopMenuCommand.EnterChatListSelection -> {
if (navController.currentBackStackEntry?.destination?.route ==
"search/conversations"
) {
navController.popBackStack()
}
if (pagerState.currentPage != PAGE_CHATS) {
pagerState.scrollToPage(PAGE_CHATS)
}
chatListSelectionRequestId += 1
}
}
}
}
val selectedPage = pagerState.currentPage val selectedPage = pagerState.currentPage
LaunchedEffect(selectedPage) { LaunchedEffect(selectedPage) {
onPageChanged(selectedPage) onPageChanged(selectedPage)
@@ -153,38 +246,6 @@ fun MainScreen(
val settingsLabel = stringResource(Res.string.settings) val settingsLabel = stringResource(Res.string.settings)
val profileLabel = stringResource(Res.string.profile) val profileLabel = stringResource(Res.string.profile)
fun openOwnProfileInDetailPane() {
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return
navController.navigateReplacingMainDetail(
route = "profile/$userId",
preserveConversationDetail = embeddedInListDetail,
)
}
fun selectMainPage(page: Int) {
when {
page == PAGE_PROFILE && widthClass != WindowWidthSizeClass.COMPACT -> {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
openOwnProfileInDetailPane()
}
else -> {
// Desktop listdetail: do not pop the chat (or settings) stack — each tab
// keeps its own detail host, so switching tabs must not wipe the other tab.
scope.launch { pagerState.animateScrollToPage(page) }
}
}
}
val navSelectedPage =
if (selectedPage == PAGE_SETTINGS &&
widthClass != WindowWidthSizeClass.COMPACT &&
navController.currentBackStackEntry?.destination?.route?.startsWith("profile/") == true
) {
PAGE_PROFILE
} else {
selectedPage
}
BoxWithConstraints( BoxWithConstraints(
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
) { ) {
@@ -227,6 +288,7 @@ fun MainScreen(
chatContextMenuOverlay = chatContextMenuOverlay, chatContextMenuOverlay = chatContextMenuOverlay,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
enterSelectionRequestId = chatListSelectionRequestId,
) )
PAGE_CONTACTS -> ContactsTab() PAGE_CONTACTS -> ContactsTab()
PAGE_SETTINGS -> SettingsTab() PAGE_SETTINGS -> SettingsTab()
@@ -278,8 +340,17 @@ fun MainScreen(
windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom), windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
) { ) {
NavigationBarItem( NavigationBarItem(
selected = navSelectedPage == PAGE_CHATS, selected = selectedPage == PAGE_CHATS,
onClick = { selectMainPage(PAGE_CHATS) }, onClick = {
selectMainPage(
page = PAGE_CHATS,
widthClass = widthClass,
scope = scope,
pagerState = pagerState,
navController = navController,
embeddedInListDetail = embeddedInListDetail,
)
},
label = { Text(chatsLabel) }, label = { Text(chatsLabel) },
icon = { icon = {
Icon( Icon(
@@ -289,24 +360,54 @@ fun MainScreen(
}, },
) )
NavigationBarItem( NavigationBarItem(
selected = navSelectedPage == PAGE_CONTACTS, selected = selectedPage == PAGE_CONTACTS,
onClick = { selectMainPage(PAGE_CONTACTS) }, onClick = {
selectMainPage(
page = PAGE_CONTACTS,
widthClass = widthClass,
scope = scope,
pagerState = pagerState,
navController = navController,
embeddedInListDetail = embeddedInListDetail,
)
},
label = { Text(contactsLabel) }, label = { Text(contactsLabel) },
icon = { icon = {
Icon(Icons.Filled.Contacts, contentDescription = null) Icon(Icons.Filled.Contacts, contentDescription = null)
}, },
) )
NavigationBarItem( NavigationBarItem(
selected = navSelectedPage == PAGE_SETTINGS, selected = selectedPage == PAGE_SETTINGS,
onClick = { selectMainPage(PAGE_SETTINGS) }, onClick = {
selectMainPage(
page = PAGE_SETTINGS,
widthClass = widthClass,
scope = scope,
pagerState = pagerState,
navController = navController,
embeddedInListDetail = embeddedInListDetail,
)
},
label = { Text(settingsLabel) }, label = { Text(settingsLabel) },
icon = { icon = {
Icon(Icons.Filled.Settings, contentDescription = null) Icon(Icons.Filled.Settings, contentDescription = null)
}, },
) )
NavigationBarItem( NavigationBarItem(
selected = navSelectedPage == PAGE_PROFILE, // Two-pane: Profile is a shortcut button — never selected; Settings stays
onClick = { selectMainPage(PAGE_PROFILE) }, // highlighted while own profile is open in the detail pane.
selected = widthClass == WindowWidthSizeClass.COMPACT &&
selectedPage == PAGE_PROFILE,
onClick = {
selectMainPage(
page = PAGE_PROFILE,
widthClass = widthClass,
scope = scope,
pagerState = pagerState,
navController = navController,
embeddedInListDetail = embeddedInListDetail,
)
},
label = { Text(profileLabel) }, label = { Text(profileLabel) },
icon = { icon = {
Icon(Icons.Filled.Person, contentDescription = null) Icon(Icons.Filled.Person, contentDescription = null)
@@ -541,6 +541,7 @@ fun ChatsTab(
chatContextMenuOverlay: ChatContextMenuOverlayController, chatContextMenuOverlay: ChatContextMenuOverlayController,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
enterSelectionRequestId: Long = 0L,
) { ) {
val navController = LocalNavController.current val navController = LocalNavController.current
val clipboard = supportClipboardManagerImpl val clipboard = supportClipboardManagerImpl
@@ -625,6 +626,8 @@ fun ChatsTab(
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var pendingDeleteUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) } var pendingDeleteUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
var hadNonEmptySelection by remember { mutableStateOf(false) }
fun enterSelectionModeFor(target: ChatContextMenuTarget, userId: Int?) { fun enterSelectionModeFor(target: ChatContextMenuTarget, userId: Int?) {
haptic(HapticFeedbackEvent.SelectionModeEntered) haptic(HapticFeedbackEvent.SelectionModeEntered)
listMode = ChatsListMode.Selecting listMode = ChatsListMode.Selecting
@@ -636,11 +639,21 @@ fun ChatsTab(
} }
} }
fun enterSelectionModeEmpty() {
if (listMode == ChatsListMode.Selecting) return
haptic(HapticFeedbackEvent.SelectionModeEntered)
hadNonEmptySelection = false
listMode = ChatsListMode.Selecting
publicChatSelected = false
selectedOtherUserIds = emptySet()
}
fun exitSelectionMode() { fun exitSelectionMode() {
scope.launch { selectionTransitionProgress.snapTo(0f) } scope.launch { selectionTransitionProgress.snapTo(0f) }
listMode = ChatsListMode.Normal listMode = ChatsListMode.Normal
publicChatSelected = false publicChatSelected = false
selectedOtherUserIds = emptySet() selectedOtherUserIds = emptySet()
hadNonEmptySelection = false
contextMenuState = ChatContextMenuState() contextMenuState = ChatContextMenuState()
chatContextMenuOverlay.clear() chatContextMenuOverlay.clear()
} }
@@ -659,6 +672,11 @@ fun ChatsTab(
} }
} }
LaunchedEffect(enterSelectionRequestId, isVisible) {
if (enterSelectionRequestId == 0L || !isVisible) return@LaunchedEffect
enterSelectionModeEmpty()
}
fun refreshDmList() { fun refreshDmList() {
scope.launch { scope.launch {
runCatching { runCatching {
@@ -683,9 +701,13 @@ fun ChatsTab(
} }
} }
LaunchedEffect(publicChatSelected, selectedOtherUserIds) { LaunchedEffect(publicChatSelected, selectedOtherUserIds, listMode) {
if (listMode == ChatsListMode.Selecting && !publicChatSelected && selectedOtherUserIds.isEmpty()) { if (listMode != ChatsListMode.Selecting) return@LaunchedEffect
requestExitSelectionMode() val empty = !publicChatSelected && selectedOtherUserIds.isEmpty()
if (empty) {
if (hadNonEmptySelection) requestExitSelectionMode()
} else {
hadNonEmptySelection = true
} }
} }
@@ -16,8 +16,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 ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -32,6 +30,7 @@ import coil3.compose.AsyncImage
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.ui.Website import com.pr0gramm3r101.ui.Website
import com.pr0gramm3r101.utils.conditional
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.AppBuildInfo import ru.fromchat.AppBuildInfo
@@ -56,16 +55,11 @@ private const val URL_WEBSITE = "https://fromchat.ru"
@Composable @Composable
fun AboutScreen() { fun AboutScreen() {
val useCollapsing = settingsDetailUseCollapsingTopBar() val useCollapsing = settingsDetailUseCollapsingTopBar()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = rememberSettingsCollapsingScrollBehavior()
val navController = LocalNavController.current val navController = LocalNavController.current
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
Scaffold( Scaffold(
modifier = if (useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
topBar = { topBar = {
SettingsDetailTopBar( SettingsDetailTopBar(
title = { title = {
@@ -83,6 +77,9 @@ fun AboutScreen() {
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.conditional(useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
}
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(innerPadding) .padding(innerPadding)
) { ) {
@@ -23,8 +23,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 ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -38,6 +36,7 @@ import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.SwitchListItem import com.pr0gramm3r101.components.SwitchListItem
import com.pr0gramm3r101.utils.conditional
import com.pr0gramm3r101.utils.materialYouAvailable import com.pr0gramm3r101.utils.materialYouAvailable
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -59,7 +58,7 @@ import ru.fromchat.ui.theme
@Composable @Composable
fun AppearanceScreen(onBack: () -> Unit) { fun AppearanceScreen(onBack: () -> Unit) {
val useCollapsing = settingsDetailUseCollapsingTopBar() val useCollapsing = settingsDetailUseCollapsingTopBar()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = rememberSettingsCollapsingScrollBehavior()
var materialYouSwitch by remember { var materialYouSwitch by remember {
mutableStateOf(Settings.materialYou && materialYouAvailable) mutableStateOf(Settings.materialYou && materialYouAvailable)
@@ -68,11 +67,6 @@ fun AppearanceScreen(onBack: () -> Unit) {
var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) } var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
Scaffold( Scaffold(
modifier = if (useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
topBar = { topBar = {
SettingsDetailTopBar( SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_category_appearance)) }, title = { Text(stringResource(Res.string.settings_category_appearance)) },
@@ -84,6 +78,9 @@ fun AppearanceScreen(onBack: () -> Unit) {
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.conditional(useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
}
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(innerPadding) .padding(innerPadding)
) { ) {
@@ -12,8 +12,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -27,6 +25,7 @@ import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.SwitchListItem import com.pr0gramm3r101.components.SwitchListItem
import com.pr0gramm3r101.utils.conditional
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -48,7 +47,7 @@ import ru.fromchat.ui.components.Text
@Composable @Composable
fun NotificationsScreen(onBack: () -> Unit) { fun NotificationsScreen(onBack: () -> Unit) {
val useCollapsing = settingsDetailUseCollapsingTopBar() val useCollapsing = settingsDetailUseCollapsingTopBar()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = rememberSettingsCollapsingScrollBehavior()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var notificationsEnabled by remember { mutableStateOf(false) } var notificationsEnabled by remember { mutableStateOf(false) }
@@ -60,11 +59,6 @@ fun NotificationsScreen(onBack: () -> Unit) {
Scaffold( Scaffold(
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
modifier = if (useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
topBar = { topBar = {
SettingsDetailTopBar( SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_notifications_title)) }, title = { Text(stringResource(Res.string.settings_notifications_title)) },
@@ -76,6 +70,9 @@ fun NotificationsScreen(onBack: () -> Unit) {
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.conditional(useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
}
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(innerPadding) .padding(innerPadding)
) { ) {
@@ -1,5 +1,7 @@
package ru.fromchat.ui.main.settings package ru.fromchat.ui.main.settings
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.animate
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -7,16 +9,34 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.TopAppBarState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
import com.pr0gramm3r101.utils.WindowWidthSizeClass import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.widthSizeClass import com.pr0gramm3r101.utils.widthSizeClass
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
/** Idle gap after mouse-wheel / trackpad deltas before snapping the collapsing bar. */
private const val AppBarWheelSettleDelayMs = 64L
/** /**
* Back is shown on compact (full-screen stack), and on large screens only when the * Back is shown on compact (full-screen stack), and on large screens only when the
* previous destination is not the listdetail shell (`chat`) i.e. when nested * previous destination is not the listdetail shell (`chat`) i.e. when nested
@@ -35,6 +55,25 @@ fun settingsDetailShowBackButton(): Boolean {
fun settingsDetailUseCollapsingTopBar(): Boolean = fun settingsDetailUseCollapsingTopBar(): Boolean =
currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT
/**
* [TopAppBarDefaults.exitUntilCollapsedScrollBehavior] plus a nested-scroll wrapper that
* snaps `heightOffset` to expanded (0) or collapsed (`heightOffsetLimit`) after UserInput
* scroll settles.
*
* Mouse wheel / trackpad on desktop often never fires a fling, so Material's built-in snap
* (in `onPostFling`) never runs and the bar freezes mid-collapse. Debounced settle after
* scroll deltas fixes that without forking Material3.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun rememberSettingsCollapsingScrollBehavior(): TopAppBarScrollBehavior {
val behavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val scope = rememberCoroutineScope()
return remember(behavior, scope) {
SnappingExitUntilCollapsedScrollBehavior(behavior, scope)
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsDetailTopBar( fun SettingsDetailTopBar(
@@ -57,6 +96,10 @@ fun SettingsDetailTopBar(
title = title, title = title,
navigationIcon = navigationIcon, navigationIcon = navigationIcon,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
// Transparent while expanded; default scrolledContainerColor when collapsed.
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
) )
} else { } else {
TopAppBar( TopAppBar(
@@ -65,3 +108,109 @@ fun SettingsDetailTopBar(
) )
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
private class SnappingExitUntilCollapsedScrollBehavior(
private val behavior: TopAppBarScrollBehavior,
private val scope: CoroutineScope,
) : TopAppBarScrollBehavior by behavior {
private var settleJob: Job? = null
private var gestureStartOffset = 0f
private var trackingGesture = false
override val nestedScrollConnection = object : NestedScrollConnection {
private val inner = behavior.nestedScrollConnection
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.UserInput) {
onUserScrollDelta()
}
return inner.onPreScroll(available, source)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
val result = inner.onPostScroll(consumed, available, source)
if (source == NestedScrollSource.UserInput) {
scheduleSettleIfNeeded()
}
return result
}
override suspend fun onPreFling(available: Velocity): Velocity {
cancelSettle()
trackingGesture = false
return inner.onPreFling(available)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
cancelSettle()
trackingGesture = false
return inner.onPostFling(consumed, available)
}
private fun onUserScrollDelta() {
cancelSettle()
if (!trackingGesture) {
gestureStartOffset = state.heightOffset
trackingGesture = true
}
}
private fun scheduleSettleIfNeeded() {
val offset = state.heightOffset
val limit = state.heightOffsetLimit
if (limit >= -0.5f || offset >= -0.5f || offset <= limit + 0.5f) {
trackingGesture = false
return
}
val snapSpec = snapAnimationSpec ?: return
cancelSettle()
settleJob = scope.launch {
delay(AppBarWheelSettleDelayMs)
snapAppBarHeightOffset(
state = state,
gestureStartOffset = gestureStartOffset,
snapAnimationSpec = snapSpec,
)
trackingGesture = false
}
}
private fun cancelSettle() {
settleJob?.cancel()
settleJob = null
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
private suspend fun snapAppBarHeightOffset(
state: TopAppBarState,
gestureStartOffset: Float,
snapAnimationSpec: AnimationSpec<Float>,
) {
val limit = state.heightOffsetLimit
val offset = state.heightOffset
if (limit >= -0.5f || offset >= -0.5f || offset <= limit + 0.5f) return
val movedTowardCollapse = offset < gestureStartOffset - 0.5f
val movedTowardExpand = offset > gestureStartOffset + 0.5f
val target = when {
movedTowardCollapse -> limit
movedTowardExpand -> 0f
else -> if (state.collapsedFraction < 0.5f) 0f else limit
}
if (abs(offset - target) < 0.5f) return
animate(
initialValue = offset,
targetValue = target,
animationSpec = snapAnimationSpec,
) { value, _ ->
state.heightOffset = value
}
}
@@ -16,15 +16,13 @@ import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
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
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
@@ -64,7 +62,6 @@ val SettingsStepHorizontalPadding = 24.dp
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun SettingsTab() { fun SettingsTab() {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current val navController = LocalNavController.current
val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
@@ -76,11 +73,11 @@ fun SettingsTab() {
} }
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.fillMaxSize(),
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = { topBar = {
MediumTopAppBar( TopAppBar(
windowInsets = if (LocalMainChromeInsets.current.top > 0.dp) { windowInsets = if (LocalMainChromeInsets.current.top > 0.dp) {
WindowInsets.extraStatusBars WindowInsets.extraStatusBars
} else { } else {
@@ -91,9 +88,7 @@ fun SettingsTab() {
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
), ),
scrollBehavior = scrollBehavior
) )
} }
) { innerPadding -> ) { innerPadding ->
@@ -19,8 +19,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.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -33,6 +31,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.utils.conditional
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource import org.jetbrains.compose.resources.vectorResource
@@ -52,6 +51,7 @@ import ru.fromchat.settings_change_password
import ru.fromchat.settings_security_change_password_sub import ru.fromchat.settings_security_change_password_sub
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.main.settings.SettingsDetailTopBar import ru.fromchat.ui.main.settings.SettingsDetailTopBar
import ru.fromchat.ui.main.settings.rememberSettingsCollapsingScrollBehavior
import ru.fromchat.ui.main.settings.settingsDetailUseCollapsingTopBar import ru.fromchat.ui.main.settings.settingsDetailUseCollapsingTopBar
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -64,7 +64,7 @@ fun AccountScreen(
onDeleteAccount: () -> Unit, onDeleteAccount: () -> Unit,
) { ) {
val useCollapsing = settingsDetailUseCollapsingTopBar() val useCollapsing = settingsDetailUseCollapsingTopBar()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = rememberSettingsCollapsingScrollBehavior()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showLogoutConfirm by remember { mutableStateOf(false) } var showLogoutConfirm by remember { mutableStateOf(false) }
var yandexAvailable by remember { mutableStateOf(false) } var yandexAvailable by remember { mutableStateOf(false) }
@@ -75,11 +75,6 @@ fun AccountScreen(
} }
Scaffold( Scaffold(
modifier = if (useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
topBar = { topBar = {
SettingsDetailTopBar( SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_account_title)) }, title = { Text(stringResource(Res.string.settings_account_title)) },
@@ -91,6 +86,9 @@ fun AccountScreen(
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.conditional(useCollapsing) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
}
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(innerPadding) .padding(innerPadding)
) { ) {