mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Improve UI, add search bar
This commit is contained in:
Generated
+1
-3
@@ -1,8 +1,6 @@
|
|||||||
<component name="ArtifactManager">
|
<component name="ArtifactManager">
|
||||||
<artifact type="jar" name="shared">
|
<artifact type="jar" name="shared">
|
||||||
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
|
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
|
||||||
<root id="archive" name="shared.jar">
|
<root id="archive" name="shared.jar" />
|
||||||
<element id="module-output" name="FromChat.utils.shared.androidMain" />
|
|
||||||
</root>
|
|
||||||
</artifact>
|
</artifact>
|
||||||
</component>
|
</component>
|
||||||
+2
@@ -34,9 +34,11 @@
|
|||||||
<inspection_tool class="LocalVariableName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
<inspection_tool class="LocalVariableName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||||
<inspection_tool class="MultiplatformPreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="MultiplatformPreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
<inspection_tool class="MultiplatformPreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="MultiplatformPreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
<inspection_tool class="NestedLambdaShadowedImplicitParameter" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
<inspection_tool class="NestedLambdaShadowedImplicitParameter" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
<string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string>
|
<string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string>
|
||||||
<string name="public_chat">Общий чат</string>
|
<string name="public_chat">Общий чат</string>
|
||||||
<string name="chat_last_mesaage">Вы: последнее сообщение</string>
|
<string name="chat_last_mesaage">Вы: последнее сообщение</string>
|
||||||
|
<string name="search_hint">Найдите пользователя, имя или чат</string>
|
||||||
|
<string name="search_title">Поиск</string>
|
||||||
|
<string name="search_not_found">Ничего не найдено</string>
|
||||||
|
<string name="search_not_found_message">Ничего не найдено. Попробуйте переформулировать запрос.</string>
|
||||||
<string name="message_placeholder">Напишите сообщение…</string>
|
<string name="message_placeholder">Напишите сообщение…</string>
|
||||||
<string name="status_connecting">Соединение</string>
|
<string name="status_connecting">Соединение</string>
|
||||||
<string name="status_updating">Обновление</string>
|
<string name="status_updating">Обновление</string>
|
||||||
|
|||||||
@@ -43,6 +43,10 @@
|
|||||||
<string name="contacts_empty_body">Your contacts will appear here when this feature is ready.</string>
|
<string name="contacts_empty_body">Your contacts will appear here when this feature is ready.</string>
|
||||||
<string name="public_chat">Main chat</string>
|
<string name="public_chat">Main chat</string>
|
||||||
<string name="chat_last_mesaage">You: last message</string>
|
<string name="chat_last_mesaage">You: last message</string>
|
||||||
|
<string name="search_hint">Search by name, username or chat</string>
|
||||||
|
<string name="search_title">Search</string>
|
||||||
|
<string name="search_not_found">No results</string>
|
||||||
|
<string name="search_not_found_message">Nothing found here. Try rephrasing your query.</string>
|
||||||
<string name="message_placeholder">Write a message…</string>
|
<string name="message_placeholder">Write a message…</string>
|
||||||
|
|
||||||
<!-- Connection / chat chrome -->
|
<!-- Connection / chat chrome -->
|
||||||
|
|||||||
@@ -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<String, StringResource> {
|
||||||
|
val map = HashMap<String, StringResource>()
|
||||||
|
_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")
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import kotlinx.coroutines.flow.update
|
|||||||
|
|
||||||
data class UserStatus(
|
data class UserStatus(
|
||||||
val online: Boolean,
|
val online: Boolean,
|
||||||
val lastSeen: String? = null
|
val lastSeen: String? = null,
|
||||||
|
val typingUsernames: List<String> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
object UserStatusStore {
|
object UserStatusStore {
|
||||||
@@ -15,6 +16,29 @@ object UserStatusStore {
|
|||||||
val status: StateFlow<Map<Int, UserStatus>> = _status.asStateFlow()
|
val status: StateFlow<Map<Int, UserStatus>> = _status.asStateFlow()
|
||||||
|
|
||||||
fun update(userId: Int, online: Boolean, lastSeen: String?) {
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.outlined.BugReport
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -44,6 +45,8 @@ import ru.fromchat.about_link_website
|
|||||||
import ru.fromchat.about_version
|
import ru.fromchat.about_version
|
||||||
import ru.fromchat.app_desc
|
import ru.fromchat.app_desc
|
||||||
import ru.fromchat.app_name
|
import ru.fromchat.app_name
|
||||||
|
import ru.fromchat.debug_tools
|
||||||
|
import ru.fromchat.debug_tools_d
|
||||||
import ru.fromchat.back
|
import ru.fromchat.back
|
||||||
import ru.fromchat.ui.branding.FromChatBrandTitle
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,19 +31,24 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.UserStatusStore
|
||||||
import ru.fromchat.api.ProfileCache
|
import ru.fromchat.api.ProfileCache
|
||||||
import ru.fromchat.api.UpdateSyncManager
|
import ru.fromchat.api.UpdateSyncManager
|
||||||
import ru.fromchat.api.WebSocketManager
|
import ru.fromchat.api.WebSocketManager
|
||||||
|
import ru.fromchat.api.WebSocketMessage
|
||||||
|
import ru.fromchat.api.WebSocketUpdatesData
|
||||||
import ru.fromchat.net.NetworkConnectivity
|
import ru.fromchat.net.NetworkConnectivity
|
||||||
import ru.fromchat.core.config.Config
|
import ru.fromchat.core.config.Config
|
||||||
import ru.fromchat.ui.auth.LoginScreen
|
import ru.fromchat.ui.auth.LoginScreen
|
||||||
import ru.fromchat.ui.auth.RegisterScreen
|
import ru.fromchat.ui.auth.RegisterScreen
|
||||||
import ru.fromchat.ui.chat.PublicChatScreen
|
import ru.fromchat.ui.chat.PublicChatScreen
|
||||||
import ru.fromchat.ui.debug.DebugApiScreen
|
import ru.fromchat.ui.debug.DebugApiScreen
|
||||||
|
import ru.fromchat.ui.debug.DebugSearchBarDocsScreen
|
||||||
import ru.fromchat.ui.dm.DmChatRoute
|
import ru.fromchat.ui.dm.DmChatRoute
|
||||||
import ru.fromchat.ui.dm.DmNav
|
import ru.fromchat.ui.dm.DmNav
|
||||||
import ru.fromchat.ui.dm.DmProfileRoute
|
import ru.fromchat.ui.dm.DmProfileRoute
|
||||||
import ru.fromchat.ui.main.MainScreen
|
import ru.fromchat.ui.main.MainScreen
|
||||||
|
import ru.fromchat.ui.main.ChatsSearchScreen
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||||
import ru.fromchat.ui.LocalSystemBarsVisibility
|
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.SettingsSecurityHubScreen
|
||||||
import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen
|
import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen
|
||||||
import ru.fromchat.ui.main.settings.SettingsServerToolsScreen
|
import ru.fromchat.ui.main.settings.SettingsServerToolsScreen
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
|
||||||
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
val LocalNavController = compositionLocalOf<NavController> { 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<WebSocketUpdatesData>(data)
|
||||||
|
updates.updates.forEach(::handlePresenceEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun NavGraphBuilder.settingsSlideComposable(
|
private fun NavGraphBuilder.settingsSlideComposable(
|
||||||
route: String,
|
route: String,
|
||||||
animationSpec: FiniteAnimationSpec<IntOffset>,
|
animationSpec: FiniteAnimationSpec<IntOffset>,
|
||||||
@@ -157,6 +191,16 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val handler: (WebSocketMessage) -> Unit = { message ->
|
||||||
|
runCatching { handlePresenceEvent(message) }
|
||||||
|
}
|
||||||
|
WebSocketManager.addGlobalMessageHandler(handler)
|
||||||
|
onDispose {
|
||||||
|
WebSocketManager.removeGlobalMessageHandler(handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FromChatTheme {
|
FromChatTheme {
|
||||||
SharedTransitionLayout {
|
SharedTransitionLayout {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
@@ -254,7 +298,10 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable("chat") {
|
composable("chat") {
|
||||||
MainScreen()
|
MainScreen(
|
||||||
|
sharedTransitionScope = this@SharedTransitionLayout,
|
||||||
|
animatedVisibilityScope = this
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable("chats/publicChat") {
|
composable("chats/publicChat") {
|
||||||
@@ -265,9 +312,37 @@ fun App(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val searchScreenFade = tween<Float>(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") {
|
composable("debug") {
|
||||||
DebugApiScreen()
|
DebugApiScreen()
|
||||||
}
|
}
|
||||||
|
composable("debug/searchbar-docs") {
|
||||||
|
DebugSearchBarDocsScreen()
|
||||||
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = "profile/{userId}?useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}",
|
route = "profile/{userId}?useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,8 @@ private object DebugScreenText {
|
|||||||
const val OpenProfileDesc = "Your profile with chat and link actions"
|
const val OpenProfileDesc = "Your profile with chat and link actions"
|
||||||
const val OpenDmUser2 = "DM (user 2)"
|
const val OpenDmUser2 = "DM (user 2)"
|
||||||
const val OpenDmUser2Desc = "Open a direct message with user ID 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 BugIconCd = "Debug"
|
||||||
const val StatusPlaceholder = "Status will appear here"
|
const val StatusPlaceholder = "Status will appear here"
|
||||||
const val UnknownError = "Something went wrong. Please try again."
|
const val UnknownError = "Something went wrong. Please try again."
|
||||||
@@ -177,6 +179,24 @@ fun DebugApiScreenContent() {
|
|||||||
Text(text = DebugScreenText.OpenAction)
|
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(
|
HorizontalDivider(
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
thickness = DividerDefaults.Thickness,
|
thickness = DividerDefaults.Thickness,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<CachedConversation>>(emptyList()) }
|
||||||
|
val statusSubscriptionScope = rememberCoroutineScope()
|
||||||
|
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package ru.fromchat.ui.main
|
package ru.fromchat.ui.main
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -11,27 +13,38 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.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.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.*
|
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.ConnectionStateStore
|
import ru.fromchat.api.ConnectionStateStore
|
||||||
import ru.fromchat.api.ConnectionStatus
|
import ru.fromchat.api.ConnectionStatus
|
||||||
import ru.fromchat.api.ProfileCache
|
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.CachedConversation
|
||||||
import ru.fromchat.api.db.MessageCacheStore
|
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.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.ConnectingEllipsis
|
||||||
import ru.fromchat.ui.LocalNavController
|
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.branding.FromChatBrandTitle
|
||||||
import ru.fromchat.ui.chat.Avatar
|
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
|
@Composable
|
||||||
private fun ChatRowAvatar(
|
private fun ChatRowAvatar(
|
||||||
@@ -82,12 +110,63 @@ private fun ChatRowAvatar(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatsTab() {
|
fun ChatsTab(
|
||||||
|
isVisible: Boolean = true,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
|
) {
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
||||||
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
|
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
|
||||||
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
|
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
|
||||||
var publicLastMessagePreview by remember { mutableStateOf<String?>(null) }
|
var publicLastMessagePreview by remember { mutableStateOf<String?>(null) }
|
||||||
|
val searchBarHint = stringResource(resolveSearchTitleResource())
|
||||||
|
val tabListState = rememberLazyListState()
|
||||||
|
val statusMap by UserStatusStore.status.collectAsState()
|
||||||
|
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
|
||||||
|
val statusSubscriptionScope = rememberCoroutineScope()
|
||||||
|
val publicConversationsOffset = 1
|
||||||
|
|
||||||
|
LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch) {
|
||||||
|
snapshotFlow {
|
||||||
|
if (!isVisible) {
|
||||||
|
emptySet<Int>()
|
||||||
|
} 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) {
|
LaunchedEffect(Unit) {
|
||||||
// Load cached DM conversations first for instant offline display.
|
// Load cached DM conversations first for instant offline display.
|
||||||
@@ -122,6 +201,8 @@ fun ChatsTab() {
|
|||||||
val connectingTitle = stringResource(Res.string.status_connecting)
|
val connectingTitle = stringResource(Res.string.status_connecting)
|
||||||
val updatingTitle = stringResource(Res.string.status_updating)
|
val updatingTitle = stringResource(Res.string.status_updating)
|
||||||
val brandTitle = stringResource(Res.string.app_name)
|
val brandTitle = stringResource(Res.string.app_name)
|
||||||
|
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
|
||||||
|
val publicChatTitle = stringResource(Res.string.public_chat)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -212,15 +293,82 @@ fun ChatsTab() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
val publicChatTitle = stringResource(Res.string.public_chat)
|
Column(
|
||||||
LazyColumn(contentPadding = innerPadding) {
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpenConversation = { userId ->
|
||||||
|
if (userId != 0) {
|
||||||
|
navController.navigate(DmNav.chatRoute(userId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChatConversationsList(
|
||||||
|
listState: LazyListState,
|
||||||
|
conversations: List<CachedConversation>,
|
||||||
|
publicChatTitle: String,
|
||||||
|
publicLastMessagePreview: String?,
|
||||||
|
defaultLastMessage: String,
|
||||||
|
statusMap: Map<Int, UserStatus>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onOpenPublic: () -> Unit,
|
||||||
|
onOpenProfile: (Int) -> Unit,
|
||||||
|
onOpenConversation: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(bottom = 12.dp)
|
||||||
|
) {
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
ChatRowAvatar(
|
ChatRowAvatar(
|
||||||
profilePictureUrl = null,
|
profilePictureUrl = null,
|
||||||
displayNameForInitials = publicChatTitle,
|
displayNameForInitials = publicChatTitle,
|
||||||
onClick = { navController.navigate("chats/publicChat") }
|
onClick = onOpenPublic
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
@@ -231,39 +379,87 @@ fun ChatsTab() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
val preview = publicLastMessagePreview
|
|
||||||
Text(
|
Text(
|
||||||
text = preview ?: stringResource(Res.string.chat_last_mesaage),
|
text = publicLastMessagePreview ?: defaultLastMessage,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable { onOpenPublic() }
|
||||||
navController.navigate("chats/publicChat")
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
items(dmConversations.size) { index ->
|
items(conversations.size) { index ->
|
||||||
val conv = dmConversations[index]
|
DmConversationRow(
|
||||||
val cached = ProfileCache.get(conv.otherUserId)
|
conversation = conversations[index],
|
||||||
|
defaultLastMessage = defaultLastMessage,
|
||||||
|
statusMap = statusMap,
|
||||||
|
onOpenProfile = onOpenProfile,
|
||||||
|
onOpenConversation = onOpenConversation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SearchConversationsList(
|
||||||
|
listState: LazyListState,
|
||||||
|
conversations: List<CachedConversation>,
|
||||||
|
defaultLastMessage: String,
|
||||||
|
statusMap: Map<Int, UserStatus>,
|
||||||
|
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<Int, UserStatus>,
|
||||||
|
onOpenProfile: (Int) -> Unit,
|
||||||
|
onOpenConversation: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
val cached = ProfileCache.get(conversation.otherUserId)
|
||||||
val avatarUrl = cached?.profilePicture
|
val avatarUrl = cached?.profilePicture
|
||||||
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
|
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
|
||||||
?: cached?.username?.takeIf { it.isNotBlank() }
|
?: cached?.username?.takeIf { it.isNotBlank() }
|
||||||
?: conv.displayName.ifBlank {
|
?: conversation.displayName.ifBlank {
|
||||||
stringResource(Res.string.user_fallback, conv.otherUserId)
|
stringResource(Res.string.user_fallback, conversation.otherUserId)
|
||||||
}
|
}
|
||||||
val preview = conv.lastMessagePreview?.trim().orEmpty()
|
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(
|
ListItem(
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
ChatRowAvatar(
|
ChatRowAvatar(
|
||||||
profilePictureUrl = avatarUrl,
|
profilePictureUrl = avatarUrl,
|
||||||
displayNameForInitials = peerTitle,
|
displayNameForInitials = peerTitle,
|
||||||
onClick = {
|
onClick = { onOpenProfile(conversation.otherUserId) }
|
||||||
if (conv.otherUserId != 0) {
|
|
||||||
navController.navigate("profile/${conv.otherUserId}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
@@ -274,25 +470,51 @@ fun ChatsTab() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(
|
AnimatedContent(
|
||||||
text = preview.takeIf { it.isNotEmpty() }
|
targetState = statusKey,
|
||||||
?: stringResource(Res.string.chat_last_mesaage),
|
transitionSpec = {
|
||||||
maxLines = 2,
|
(slideInVertically { it / 2 } + fadeIn()) togetherWith
|
||||||
overflow = TextOverflow.Ellipsis
|
(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 = {
|
trailingContent = {
|
||||||
if (conv.unreadCount > 0) {
|
if (conversation.unreadCount > 0) {
|
||||||
Text(stringResource(Res.string.unread_count, conv.unreadCount))
|
Text(stringResource(Res.string.unread_count, conversation.unreadCount))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
if (conv.otherUserId != 0) {
|
onOpenConversation(conversation.otherUserId)
|
||||||
navController.navigate(DmNav.chatRoute(conv.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package ru.fromchat.ui.main
|
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.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
|
||||||
import ru.fromchat.*
|
import ru.fromchat.*
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.utils.exclude
|
import ru.fromchat.utils.exclude
|
||||||
import ru.fromchat.ui.main.settings.SettingsTab
|
import ru.fromchat.ui.main.settings.SettingsTab
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val PAGE_CHATS = 0
|
private const val PAGE_CHATS = 0
|
||||||
private const val PAGE_CONTACTS = 1
|
private const val PAGE_CONTACTS = 1
|
||||||
@@ -37,8 +42,13 @@ private const val PAGE_PROFILE = 3
|
|||||||
private const val PAGE_COUNT = 4
|
private const val PAGE_COUNT = 4
|
||||||
|
|
||||||
@Suppress("AssignedValueIsNeverRead")
|
@Suppress("AssignedValueIsNeverRead")
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen() {
|
fun MainScreen(
|
||||||
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
|
) {
|
||||||
|
val navController = LocalNavController.current
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(
|
||||||
initialPage = PAGE_CHATS,
|
initialPage = PAGE_CHATS,
|
||||||
pageCount = { PAGE_COUNT },
|
pageCount = { PAGE_COUNT },
|
||||||
@@ -47,6 +57,7 @@ fun MainScreen() {
|
|||||||
|
|
||||||
// Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop).
|
// Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop).
|
||||||
val selectedPage = pagerState.currentPage
|
val selectedPage = pagerState.currentPage
|
||||||
|
val isChatsPage = selectedPage == PAGE_CHATS
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
@@ -88,15 +99,25 @@ fun MainScreen() {
|
|||||||
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
|
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
|
||||||
modifier = Modifier.imePadding()
|
modifier = Modifier.imePadding()
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
HorizontalPager(
|
Box(
|
||||||
state = pagerState,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
beyondViewportPageCount = 1,
|
beyondViewportPageCount = 1,
|
||||||
) { page ->
|
) { page ->
|
||||||
when (page) {
|
when (page) {
|
||||||
PAGE_CHATS -> ChatsTab()
|
PAGE_CHATS -> ChatsTab(
|
||||||
|
isVisible = isChatsPage,
|
||||||
|
onOpenSearch = {
|
||||||
|
navController.navigate("search/conversations")
|
||||||
|
},
|
||||||
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
|
)
|
||||||
PAGE_CONTACTS -> ContactsTab()
|
PAGE_CONTACTS -> ContactsTab()
|
||||||
PAGE_SETTINGS -> SettingsTab()
|
PAGE_SETTINGS -> SettingsTab()
|
||||||
PAGE_PROFILE -> {
|
PAGE_PROFILE -> {
|
||||||
@@ -116,3 +137,4 @@ fun MainScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ fun SettingsTab() {
|
|||||||
onAppearance = { nav.navigate(SettingsRoutes.Appearance) },
|
onAppearance = { nav.navigate(SettingsRoutes.Appearance) },
|
||||||
onNotifications = { nav.navigate(SettingsRoutes.Notifications) },
|
onNotifications = { nav.navigate(SettingsRoutes.Notifications) },
|
||||||
onDevices = { nav.navigate(SettingsRoutes.Devices) },
|
onDevices = { nav.navigate(SettingsRoutes.Devices) },
|
||||||
|
onSwitchServer = { nav.navigate("serverConfig") },
|
||||||
onAccount = { nav.navigate(SettingsRoutes.Account) },
|
onAccount = { nav.navigate(SettingsRoutes.Account) },
|
||||||
onAbout = { nav.navigate("about") },
|
onAbout = { nav.navigate("about") },
|
||||||
title = stringResource(Res.string.settings),
|
title = stringResource(Res.string.settings),
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ fun SettingsHubScreen(
|
|||||||
onAppearance: () -> Unit,
|
onAppearance: () -> Unit,
|
||||||
onNotifications: () -> Unit,
|
onNotifications: () -> Unit,
|
||||||
onDevices: () -> Unit,
|
onDevices: () -> Unit,
|
||||||
|
onSwitchServer: () -> Unit,
|
||||||
onAccount: () -> Unit,
|
onAccount: () -> Unit,
|
||||||
onAbout: () -> Unit,
|
onAbout: () -> Unit,
|
||||||
title: String,
|
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))
|
Spacer(Modifier.height(20.dp))
|
||||||
Category(
|
Category(
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -74,10 +75,21 @@ import ru.fromchat.*
|
|||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.ProfileCache
|
import ru.fromchat.api.ProfileCache
|
||||||
import ru.fromchat.api.UserProfile
|
import ru.fromchat.api.UserProfile
|
||||||
|
import ru.fromchat.api.UserStatus
|
||||||
|
import ru.fromchat.api.UserStatusStore
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.chat.Avatar
|
import ru.fromchat.ui.chat.Avatar
|
||||||
|
import ru.fromchat.ui.chat.TypingIndicator
|
||||||
import ru.fromchat.ui.chat.publicChatProfileSharedAvatarKey
|
import ru.fromchat.ui.chat.publicChatProfileSharedAvatarKey
|
||||||
import ru.fromchat.ui.scaleOnPress
|
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 {
|
private sealed interface ProfileLoadError {
|
||||||
data object Generic : ProfileLoadError
|
data object Generic : ProfileLoadError
|
||||||
@@ -331,6 +343,26 @@ fun ProfileScreen(
|
|||||||
val verificationLabel =
|
val verificationLabel =
|
||||||
if (profile.verified == true) verifiedSupport else verifyPromptSupport
|
if (profile.verified == true) verifiedSupport else verifyPromptSupport
|
||||||
val isOwnProfile = ApiClient.user?.id == profile.id
|
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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -345,14 +377,31 @@ fun ProfileScreen(
|
|||||||
userId = profile.id
|
userId = profile.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!compactIdentityForPublicChat && profile.username.isNotBlank()) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
AnimatedContent(
|
||||||
text = "@${profile.username}",
|
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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(36.dp))
|
Spacer(modifier = Modifier.height(36.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
Reference in New Issue
Block a user