mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
More UI improvements
This commit is contained in:
@@ -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 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<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 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)
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
+5
-8
@@ -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)
|
||||
) {
|
||||
|
||||
+149
@@ -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<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 ->
|
||||
|
||||
+6
-8
@@ -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)
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user