From 16a14da642e79ad2a35a93d890a3860f9bf00eee Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 26 Jul 2026 22:17:41 +0300 Subject: [PATCH] More UI improvements --- .../main/kotlin/ru/fromchat/desktop/Main.kt | 90 +++++++-- .../composeResources/values-ru/strings.xml | 4 +- .../composeResources/values/strings.xml | 4 +- .../fromchat/desktop/DesktopMenuCommands.kt | 28 +++ .../commonMain/kotlin/ru/fromchat/ui/App.kt | 18 +- .../kotlin/ru/fromchat/ui/chat/ChatTopBar.kt | 29 ++- .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 46 ++++- .../fromchat/ui/main/DesktopTabDetailHosts.kt | 2 + .../ru/fromchat/ui/main/MainDetailNav.kt | 21 ++ .../kotlin/ru/fromchat/ui/main/MainScreen.kt | 181 ++++++++++++++---- .../ru/fromchat/ui/main/chats/ChatsTab.kt | 28 ++- .../fromchat/ui/main/settings/AboutScreen.kt | 13 +- .../ui/main/settings/AppearanceScreen.kt | 13 +- .../ui/main/settings/NotificationsScreen.kt | 13 +- .../ui/main/settings/SettingsDetailTopBar.kt | 149 ++++++++++++++ .../fromchat/ui/main/settings/SettingsTab.kt | 11 +- .../ui/main/settings/account/AccountScreen.kt | 14 +- 17 files changed, 539 insertions(+), 125 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/desktop/DesktopMenuCommands.kt diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt index 8e8daca..ca2286c 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -53,25 +53,26 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.jetbrains.skia.Image import ru.fromchat.AppForeground import ru.fromchat.Logger import ru.fromchat.Res import ru.fromchat.action_copy +import ru.fromchat.action_select import ru.fromchat.api.ApiClient import ru.fromchat.api.local.workers.AttachmentTransferBootstrap import ru.fromchat.app_name import ru.fromchat.desktop_about_app import ru.fromchat.desktop_cut -import ru.fromchat.desktop_menu_actions import ru.fromchat.desktop_menu_edit +import ru.fromchat.desktop_menu_file import ru.fromchat.desktop_menu_window import ru.fromchat.desktop_minimize +import ru.fromchat.desktop_new_chat import ru.fromchat.desktop_paste import ru.fromchat.desktop_quit +import ru.fromchat.desktop_search_conversations import ru.fromchat.desktop_select_all import ru.fromchat.desktop_show_app import ru.fromchat.desktop_tray_show @@ -217,8 +218,9 @@ fun main(args: Array) { // (closer to Android Popup behavior). Must be set before any Compose UI runs. System.setProperty("compose.layers.type", "WINDOW") if (isMacOs()) { - val appName = runBlocking { getString(Res.string.app_name) } - System.setProperty("apple.awt.application.name", appName) + // Must be set before AWT Toolkit init (do not call getString / Compose here). + // Value matches Res.string.app_name in values / values-ru. + System.setProperty("apple.awt.application.name", "FromChat") System.setProperty("apple.laf.useScreenMenuBar", "true") } if (!DesktopSingleInstance.acquireOrForward(args)) return @@ -298,10 +300,26 @@ fun main(args: Array) { visible = windowVisible || !traySupported, icon = windowIcon, onPreviewKeyEvent = { event -> + // macOS: MenuBar KeyShortcuts handle these. Win/Linux: no menu bar. if (mac || event.type != KeyEventType.KeyDown || !event.isCtrlPressed) { return@Window false } 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 -> { aboutOpen = true true @@ -322,7 +340,8 @@ fun main(args: Array) { } }, ) { - LaunchedEffect(window) { + LaunchedEffect(window, appName) { + window.title = appName enableEdgeToEdgeTitleBar(window.rootPane) } DisposableEffect(Unit) { @@ -332,7 +351,18 @@ fun main(args: Array) { if (mac) { 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 }, onMinimize = { windowState.isMinimized = true }, onZoom = { @@ -343,6 +373,7 @@ fun main(args: Array) { WindowPlacement.Maximized } }, + onQuit = { exitApplication() }, ) } @@ -368,30 +399,46 @@ fun main(args: Array) { @Composable private fun FrameWindowScope.FromChatMenuBar( - onAbout: () -> Unit, + onNewChat: () -> Unit, + onSearchConversations: () -> Unit, + onSelectChats: () -> Unit, onShow: () -> Unit, onMinimize: () -> Unit, onZoom: () -> Unit, + onQuit: () -> Unit, ) { - val actions = stringResource(Res.string.desktop_menu_actions) - val aboutApp = stringResource(Res.string.desktop_about_app) - val showApp = stringResource(Res.string.desktop_show_app) + val file = stringResource(Res.string.desktop_menu_file) + val newChat = stringResource(Res.string.desktop_new_chat) + val searchConversations = stringResource(Res.string.desktop_search_conversations) + val quit = stringResource(Res.string.desktop_quit) val edit = stringResource(Res.string.desktop_menu_edit) val cut = stringResource(Res.string.desktop_cut) val copy = stringResource(Res.string.action_copy) val paste = stringResource(Res.string.desktop_paste) val selectAll = stringResource(Res.string.desktop_select_all) + val select = stringResource(Res.string.action_select) val windowMenu = stringResource(Res.string.desktop_menu_window) val minimize = stringResource(Res.string.desktop_minimize) val zoom = stringResource(Res.string.desktop_zoom) + val showApp = stringResource(Res.string.desktop_show_app) MenuBar { - Menu(actions, mnemonic = 'A') { - Item(aboutApp, onClick = onAbout) + Menu(file, mnemonic = 'F') { Item( - showApp, - shortcut = KeyShortcut(Key.N, meta = true, shift = true), - onClick = onShow, + newChat, + shortcut = KeyShortcut(Key.N, meta = true), + 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') { @@ -416,6 +463,11 @@ private fun FrameWindowScope.FromChatMenuBar( shortcut = KeyShortcut(Key.A, meta = true), onClick = { performAwtEditAction(DefaultEditorKit.selectAllAction) }, ) + Item( + select, + shortcut = KeyShortcut(Key.S, meta = true, shift = true), + onClick = onSelectChats, + ) } Menu(windowMenu, mnemonic = 'W') { Item( @@ -424,6 +476,12 @@ private fun FrameWindowScope.FromChatMenuBar( onClick = onMinimize, ) Item(zoom, onClick = onZoom) + Separator() + Item( + showApp, + shortcut = KeyShortcut(Key.N, meta = true, shift = true), + onClick = onShow, + ) } } } diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index d678bee..1d96884 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -18,13 +18,15 @@ Регистрируясь, вы соглашаетесь с и 100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере. - Действия + Файл Правка Окно О FromChat Показать Показать FromChat Выйти + Новый чат + Поиск чатов Вырезать Вставить Выделить всё diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 2a5af2c..cf2ef32 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -23,13 +23,15 @@ 100% free and open messenger. Supports self-hosted installation on your own server. - Actions + File Edit Window About FromChat Show Show FromChat Quit + New Chat + Search Conversations Cut Paste Select All diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/desktop/DesktopMenuCommands.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/desktop/DesktopMenuCommands.kt new file mode 100644 index 0000000..e89464e --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/desktop/DesktopMenuCommands.kt @@ -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( + extraBufferCapacity = 8, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val commands: SharedFlow = commandsFlow.asSharedFlow() + + fun emit(command: DesktopMenuCommand) { + commandsFlow.tryEmit(command) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index d8d4588..f7f6e35 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -516,23 +516,26 @@ fun App( ScreenSurface { Box(Modifier.fillMaxSize()) { - // Own profile from the Profile tab uses the settings detail host. - // Profile opened from a chat stays in the chats detail (push/slide). + // Own profile (Profile tab / settings hub) always uses the settings + // detail host — even when a chat remains under the back stack via + // preserveConversationDetail. Other users' profiles opened from a + // conversation stay in the chats detail (see chatsDetailKey). val settingsProfileSplit = showConversationListDetail && isStandaloneProfileRoute(currentRoute) && profileUserId != null && - profileUserId == ownUserId && - !isConversationChatRoute(previousRoute) + profileUserId == ownUserId val listPaneWidth = if (widthSizeClass == WindowWidthSizeClass.EXPANDED) { 448.dp } else { 360.dp } - // Only nudge the list pager when opening own profile from the - // Profile tab — do not yank the user back when they switch to Chats - // while a settings destination is still on the back stack. + // Own profile lives in the settings detail host: keep the Settings + // list tab active (Profile is only a shortcut button in two-pane). + // forceSettingsTab only scrolls when true — clearing it does not + // snap back, so switching to Chats while profile stays on the stack + // is fine. val forceSettingsListTab = settingsProfileSplit var retainedChatsRoute by remember { mutableStateOf(null) } @@ -886,6 +889,7 @@ fun App( isConversationProfileRoute(currentRoute) || ( isStandaloneProfileRoute(currentRoute) && + !settingsProfileSplit && isConversationChatRoute(previousRoute) ) -> "nav" retainedChatsRoute != null -> "retained:$retainedChatsRoute" diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt index cc19090..2b1a682 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt @@ -67,7 +67,6 @@ import dev.chrisbanes.haze.blur.HazeBlurStyle import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.HazeProgressive import dev.chrisbanes.haze.blur.blurEffect -import dev.chrisbanes.haze.blur.materials.HazeMaterials import dev.chrisbanes.haze.hazeEffect import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res @@ -393,7 +392,7 @@ fun ChatTopBar( val layoutHeight = topBarPlaceable.height + arcExtentPx val bgPlaceable = subcompose("background") { - val hazeStyle = HazeMaterials.thin() + val hazeStyle = rememberChatSurfaceContainerHazeStyle() Box( modifier = Modifier .fillMaxSize() @@ -424,10 +423,10 @@ fun ChatTopBar( } /** - * Two-pane chat chrome: separate back pill + title pill. Matches the chat input bottom bar - * ([HazeMaterials.thin] + [androidx.compose.material3.ColorScheme.surfaceContainer] + progressive - * blur). The haze strip is full-bleed; pills sit in [ConversationDetailContentPadding] so blur - * breathes at the edges (same horizontal inset as [ChatInput]). + * Two-pane chat chrome: separate back pill + title pill. Matches [ChatInput] composer chrome + * ([rememberChatSurfaceContainerHazeStyle] + [androidx.compose.material3.ColorScheme.surfaceContainer]). + * Full-bleed progressive haze strip behind the pills (high at top → fade toward bottom); pills keep + * their own surfaceContainer + [hazeEffect]. Horizontal inset matches [ChatInput]. */ @Composable private fun ChatTopBarPill( @@ -441,7 +440,7 @@ private fun ChatTopBarPill( modifier: Modifier = Modifier, hazeBlurEnabled: Boolean = true, ) { - val hazeStyle = HazeMaterials.thin() + val hazeStyle = rememberChatSurfaceContainerHazeStyle() val chromeColor = MaterialTheme.colorScheme.surfaceContainer val pillShape = RoundedCornerShape(28.dp) @@ -473,7 +472,13 @@ private fun ChatTopBarPill( modifier = Modifier .size(48.dp) .clip(CircleShape) - .background(chromeColor), + .background(chromeColor) + .hazeEffect(state = hazeState) { + blurEffect { + blurEnabled = hazeBlurEnabled + style = hazeStyle + } + }, contentAlignment = Alignment.Center, ) { IconButton(onClick = onBack) { @@ -486,8 +491,14 @@ private fun ChatTopBarPill( Row( modifier = Modifier .weight(1f) + .background(chromeColor, 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), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 676e195..6c49c62 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -29,13 +29,16 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth 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.TextSelectionColors import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Timer import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.input.pointer.AwaitPointerEventScope 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.isSecondaryPressed +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.layout @@ -1106,16 +1111,41 @@ fun MessageItem( !isFilenameOnlyMessageCaption(message) ) { 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 { - SelectionContainer { - Text( - text = message.content, - style = MaterialTheme.typography.bodyLarge, - color = contentColor, - modifier = Modifier.padding( - horizontal = 14.dp, + CompositionLocalProvider( + LocalTextSelectionColors provides + TextSelectionColors( + handleColor = selectionHandle, + backgroundColor = + selectionHandle.copy( + 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 { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt index 4118a02..6c354bb 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt @@ -35,6 +35,8 @@ fun DesktopTabDetailHosts( active = selectedTab == MAIN_PAGE_CHATS, 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( active = selectedTab == MAIN_PAGE_SETTINGS || selectedTab == MAIN_PAGE_PROFILE, content = settingsDetail, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailNav.kt index 3487521..2a017b9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailNav.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailNav.kt @@ -11,12 +11,17 @@ import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav * * When [preserveConversationDetail] is true (desktop list–detail), keeps an open chat under * the new settings/profile destination so tab hosts can retain the Chats detail. + * + * No-ops when [route] is already the current main-detail destination (avoids pop+push + * churn and AnimatedContent / NavHost transitions for the same screen). */ fun NavController.navigateReplacingMainDetail( route: String, preserveConversationDetail: Boolean = false, builder: NavOptionsBuilder.() -> Unit = {}, ) { + if (isCurrentMainDetailRoute(route)) return + if (preserveConversationDetail) { while (true) { 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("userId") == targetUserId + } + return current == route || current.substringBefore("?") == target +} + private fun isConversationChatRoute(route: String?): Boolean = route == PublicChatNav.CHAT_ROUTE || (route != null && route.startsWith("dm/") && "/chat" in route) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index 9e01191..2b11b26 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons 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.unit.dp import androidx.compose.ui.zIndex +import androidx.navigation.NavController import com.pr0gramm3r101.utils.WindowWidthSizeClass import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo import com.pr0gramm3r101.utils.widthSizeClass @@ -53,12 +55,15 @@ import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ApiClient import ru.fromchat.chats import ru.fromchat.contacts +import ru.fromchat.desktop.DesktopMenuCommand +import ru.fromchat.desktop.DesktopMenuCommands import ru.fromchat.profile import ru.fromchat.settings 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.settings.SettingsTab import ru.fromchat.ui.profile.ProfileScreen + const val MAIN_PAGE_CHATS = 0 const val MAIN_PAGE_CONTACTS = 1 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_COUNT = 4 +/** + * Opens the signed-in user's profile in the list–detail 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 list–detail: 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 list–detail layout. * Nav chrome stays inside this pane (bottom bar), never spanning the window. @@ -115,6 +179,7 @@ fun MainScreen( val paneHazeState = LocalPaneHazeState.current val contextMenuHazeState = paneHazeState ?: rememberHazeState() val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } + var chatListSelectionRequestId by remember { mutableStateOf(0L) } 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 LaunchedEffect(selectedPage) { onPageChanged(selectedPage) @@ -153,38 +246,6 @@ fun MainScreen( val settingsLabel = stringResource(Res.string.settings) 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 list–detail: 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( Modifier.fillMaxSize(), ) { @@ -227,6 +288,7 @@ fun MainScreen( chatContextMenuOverlay = chatContextMenuOverlay, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, + enterSelectionRequestId = chatListSelectionRequestId, ) PAGE_CONTACTS -> ContactsTab() PAGE_SETTINGS -> SettingsTab() @@ -278,8 +340,17 @@ fun MainScreen( windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom), ) { NavigationBarItem( - selected = navSelectedPage == PAGE_CHATS, - onClick = { selectMainPage(PAGE_CHATS) }, + selected = selectedPage == PAGE_CHATS, + onClick = { + selectMainPage( + page = PAGE_CHATS, + widthClass = widthClass, + scope = scope, + pagerState = pagerState, + navController = navController, + embeddedInListDetail = embeddedInListDetail, + ) + }, label = { Text(chatsLabel) }, icon = { Icon( @@ -289,24 +360,54 @@ fun MainScreen( }, ) NavigationBarItem( - selected = navSelectedPage == PAGE_CONTACTS, - onClick = { selectMainPage(PAGE_CONTACTS) }, + selected = selectedPage == PAGE_CONTACTS, + onClick = { + selectMainPage( + page = PAGE_CONTACTS, + widthClass = widthClass, + scope = scope, + pagerState = pagerState, + navController = navController, + embeddedInListDetail = embeddedInListDetail, + ) + }, label = { Text(contactsLabel) }, icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }, ) NavigationBarItem( - selected = navSelectedPage == PAGE_SETTINGS, - onClick = { selectMainPage(PAGE_SETTINGS) }, + selected = selectedPage == PAGE_SETTINGS, + onClick = { + selectMainPage( + page = PAGE_SETTINGS, + widthClass = widthClass, + scope = scope, + pagerState = pagerState, + navController = navController, + embeddedInListDetail = embeddedInListDetail, + ) + }, label = { Text(settingsLabel) }, icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, ) NavigationBarItem( - selected = navSelectedPage == PAGE_PROFILE, - onClick = { selectMainPage(PAGE_PROFILE) }, + // Two-pane: Profile is a shortcut button — never selected; Settings stays + // 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) }, icon = { Icon(Icons.Filled.Person, contentDescription = null) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt index 468141d..d55526b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt @@ -541,6 +541,7 @@ fun ChatsTab( chatContextMenuOverlay: ChatContextMenuOverlayController, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + enterSelectionRequestId: Long = 0L, ) { val navController = LocalNavController.current val clipboard = supportClipboardManagerImpl @@ -625,6 +626,8 @@ fun ChatsTab( var showDeleteConfirm by remember { mutableStateOf(false) } var pendingDeleteUserIds by remember { mutableStateOf>(emptySet()) } + var hadNonEmptySelection by remember { mutableStateOf(false) } + fun enterSelectionModeFor(target: ChatContextMenuTarget, userId: Int?) { haptic(HapticFeedbackEvent.SelectionModeEntered) 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() { scope.launch { selectionTransitionProgress.snapTo(0f) } listMode = ChatsListMode.Normal publicChatSelected = false selectedOtherUserIds = emptySet() + hadNonEmptySelection = false contextMenuState = ChatContextMenuState() chatContextMenuOverlay.clear() } @@ -659,6 +672,11 @@ fun ChatsTab( } } + LaunchedEffect(enterSelectionRequestId, isVisible) { + if (enterSelectionRequestId == 0L || !isVisible) return@LaunchedEffect + enterSelectionModeEmpty() + } + fun refreshDmList() { scope.launch { runCatching { @@ -683,9 +701,13 @@ fun ChatsTab( } } - LaunchedEffect(publicChatSelected, selectedOtherUserIds) { - if (listMode == ChatsListMode.Selecting && !publicChatSelected && selectedOtherUserIds.isEmpty()) { - requestExitSelectionMode() + LaunchedEffect(publicChatSelected, selectedOtherUserIds, listMode) { + if (listMode != ChatsListMode.Selecting) return@LaunchedEffect + val empty = !publicChatSelected && selectedOtherUserIds.isEmpty() + if (empty) { + if (hadNonEmptySelection) requestExitSelectionMode() + } else { + hadNonEmptySelection = true } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt index 58ef0c3..3212c7f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt @@ -16,8 +16,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import ru.fromchat.ui.components.Text -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,6 +30,7 @@ import coil3.compose.AsyncImage import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.ui.Website +import com.pr0gramm3r101.utils.conditional import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource import ru.fromchat.AppBuildInfo @@ -56,16 +55,11 @@ private const val URL_WEBSITE = "https://fromchat.ru" @Composable fun AboutScreen() { val useCollapsing = settingsDetailUseCollapsingTopBar() - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val scrollBehavior = rememberSettingsCollapsingScrollBehavior() val navController = LocalNavController.current val uriHandler = LocalUriHandler.current Scaffold( - modifier = if (useCollapsing) { - Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - } else { - Modifier - }, topBar = { SettingsDetailTopBar( title = { @@ -83,6 +77,9 @@ fun AboutScreen() { Column( Modifier .fillMaxWidth() + .conditional(useCollapsing) { + Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + } .verticalScroll(rememberScrollState()) .padding(innerPadding) ) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt index 6938f0b..6579bb3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt @@ -23,8 +23,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold 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.getValue import androidx.compose.runtime.mutableIntStateOf @@ -38,6 +36,7 @@ import androidx.compose.ui.unit.dp import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.SwitchListItem +import com.pr0gramm3r101.utils.conditional import com.pr0gramm3r101.utils.materialYouAvailable import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res @@ -59,7 +58,7 @@ import ru.fromchat.ui.theme @Composable fun AppearanceScreen(onBack: () -> Unit) { val useCollapsing = settingsDetailUseCollapsingTopBar() - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val scrollBehavior = rememberSettingsCollapsingScrollBehavior() var materialYouSwitch by remember { mutableStateOf(Settings.materialYou && materialYouAvailable) @@ -68,11 +67,6 @@ fun AppearanceScreen(onBack: () -> Unit) { var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) } Scaffold( - modifier = if (useCollapsing) { - Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - } else { - Modifier - }, topBar = { SettingsDetailTopBar( title = { Text(stringResource(Res.string.settings_category_appearance)) }, @@ -84,6 +78,9 @@ fun AppearanceScreen(onBack: () -> Unit) { Column( Modifier .fillMaxWidth() + .conditional(useCollapsing) { + Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + } .verticalScroll(rememberScrollState()) .padding(innerPadding) ) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt index 5e0ee71..03198ac 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt @@ -12,8 +12,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -27,6 +25,7 @@ import androidx.compose.ui.unit.dp import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.SwitchListItem +import com.pr0gramm3r101.utils.conditional import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res @@ -48,7 +47,7 @@ import ru.fromchat.ui.components.Text @Composable fun NotificationsScreen(onBack: () -> Unit) { val useCollapsing = settingsDetailUseCollapsingTopBar() - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val scrollBehavior = rememberSettingsCollapsingScrollBehavior() val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } var notificationsEnabled by remember { mutableStateOf(false) } @@ -60,11 +59,6 @@ fun NotificationsScreen(onBack: () -> Unit) { Scaffold( snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, - modifier = if (useCollapsing) { - Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - } else { - Modifier - }, topBar = { SettingsDetailTopBar( title = { Text(stringResource(Res.string.settings_notifications_title)) }, @@ -76,6 +70,9 @@ fun NotificationsScreen(onBack: () -> Unit) { Column( Modifier .fillMaxWidth() + .conditional(useCollapsing) { + Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + } .verticalScroll(rememberScrollState()) .padding(innerPadding) ) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsDetailTopBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsDetailTopBar.kt index 12adb21..0af9758 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsDetailTopBar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsDetailTopBar.kt @@ -1,5 +1,7 @@ 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.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api @@ -7,16 +9,34 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.TopAppBarState +import androidx.compose.material3.rememberTopAppBarState 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.currentWindowAdaptiveInfo 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 ru.fromchat.Res import ru.fromchat.back 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 * previous destination is not the list–detail shell (`chat`) — i.e. when nested @@ -35,6 +55,25 @@ fun settingsDetailShowBackButton(): Boolean { fun settingsDetailUseCollapsingTopBar(): Boolean = 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) @Composable fun SettingsDetailTopBar( @@ -57,6 +96,10 @@ fun SettingsDetailTopBar( title = title, navigationIcon = navigationIcon, scrollBehavior = scrollBehavior, + // Transparent while expanded; default scrolledContainerColor when collapsed. + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), ) } else { 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, +) { + 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 + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt index a52c823..7a2a7e6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt @@ -16,15 +16,13 @@ import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Storage import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold import ru.fromchat.ui.components.Text +import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.pr0gramm3r101.components.Category @@ -64,7 +62,6 @@ val SettingsStepHorizontalPadding = 24.dp @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun SettingsTab() { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val navController = LocalNavController.current val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT @@ -76,11 +73,11 @@ fun SettingsTab() { } Scaffold( - modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), + modifier = Modifier.fillMaxSize(), containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { - MediumTopAppBar( + TopAppBar( windowInsets = if (LocalMainChromeInsets.current.top > 0.dp) { WindowInsets.extraStatusBars } else { @@ -91,9 +88,7 @@ fun SettingsTab() { }, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, - scrolledContainerColor = Color.Transparent, ), - scrollBehavior = scrollBehavior ) } ) { innerPadding -> diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt index cfdb1a6..880e737 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt @@ -19,8 +19,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -33,6 +31,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.ListItem +import com.pr0gramm3r101.utils.conditional import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource 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.ui.components.Text import ru.fromchat.ui.main.settings.SettingsDetailTopBar +import ru.fromchat.ui.main.settings.rememberSettingsCollapsingScrollBehavior import ru.fromchat.ui.main.settings.settingsDetailUseCollapsingTopBar @OptIn(ExperimentalMaterial3Api::class) @@ -64,7 +64,7 @@ fun AccountScreen( onDeleteAccount: () -> Unit, ) { val useCollapsing = settingsDetailUseCollapsingTopBar() - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val scrollBehavior = rememberSettingsCollapsingScrollBehavior() val scope = rememberCoroutineScope() var showLogoutConfirm by remember { mutableStateOf(false) } var yandexAvailable by remember { mutableStateOf(false) } @@ -75,11 +75,6 @@ fun AccountScreen( } Scaffold( - modifier = if (useCollapsing) { - Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - } else { - Modifier - }, topBar = { SettingsDetailTopBar( title = { Text(stringResource(Res.string.settings_account_title)) }, @@ -91,6 +86,9 @@ fun AccountScreen( Column( Modifier .fillMaxWidth() + .conditional(useCollapsing) { + Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + } .verticalScroll(rememberScrollState()) .padding(innerPadding) ) {