diff --git a/.idea/artifacts/shared.xml b/.idea/artifacts/shared.xml index df2c7e6..59ff3f6 100644 --- a/.idea/artifacts/shared.xml +++ b/.idea/artifacts/shared.xml @@ -1,8 +1,6 @@ $PROJECT_DIR$/utils/shared/build/libs - - - + \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt index 24ba949..c44935f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt @@ -3,10 +3,16 @@ package ru.fromchat.api import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch +import kotlinx.coroutines.FlowPreview import kotlinx.serialization.json.JsonElement import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.db.store.ConnectionStateStore +import ru.fromchat.api.local.db.store.ConnectionStatus import ru.fromchat.api.local.db.store.MessageCacheStore import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.ProfileCache @@ -19,7 +25,10 @@ import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData * Keeps the chats tab list in sync: DM conversations from the server and the latest public-chat * message for list previews (without opening each chat first). */ +@OptIn(FlowPreview::class) object ChatListSync { + private const val CONNECTED_SYNC_DEBOUNCE_MS = 300L + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var started = false @@ -31,6 +40,14 @@ object ChatListSync { scope.launch { syncFromNetwork() } } + scope.launch { + ConnectionStateStore.status + .filter { it == ConnectionStatus.CONNECTED } + .distinctUntilChanged() + .debounce(CONNECTED_SYNC_DEBOUNCE_MS) + .collect { syncFromNetwork() } + } + WebSocketManager.addGlobalMessageHandler(::handleWebSocketMessage) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt index 3df9faf..66a0f4f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import ru.fromchat.api.ApiClient import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.dm.DmConversationUser import ru.fromchat.api.schema.user.User import ru.fromchat.api.schema.user.profile.UserProfile import ru.fromchat.api.schema.user.profile.VerificationStatus @@ -165,7 +166,7 @@ object ProfileCache { if (!hasIdentity) remove(userId) } - fun mergeFromDmUser(user: User) { + fun mergeFromDmUser(user: DmConversationUser) { if (user.id <= 0) return val incomingUsername = user.username.trim() @@ -188,7 +189,7 @@ object ProfileCache { } else { user.profile_picture?.takeIf { it.isNotBlank() } ?: existing.profilePicture }, - online = user.online, + online = user.online ?: existing.online, lastSeen = user.last_seen?.takeIf { it.isNotBlank() } ?: existing.lastSeen, verified = user.verified ?: existing.verified, verificationStatus = user.verificationStatus ?: existing.verificationStatus, @@ -209,9 +210,9 @@ object ProfileCache { profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture, bio = existing?.bio, - online = user.online, + online = user.online ?: existing?.online ?: false, lastSeen = user.last_seen?.takeIf { it.isNotBlank() } ?: existing?.lastSeen, - createdAt = user.created_at.takeIf { it.isNotBlank() } ?: existing?.createdAt, + createdAt = existing?.createdAt, verified = user.verified ?: existing?.verified, verificationStatus = user.verificationStatus ?: existing?.verificationStatus, suspended = user.suspended ?: existing?.suspended, @@ -222,6 +223,24 @@ object ProfileCache { ) } + fun mergeFromUser(user: User) { + mergeFromDmUser( + DmConversationUser( + id = user.id, + username = user.username, + displayName = user.displayName, + profile_picture = user.profile_picture, + online = user.online, + last_seen = user.last_seen, + verified = user.verified, + verificationStatus = user.verificationStatus, + suspended = user.suspended, + suspensionReason = user.suspensionReason, + deleted = user.deleted, + ), + ) + } + fun mergePreviewFromPublicMessage(message: Message) { val uid = message.user_id if (uid <= 0) return diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversation.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversation.kt index b5c2973..f500b5c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversation.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversation.kt @@ -1,11 +1,10 @@ package ru.fromchat.api.schema.messages.dm import kotlinx.serialization.Serializable -import ru.fromchat.api.schema.user.User @Serializable data class DmConversation( - val user: User, + val user: DmConversationUser, val lastMessage: DmEnvelope, val unreadCount: Int ) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversationUser.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversationUser.kt new file mode 100644 index 0000000..7d02df6 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversationUser.kt @@ -0,0 +1,20 @@ +package ru.fromchat.api.schema.messages.dm + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.fromchat.api.schema.user.profile.VerificationStatus + +@Serializable +data class DmConversationUser( + val id: Int, + val username: String = "", + @SerialName("display_name") val displayName: String? = null, + val profile_picture: String? = null, + val online: Boolean? = null, + val last_seen: String? = null, + val verified: Boolean? = null, + @SerialName("verification_status") val verificationStatus: VerificationStatus? = null, + val suspended: Boolean? = null, + @SerialName("suspension_reason") val suspensionReason: String? = null, + val deleted: Boolean? = null, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt index 383391b..b1d0163 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt @@ -66,6 +66,7 @@ class DmPanel( private var otherDisplayName: String = "" private var otherProfilePicture: String? = null private val dmEnvelopeMutex = Mutex() + private val loadMessagesMutex = Mutex() private var messagesLoaded = false private data class DmDecryptOutcome(val plaintext: String, val isCorrupted: Boolean) @@ -219,78 +220,82 @@ class DmPanel( } override suspend fun loadMessages() { - if (messagesLoaded) return - messagesLoaded = true + loadMessagesMutex.withLock { + if (messagesLoaded) return - runCatching { MessageRepository.ensureDmConversationRow(otherUserId) } + runCatching { MessageRepository.ensureDmConversationRow(otherUserId) } - // Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner - // when the chat screen re-entered composition (e.g. pop back from profile). - val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList()) - if (cached.isNotEmpty()) { - batchStateUpdates { - clearMessages() - addMessages(cached) - setLoading(false) - } - } else { - setLoading(true) - } - - try { - OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance( - CacheContext.requireActiveInstanceId(), - ) - - val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) } - if (historyResult.isSuccess) { - val response = historyResult.getOrNull() ?: return - val priorMessages = _state.messages - val optimisticSnapshot = snapshotPendingOptimisticMessages() - val decryptedForLog = mutableListOf>() - val parsedReplyIds = mutableMapOf() - val messages = response.messages.map { envelope -> - val outcome = decryptDmEnvelopeForUi(envelope) - decryptedForLog.add(envelope.id to outcome.plaintext) - val dec = parseDmMessageContent(outcome.plaintext) - resolveDmReplyToId(envelope, dec.replyToId)?.let { parsedReplyIds[envelope.id] = it } - createMessage(envelope, outcome.plaintext, outcome.isCorrupted) - } - decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) -> - Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json") - } - val messagesWithReplies = attachDmReplyReferences(messages, parsedReplyIds) - val mergedForUi = preserveReplyToFromExisting( - priorMessages + optimisticSnapshot, - messagesWithReplies, - ) + // Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner + // when the chat screen re-entered composition (e.g. pop back from profile). + val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList()) + if (cached.isNotEmpty()) { batchStateUpdates { clearMessages() - addMessages(mergedForUi) - restorePendingOptimisticMessages(optimisticSnapshot) - updateState { state -> - val cleaned = dedupeMessagesByClientId( - dropSupersededOptimisticMessages(state.messages, currentUserId), - ) - if (cleaned == state.messages) state else state.copy(messages = cleaned) - } - setHasMoreMessages(false) - } - - // Persist the most recent DM messages for offline use. - val mergedForCache = _state.messages - MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache) - } else { - val error = historyResult.exceptionOrNull() - Logger.e("DmPanel", "Failed to load DM history: ${error?.message}", error) - if (error is ClientRequestException && error.response.status.value == 403) { - MessageCacheStore.clearDmMessages(otherUserId) - clearMessages() - setHasMoreMessages(false) + addMessages(cached) + setLoading(false) } + messagesLoaded = true + return + } + + setLoading(true) + try { + OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance( + CacheContext.requireActiveInstanceId(), + ) + + val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) } + if (historyResult.isSuccess) { + val response = historyResult.getOrNull() ?: return + val priorMessages = _state.messages + val optimisticSnapshot = snapshotPendingOptimisticMessages() + val decryptedForLog = mutableListOf>() + val parsedReplyIds = mutableMapOf() + val messages = response.messages.map { envelope -> + val outcome = decryptDmEnvelopeForUi(envelope) + decryptedForLog.add(envelope.id to outcome.plaintext) + val dec = parseDmMessageContent(outcome.plaintext) + resolveDmReplyToId(envelope, dec.replyToId)?.let { parsedReplyIds[envelope.id] = it } + createMessage(envelope, outcome.plaintext, outcome.isCorrupted) + } + decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) -> + Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json") + } + val messagesWithReplies = attachDmReplyReferences(messages, parsedReplyIds) + val mergedForUi = preserveReplyToFromExisting( + priorMessages + optimisticSnapshot, + messagesWithReplies, + ) + batchStateUpdates { + clearMessages() + addMessages(mergedForUi) + restorePendingOptimisticMessages(optimisticSnapshot) + updateState { state -> + val cleaned = dedupeMessagesByClientId( + dropSupersededOptimisticMessages(state.messages, currentUserId), + ) + if (cleaned == state.messages) state else state.copy(messages = cleaned) + } + setHasMoreMessages(false) + } + + // Persist the most recent DM messages for offline use. + val mergedForCache = _state.messages + MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache) + messagesLoaded = true + } else { + val error = historyResult.exceptionOrNull() + Logger.e("DmPanel", "Failed to load DM history: ${error?.message}", error) + if (error is ClientRequestException && error.response.status.value == 403) { + MessageCacheStore.clearDmMessages(otherUserId) + clearMessages() + setHasMoreMessages(false) + messagesLoaded = true + } + } + } finally { + setLoading(false) } - } finally { - setLoading(false) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt index 7651005..4d8d44b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt @@ -54,9 +54,7 @@ fun DmScreen( if (activeInstanceId.isBlank()) return@LaunchedEffect val peerId = peerUserId ?: return@LaunchedEffect if (peerId <= 0) return@LaunchedEffect - if (panel.getState().messages.isEmpty()) { - panel.loadMessages() - } + panel.loadMessages() } LaunchedEffect(activeInstanceId, peerUserId) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ContactsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ContactsTab.kt index 3325429..e2bc779 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ContactsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ContactsTab.kt @@ -50,6 +50,7 @@ fun ContactsTab() { Box( modifier = Modifier .fillMaxSize() + .mainPagerBottomInset() .padding(innerPadding), contentAlignment = Alignment.Center ) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainChromeInsets.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainChromeInsets.kt new file mode 100644 index 0000000..d85eba6 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainChromeInsets.kt @@ -0,0 +1,28 @@ +package ru.fromchat.ui.main + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +data class MainChromeInsets( + val top: Dp = 0.dp, + val bottom: Dp = 0.dp, +) { + fun asPaddingValues( + extraTop: Dp = 0.dp, + extraBottom: Dp = 0.dp, + ): PaddingValues = PaddingValues( + top = top + extraTop, + bottom = bottom + extraBottom, + ) +} + +val LocalMainChromeInsets = staticCompositionLocalOf { MainChromeInsets() } + +@Composable +fun Modifier.mainPagerBottomInset(): Modifier = + padding(bottom = LocalMainChromeInsets.current.bottom) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index f6eff4a..dce726b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -4,16 +4,18 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons @@ -28,13 +30,18 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState -import ru.fromchat.ui.components.Text -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import dev.chrisbanes.haze.HazeState @@ -44,6 +51,7 @@ import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.rememberHazeState +import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res @@ -53,14 +61,13 @@ import ru.fromchat.contacts import ru.fromchat.profile import ru.fromchat.settings import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle import ru.fromchat.ui.components.FromChatSnackbarHost +import ru.fromchat.ui.components.Text import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController 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 -import com.pr0gramm3r101.utils.exclude private const val PAGE_CHATS = 0 private const val PAGE_CONTACTS = 1 @@ -73,7 +80,7 @@ private const val PAGE_COUNT = 4 fun MainScreen( sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, - snackbarHostState: SnackbarHostState? = null + snackbarHostState: SnackbarHostState? = null, ) { val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() } val navController = LocalNavController.current @@ -82,15 +89,26 @@ fun MainScreen( pageCount = { PAGE_COUNT }, ) val scope = rememberCoroutineScope() + val density = LocalDensity.current val navBarHazeState = rememberHazeState(blurEnabled = true) + val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle() val contextMenuHazeState = rememberHazeState(blurEnabled = true) val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } - // Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop). val selectedPage = pagerState.currentPage val isChatsPage = selectedPage == PAGE_CHATS val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress + val statusBarTopDp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + var bottomChromeHeightDp by remember { mutableStateOf(0.dp) } + + val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp) { + MainChromeInsets( + top = statusBarTopDp, + bottom = bottomChromeHeightDp, + ) + } + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier @@ -98,102 +116,114 @@ fun MainScreen( .hazeSource(contextMenuHazeState), ) { Scaffold( - snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) }, - bottomBar = { - Column( - modifier = Modifier - .windowInsetsPadding(WindowInsets.ime) - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainer) - .hazeEffect( - state = navBarHazeState, - style = rememberChatSurfaceContainerHazeStyle(), - ), - ) { - NavigationBar( - containerColor = Color.Transparent, - tonalElevation = 0.dp, - ) { - NavigationBarItem( - selected = selectedPage == PAGE_CHATS, - onClick = { - scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) } - }, - label = { Text(stringResource(Res.string.chats)) }, - icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) } - ) - NavigationBarItem( - selected = selectedPage == PAGE_CONTACTS, - onClick = { - scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) } - }, - label = { Text(stringResource(Res.string.contacts)) }, - icon = { Icon(Icons.Filled.Contacts, contentDescription = null) } - ) - NavigationBarItem( - selected = selectedPage == PAGE_SETTINGS, - onClick = { - scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } - }, - label = { Text(stringResource(Res.string.settings)) }, - icon = { Icon(Icons.Filled.Settings, contentDescription = null) } - ) - NavigationBarItem( - selected = selectedPage == PAGE_PROFILE, - onClick = { - scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) } - }, - label = { Text(stringResource(Res.string.profile)) }, - icon = { Icon(Icons.Filled.Person, contentDescription = null) } - ) - } - } + snackbarHost = { + FromChatSnackbarHost( + hostState = effectiveSnackbarHostState, + modifier = Modifier.padding(bottom = mainChromeInsets.bottom), + ) }, - contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top), - modifier = Modifier.imePadding(), - ) { innerPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .hazeSource(navBarHazeState), - ) { + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + modifier = Modifier + .fillMaxSize() + .imePadding(), + ) { + CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) { Box( modifier = Modifier .fillMaxSize() - .padding(innerPadding), + .hazeSource(navBarHazeState), ) { HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), beyondViewportPageCount = 1, ) { page -> - when (page) { - PAGE_CHATS -> ChatsTab( - isVisible = isChatsPage, - onOpenSearch = { - navController.navigate("search/conversations") - }, - chatContextMenuOverlay = chatContextMenuOverlay, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ) - PAGE_CONTACTS -> ContactsTab() - PAGE_SETTINGS -> SettingsTab() - PAGE_PROFILE -> { - ProfileScreen( - userId = ApiClient.user?.id, - onBack = {}, - onChat = { _ -> }, - modifier = Modifier.fillMaxSize(), - onOpenSettings = { - scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } - } + when (page) { + PAGE_CHATS -> ChatsTab( + isVisible = isChatsPage, + onOpenSearch = { + navController.navigate("search/conversations") + }, + chatContextMenuOverlay = chatContextMenuOverlay, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, ) + PAGE_CONTACTS -> ContactsTab() + PAGE_SETTINGS -> SettingsTab() + PAGE_PROFILE -> { + ProfileScreen( + userId = ApiClient.user?.id, + onBack = {}, + onChat = { _ -> }, + modifier = Modifier + .fillMaxSize() + .mainPagerBottomInset(), + onOpenSettings = { + scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } + }, + ) + } + else -> Unit } - else -> Unit } } + } + } + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .zIndex(1f) + .imePadding() + .onSizeChanged { size -> + val measured = with(density) { size.height.toDp() } + if (measured != bottomChromeHeightDp) { + bottomChromeHeightDp = measured + } } + .background(MaterialTheme.colorScheme.surfaceContainer) + .hazeEffect(state = navBarHazeState, style = navBarHazeStyle), + ) { + NavigationBar( + modifier = Modifier.fillMaxWidth(), + containerColor = Color.Transparent, + tonalElevation = 0.dp, + windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom), + ) { + NavigationBarItem( + selected = selectedPage == PAGE_CHATS, + onClick = { + scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) } + }, + label = { Text(stringResource(Res.string.chats)) }, + icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }, + ) + NavigationBarItem( + selected = selectedPage == PAGE_CONTACTS, + onClick = { + scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) } + }, + label = { Text(stringResource(Res.string.contacts)) }, + icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }, + ) + NavigationBarItem( + selected = selectedPage == PAGE_SETTINGS, + onClick = { + scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } + }, + label = { Text(stringResource(Res.string.settings)) }, + icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, + ) + NavigationBarItem( + selected = selectedPage == PAGE_PROFILE, + onClick = { + scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) } + }, + label = { Text(stringResource(Res.string.profile)) }, + icon = { Icon(Icons.Filled.Person, contentDescription = null) }, + ) } } } @@ -204,7 +234,7 @@ fun MainScreen( blurProgress = chatMenuBlurProgress, modifier = Modifier .fillMaxSize() - .zIndex(1f), + .zIndex(2f), ) } @@ -214,7 +244,7 @@ fun MainScreen( screenHeightPx = constraints.maxHeight, modifier = Modifier .fillMaxSize() - .zIndex(2f), + .zIndex(3f), ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt index d54a810..545d416 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt @@ -1,6 +1,8 @@ package ru.fromchat.ui.main.chats import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.SpringSpec @@ -27,10 +29,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.CircularProgressIndicator @@ -59,8 +63,11 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import ru.fromchat.ui.components.SearchBar +import ru.fromchat.ui.components.SearchBarSharedElement import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItemPosition @@ -92,8 +99,9 @@ import ru.fromchat.ui.profile.displayNameForUi import ru.fromchat.ui.profile.peerIsDeleted internal object ChatListLayout { - private const val CATEGORY_TOP_SPACER = 0 - const val PUBLIC_CHAT_ROW = CATEGORY_TOP_SPACER + 1 + const val SEARCH_BAR_ROW = 0 + const val COLLAPSED_SCROLL_TARGET_ROW = SEARCH_BAR_ROW + 1 + const val PUBLIC_CHAT_ROW = SEARCH_BAR_ROW + 1 fun dmRow(dmIndex: Int): Int = PUBLIC_CHAT_ROW + 1 + dmIndex @@ -113,8 +121,8 @@ internal object SearchListIndices { private val ChatListCategoryMargin = PaddingValues( start = 16.dp, end = 16.dp, - top = 16.dp, - bottom = 20.dp, + top = 8.dp, + bottom = 12.dp, ) @Composable @@ -154,6 +162,15 @@ internal fun ChatConversationsList( contextMenuState: ChatContextMenuState, overlayCloneReady: Boolean, rowRevealProgress: Float, + listContentPadding: PaddingValues = PaddingValues(0.dp), + showSearchBar: Boolean = false, + searchBarHeight: Dp = 56.dp, + searchBarVisibleFraction: Float = 1f, + searchBarPlaceholder: String = "", + onSearchBarActivate: () -> Unit = {}, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, + listBottomInset: Dp = 0.dp, modifier: Modifier = Modifier, onOpenPublic: () -> Unit, onOpenConversation: (Int) -> Unit, @@ -188,8 +205,45 @@ internal fun ChatConversationsList( state = listState, modifier = modifier, userScrollEnabled = !scrollBlocked, - contentPadding = PaddingValues(bottom = 12.dp), + contentPadding = listContentPadding, ) { + if (showSearchBar) { + item(key = "chats-search-bar") { + val fraction = searchBarVisibleFraction.coerceIn(0f, 1f) + val visibleHeight = searchBarHeight * fraction + Box( + modifier = Modifier + .fillMaxWidth() + .height(visibleHeight) + .clip(RoundedCornerShape(0.dp)), + ) { + if (fraction > 0f) { + SearchBar( + query = "", + onQueryChange = {}, + onSearch = {}, + placeholder = searchBarPlaceholder, + readOnly = true, + onReadOnlyActivate = onSearchBarActivate, + modifier = Modifier + .fillMaxWidth() + .height(searchBarHeight) + .padding(horizontal = 16.dp), + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedElementKey = SearchBarSharedElement, + ) + } + } + } + } if (groupCount > 0) { Category( margin = ChatListCategoryMargin, @@ -327,6 +381,11 @@ internal fun ChatConversationsList( } } } + if (listBottomInset > 0.dp) { + item(key = "chats-main-chrome-bottom-spacer") { + Spacer(Modifier.height(listBottomInset)) + } + } } } @@ -712,15 +771,17 @@ internal fun PublicChatRowContent( modifier: Modifier = Modifier, ) { val preview = publicChatPreviewState?.displayText(defaultLastMessage) ?: defaultLastMessage + val pendingIndicator = publicChatPreviewState?.pendingIndicator + ?: ChatListPreviewPendingIndicator.None + val showPreview = preview.isNotBlank() || pendingIndicator != ChatListPreviewPendingIndicator.None ListItem( headline = publicChatTitle.orEmpty(), - supportingSlot = if (publicChatTitle != null) { + supportingSlot = if (publicChatTitle != null && showPreview) { { ChatListPreviewSupportingText( preview = preview, - pendingIndicator = publicChatPreviewState?.pendingIndicator - ?: ChatListPreviewPendingIndicator.None, + pendingIndicator = pendingIndicator, uploadProgress = publicChatPreviewState?.uploadProgress, ) } @@ -884,21 +945,28 @@ internal fun DmConversationRowContent( val status = statusMap[conversation.otherUserId] val typingUsers = status?.typingUsernames.orEmpty() val isTyping = typingUsers.isNotEmpty() + val showPreview = isTyping || + preview.isNotBlank() || + conversation.lastMessagePendingIndicator != ChatListPreviewPendingIndicator.None val isOnline = status?.online ?: (cached?.online == true) val listSurfaceColor = MaterialTheme.colorScheme.surfaceContainerLow ListItem( headline = peerTitle, headlineSlot = { ChatListHeadlineWithBadge(peerTitle, conversation.otherUserId) }, - supportingSlot = { - if (isTyping) { - TypingIndicator(typingUsers = typingUsers) - } else { - ChatListPreviewSupportingText( - preview = preview, - pendingIndicator = conversation.lastMessagePendingIndicator, - uploadProgress = conversation.lastMessageUploadProgress, - ) + supportingSlot = if (showPreview) { + { + if (isTyping) { + TypingIndicator(typingUsers = typingUsers) + } else { + ChatListPreviewSupportingText( + preview = preview, + pendingIndicator = conversation.lastMessagePendingIndicator, + uploadProgress = conversation.lastMessageUploadProgress, + ) + } } + } else { + null }, containerColor = Color.Transparent, position = listItemPosition, @@ -990,6 +1058,10 @@ internal fun ChatListPreviewSupportingText( val sendingCd = stringResource(Res.string.cd_chat_preview_sending) val uploadingCd = stringResource(Res.string.cd_chat_preview_uploading) + if (preview.isBlank() && pendingIndicator == ChatListPreviewPendingIndicator.None) { + return + } + Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt index 12da162..20bf686 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt @@ -66,7 +66,6 @@ import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.UserStatusStore import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.api.schema.user.User -import ru.fromchat.chat_last_mesaage import ru.fromchat.chat_preview_attachment import ru.fromchat.chat_preview_image import ru.fromchat.chat_preview_image_emoji @@ -98,7 +97,6 @@ fun ChatsSearchScreen( imageOnly = stringResource(Res.string.chat_preview_image, imageEmoji), attachmentOnly = stringResource(Res.string.chat_preview_attachment), ) - val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) val searchHint = stringResource(Res.string.search_hint) val searchBarHint = stringResource(Res.string.search_title) var dmConversations by remember { mutableStateOf>(emptyList()) } @@ -157,7 +155,7 @@ fun ChatsSearchScreen( try { val users = ApiClient.searchUsers(querySnapshot) if (querySnapshot == searchText.trim().lowercase().trimStart('@')) { - users.forEach { ProfileCache.mergeFromDmUser(it) } + users.forEach { ProfileCache.mergeFromUser(it) } remoteUsers = users lastCompletedSearchQuery = querySnapshot } @@ -313,7 +311,7 @@ fun ChatsSearchScreen( remoteUsers = remoteUsers.filter { user -> filteredDmConversations.none { it.otherUserId == user.id } }, - defaultLastMessage = defaultLastMessage, + defaultLastMessage = "", statusMap = statusMap, modifier = Modifier.fillMaxSize(), onOpenConversation = { userId -> diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt index 736dca0..7cc0a1c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt @@ -5,7 +5,9 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animate +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically @@ -25,6 +27,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -42,10 +45,25 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.union +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.ui.graphics.Color +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.chrisbanes.haze.rememberHazeState import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -62,7 +80,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerEventPass @@ -74,6 +94,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -107,7 +128,6 @@ import ru.fromchat.cd_close_selection import ru.fromchat.cd_selection_more import ru.fromchat.chat_delete_confirm_body import ru.fromchat.chat_delete_confirm_title -import ru.fromchat.chat_last_mesaage import ru.fromchat.chat_preview_attachment import ru.fromchat.chat_preview_image import ru.fromchat.chat_preview_image_emoji @@ -119,13 +139,12 @@ import ru.fromchat.status_connecting import ru.fromchat.status_updating import ru.fromchat.account_suspended import ru.fromchat.ui.LocalNavController +import ru.fromchat.ui.main.LocalMainChromeInsets import ru.fromchat.ui.chat.panels.dm.DmNav import ru.fromchat.ui.components.BackHandler import ru.fromchat.ui.components.BrandTitle import ru.fromchat.ui.components.ConnectingEllipsis import ru.fromchat.ui.components.PredictiveBackHandler -import ru.fromchat.ui.components.SearchBar -import ru.fromchat.ui.components.SearchBarSharedElement import ru.fromchat.ui.components.SuspendedAccountSupportSheet import ru.fromchat.ui.components.Text import ru.fromchat.utils.NetworkConnectivity @@ -135,17 +154,225 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.TimeSource private const val ChatContextMenuHoldGateMs = 250L +private val ChatsSearchBarHeight = 56.dp +private val ChatsTopBarContentHeight = 64.dp + +private fun LazyListState.searchBarHeightPx(collapseRangePx: Float): Float { + val collapseProgress = when (firstVisibleItemIndex) { + ChatListLayout.SEARCH_BAR_ROW -> + (firstVisibleItemScrollOffset / collapseRangePx).coerceIn(0f, 1f) + else -> 1f + } + return collapseRangePx * (1f - collapseProgress) +} + +private class SearchBarCollapseSnapState { + var settledHeightPx: Float = 0f + var gestureStartHeightPx: Float = 0f + var gestureMinHeightPx: Float = 0f + var gestureMaxHeightPx: Float = 0f + var dragActive: Boolean = false + var suppressNextEnd: Boolean = false +} + +private class SearchBarCollapseController( + private val listState: LazyListState, + private val collapseRangePx: Float, + val snapState: SearchBarCollapseSnapState = SearchBarCollapseSnapState(), +) { + private fun currentSearchBarHeightPx(): Float = listState.searchBarHeightPx(collapseRangePx) + + fun beginGesture() { + val currentHeightPx = currentSearchBarHeightPx() + snapState.dragActive = true + snapState.gestureStartHeightPx = currentHeightPx + snapState.gestureMinHeightPx = currentHeightPx + snapState.gestureMaxHeightPx = currentHeightPx + } + + fun updateGestureBounds() { + if (!snapState.dragActive) return + val currentHeightPx = currentSearchBarHeightPx() + snapState.gestureMinHeightPx = minOf(snapState.gestureMinHeightPx, currentHeightPx) + snapState.gestureMaxHeightPx = maxOf(snapState.gestureMaxHeightPx, currentHeightPx) + } + + private suspend fun snapSearchCollapsed() { + if (listState.layoutInfo.totalItemsCount > ChatListLayout.COLLAPSED_SCROLL_TARGET_ROW) { + listState.animateScrollToItem(ChatListLayout.COLLAPSED_SCROLL_TARGET_ROW) + } else { + listState.animateScrollToItem( + ChatListLayout.SEARCH_BAR_ROW, + scrollOffset = collapseRangePx.toInt(), + ) + } + snapState.settledHeightPx = 0f + } + + private suspend fun snapSearchExpanded() { + listState.animateScrollToItem( + ChatListLayout.SEARCH_BAR_ROW, + scrollOffset = 0, + ) + snapState.settledHeightPx = collapseRangePx + } + + suspend fun onScrollGestureEnded() { + val index = listState.firstVisibleItemIndex + val offset = listState.firstVisibleItemScrollOffset + val endHeightPx = currentSearchBarHeightPx() + val fullyExpanded = index == ChatListLayout.SEARCH_BAR_ROW && + offset == 0 && + endHeightPx >= collapseRangePx - 1f + val fullyCollapsed = endHeightPx <= 0f || + index > ChatListLayout.SEARCH_BAR_ROW + + val heightReducedByGesture = snapState.gestureStartHeightPx - snapState.gestureMinHeightPx >= 1f + val heightIncreasedByGesture = snapState.gestureMaxHeightPx - snapState.gestureStartHeightPx >= 1f + val startedExpanded = snapState.gestureStartHeightPx >= collapseRangePx - 1f + + if (fullyExpanded && !heightReducedByGesture) { + snapState.settledHeightPx = collapseRangePx + return + } + if (fullyCollapsed && !heightIncreasedByGesture) { + snapState.settledHeightPx = 0f + return + } + + val shouldCollapse = heightReducedByGesture || + (startedExpanded && !fullyExpanded) || + (!fullyExpanded && !fullyCollapsed && !heightIncreasedByGesture) + val shouldExpand = heightIncreasedByGesture && snapState.gestureStartHeightPx < collapseRangePx - 1f + + snapState.suppressNextEnd = true + try { + when { + shouldCollapse && !shouldExpand -> snapSearchCollapsed() + shouldExpand -> snapSearchExpanded() + !fullyExpanded -> snapSearchCollapsed() + else -> snapState.settledHeightPx = endHeightPx + } + } finally { + snapState.suppressNextEnd = false + } + } + + fun nestedScrollConnection(enabled: Boolean): NestedScrollConnection = + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset { + if (!enabled || source != NestedScrollSource.UserInput || snapState.suppressNextEnd) { + return Offset.Zero + } + if (!snapState.dragActive) { + beginGesture() + } + updateGestureBounds() + return Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (!enabled || source != NestedScrollSource.UserInput) { + return Offset.Zero + } + if (!snapState.dragActive && !snapState.suppressNextEnd) { + beginGesture() + } + updateGestureBounds() + return Offset.Zero + } + } +} + +@Composable +private fun rememberSearchBarCollapseSnap( + listState: LazyListState, + collapseRangePx: Float, + enabled: Boolean, +): Modifier { + val controller = remember(listState, collapseRangePx) { + SearchBarCollapseController(listState, collapseRangePx) + } + + LaunchedEffect(listState, enabled, collapseRangePx) { + if (!enabled) return@LaunchedEffect + controller.snapState.settledHeightPx = listState.searchBarHeightPx(collapseRangePx) + snapshotFlow { + listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset + }.collect { + controller.updateGestureBounds() + } + } + + LaunchedEffect(listState, enabled, controller) { + if (!enabled) return@LaunchedEffect + snapshotFlow { listState.isScrollInProgress }.collect { scrolling -> + if (scrolling) { + if (controller.snapState.suppressNextEnd) return@collect + if (!controller.snapState.dragActive) { + controller.beginGesture() + } + return@collect + } + if (!controller.snapState.dragActive) return@collect + controller.snapState.dragActive = false + if (controller.snapState.suppressNextEnd) return@collect + controller.onScrollGestureEnded() + } + } + + return Modifier.nestedScroll( + remember(controller, enabled) { controller.nestedScrollConnection(enabled) }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun chatsTopAppBarColors(blurReveal: Float) = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 1f - blurReveal.coerceIn(0f, 1f)), + scrolledContainerColor = Color.Transparent, +) + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +private fun ChatsTopBarHazeBackdrop( + hazeState: HazeState, + blurReveal: Float, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .graphicsLayer { alpha = blurReveal.coerceIn(0f, 1f) } + .hazeEffect( + state = hazeState, + style = HazeMaterials.thin(), + ), + ) +} @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatsNormalTopBar( + blurReveal: Float, titleKey: String, connectingTitle: String, updatingTitle: String, + searchCollapseProgress: Float, + searchContentDescription: String, + onSearchClick: () -> Unit, modifier: Modifier = Modifier, ) { TopAppBar( modifier = modifier, + windowInsets = WindowInsets.statusBars, + colors = chatsTopAppBarColors(blurReveal), title = { Row( modifier = Modifier.fillMaxWidth(), @@ -203,12 +430,26 @@ private fun ChatsNormalTopBar( } } }, + actions = { + IconButton( + onClick = onSearchClick, + modifier = Modifier.graphicsLayer { + alpha = searchCollapseProgress.coerceIn(0f, 1f) + }, + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = searchContentDescription, + ) + } + }, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatsSelectionTopBar( + blurReveal: Float, selectedCountTitle: String, closeSelectionCd: String, bulkActions: ChatsBulkActions, @@ -224,6 +465,8 @@ private fun ChatsSelectionTopBar( TopAppBar( modifier = modifier, + windowInsets = WindowInsets.statusBars, + colors = chatsTopAppBarColors(blurReveal), navigationIcon = { IconButton(onClick = onClose) { Icon( @@ -275,7 +518,7 @@ private fun ChatsSelectionTopBar( ) } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable fun ChatsTab( isVisible: Boolean = true, @@ -330,12 +573,12 @@ fun ChatsTab( val publicChatProfile = publicChatProfileFromFlow ?: publicChatProfileFromDisk val searchBarHint = stringResource(Res.string.search_title) val tabListState = rememberLazyListState() + val topChromeHazeState = rememberHazeState(blurEnabled = isVisible) val statusMap by UserStatusStore.status.collectAsState() var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } val statusSubscriptionScope = rememberCoroutineScope() val suspensionState by ApiClient.suspensionState.collectAsState() var showSuspendedSupportSheet by remember { mutableStateOf(false) } - val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly, activeInstanceId) { MessageCacheStore.listPreviewStrings = previewStrings @@ -369,7 +612,6 @@ fun ChatsTab( fun enterSelectionModeFor(target: ChatContextMenuTarget, userId: Int?) { haptic(HapticFeedbackEvent.SelectionModeEntered) - scope.launch { selectionTransitionProgress.snapTo(0f) } listMode = ChatsListMode.Selecting publicChatSelected = false selectedOtherUserIds = emptySet() @@ -377,6 +619,10 @@ fun ChatsTab( ChatContextMenuTarget.Public -> publicChatSelected = true ChatContextMenuTarget.Dm -> userId?.let { selectedOtherUserIds = setOf(it) } } + scope.launch { + selectionTransitionProgress.snapTo(0f) + selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring) + } } fun exitSelectionMode() { @@ -435,12 +681,6 @@ fun ChatsTab( } } - LaunchedEffect(listMode) { - if (listMode == ChatsListMode.Selecting) { - selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring) - } - } - LaunchedEffect(publicChatSelected, selectedOtherUserIds) { if (listMode == ChatsListMode.Selecting && !publicChatSelected && selectedOtherUserIds.isEmpty()) { requestExitSelectionMode() @@ -526,21 +766,8 @@ fun ChatsTab( } } - LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) { - if (activeInstanceId.isBlank() || connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect - - runCatching { - ApiClient.getDmConversations() - }.onSuccess { conversations -> - runCatching { - conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } - MessageRepository.replaceDmConversations(conversations, previewStrings) - dmConversations = ChatListReorderController.applyOrdered( - MessageRepository.loadCachedDmConversations(), - ) - } - } - + LaunchedEffect(serverConfig, activeInstanceId) { + if (activeInstanceId.isBlank()) return@LaunchedEffect runCatching { PublicChatProfileCache.hydrateFromDisk() } } @@ -636,17 +863,203 @@ fun ChatsTab( }, ) - Scaffold( - topBar = { - Box { + val density = LocalDensity.current + val mainChromeInsets = LocalMainChromeInsets.current + val statusBarTopDp = mainChromeInsets.top + val fixedTopBarHeight = statusBarTopDp + ChatsTopBarContentHeight + val searchCollapseProgress by remember(density, tabListState) { + derivedStateOf { + val collapseRangePx = with(density) { ChatsSearchBarHeight.toPx() }.coerceAtLeast(1f) + when (tabListState.firstVisibleItemIndex) { + ChatListLayout.SEARCH_BAR_ROW -> + (tabListState.firstVisibleItemScrollOffset / collapseRangePx).coerceIn(0f, 1f) + else -> 1f + } + } + } + val collapseRangePx = with(density) { ChatsSearchBarHeight.toPx() }.coerceAtLeast(1f) + val searchCollapseSnapEnabled = isVisible && !selectionMode && selectionProgress <= 0f + val searchCollapseSnapModifier = rememberSearchBarCollapseSnap( + listState = tabListState, + collapseRangePx = collapseRangePx, + enabled = searchCollapseSnapEnabled, + ) + val searchBarVisibleFraction = (1f - selectionProgress).coerceIn(0f, 1f) + val topBarSearchReveal = searchCollapseProgress * (1f - selectionProgress) + val listScrollFromTopPx by remember(density, tabListState) { + derivedStateOf { + when (tabListState.firstVisibleItemIndex) { + ChatListLayout.SEARCH_BAR_ROW -> tabListState.firstVisibleItemScrollOffset.toFloat() + else -> collapseRangePx + tabListState.firstVisibleItemScrollOffset.toFloat() + } + } + } + val topBarBlurReveal by animateFloatAsState( + targetValue = if (listScrollFromTopPx >= 1f) 1f else 0f, + animationSpec = tween(durationMillis = 200), + label = "chatsTopBarBlurReveal", + ) + + Box(modifier = Modifier.fillMaxSize()) { + if (suspensionState.isSuspended) { + ListItem( + headline = accountSuspendedTitle, + position = ListItemPosition.START, + groupItemCount = 1, + divider = false, + leadingContent = { + Icon( + imageVector = Icons.Rounded.Block, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { showSuspendedSupportSheet = true }, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding( + top = fixedTopBarHeight + ChatsSearchBarHeight * searchBarVisibleFraction, + start = 12.dp, + end = 12.dp, + ), + ) + } else { + key(profileCacheRevision) { + ChatConversationsList( + listState = tabListState, + listFilter = ChatListFilter.Active, + conversations = dmConversations, + publicChatTitle = publicChatTitle, + publicChatPreviewState = publicChatPreviewState, + defaultLastMessage = "", + statusMap = statusMap, + listMode = listMode, + selectionTransitionProgress = selectionProgress, + publicChatSelected = publicChatSelected, + selectedOtherUserIds = selectedOtherUserIds, + contextMenuState = contextMenuState, + overlayCloneReady = overlayCloneReady, + rowRevealProgress = rowRevealProgress, + listContentPadding = PaddingValues(top = fixedTopBarHeight), + showSearchBar = true, + searchBarHeight = ChatsSearchBarHeight, + searchBarVisibleFraction = searchBarVisibleFraction, + searchBarPlaceholder = searchBarHint, + onSearchBarActivate = onOpenSearch, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + listBottomInset = mainChromeInsets.bottom, + modifier = Modifier + .fillMaxSize() + .hazeSource(topChromeHazeState) + .then(searchCollapseSnapModifier), + onOpenPublic = { + when { + selectionMode && publicChatSelected -> publicChatSelected = false + selectionMode -> publicChatSelected = true + else -> navController.navigate("chats/publicChat") + } + }, + onOpenConversation = { userId -> + when { + selectionMode && userId in selectedOtherUserIds -> { + selectedOtherUserIds -= userId + } + + selectionMode -> selectedOtherUserIds += userId + + userId != 0 -> navController.navigate(DmNav.chatRoute(userId)) + } + }, + onAvatarContextMenuPressStart = { lazyIndex, target, userId, rowOffset, rowSize, position, groupCount -> + if (suspensionState.isSuspended) return@ChatConversationsList + avatarPressMark = TimeSource.Monotonic.markNow() + contextMenuState = ChatContextMenuState( + phase = ChatContextMenuPhase.Pressing, + target = target, + otherUserId = userId, + listIndex = lazyIndex, + rowOffset = rowOffset, + rowSize = rowSize, + listItemPosition = position, + groupItemCount = groupCount, + ) + }, + onAvatarContextMenuPressEnd = { + avatarPressMark = null + if (contextMenuState.phase == ChatContextMenuPhase.Pressing) { + contextMenuState = ChatContextMenuState() + } + }, + onAvatarContextMenuOpen = { lazyIndex, target, userId, _, rowOffset, rowSize, position, groupCount -> + if (suspensionState.isSuspended) return@ChatConversationsList + val pressMark = avatarPressMark + if (pressMark == null || + pressMark.elapsedNow() < ChatContextMenuHoldGateMs.milliseconds + ) { + return@ChatConversationsList + } + if (contextMenuState.phase != ChatContextMenuPhase.Pressing) { + return@ChatConversationsList + } + haptic(HapticFeedbackEvent.ContextMenuOpened) + chatContextMenuOverlay.overlayCloneReady = false + contextMenuState = ChatContextMenuState( + phase = ChatContextMenuPhase.Animating, + target = target, + otherUserId = userId, + listIndex = lazyIndex, + rowOffset = rowOffset, + rowSize = rowSize, + listItemPosition = position, + groupItemCount = groupCount, + ) + }, + onEnterSelectionMode = { _, target, userId -> + if (suspensionState.isSuspended) return@ChatConversationsList + enterSelectionModeFor(target, userId) + }, + onRowPositioned = { lazyIndex, offset, size -> + if ( + contextMenuState.listIndex == lazyIndex && + contextMenuState.phase != ChatContextMenuPhase.Closed + ) { + contextMenuState = contextMenuState.copy( + rowOffset = offset, + rowSize = size, + ) + } + }, + ) + } + } + + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(fixedTopBarHeight), + ) { + ChatsTopBarHazeBackdrop( + hazeState = topChromeHazeState, + blurReveal = topBarBlurReveal, + modifier = Modifier.matchParentSize(), + ) + Box(modifier = Modifier.fillMaxWidth()) { ChatsNormalTopBar( + blurReveal = topBarBlurReveal, titleKey = titleKey, connectingTitle = connectingTitle, updatingTitle = updatingTitle, + searchCollapseProgress = topBarSearchReveal, + searchContentDescription = searchBarHint, + onSearchClick = onOpenSearch, modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress }, ) if (selectionMode || selectionProgress > 0f) { ChatsSelectionTopBar( + blurReveal = topBarBlurReveal, selectedCountTitle = selectedCountTitle, closeSelectionCd = closeSelectionCd, bulkActions = bulkActions, @@ -663,160 +1076,6 @@ fun ChatsTab( ) } } - }, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - val searchBarReveal = 1f - selectionProgress - Box( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { alpha = searchBarReveal } - .height((56.dp * searchBarReveal).coerceAtLeast(0.dp)) - .clip(RectangleShape), - ) { - if (searchBarReveal > 0f) { - SearchBar( - query = "", - onQueryChange = {}, - onSearch = {}, - placeholder = searchBarHint, - readOnly = true, - onReadOnlyActivate = onOpenSearch, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - sharedElementKey = SearchBarSharedElement, - ) - } - } - - if (suspensionState.isSuspended) { - ListItem( - headline = accountSuspendedTitle, - position = ListItemPosition.START, - groupItemCount = 1, - divider = false, - leadingContent = { - Icon( - imageVector = Icons.Rounded.Block, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - }, - onClick = { showSuspendedSupportSheet = true }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - ) - } else { - key(profileCacheRevision) { - ChatConversationsList( - listState = tabListState, - listFilter = ChatListFilter.Active, - conversations = dmConversations, - publicChatTitle = publicChatTitle, - publicChatPreviewState = publicChatPreviewState, - defaultLastMessage = defaultLastMessage, - statusMap = statusMap, - listMode = listMode, - selectionTransitionProgress = selectionProgress, - publicChatSelected = publicChatSelected, - selectedOtherUserIds = selectedOtherUserIds, - contextMenuState = contextMenuState, - overlayCloneReady = overlayCloneReady, - rowRevealProgress = rowRevealProgress, - modifier = Modifier.fillMaxSize(), - onOpenPublic = { - when { - selectionMode && publicChatSelected -> publicChatSelected = false - selectionMode -> publicChatSelected = true - else -> navController.navigate("chats/publicChat") - } - }, - onOpenConversation = { userId -> - when { - selectionMode && userId in selectedOtherUserIds -> { - selectedOtherUserIds -= userId - } - - selectionMode -> selectedOtherUserIds += userId - - userId != 0 -> navController.navigate(DmNav.chatRoute(userId)) - } - }, - onAvatarContextMenuPressStart = { lazyIndex, target, userId, rowOffset, rowSize, position, groupCount -> - if (suspensionState.isSuspended) return@ChatConversationsList - avatarPressMark = TimeSource.Monotonic.markNow() - contextMenuState = ChatContextMenuState( - phase = ChatContextMenuPhase.Pressing, - target = target, - otherUserId = userId, - listIndex = lazyIndex, - rowOffset = rowOffset, - rowSize = rowSize, - listItemPosition = position, - groupItemCount = groupCount, - ) - }, - onAvatarContextMenuPressEnd = { - avatarPressMark = null - if (contextMenuState.phase == ChatContextMenuPhase.Pressing) { - contextMenuState = ChatContextMenuState() - } - }, - onAvatarContextMenuOpen = { lazyIndex, target, userId, _, rowOffset, rowSize, position, groupCount -> - if (suspensionState.isSuspended) return@ChatConversationsList - val pressMark = avatarPressMark - if (pressMark == null || - pressMark.elapsedNow() < ChatContextMenuHoldGateMs.milliseconds - ) { - return@ChatConversationsList - } - if (contextMenuState.phase != ChatContextMenuPhase.Pressing) return@ChatConversationsList - haptic(HapticFeedbackEvent.ContextMenuOpened) - chatContextMenuOverlay.overlayCloneReady = false - contextMenuState = ChatContextMenuState( - phase = ChatContextMenuPhase.Animating, - target = target, - otherUserId = userId, - listIndex = lazyIndex, - rowOffset = rowOffset, - rowSize = rowSize, - listItemPosition = position, - groupItemCount = groupCount, - ) - }, - onEnterSelectionMode = { _, target, userId -> - if (suspensionState.isSuspended) return@ChatConversationsList - enterSelectionModeFor(target, userId) - }, - onRowPositioned = { lazyIndex, offset, size -> - if ( - contextMenuState.listIndex == lazyIndex && - contextMenuState.phase != ChatContextMenuPhase.Closed - ) { - contextMenuState = contextMenuState.copy( - rowOffset = offset, - rowSize = size, - ) - } - }, - ) - } - } } } @@ -893,7 +1152,7 @@ fun ChatsTab( publicChatTitle = publicChatTitle, publicChatPreviewState = publicChatPreviewState, publicChatLink = publicChatLink, - defaultLastMessage = defaultLastMessage, + defaultLastMessage = "", conversations = dmConversations, statusMap = statusMap, listMode = listMode, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt index ed99735..9294143 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt @@ -45,6 +45,7 @@ import ru.fromchat.logs_title import ru.fromchat.settings_hub_about_sub import ru.fromchat.settings_hub_logs_sub import ru.fromchat.ui.LocalNavController +import ru.fromchat.ui.main.mainPagerBottomInset val SettingsStepHorizontalPadding = 24.dp @@ -69,6 +70,7 @@ fun SettingsTab() { Modifier .fillMaxSize() .verticalScroll() + .mainPagerBottomInset() .padding(innerPadding) ) { Category(Modifier.padding(top = 16.dp)) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index 6b5067c..7f6248d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -298,10 +298,12 @@ fun ProfileScreen( val latestUi by rememberUpdatedState(state) val profileCacheRevision by ProfileCache.revision.collectAsState() - val isOwnProfileLookup = targetUserId == null && targetUsername == null + val isViewingOwnProfile = ownUserId != null && ( + (targetUserId == null && targetUsername == null) || targetUserId == ownUserId + ) LaunchedEffect(profileCacheRevision) { - if (!isOwnProfileLookup) return@LaunchedEffect + if (!isViewingOwnProfile) return@LaunchedEffect ownUserId?.let { ProfileCache.get(it) }?.let { cached -> if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) { state = latestUi.copy(profile = cached, isLoading = false, error = null) @@ -315,7 +317,7 @@ fun ProfileScreen( handle.getStateFlow(ProfileRoutes.REFRESH_KEY, false).collect { shouldRefresh -> if (!shouldRefresh) return@collect handle[ProfileRoutes.REFRESH_KEY] = false - if (targetUserId != null || targetUsername != null) return@collect + if (!isViewingOwnProfile) return@collect try { val refreshed = ApiClient.getOwnProfile() ProfileCache.put(refreshed) @@ -350,7 +352,7 @@ fun ProfileScreen( return try { val profile = when { - targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile() + isViewingOwnProfile -> ApiClient.getOwnProfile() targetUsername != null -> ApiClient.getProfileByUsername(targetUsername) else -> ApiClient.getProfileById(targetUserId!!) } @@ -380,7 +382,7 @@ fun ProfileScreen( ) ProfileCache.put(profile) - if (targetUserId == null && targetUsername == null) { + if (isViewingOwnProfile) { ApiClient.applyOwnProfile(profile) } state = latestUi.copy(profile = profile, isLoading = false, error = null)