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.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<String>) {
// (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<String>) {
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<String>) {
}
},
) {
LaunchedEffect(window) {
LaunchedEffect(window, appName) {
window.title = appName
enableEdgeToEdgeTitleBar(window.rootPane)
}
DisposableEffect(Unit) {
@@ -332,7 +351,18 @@ fun main(args: Array<String>) {
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<String>) {
WindowPlacement.Maximized
}
},
onQuit = { exitApplication() },
)
}
@@ -368,30 +399,46 @@ fun main(args: Array<String>) {
@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,
)
}
}
}
@@ -18,13 +18,15 @@
<string name="auth_legal_notice_prefix">Регистрируясь, вы соглашаетесь с </string>
<string name="auth_legal_notice_and"> и </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_window">Окно</string>
<string name="desktop_about_app">О FromChat</string>
<string name="desktop_tray_show">Показать</string>
<string name="desktop_show_app">Показать FromChat</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_paste">Вставить</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>
<!-- 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_window">Window</string>
<string name="desktop_about_app">About FromChat</string>
<string name="desktop_tray_show">Show</string>
<string name="desktop_show_app">Show FromChat</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_paste">Paste</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 {
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<String?>(null) }
@@ -886,6 +889,7 @@ fun App(
isConversationProfileRoute(currentRoute) ||
(
isStandaloneProfileRoute(currentRoute) &&
!settingsProfileSplit &&
isConversationChatRoute(previousRoute)
) -> "nav"
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.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,
) {
@@ -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 {
@@ -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,
@@ -11,12 +11,17 @@ import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
*
* When [preserveConversationDetail] is true (desktop listdetail), 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<String>("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)
@@ -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 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.
* 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 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(
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)
@@ -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<Set<Int>>(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
}
}
@@ -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)
) {
@@ -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)
) {
@@ -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)
) {
@@ -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 listdetail 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<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.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 ->
@@ -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)
) {