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