From cc85731256f5e30d605577232ce1d15850f26b09 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 12 Apr 2026 19:47:58 +0300 Subject: [PATCH] Improve UI, add search bar --- .idea/artifacts/shared.xml | 4 +- .idea/inspectionProfiles/Project_Default.xml | 2 + .../composeResources/values-ru/strings.xml | 4 + .../composeResources/values/strings.xml | 4 + .../kotlin/ru/fromchat/SearchHintResource.kt | 44 ++ .../kotlin/ru/fromchat/api/UserStatusStore.kt | 28 +- .../kotlin/ru/fromchat/ui/AboutScreen.kt | 16 + .../commonMain/kotlin/ru/fromchat/ui/App.kt | 77 +++- .../kotlin/ru/fromchat/ui/M3SearchBar.kt | 152 +++++++ .../ui/debug/DebugApiScreenContent.kt | 20 + .../ui/debug/DebugApiSearchBarDocsScreen.kt | 112 +++++ .../ru/fromchat/ui/main/ChatsSearchScreen.kt | 354 ++++++++++++++++ .../kotlin/ru/fromchat/ui/main/ChatsTab.kt | 386 ++++++++++++++---- .../kotlin/ru/fromchat/ui/main/MainScreen.kt | 68 +-- .../ui/main/settings/SettingsNavHost.kt | 1 + .../ui/main/settings/SettingsScreens.kt | 18 + .../ru/fromchat/ui/profile/ProfileScreen.kt | 63 ++- 17 files changed, 1235 insertions(+), 118 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/SearchHintResource.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/M3SearchBar.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiSearchBarDocsScreen.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsSearchScreen.kt 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/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 44dc092..4036839 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -34,9 +34,11 @@ diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 8738e92..60fb2c4 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -34,6 +34,10 @@ Список контактов появится здесь, когда функция будет готова. Общий чат Вы: последнее сообщение + Найдите пользователя, имя или чат + Поиск + Ничего не найдено + Ничего не найдено. Попробуйте переформулировать запрос. Напишите сообщение… Соединение Обновление diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 8609e0a..64ddfd6 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -43,6 +43,10 @@ Your contacts will appear here when this feature is ready. Main chat You: last message + Search by name, username or chat + Search + No results + Nothing found here. Try rephrasing your query. Write a message… diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/SearchHintResource.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/SearchHintResource.kt new file mode 100644 index 0000000..9a94ff7 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/SearchHintResource.kt @@ -0,0 +1,44 @@ +package ru.fromchat + +import kotlin.collections.HashMap +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.InternalResourceApi + +@OptIn(InternalResourceApi::class) +private fun collectCommonMainStringResources(): HashMap { + val map = HashMap() + _collectCommonMainString0Resources(map) + _collectCommonMainString1Resources(map) + return map +} + +internal fun resolveSearchHintResource(): StringResource { + val map = collectCommonMainStringResources() + return map["search_hint"] + ?: map["app_name"] + ?: error("Search hint resource is missing") +} + +internal fun resolveSearchNotFoundTitleResource(): StringResource { + val map = collectCommonMainStringResources() + return map["search_not_found"] + ?: map["search_not_found_message"] + ?: map["app_name"] + ?: error("Search not-found title resource is missing") +} + +internal fun resolveSearchNotFoundMessageResource(): StringResource { + val map = collectCommonMainStringResources() + return map["search_not_found_message"] + ?: map["search_not_found"] + ?: map["app_name"] + ?: error("Search not-found message resource is missing") +} + +internal fun resolveSearchTitleResource(): StringResource { + val map = collectCommonMainStringResources() + return map["search_title"] + ?: map["search_hint"] + ?: map["app_name"] + ?: error("Search title resource is missing") +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UserStatusStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UserStatusStore.kt index f35b89e..77c4492 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UserStatusStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UserStatusStore.kt @@ -7,7 +7,8 @@ import kotlinx.coroutines.flow.update data class UserStatus( val online: Boolean, - val lastSeen: String? = null + val lastSeen: String? = null, + val typingUsernames: List = emptyList() ) object UserStatusStore { @@ -15,6 +16,29 @@ object UserStatusStore { val status: StateFlow> = _status.asStateFlow() fun update(userId: Int, online: Boolean, lastSeen: String?) { - _status.update { it + (userId to UserStatus(online, lastSeen)) } + _status.update { current -> + val existing = current[userId] ?: UserStatus(online = false) + current + (userId to existing.copy(online = online, lastSeen = lastSeen ?: existing.lastSeen)) + } + } + + fun addTyping(userId: Int, username: String) { + val trimmed = username.trim().takeIf { it.isNotBlank() } ?: return + _status.update { current -> + val existing = current[userId] ?: UserStatus(online = false) + val typing = (existing.typingUsernames + trimmed) + .filter { it.isNotBlank() } + .distinct() + if (typing == existing.typingUsernames) current else current + (userId to existing.copy(typingUsernames = typing)) + } + } + + fun removeTyping(userId: Int, username: String) { + val trimmed = username.trim().takeIf { it.isNotBlank() } ?: return + _status.update { current -> + val existing = current[userId] ?: return@update current + val typing = existing.typingUsernames.filterNot { it.equals(trimmed, ignoreCase = true) } + current + (userId to existing.copy(typingUsernames = typing)) + } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt index 5b90d1a..f143f86 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -44,6 +45,8 @@ import ru.fromchat.about_link_website import ru.fromchat.about_version import ru.fromchat.app_desc import ru.fromchat.app_name +import ru.fromchat.debug_tools +import ru.fromchat.debug_tools_d import ru.fromchat.back import ru.fromchat.ui.branding.FromChatBrandTitle @@ -172,6 +175,19 @@ fun AboutScreen() { ) } ) + ListItem( + headline = stringResource(Res.string.debug_tools), + supportingText = stringResource(Res.string.debug_tools_d), + onClick = { navController.navigate("debug") }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.BugReport, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + ) } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index e15ba29..b8bb3df 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -31,19 +31,24 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import ru.fromchat.api.ApiClient +import ru.fromchat.api.UserStatusStore import ru.fromchat.api.ProfileCache import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.WebSocketManager +import ru.fromchat.api.WebSocketMessage +import ru.fromchat.api.WebSocketUpdatesData import ru.fromchat.net.NetworkConnectivity import ru.fromchat.core.config.Config import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.RegisterScreen import ru.fromchat.ui.chat.PublicChatScreen import ru.fromchat.ui.debug.DebugApiScreen +import ru.fromchat.ui.debug.DebugSearchBarDocsScreen import ru.fromchat.ui.dm.DmChatRoute import ru.fromchat.ui.dm.DmNav import ru.fromchat.ui.dm.DmProfileRoute import ru.fromchat.ui.main.MainScreen +import ru.fromchat.ui.main.ChatsSearchScreen import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.setup.ServerConfigScreen import ru.fromchat.ui.LocalSystemBarsVisibility @@ -62,9 +67,38 @@ import ru.fromchat.ui.main.settings.SettingsRoutes import ru.fromchat.ui.main.settings.SettingsSecurityHubScreen import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen import ru.fromchat.ui.main.settings.SettingsServerToolsScreen +import kotlinx.serialization.json.* val LocalNavController = compositionLocalOf { error("NavController not provided") } +private fun handlePresenceStatus(data: JsonObject?) { + val userId = data?.get("userId")?.jsonPrimitive?.content?.toIntOrNull() ?: return + val online = data["online"]?.jsonPrimitive?.booleanOrNull == true + val lastSeen = data["lastSeen"]?.jsonPrimitive?.content + UserStatusStore.update(userId, online, lastSeen) +} + +private fun handlePresenceTyping(type: String, data: JsonObject?) { + val userId = data?.get("userId")?.jsonPrimitive?.content?.toIntOrNull() ?: return + val username = data["username"]?.jsonPrimitive?.contentOrNull ?: return + when (type) { + "dmTyping" -> UserStatusStore.addTyping(userId, username) + "stopDmTyping" -> UserStatusStore.removeTyping(userId, username) + } +} + +private fun handlePresenceEvent(message: WebSocketMessage) { + when (message.type) { + "statusUpdate" -> message.data?.jsonObject?.let(::handlePresenceStatus) + "dmTyping", "stopDmTyping" -> message.data?.jsonObject?.let { handlePresenceTyping(message.type, it) } + "updates" -> { + val data = message.data ?: return + val updates = ApiClient.json.decodeFromJsonElement(data) + updates.updates.forEach(::handlePresenceEvent) + } + } +} + private fun NavGraphBuilder.settingsSlideComposable( route: String, animationSpec: FiniteAnimationSpec, @@ -157,6 +191,16 @@ fun App( } } + DisposableEffect(Unit) { + val handler: (WebSocketMessage) -> Unit = { message -> + runCatching { handlePresenceEvent(message) } + } + WebSocketManager.addGlobalMessageHandler(handler) + onDispose { + WebSocketManager.removeGlobalMessageHandler(handler) + } + } + FromChatTheme { SharedTransitionLayout { val navController = rememberNavController() @@ -254,7 +298,10 @@ fun App( } composable("chat") { - MainScreen() + MainScreen( + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this + ) } composable("chats/publicChat") { @@ -265,9 +312,37 @@ fun App( ) } + val searchScreenFade = tween(260) + composable( + route = "search/conversations", + enterTransition = { fadeIn(animationSpec = searchScreenFade) }, + exitTransition = { fadeOut(animationSpec = searchScreenFade) }, + popEnterTransition = { fadeIn(animationSpec = searchScreenFade) }, + popExitTransition = { fadeOut(animationSpec = searchScreenFade) } + ) { + ChatsSearchScreen( + onBack = { navController.popBackStack() }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, + onOpenProfile = { userId: Int -> + if (userId != 0) { + navController.navigate("profile/$userId") + } + }, + onOpenConversation = { userId: Int -> + if (userId != 0) { + navController.navigate(DmNav.chatRoute(userId)) + } + } + ) + } + composable("debug") { DebugApiScreen() } + composable("debug/searchbar-docs") { + DebugSearchBarDocsScreen() + } composable( route = "profile/{userId}?useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}", diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/M3SearchBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/M3SearchBar.kt new file mode 100644 index 0000000..f891e1a --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/M3SearchBar.kt @@ -0,0 +1,152 @@ +package ru.fromchat.ui + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp + +internal const val SearchBarSharedElement = "search_bar_shared_element" + +@Composable +fun M3SearchBar( + query: String, + onQueryChange: (String) -> Unit, + onSearch: () -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + shape: Shape = CircleShape, + readOnly: Boolean = false, + onReadOnlyActivate: (() -> Unit)? = null, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedElementKey: Any? = null, + autoFocus: Boolean = false, + leadingIcon: @Composable () -> Unit = {}, + trailingIcon: @Composable () -> Unit = {}, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(autoFocus, readOnly) { + if (autoFocus && !readOnly) { + focusRequester.requestFocus() + } + } + + val interactionSource = remember { MutableInteractionSource() } + val onReadOnlyActivateOrNull = onReadOnlyActivate + val sharedTransitionModifier = if ( + sharedTransitionScope != null && + animatedVisibilityScope != null && + sharedElementKey != null + ) { + with(sharedTransitionScope) { + Modifier.sharedElement( + rememberSharedContentState(key = sharedElementKey), + animatedVisibilityScope = animatedVisibilityScope + ) + } + } else { + Modifier + } + + val containerModifier = if (readOnly && onReadOnlyActivateOrNull != null) { + modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onReadOnlyActivateOrNull + ) + } else { + modifier + } + val finalModifier = containerModifier + .then(sharedTransitionModifier) + .height(56.dp) + + val typography = MaterialTheme.typography.bodyLarge + Surface( + modifier = finalModifier, + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.size(24.dp)) { + leadingIcon() + } + + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = typography.copy( + color = MaterialTheme.colorScheme.onSurface + ), + enabled = !readOnly, + readOnly = readOnly, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { onSearch() } + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth() + .padding(start = 12.dp, end = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + if (query.isEmpty()) { + Text( + text = placeholder, + style = typography.copy(color = MaterialTheme.colorScheme.onSurfaceVariant) + ) + } + innerTextField() + } + }, + modifier = Modifier + .weight(1f) + .padding(start = 4.dp) + .focusRequester(focusRequester) + ) + + Box(modifier = Modifier.width(6.dp)) + Box(modifier = Modifier.size(24.dp)) { + trailingIcon() + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt index 82e8804..d38adff 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt @@ -50,6 +50,8 @@ private object DebugScreenText { const val OpenProfileDesc = "Your profile with chat and link actions" const val OpenDmUser2 = "DM (user 2)" const val OpenDmUser2Desc = "Open a direct message with user ID 2" + const val SearchBarDocs = "SearchBar docs" + const val SearchBarDocsDesc = "Official Material3 SearchBar snippet in an isolated screen" const val BugIconCd = "Debug" const val StatusPlaceholder = "Status will appear here" const val UnknownError = "Something went wrong. Please try again." @@ -177,6 +179,24 @@ fun DebugApiScreenContent() { Text(text = DebugScreenText.OpenAction) } + Text( + text = DebugScreenText.SearchBarDocs, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = DebugScreenText.SearchBarDocsDesc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { navController.navigate("debug/searchbar-docs") }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = DebugScreenText.OpenAction) + } + HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp), thickness = DividerDefaults.Thickness, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiSearchBarDocsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiSearchBarDocsScreen.kt new file mode 100644 index 0000000..b8b5d57 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiSearchBarDocsScreen.kt @@ -0,0 +1,112 @@ +package ru.fromchat.ui.debug + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview + +import ru.fromchat.ui.M3SearchBar + +@Composable +fun DebugSearchBarDocsScreen() { + SearchBarExamples() +} + +@Preview(showBackground = true) +@Composable +fun SearchBarExamples() { + val allItems = remember { + listOf( + "Cupcake", + "Donut", + "Eclair", + "Froyo", + "Honeycomb", + "Ice Cream Sandwich", + "Jelly Bean", + "KitKat", + "Lollipop", + "Marshmallow", + "Nougat", + "Oreo", + "Pie" + ) + } + + var query by remember { mutableStateOf("") } + val filtered by remember { + derivedStateOf { + val searchText = query.trim().lowercase() + if (searchText.isEmpty()) { + allItems + } else { + allItems.filter { it.lowercase().contains(searchText) } + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + M3SearchBar( + query = query, + onQueryChange = { query = it }, + onSearch = {}, + placeholder = "Search", + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + }, + trailingIcon = { + if (query.isNotBlank()) { + IconButton(onClick = { query = "" }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear" + ) + } + } + }, + modifier = Modifier.fillMaxWidth() + ) + + Text("Results") + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 16.dp) + ) { + items(filtered) { item -> + Text( + text = item, + modifier = Modifier.padding(vertical = 6.dp) + ) + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsSearchScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsSearchScreen.kt new file mode 100644 index 0000000..2de3821 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsSearchScreen.kt @@ -0,0 +1,354 @@ +package ru.fromchat.ui.main + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +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.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.utils.resetFocus +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.api.ApiClient +import ru.fromchat.api.ProfileCache +import ru.fromchat.api.UserStatusStore +import ru.fromchat.api.db.CachedConversation +import ru.fromchat.api.db.MessageCacheStore +import ru.fromchat.chat_last_mesaage +import ru.fromchat.resolveSearchHintResource +import ru.fromchat.resolveSearchTitleResource +import ru.fromchat.resolveSearchNotFoundMessageResource +import ru.fromchat.resolveSearchNotFoundTitleResource +import ru.fromchat.ui.BackHandler +import ru.fromchat.ui.M3SearchBar +import ru.fromchat.ui.SearchBarSharedElement + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ChatsSearchScreen( + onBack: () -> Unit, + onOpenProfile: (Int) -> Unit, + onOpenConversation: (Int) -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + var searchText by remember { mutableStateOf("") } + val searchListState = rememberLazyListState() + val statusMap by UserStatusStore.status.collectAsState() + val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) + val searchHint = stringResource(resolveSearchHintResource()) + val searchBarHint = stringResource(resolveSearchTitleResource()) + var dmConversations by remember { mutableStateOf>(emptyList()) } + val statusSubscriptionScope = rememberCoroutineScope() + var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val hideIme: () -> Unit = { + resetFocus(keyboardController, focusManager) + } + + val searchQuery = searchText.trim().lowercase().trimStart('@') + val filteredDmConversations = remember(dmConversations, searchQuery) { + if (searchQuery.isBlank()) { + dmConversations + } else { + dmConversations.filter { conv -> + matchesSearchConversations(conv, searchQuery) + } + } + } + + val searchUiState by remember(searchText, filteredDmConversations) { + derivedStateOf { + when { + searchText.isBlank() -> SearchScreenState.EmptyQuery + filteredDmConversations.isEmpty() -> SearchScreenState.NotFound + else -> SearchScreenState.Results + } + } + } + LaunchedEffect(Unit) { + // Load cached DM conversations first for immediate display. + runCatching { + dmConversations = MessageCacheStore.loadCachedDmConversations() + } + + // Then refresh from network and update cache + state. + runCatching { + ApiClient.getDmConversations() + }.onSuccess { conversations -> + runCatching { + conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } + MessageCacheStore.replaceDmConversations(conversations) + dmConversations = MessageCacheStore.loadCachedDmConversations() + } + } + } + + LaunchedEffect(filteredDmConversations, searchListState) { + snapshotFlow { + searchListState.layoutInfo.visibleItemsInfo + .mapNotNull { item -> + filteredDmConversations.getOrNull(item.index)?.otherUserId + } + .filter { it > 0 } + .toSet() + } + .distinctUntilChanged() + .collect { visibleIds -> + val toSubscribe = visibleIds - subscribedDmUserIds + val toUnsubscribe = subscribedDmUserIds - visibleIds + + toSubscribe.forEach { userId -> + runCatching { ApiClient.sendSubscribeStatus(userId) } + } + toUnsubscribe.forEach { userId -> + runCatching { ApiClient.sendUnsubscribeStatus(userId) } + } + subscribedDmUserIds = visibleIds + } + } + + DisposableEffect(Unit) { + onDispose { + if (subscribedDmUserIds.isNotEmpty()) { + statusSubscriptionScope.launch { + subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) } + } + } + subscribedDmUserIds = emptySet() + } + } + + BackHandler(enabled = true) { + hideIme() + onBack() + } + + Scaffold( + modifier = Modifier + .imePadding() + .windowInsetsPadding(WindowInsets.safeDrawing), + contentWindowInsets = WindowInsets.safeDrawing, + topBar = { + M3SearchBar( + query = searchText, + onQueryChange = { searchText = it }, + onSearch = {}, + placeholder = searchBarHint, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + leadingIcon = { + IconButton(onClick = { + hideIme() + onBack() + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + trailingIcon = { + if (searchText.isNotBlank()) { + IconButton(onClick = { searchText = "" }) { + Icon(imageVector = Icons.Filled.Clear, contentDescription = null) + } + } + }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedElementKey = SearchBarSharedElement, + autoFocus = true + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + AnimatedContent( + targetState = searchUiState, + modifier = Modifier.fillMaxSize(), + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + label = "search_screen_state" + ) { state -> + when (state) { + SearchScreenState.EmptyQuery -> { + SearchScreenInfo( + title = stringResource(resolveSearchTitleResource()), + subtitle = searchHint, + showSearchIcon = true + ) + } + + SearchScreenState.NotFound -> { + SearchScreenInfo( + title = stringResource(resolveSearchNotFoundTitleResource()), + subtitle = stringResource(resolveSearchNotFoundMessageResource()), + showSearchIcon = false + ) + } + + SearchScreenState.Results -> { + SearchConversationsList( + listState = searchListState, + conversations = filteredDmConversations, + defaultLastMessage = defaultLastMessage, + statusMap = statusMap, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + onOpenProfile = { userId -> + if (userId != 0) { + onOpenProfile(userId) + } + }, + onOpenConversation = { userId -> + if (userId != 0) { + onOpenConversation(userId) + } + } + ) + } + } + } + } + } +} + +private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String): Boolean { + val cached = ProfileCache.get(conv.otherUserId) + val candidates = buildList { + add(conv.displayName) + cached?.displayName?.let { add(it) } + cached?.username?.let { add(it) } + } + + return candidates.any { candidate -> + candidate.lowercase().contains(normalizedQuery) + } +} + +private enum class SearchScreenState { + EmptyQuery, + NotFound, + Results +} + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun SearchScreenInfo( + title: String, + subtitle: String, + showSearchIcon: Boolean, + modifier: Modifier = Modifier, +) { + val tonal = ButtonDefaults.filledTonalButtonColors() + val pillWidth = 148.dp + val markerHeight = 132.dp + val expressiveShape = MaterialShapes.Cookie4Sided.toShape() + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .width(pillWidth) + .height(markerHeight) + ) { + val markerColor = if (showSearchIcon) { + tonal.containerColor + } else { + tonal.containerColor.copy(alpha = 0.7f) + } + Surface( + modifier = Modifier + .width(pillWidth) + .height(markerHeight), + shape = expressiveShape, + color = markerColor + ) { + Box(Modifier.fillMaxSize()) + } + + if (showSearchIcon) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + modifier = Modifier.size(88.dp), + tint = tonal.contentColor + ) + } + } + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt index 79db48f..4201761 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt @@ -1,5 +1,7 @@ package ru.fromchat.ui.main +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -11,27 +13,38 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -39,21 +52,36 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.* import ru.fromchat.api.ApiClient import ru.fromchat.api.ConnectionStateStore import ru.fromchat.api.ConnectionStatus import ru.fromchat.api.ProfileCache +import ru.fromchat.api.UserStatus +import ru.fromchat.api.UserStatusStore import ru.fromchat.api.db.CachedConversation import ru.fromchat.api.db.MessageCacheStore +import ru.fromchat.app_name +import ru.fromchat.chat_last_mesaage import ru.fromchat.net.NetworkConnectivity +import ru.fromchat.resolveSearchTitleResource +import ru.fromchat.presence_online +import ru.fromchat.public_chat +import ru.fromchat.status_connecting +import ru.fromchat.status_updating import ru.fromchat.ui.ConnectingEllipsis import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.dm.DmNav +import ru.fromchat.ui.M3SearchBar +import ru.fromchat.ui.SearchBarSharedElement import ru.fromchat.ui.branding.FromChatBrandTitle import ru.fromchat.ui.chat.Avatar +import ru.fromchat.ui.chat.TypingIndicator +import ru.fromchat.ui.dm.DmNav +import ru.fromchat.unread_count +import ru.fromchat.user_fallback @Composable private fun ChatRowAvatar( @@ -82,12 +110,63 @@ private fun ChatRowAvatar( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ChatsTab() { +fun ChatsTab( + isVisible: Boolean = true, + onOpenSearch: () -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { val navController = LocalNavController.current val connectionStatus by ConnectionStateStore.status.collectAsState() val online by NetworkConnectivity.isOnline.collectAsState(initial = true) var dmConversations by remember { mutableStateOf>(emptyList()) } var publicLastMessagePreview by remember { mutableStateOf(null) } + val searchBarHint = stringResource(resolveSearchTitleResource()) + val tabListState = rememberLazyListState() + val statusMap by UserStatusStore.status.collectAsState() + var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } + val statusSubscriptionScope = rememberCoroutineScope() + val publicConversationsOffset = 1 + + LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch) { + snapshotFlow { + if (!isVisible) { + emptySet() + } else { + tabListState.layoutInfo.visibleItemsInfo + .mapNotNull { item -> + val dmRowIndex = item.index - publicConversationsOffset + dmConversations.getOrNull(dmRowIndex)?.otherUserId + } + .filter { it > 0 } + .toSet() + } + } + .distinctUntilChanged() + .collect { visibleIds -> + val toSubscribe = visibleIds - subscribedDmUserIds + val toUnsubscribe = subscribedDmUserIds - visibleIds + + toSubscribe.forEach { userId -> + runCatching { ApiClient.sendSubscribeStatus(userId) } + } + toUnsubscribe.forEach { userId -> + runCatching { ApiClient.sendUnsubscribeStatus(userId) } + } + subscribedDmUserIds = visibleIds + } + } + + DisposableEffect(Unit) { + onDispose { + if (subscribedDmUserIds.isNotEmpty()) { + statusSubscriptionScope.launch { + subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) } + } + } + subscribedDmUserIds = emptySet() + } + } LaunchedEffect(Unit) { // Load cached DM conversations first for instant offline display. @@ -122,6 +201,8 @@ fun ChatsTab() { val connectingTitle = stringResource(Res.string.status_connecting) val updatingTitle = stringResource(Res.string.status_updating) val brandTitle = stringResource(Res.string.app_name) + val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) + val publicChatTitle = stringResource(Res.string.public_chat) Scaffold( topBar = { @@ -212,87 +293,228 @@ fun ChatsTab() { ) } ) { innerPadding -> - val publicChatTitle = stringResource(Res.string.public_chat) - LazyColumn(contentPadding = innerPadding) { - item { - ListItem( - leadingContent = { - ChatRowAvatar( - profilePictureUrl = null, - displayNameForInitials = publicChatTitle, - onClick = { navController.navigate("chats/publicChat") } - ) - }, - headlineContent = { - Text( - text = publicChatTitle, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - supportingContent = { - val preview = publicLastMessagePreview - Text( - text = preview ?: stringResource(Res.string.chat_last_mesaage), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - }, - modifier = Modifier.clickable { - navController.navigate("chats/publicChat") - } - ) - } + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + M3SearchBar( + 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 + ) - items(dmConversations.size) { index -> - val conv = dmConversations[index] - val cached = ProfileCache.get(conv.otherUserId) - val avatarUrl = cached?.profilePicture - val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() } - ?: cached?.username?.takeIf { it.isNotBlank() } - ?: conv.displayName.ifBlank { - stringResource(Res.string.user_fallback, conv.otherUserId) + ChatConversationsList( + listState = tabListState, + conversations = dmConversations, + publicChatTitle = publicChatTitle, + publicLastMessagePreview = publicLastMessagePreview, + defaultLastMessage = defaultLastMessage, + statusMap = statusMap, + modifier = Modifier.fillMaxSize(), + onOpenPublic = { navController.navigate("chats/publicChat") }, + onOpenProfile = { userId -> + if (userId != 0) { + navController.navigate("profile/$userId") } - val preview = conv.lastMessagePreview?.trim().orEmpty() - ListItem( - leadingContent = { - ChatRowAvatar( - profilePictureUrl = avatarUrl, - displayNameForInitials = peerTitle, - onClick = { - if (conv.otherUserId != 0) { - navController.navigate("profile/${conv.otherUserId}") - } - } - ) - }, - headlineContent = { - Text( - text = peerTitle, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - supportingContent = { - Text( - text = preview.takeIf { it.isNotEmpty() } - ?: stringResource(Res.string.chat_last_mesaage), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - }, - trailingContent = { - if (conv.unreadCount > 0) { - Text(stringResource(Res.string.unread_count, conv.unreadCount)) - } - }, - modifier = Modifier.clickable { - if (conv.otherUserId != 0) { - navController.navigate(DmNav.chatRoute(conv.otherUserId)) - } + }, + onOpenConversation = { userId -> + if (userId != 0) { + navController.navigate(DmNav.chatRoute(userId)) } - ) - } + } + ) } } +} + +@Composable +private fun ChatConversationsList( + listState: LazyListState, + conversations: List, + publicChatTitle: String, + publicLastMessagePreview: String?, + defaultLastMessage: String, + statusMap: Map, + modifier: Modifier = Modifier, + onOpenPublic: () -> Unit, + onOpenProfile: (Int) -> Unit, + onOpenConversation: (Int) -> Unit +) { + LazyColumn( + state = listState, + modifier = modifier, + contentPadding = PaddingValues(bottom = 12.dp) + ) { + item { + ListItem( + leadingContent = { + ChatRowAvatar( + profilePictureUrl = null, + displayNameForInitials = publicChatTitle, + onClick = onOpenPublic + ) + }, + headlineContent = { + Text( + text = publicChatTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Text( + text = publicLastMessagePreview ?: defaultLastMessage, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + modifier = Modifier.clickable { onOpenPublic() } + ) + } + + items(conversations.size) { index -> + DmConversationRow( + conversation = conversations[index], + defaultLastMessage = defaultLastMessage, + statusMap = statusMap, + onOpenProfile = onOpenProfile, + onOpenConversation = onOpenConversation + ) + } + } +} + +@Composable +internal fun SearchConversationsList( + listState: LazyListState, + conversations: List, + defaultLastMessage: String, + statusMap: Map, + modifier: Modifier = Modifier, + onOpenProfile: (Int) -> Unit, + onOpenConversation: (Int) -> Unit +) { + LazyColumn( + state = listState, + modifier = modifier, + contentPadding = PaddingValues(bottom = 12.dp) + ) { + items(conversations.size) { index -> + DmConversationRow( + conversation = conversations[index], + defaultLastMessage = defaultLastMessage, + statusMap = statusMap, + onOpenProfile = onOpenProfile, + onOpenConversation = onOpenConversation + ) + } + } +} + +@Composable +private fun DmConversationRow( + conversation: CachedConversation, + defaultLastMessage: String, + statusMap: Map, + onOpenProfile: (Int) -> Unit, + onOpenConversation: (Int) -> Unit +) { + val cached = ProfileCache.get(conversation.otherUserId) + val avatarUrl = cached?.profilePicture + val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() } + ?: cached?.username?.takeIf { it.isNotBlank() } + ?: conversation.displayName.ifBlank { + stringResource(Res.string.user_fallback, conversation.otherUserId) + } + val preview = conversation.lastMessagePreview?.trim().orEmpty().ifEmpty { defaultLastMessage } + val status = statusMap[conversation.otherUserId] + val typingUsers = status?.typingUsernames.orEmpty() + val isTyping = typingUsers.isNotEmpty() + val isOnline = status?.online ?: (cached?.online == true) + val statusKey = when { + isTyping -> "typing:${typingUsers.joinToString("|")}" + isOnline -> "online" + else -> "offline" + } + + ListItem( + leadingContent = { + ChatRowAvatar( + profilePictureUrl = avatarUrl, + displayNameForInitials = peerTitle, + onClick = { onOpenProfile(conversation.otherUserId) } + ) + }, + headlineContent = { + Text( + text = peerTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + AnimatedContent( + targetState = statusKey, + transitionSpec = { + (slideInVertically { it / 2 } + fadeIn()) togetherWith + (slideOutVertically { -it / 2 } + fadeOut()) + }, + label = "dm_status_${conversation.otherUserId}" + ) { state -> + when { + state.startsWith("typing:") -> TypingIndicator(typingUsers = typingUsers) + state == "online" -> Text( + text = stringResource(Res.string.presence_online), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.primary + ) + else -> Text( + text = preview, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + trailingContent = { + if (conversation.unreadCount > 0) { + Text(stringResource(Res.string.unread_count, conversation.unreadCount)) + } + }, + modifier = Modifier.clickable { + onOpenConversation(conversation.otherUserId) + } + ) +} + +private fun matchesDmSearch(conv: CachedConversation, normalizedQuery: String): Boolean { + val cached = ProfileCache.get(conv.otherUserId) + val candidates = buildList { + add(conv.displayName) + cached?.displayName?.let { add(it) } + cached?.username?.let { add(it) } + } + + return candidates.any { candidate -> + candidate.lowercase().contains(normalizedQuery) + } } \ No newline at end of file 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 7bbfe92..4a6b3a0 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 @@ -1,7 +1,10 @@ package ru.fromchat.ui.main +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding @@ -16,19 +19,21 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -import ru.fromchat.Res import ru.fromchat.* import ru.fromchat.api.ApiClient +import ru.fromchat.ui.LocalNavController import ru.fromchat.utils.exclude import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.profile.ProfileScreen +import kotlinx.coroutines.launch private const val PAGE_CHATS = 0 private const val PAGE_CONTACTS = 1 @@ -37,8 +42,13 @@ private const val PAGE_PROFILE = 3 private const val PAGE_COUNT = 4 @Suppress("AssignedValueIsNeverRead") +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun MainScreen() { +fun MainScreen( + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + val navController = LocalNavController.current val pagerState = rememberPagerState( initialPage = PAGE_CHATS, pageCount = { PAGE_COUNT }, @@ -47,6 +57,7 @@ fun MainScreen() { // Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop). val selectedPage = pagerState.currentPage + val isChatsPage = selectedPage == PAGE_CHATS Scaffold( bottomBar = { @@ -88,30 +99,41 @@ fun MainScreen() { contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top), modifier = Modifier.imePadding() ) { innerPadding -> - HorizontalPager( - state = pagerState, + Box( modifier = Modifier .fillMaxSize() - .padding(innerPadding), - beyondViewportPageCount = 1, - ) { page -> - when (page) { - PAGE_CHATS -> ChatsTab() - PAGE_CONTACTS -> ContactsTab() - PAGE_SETTINGS -> SettingsTab() - PAGE_PROFILE -> { - val currentUserId = ApiClient.user?.id - ProfileScreen( - userId = currentUserId, - onBack = {}, - onChat = { _ -> }, - modifier = Modifier.fillMaxSize(), - onOpenSettings = { - scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } - } + .padding(innerPadding) + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + beyondViewportPageCount = 1, + ) { page -> + when (page) { + PAGE_CHATS -> ChatsTab( + isVisible = isChatsPage, + onOpenSearch = { + navController.navigate("search/conversations") + }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, ) + PAGE_CONTACTS -> ContactsTab() + PAGE_SETTINGS -> SettingsTab() + PAGE_PROFILE -> { + val currentUserId = ApiClient.user?.id + ProfileScreen( + userId = currentUserId, + onBack = {}, + onChat = { _ -> }, + modifier = Modifier.fillMaxSize(), + onOpenSettings = { + scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } + } + ) + } + else -> Unit } - else -> Unit } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt index f063596..1b79a79 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt @@ -17,6 +17,7 @@ fun SettingsTab() { onAppearance = { nav.navigate(SettingsRoutes.Appearance) }, onNotifications = { nav.navigate(SettingsRoutes.Notifications) }, onDevices = { nav.navigate(SettingsRoutes.Devices) }, + onSwitchServer = { nav.navigate("serverConfig") }, onAccount = { nav.navigate(SettingsRoutes.Account) }, onAbout = { nav.navigate("about") }, title = stringResource(Res.string.settings), diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt index aa95965..7c0255d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt @@ -240,6 +240,7 @@ fun SettingsHubScreen( onAppearance: () -> Unit, onNotifications: () -> Unit, onDevices: () -> Unit, + onSwitchServer: () -> Unit, onAccount: () -> Unit, onAbout: () -> Unit, title: String, @@ -335,6 +336,23 @@ fun SettingsHubScreen( ) } ) + ListItem( + headline = stringResource(Res.string.change_server), + supportingText = stringResource(Res.string.change_server_d), + onClick = onSwitchServer, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Storage) }, + divider = true, + dividerColor = settingsSurfaceCutDividerColor(), + dividerThickness = SettingsSurfaceCutDividerThickness, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) } Spacer(Modifier.height(20.dp)) Category( 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 887615d..65188b7 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 @@ -40,6 +40,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -74,10 +75,21 @@ import ru.fromchat.* import ru.fromchat.api.ApiClient import ru.fromchat.api.ProfileCache import ru.fromchat.api.UserProfile +import ru.fromchat.api.UserStatus +import ru.fromchat.api.UserStatusStore import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.chat.Avatar +import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.chat.publicChatProfileSharedAvatarKey import ru.fromchat.ui.scaleOnPress +import ru.fromchat.utils.formatLastSeen +import ru.fromchat.utils.rememberLastSeenFormatStrings +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith private sealed interface ProfileLoadError { data object Generic : ProfileLoadError @@ -331,6 +343,26 @@ fun ProfileScreen( val verificationLabel = if (profile.verified == true) verifiedSupport else verifyPromptSupport val isOwnProfile = ApiClient.user?.id == profile.id + val statusMap by UserStatusStore.status.collectAsState() + val lastSeenFormat = rememberLastSeenFormatStrings() + val statusState = statusMap[profile.id] ?: UserStatus( + online = profile.online, + lastSeen = profile.lastSeen + ) + val typingUsers = statusState.typingUsernames + val statusText = if (statusState.online) { + stringResource(Res.string.presence_online) + } else { + formatLastSeen(false, statusState.lastSeen, lastSeenFormat) + .ifEmpty { stringResource(Res.string.presence_recently) } + } + val statusStateKey = if (typingUsers.isNotEmpty()) { + "typing:${typingUsers.joinToString("|")}" + } else if (statusState.online) { + "online" + } else { + "offline" + } Row( verticalAlignment = Alignment.CenterVertically, @@ -345,13 +377,30 @@ fun ProfileScreen( userId = profile.id ) } - if (!compactIdentityForPublicChat && profile.username.isNotBlank()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "@${profile.username}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Spacer(modifier = Modifier.height(4.dp)) + AnimatedContent( + targetState = statusStateKey, + transitionSpec = { + (slideInVertically { it / 2 } + fadeIn()) togetherWith + (slideOutVertically { -it / 2 } + fadeOut()) + }, + label = "profile_status_${profile.id}" + ) { state -> + when { + state.startsWith("typing:") -> TypingIndicator( + typingUsers = typingUsers + ) + state == "online" -> Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + else -> Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } Spacer(modifier = Modifier.height(36.dp))